> ## Documentation Index
> Fetch the complete documentation index at: https://docs.eco.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a browser wallet transfer app

> A minimal Vite and React app that connects a browser wallet, requests and verifies an Eco API v1 quote, funds it from Base, and tracks delivery.

This app sends USDC from Base to OP Mainnet, Arbitrum, or Polygon from a browser wallet. It requests a quote, [verifies it](/cookbook/verify-quotes), approves the exact amount, sends the returned funding transaction, and polls the intent status. It has six source files.

`api.eco.com` does not send CORS headers, and your API key must stay on a server, so the browser calls `/eco/v1/*` on the Vite dev server, which forwards the request to `https://api.eco.com` and adds `x-api-key`.

## Prerequisites

* Node.js 22.18 or later
* A server-side API key enabled for v1. [Contact Eco](mailto:contact@eco.com) to request access
* A browser wallet extension, such as MetaMask or Rabby, holding USDC and a small amount of ETH on Base for the approval and funding transactions

## Create the project

Create these files in an empty folder.

```json package.json theme={null}
{
  "name": "eco-wallet-starter",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc --noEmit && vite build"
  },
  "dependencies": {
    "react": "^19.1.0",
    "react-dom": "^19.1.0",
    "viem": "^2.37.0"
  },
  "devDependencies": {
    "@types/react": "^19.1.0",
    "@types/react-dom": "^19.1.0",
    "@vitejs/plugin-react": "^5.0.0",
    "typescript": "^5.9.0",
    "vite": "^7.1.0"
  }
}
```

```json tsconfig.json theme={null}
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "skipLibCheck": true,
    "noEmit": true,
    "types": ["vite/client"]
  },
  "include": ["src"]
}
```

```typescript vite.config.ts theme={null}
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';

// The browser calls /eco/v1/*; the dev server forwards it to api.eco.com and adds your key.
// ECO_API_KEY has no VITE_ prefix, so it is never bundled into browser code.
export default defineConfig(({ mode }) => {
  const { ECO_API_KEY } = loadEnv(mode, '.', '');
  return {
    plugins: [react()],
    server: {
      proxy: {
        '/eco/v1/': {
          target: 'https://api.eco.com',
          changeOrigin: true,
          rewrite: (path) => path.replace(/^\/eco/, ''),
          headers: ECO_API_KEY ? { 'x-api-key': ECO_API_KEY } : {},
        },
      },
    },
  };
});
```

```html index.html theme={null}
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Eco wallet starter</title>
  </head>
  <body style="font-family: system-ui, sans-serif; max-width: 480px; margin: 40px auto; padding: 0 16px">
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
```

Save the module from [Verify quotes before funding](/cookbook/verify-quotes#the-module) as `src/verify-quote.ts`, then add the app:

```tsx src/main.tsx theme={null}
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 />);
```

Put your key in `.env` in the same folder. Vite reads it on the server side only.

```bash .env theme={null}
ECO_API_KEY=your_v1_key
```

Keep the key and build output out of version control:

```text .gitignore theme={null}
node_modules
dist
.env
```

## Run it

```bash theme={null}
npm install
npm run dev
```

Open the printed `http://localhost:5173` URL in the browser that has your wallet extension.

1. Select **Connect wallet** and approve the connection in your wallet.
2. Enter an amount, choose a destination, and select **Get quote**. The quote is verified before it is shown; a quote that fails verification shows the reason instead.
3. Select **Send**. Your wallet asks for an approval of exactly the quoted amount, if the Portal does not already have one, and then for the funding transaction.
4. After funding, the quote is cleared so it cannot be sent twice, and a second status line polls [`GET /v1/intents/status`](/api-reference/v1/intent-status) every 5 seconds until the intent reaches a final state such as `filled`.

**Send** moves real funds on mainnet.

## Security

* The API key is read from `ECO_API_KEY` by the dev server and never reaches the browser bundle. The proxy forwards only `/eco/v1/*` to `https://api.eco.com`.
* **Send** is enabled only for a quote that passed `verifyQuote()` with `recipientVerified: true`, and the app sends the verified `execution.transaction` unchanged.
* The recipient and funder are the connected account. The app rechecks the account, the chain, and the quote expiry immediately before sending.
* The approval is for the exact quoted amount, not an unlimited allowance.

## Deploy it

`vite.config.ts` proxies requests only while `npm run dev` runs. For a deployed app, serve the same forwarding from your backend: accept `/eco/v1/*`, call `https://api.eco.com/v1/*` with `x-api-key`, and return the response. Do not forward arbitrary hosts or paths.

## Troubleshooting

* `Eco API 401: API key is missing, unknown, or revoked`: the key is not enabled for v1. Check `ECO_API_KEY` in `.env` and restart `npm run dev`.
* `No browser wallet found`: open the page in a browser with a wallet extension installed.
* `Quote rejected: ...`: the quote did not match the request; request a new quote. See [What it checks](/cookbook/verify-quotes#what-it-checks).
* `Signed, but the recipient could not be proven from route data`: the route pays out inside a swap or bridge call, so **Send** stays disabled.
* `Quote expired`: select **Get quote** again. Quotes are valid until `expiresAt`.

## Next steps

* [Verify quotes before funding](/cookbook/verify-quotes)
* [Errors and retries](/api-reference/errors)
* [Intent status](/api-reference/v1/intent-status)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.