> ## 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.

# Verify quotes before funding

> A single TypeScript module that checks an Eco API v1 quote, its signature, and its funding transaction against your request before you fund it.

`verify-quote.ts` is one function, `verifyQuote()`, that you call between requesting a quote and funding it. It throws if the quote does not match your request, and returns whether the recipient could be proven from route data. It depends only on `viem` and runs in Node.js and in the browser.

The signature covers the quote ID, the intent hash set, and the expiry ([what a signature proves](/api-reference/quote-verification#what-verification-proves)). The module also decodes the funding transaction, recomputes the intent hashes, and checks who gets paid.

## Prerequisites

* `viem` 2.x: `npm install viem`
* Node.js 22.18 or later, or a bundler such as Vite
* An EVM source and destination chain. The module rejects other quotes; for a Solana source, use the [reference script](/api-reference/agent-integration#reference-script)

## The module

```typescript verify-quote.ts theme={null}
import {
  decodeFunctionData, encodeAbiParameters, encodePacked, erc20Abi, hashTypedData, isAddressEqual, keccak256, parseAbi,
  recoverAddress, type Address, type Hex,
} from 'viem';

type TokenAmount = { token: Address; amount: string };
type Route = {
  salt: Hex; deadline: number; source: number; destination: number; portal: Address; nativeAmount: string;
  tokens: TokenAmount[]; calls: { target: Address; data: Hex; value: string }[];
};
type Reward = { deadline: number; creator: Address; prover: Address; nativeAmount: string; tokens: TokenAmount[] };
type Intent = { route: Route; reward: Reward };

export type Chain = { chainId: number; type: string; quoteSigner: Address; contracts: { portal: Address } };
export type QuoteRequest = {
  type: 'exact-in';
  source: { chainId: number; token: Address; amount: string; funder: Address };
  destination: { chainId: number; token: Address; recipient: Address };
};
export type Quote = {
  id: string;
  expiresAt: number;
  signature: Hex;
  intentHash: Hex | null;
  source: QuoteRequest['source'];
  destination: QuoteRequest['destination'] & { amountOut: string; minAmountOut: string };
  steps: { intents: { role: string; intentHash: Hex | null; intent: Intent }[] }[];
  execution: { transaction: { type: string; chainId: number; to: Address; data: Hex; value: string }; intent: Intent };
};

const PORTAL_ABI = parseAbi([
  'struct TokenAmount { address token; uint256 amount; }',
  'struct Reward { uint64 deadline; address creator; address prover; uint256 nativeAmount; TokenAmount[] tokens; }',
  'function publishAndFund(uint64 destination, bytes route, Reward reward, bool allowPartial) payable returns (bytes32, address)',
]);
const CCTP_ABI = parseAbi([
  'function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold)',
]);
// Circle CCTP v2 TokenMessenger (same address on every supported EVM chain) and domains:
// https://developers.circle.com/cctp/references/technical-guide
const TOKEN_MESSENGER_V2 = '0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d';
const CCTP_DOMAIN: Record<number, number> = { 1: 0, 10: 2, 42161: 3, 8453: 6, 137: 7, 130: 10 };
const TOKEN_AMOUNTS = { name: 'tokens', type: 'tuple[]', components: [{ name: 'token', type: 'address' }, { name: 'amount', type: 'uint256' }] } as const;
const ROUTE = [{ type: 'tuple', components: [
  { name: 'salt', type: 'bytes32' }, { name: 'deadline', type: 'uint64' }, { name: 'portal', type: 'address' }, { name: 'nativeAmount', type: 'uint256' },
  TOKEN_AMOUNTS,
  { name: 'calls', type: 'tuple[]', components: [{ name: 'target', type: 'address' }, { name: 'data', type: 'bytes' }, { name: 'value', type: 'uint256' }] },
] }] as const;
const REWARD = [{ type: 'tuple', components: [
  { name: 'deadline', type: 'uint64' }, { name: 'creator', type: 'address' }, { name: 'prover', type: 'address' }, { name: 'nativeAmount', type: 'uint256' },
  TOKEN_AMOUNTS,
] }] as const;

const routeHash = (r: Route) => keccak256(encodeAbiParameters(ROUTE, [{
  ...r, deadline: BigInt(r.deadline), nativeAmount: BigInt(r.nativeAmount),
  tokens: r.tokens.map((t) => ({ token: t.token, amount: BigInt(t.amount) })),
  calls: r.calls.map((c) => ({ ...c, value: BigInt(c.value) })),
}]));
const rewardHash = (r: Reward) => keccak256(encodeAbiParameters(REWARD, [{
  ...r, deadline: BigInt(r.deadline), nativeAmount: BigInt(r.nativeAmount),
  tokens: r.tokens.map((t) => ({ token: t.token, amount: BigInt(t.amount) })),
}]));
const intentHash = (destination: bigint, route: Hex, reward: Hex) =>
  keccak256(encodePacked(['uint64', 'bytes32', 'bytes32'], [destination, route, reward]));

function check(ok: boolean, message: string): asserts ok {
  if (!ok) throw new Error(`Quote rejected: ${message}`);
}

/**
 * Verifies an Eco API v1 quote for an EVM-to-EVM transfer before you fund it. Throws if any check fails.
 * `chains` is the `chains` array from GET /v1/chains.
 * Returns `recipientVerified: false` when the payout happens inside a swap or bridge call that route data cannot prove;
 * decide whether to fund such quotes. Everything else that fails throws.
 */
export async function verifyQuote(quote: Quote, request: QuoteRequest, chains: Chain[]): Promise<{ recipientVerified: boolean }> {
  const source = chains.find((c) => c.chainId === request.source.chainId);
  const destination = chains.find((c) => c.chainId === request.destination.chainId);
  check(source?.type === 'evm' && destination?.type === 'evm', 'only EVM-to-EVM quotes are supported by this module');
  const tx = quote.execution.transaction;
  check(tx.type === 'evm' && tx.chainId === request.source.chainId, 'funding transaction is not on the source chain');

  // 1. The quote answers the request you sent.
  check(quote.source.chainId === request.source.chainId && quote.destination.chainId === request.destination.chainId, 'chains differ');
  check(isAddressEqual(quote.source.token, request.source.token) && isAddressEqual(quote.destination.token, request.destination.token), 'tokens differ');
  check(quote.source.amount === request.source.amount, 'source amount differs');
  check(isAddressEqual(quote.source.funder, request.source.funder), 'funder differs');
  check(isAddressEqual(quote.destination.recipient, request.destination.recipient), 'recipient differs');

  // 2. Not expired.
  check(quote.expiresAt > Math.floor(Date.now() / 1000), 'expired; request a new quote');

  // 3. Eco signed the quote ID, the intent hash set, and the expiry.
  const signed = [...new Set(
    [quote.intentHash, ...quote.steps.flatMap((s) => s.intents.map((i) => i.intentHash))]
      .filter((h): h is Hex => h !== null)
      .map((h) => h.toLowerCase() as Hex),
  )].sort();
  check(signed.length > 0, 'no signed intents');
  const signer = await recoverAddress({
    hash: hashTypedData({
      domain: { name: 'EcoQuoteV1', version: '1', chainId: quote.source.chainId },
      types: { Quote: [{ name: 'id', type: 'string' }, { name: 'intentHashes', type: 'bytes32[]' }, { name: 'expiresAt', type: 'uint64' }] },
      primaryType: 'Quote',
      message: { id: quote.id, intentHashes: signed, expiresAt: BigInt(quote.expiresAt) },
    }),
    signature: quote.signature,
  });
  check(isAddressEqual(signer, source.quoteSigner), 'signature is not from the source chain quoteSigner');

  // 4. The funding transaction funds a signed intent, from your wallet, for exactly your amount.
  check(isAddressEqual(tx.to, source.contracts.portal), 'funding transaction is not addressed to the Eco Portal');
  check(BigInt(tx.value) === 0n, 'funding transaction sends native value');
  const call = decodeFunctionData({ abi: PORTAL_ABI, data: tx.data });
  check(call.functionName === 'publishAndFund', 'funding transaction is not publishAndFund');
  const [dest, routeBytes, reward, allowPartial] = call.args;
  check(!allowPartial, 'funding transaction allows partial funding');
  check(keccak256(routeBytes) === routeHash(quote.execution.intent.route), 'funding route differs from the quoted route');
  check(Number(dest) === quote.execution.intent.route.destination, 'funding destination chain differs from the quoted route');
  const funded = intentHash(dest, keccak256(routeBytes), keccak256(encodeAbiParameters(REWARD, [reward])));
  check(signed.includes(funded), 'funding transaction encodes an intent Eco did not sign');
  check(isAddressEqual(reward.creator, request.source.funder), 'reward creator (refund address) is not your wallet');
  check(reward.nativeAmount === 0n && reward.tokens.length === 1, 'unexpected reward');
  check(isAddressEqual(reward.tokens[0].token, request.source.token) && reward.tokens[0].amount === BigInt(request.source.amount), 'funding spends a different amount');

  // 5. Every listed intent re-hashes to a signed hash.
  for (const { role, intentHash: listed, intent } of quote.steps.flatMap((s) => s.intents)) {
    const h = intentHash(BigInt(intent.route.destination), routeHash(intent.route), rewardHash(intent.reward));
    check(listed !== null && h === listed.toLowerCase() && signed.includes(h), `listed ${role} intent does not match the signature`);
  }

  // 6. The delivery pays your recipient at least minAmountOut: a direct ERC-20 transfer, or a CCTP burn to the recipient.
  const minOut = BigInt(quote.destination.minAmountOut);
  const route = quote.execution.intent.route;
  const multiHop = quote.steps.some((s) => s.intents.some((i) => i.role === 'stitched-destination'));
  for (const c of multiHop ? [] : route.calls) {
    if (c.data.startsWith('0x8e0250ee')) {
      check(isAddressEqual(c.target, TOKEN_MESSENGER_V2), 'CCTP burn is not sent to Circle TokenMessengerV2');
      check(route.destination === request.source.chainId, 'CCTP burn does not run on the source chain');
      const [amount, domain, mintRecipient, burnToken, , maxFee] = decodeFunctionData({ abi: CCTP_ABI, data: c.data }).args;
      check(domain === CCTP_DOMAIN[request.destination.chainId], 'CCTP burn targets another chain');
      check(isAddressEqual(burnToken, request.source.token), 'CCTP burns another token');
      check(isAddressEqual(`0x${mintRecipient.slice(26)}`, request.destination.recipient), 'CCTP mints to another address');
      check(amount - maxFee >= minOut, 'CCTP amount after fees is below minAmountOut');
      return { recipientVerified: true };
    }
    if (route.destination !== request.destination.chainId || !isAddressEqual(c.target, request.destination.token)) continue;
    const transfer = decodeFunctionData({ abi: erc20Abi, data: c.data });
    if (transfer.functionName !== 'transfer') continue;
    const [to, amount] = transfer.args;
    check(isAddressEqual(to, request.destination.recipient), 'delivery pays another address');
    check(amount >= minOut, 'delivery amount is below minAmountOut');
    return { recipientVerified: true };
  }
  return { recipientVerified: false };
}
```

## Use it

Pass the request you sent, the quote you received, and the `chains` array from [`GET /v1/chains`](/api-reference/v1/chains). Fund only after it returns. This script runs server-side with your key. Set up an ES module project in an empty folder, save `verify-quote.ts` and the script below in it, then run the script:

```bash theme={null}
npm init -y
npm pkg set type=module
npm install viem
ECO_API_KEY=your_v1_key ECO_WALLET=0xYourBaseWallet node check-quote.ts
```

```typescript check-quote.ts theme={null}
import process from 'node:process';
import { getAddress } from 'viem';
import { verifyQuote, type Chain, type Quote, type QuoteRequest } from './verify-quote.ts';

const apiKey = process.env.ECO_API_KEY;
const wallet = process.env.ECO_WALLET;
if (!apiKey || !wallet) throw new Error('Set ECO_API_KEY and ECO_WALLET');

async function eco<T>(path: string, body?: unknown): Promise<T> {
  const res = await fetch(`https://api.eco.com${path}`, {
    method: body ? 'POST' : 'GET',
    headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey! },
    body: body ? JSON.stringify(body) : undefined,
  });
  const text = await res.text();
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${text}`);
  return JSON.parse(text) as T;
}

const request: QuoteRequest = {
  type: 'exact-in',
  source: { chainId: 8453, token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', amount: '1000000', funder: getAddress(wallet) },
  destination: { chainId: 10, token: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', recipient: getAddress(wallet) },
};
const [quote, { chains }] = await Promise.all([
  eco<Quote>('/v1/quotes', { ...request, slippage: 0.005, dappId: 'your-app' }),
  eco<{ chains: Chain[] }>('/v1/chains'),
]);

const { recipientVerified } = await verifyQuote(quote, request, chains); // throws if any check fails
console.log({ quoteId: quote.id, amountOut: quote.destination.amountOut, recipientVerified });
// Fund quote.execution.transaction only after this point.
```

## What it checks

| Check | Fails when |
| - | - |
| The quote answers your request | Chains, tokens, amount, funder, or recipient differ from what you sent |
| Not expired | `expiresAt` is in the past |
| Signed by Eco | The EIP-712 `EcoQuoteV1` signature does not recover to the source chain's `quoteSigner` |
| The funding transaction funds a signed intent | It is not `publishAndFund` on the source Portal, sends native value, allows partial funding, encodes a different route or destination chain than the quote, or encodes an intent outside the signed set |
| Your wallet, your amount | The reward creator (refund address) is not your wallet, or the reward is not exactly your token and amount |
| Every listed intent is signed | An intent in `steps[].intents[]` does not re-hash to a signed hash |
| The delivery pays your recipient | A direct ERC-20 `transfer` or a CCTP `depositForBurn` pays another address, another chain, or less than `minAmountOut`; or the burn is not a source-chain call to Circle's TokenMessengerV2 |

`recipientVerified` is `false` when the payout happens inside a swap or bridge call, as on the multi-hop routes in [Intent types](/resources/intent-types). The signature and funding checks still pass; whether to fund such a quote is your decision. With `options.allQuotes`, run `verifyQuote()` on each entry in `quotes[]` and choose among the ones that pass.

The module does not check price, solver behavior, or whether the intent will be fulfilled.

## Next steps

* [Build a browser wallet transfer app](/cookbook/wallet-transfer-app) with this module
* [Fund and track a quote](/get-started/integrate-routes-api#fund-the-quote)
* [Understand multi-intent routes](/resources/intent-types)


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