import { useState } from 'react';
import { createRoot } from 'react-dom/client';
import {
createPublicClient, createWalletClient, custom, erc20Abi, formatUnits, getAddress, isAddressEqual, parseUnits,
type Address, type EIP1193Provider,
} from 'viem';
import { base } from 'viem/chains';
import { verifyQuote, type Chain, type Quote, type QuoteRequest } from './verify-quote';
const BASE_USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
const DESTINATIONS = [
{ chainId: 10, name: 'OP Mainnet', usdc: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85' },
{ chainId: 42161, name: 'Arbitrum', usdc: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' },
{ chainId: 137, name: 'Polygon', usdc: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359' },
] as const;
const DONE = ['filled', 'settled', 'refundable', 'refunded', 'expired', 'failed'];
async function eco<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(`/eco${path}`, {
method: body ? 'POST' : 'GET',
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
const json = await res.json();
if (!res.ok) throw new Error(`Eco API ${res.status}: ${json.title ?? json.Message ?? 'request failed'}`);
return json as T;
}
/** The first wallet that announces itself (EIP-6963), or window.ethereum. */
function findWallet(): Promise<EIP1193Provider> {
return new Promise((resolve, reject) => {
const onAnnounce = (e: Event) => resolve((e as CustomEvent).detail.provider);
window.addEventListener('eip6963:announceProvider', onAnnounce, { once: true });
window.dispatchEvent(new Event('eip6963:requestProvider'));
setTimeout(() => {
const injected = (window as { ethereum?: EIP1193Provider }).ethereum;
if (injected) resolve(injected);
else reject(new Error('No browser wallet found'));
}, 300);
});
}
function App() {
const [provider, setProvider] = useState<EIP1193Provider>();
const [account, setAccount] = useState<Address>();
const [amount, setAmount] = useState('1');
const [destination, setDestination] = useState<(typeof DESTINATIONS)[number]>(DESTINATIONS[0]);
const [quote, setQuote] = useState<Quote>();
const [recipientVerified, setRecipientVerified] = useState(false);
const [status, setStatus] = useState('');
const [delivery, setDelivery] = useState('');
const [busy, setBusy] = useState(false);
const run = (task: () => Promise<void>) => async () => {
setBusy(true);
try {
await task();
} catch (e) {
setStatus(e instanceof Error ? e.message.split('\n')[0] : String(e));
} finally {
setBusy(false);
}
};
const connect = run(async () => {
const p = await findWallet();
const [address] = await createWalletClient({ transport: custom(p) }).requestAddresses();
setProvider(p);
setAccount(getAddress(address));
setStatus('');
});
const getQuote = run(async () => {
setQuote(undefined);
setStatus('Requesting quote…');
const request: QuoteRequest = {
type: 'exact-in',
source: { chainId: base.id, token: BASE_USDC, amount: parseUnits(amount, 6).toString(), funder: account! },
destination: { chainId: destination.chainId, token: destination.usdc, recipient: account! },
};
const [q, { chains }] = await Promise.all([
eco<Quote>('/v1/quotes', { ...request, slippage: 0.005, dappId: 'eco-wallet-starter' }),
eco<{ chains: Chain[] }>('/v1/chains'),
]);
const result = await verifyQuote(q, request, chains); // throws if the quote doesn't match what you asked for
setQuote(q);
setRecipientVerified(result.recipientVerified);
setStatus(result.recipientVerified ? 'Quote verified' : 'Signed, but the recipient could not be proven from route data');
});
/** Polls the delivery intent in the background until it reaches a final state (up to 10 minutes). */
const track = async (intentHash: string, fundingTx: string) => {
for (let i = 0; i < 120; i++) {
const { results } = await eco<{ results: { status: string }[] }>(`/v1/intents/status?intentHash=${intentHash}`).catch(() => ({ results: [] }));
const state = results[0]?.status ?? 'unknown';
setDelivery(`Last transfer: funded on Base (${fundingTx.slice(0, 10)}…), intent status ${state}`);
if (DONE.includes(state)) return;
await new Promise((r) => setTimeout(r, 5000));
}
};
const send = run(async () => {
if (!quote || !provider || !account) return;
const wallet = createWalletClient({ account, chain: base, transport: custom(provider) });
const client = createPublicClient({ chain: base, transport: custom(provider) });
const [current] = await wallet.getAddresses();
if (!current || !isAddressEqual(current, account)) throw new Error('Wallet account changed; reconnect and get a new quote');
await wallet.switchChain({ id: base.id });
if ((await client.getChainId()) !== base.id) throw new Error('Switch your wallet to Base');
const { to, data } = quote.execution.transaction;
const needed = BigInt(quote.source.amount);
const allowance = await client.readContract({ address: BASE_USDC, abi: erc20Abi, functionName: 'allowance', args: [account, to] });
if (allowance < needed) {
setStatus('Approve USDC in your wallet…');
const hash = await wallet.writeContract({ address: BASE_USDC, abi: erc20Abi, functionName: 'approve', args: [to, needed] });
if ((await client.waitForTransactionReceipt({ hash })).status !== 'success') throw new Error('Approval failed');
}
if (quote.expiresAt <= Date.now() / 1000) throw new Error('Quote expired; get a new quote');
setStatus('Confirm the transfer in your wallet…');
const hash = await wallet.sendTransaction({ to, data });
if ((await client.waitForTransactionReceipt({ hash })).status !== 'success') throw new Error('Funding failed');
setQuote(undefined); // a funded quote must not be sent again
setStatus('');
void track(quote.intentHash!, hash);
});
if (!account) return <button onClick={connect} disabled={busy}>Connect wallet</button>;
return (
<div style={{ display: 'grid', gap: 12 }}>
<div>Connected: {account}</div>
<label>
Send USDC from Base: <input value={amount} onChange={(e) => { setAmount(e.target.value); setQuote(undefined); }} size={8} />
</label>
<label>
To:{' '}
<select value={destination.chainId} onChange={(e) => { setDestination(DESTINATIONS.find((d) => d.chainId === Number(e.target.value))!); setQuote(undefined); }}>
{DESTINATIONS.map((d) => <option key={d.chainId} value={d.chainId}>{d.name}</option>)}
</select>
</label>
<button onClick={getQuote} disabled={busy}>Get quote</button>
{quote && (
<>
<div>
You receive {formatUnits(BigInt(quote.destination.amountOut), 6)} USDC
(minimum {formatUnits(BigInt(quote.destination.minAmountOut), 6)}) on {destination.name}.
</div>
<button onClick={send} disabled={busy || !recipientVerified}>Send</button>
</>
)}
<div>{status}</div>
<div>{delivery}</div>
</div>
);
}
createRoot(document.getElementById('root')!).render(<App />);