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). The module also decodes the funding transaction, recomputes the intent hashes, and checks who gets paid.
Prerequisites
viem2.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
The module
verify-quote.ts
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 thechains array from GET /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:
npm init -y
npm pkg set type=module
npm install viem
ECO_API_KEY=your_v1_key ECO_WALLET=0xYourBaseWallet node check-quote.ts
check-quote.ts
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. 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.
