Docs MCP server
https://docs.eco.com/mcp exposes search and read-only page access over every page in this reference and the OpenAPI document. No authentication.
claude mcp add --transport http eco-docs https://docs.eco.com/mcp
Copy page menu on every page also offers a one-click connection.
Skill
The skill is published at/.well-known/skills/eco-api-v1/SKILL.md and listed in /.well-known/skills/index.json. It states the access rules, the quote request and response fields that drive an integration, signature verification, the gasless funding lanes and their binding rules, status vocabulary, Circle Gateway deposits, and the error-to-action table.
Install it into any supported agent from the docs domain:
npx skills add https://docs.eco.com
SKILL.md only. Copy transfer.ts and package.json below next to it as scripts/. Alternatively, place all three files under .claude/skills/eco-api-v1/ in the project (Claude Code) or the equivalent skills directory of the agent in use.
Reference script
transfer.ts runs the full flow from an EVM or Solana source chain: chains, quote, signature verification, verification of the funded intent and every listed intent against the signed set, a recipient check on the delivery route, funding, then status polling until filled. Without ECO_EXECUTE=yes it stops after printing the verified quote and moves no funds. On stitched routes and same-chain swaps the recipient sits inside an aggregator or bridge call and is reported as not provable; broadcasting then needs ECO_ALLOW_UNVERIFIED_RECIPIENT=yes. Requires Node 22.18 or later, viem, and for Solana @solana/web3.js.
ECO_API_KEY=eco_live_... ECO_FUNDER=0x... ECO_RECIPIENT=0x... node transfer.ts
transfer.ts
/**
* Eco API v1: end-to-end stablecoin transfer from an EVM or Solana source chain.
*
* chains -> quote -> verify signature -> verify funded and listed intents against the signed set and the request -> (fund) -> track intent
*
* Dry run (default): requests and verifies a quote, prints the funding transaction, moves no funds.
* Execute: set ECO_EXECUTE=yes plus ECO_PRIVATE_KEY and ECO_RPC_URL to broadcast on mainnet.
*
* Requires Node 22.18+ (runs TypeScript directly), `viem`, and for Solana source chains `@solana/web3.js`.
*
* ECO_API_KEY=eco_live_... ECO_FUNDER=0x... ECO_RECIPIENT=0x... node transfer.ts
* ECO_SOURCE_CHAIN=1399811149 ECO_SOURCE_TOKEN=EPjF... ECO_FUNDER=<base58> node transfer.ts
*/
import { createHash } from 'node:crypto';
import {
createPublicClient, createWalletClient, decodeAbiParameters, decodeFunctionData, defineChain, encodeAbiParameters, encodePacked,
erc20Abi, getAddress, hashTypedData, http, keccak256, parseAbi, recoverAddress, toHex,
type Address, type Hex,
} from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
const API = 'https://api.eco.com';
function env(name: string, fallback?: string): string {
const value = process.env[name] ?? fallback;
if (value === undefined || value === '') throw new Error(`Set ${name}`);
return value;
}
function loadConfig() {
return {
apiKey: env('ECO_API_KEY'),
funder: env('ECO_FUNDER'),
recipient: env('ECO_RECIPIENT'),
dappId: env('ECO_DAPP_ID', 'eco-api-v1-example'),
sourceChainId: Number(env('ECO_SOURCE_CHAIN', '8453')),
sourceToken: env('ECO_SOURCE_TOKEN', '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'), // USDC on Base
destinationChainId: Number(env('ECO_DESTINATION_CHAIN', '10')),
destinationToken: env('ECO_DESTINATION_TOKEN', '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85'), // USDC on OP Mainnet
amount: env('ECO_AMOUNT', '1000000'), // base units: 1 USDC
slippage: Number(env('ECO_SLIPPAGE', '0.005')),
execute: process.env.ECO_EXECUTE === 'yes',
};
}
type Config = ReturnType<typeof loadConfig>;
let config: Config;
async function api<T>(path: string, body?: unknown): Promise<T> {
const response = await fetch(`${API}${path}`, {
method: body === undefined ? 'GET' : 'POST',
headers: { 'content-type': 'application/json', 'x-api-key': config.apiKey },
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(45_000),
});
const text = await response.text();
if (!response.ok) throw new Error(`${path} -> HTTP ${response.status}: ${text.slice(0, 400)}`);
return JSON.parse(text) as T;
}
type Chain = { chainId: number; type: 'evm' | 'svm' | 'tvm'; status: string; quoteSigner: Address; contracts: { portal: string } };
type Vm = 'evm' | 'svm';
let chainsById: Map<number, Chain>;
function chainType(chainId: number): Vm {
const chain = chainsById.get(chainId);
if (!chain || chain.type === 'tvm') throw new Error(`Chain ${chainId} is not a live EVM or Solana chain in /v1/chains`);
return chain.type;
}
type Intent = { intentHash: Hex | null; role: string; intent: { route: RouteJson; reward: RewardJson } };
type RouteJson = { salt: Hex; deadline: number; portal: string; nativeAmount: string; tokens: { token: string; amount: string }[]; calls: { target: string; data: Hex; value: string }[] };
type RewardJson = { deadline: number; creator: string; prover: string; nativeAmount: string; tokens: { token: string; amount: string }[] };
type EvmTransaction = { type: 'evm'; chainId: number; to: Address; data: Hex; value: string };
type SvmInstruction = { programId: string; accounts: { pubkey: string; isSigner: boolean; isWritable: boolean }[]; data: string };
type SvmTransaction = { type: 'svm'; chainId: number; feePayer: string; instructions: SvmInstruction[] };
type Quote = {
id: string; expiresAt: number; signature: Hex; intentHash: Hex | null;
source: { chainId: number; token: string; amount: string; funder: string };
destination: { chainId: number; token: string; amountOut: string; minAmountOut: string; recipient: string };
fees: { type: string; amount: string; token: { symbol: string | null } }[];
steps: { intents: Intent[] }[];
execution: { transaction: EvmTransaction | SvmTransaction; intent: { route: RouteJson; reward: RewardJson }; vault: string | null };
};
type StatusPage = { results: { id: string; type: string; status: string; updatedAt: number | null }[] };
// --- address helpers ----------------------------------------------------------------------------------------------
const BASE58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
function base58Decode(value: string): Uint8Array {
let n = 0n;
for (const char of value) {
const digit = BASE58.indexOf(char);
if (digit < 0) throw new Error(`Invalid base58 character in ${value}`);
n = n * 58n + BigInt(digit);
}
const bytes: number[] = [];
while (n > 0n) { bytes.unshift(Number(n & 0xffn)); n >>= 8n; }
for (const char of value) { if (char !== '1') break; bytes.unshift(0); }
return Uint8Array.from(bytes);
}
function pubkeyBytes(value: string, label: string): Uint8Array {
const bytes = base58Decode(value);
if (bytes.length !== 32) throw new Error(`${label} is not a 32-byte Solana public key`);
return bytes;
}
function sameAddress(vm: 'evm' | 'svm', a: string, b: string): boolean {
return vm === 'evm' ? getAddress(a) === getAddress(b) : toHex(pubkeyBytes(a, 'address')) === toHex(pubkeyBytes(b, 'address'));
}
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
return a.length === b.length && a.every((byte, index) => byte === b[index]);
}
// --- Portal encodings ---------------------------------------------------------------------------------------------
// EVM: Portal.publishAndFund(uint64 destination, bytes route, Reward reward, bool allowPartial).
const PORTAL_EVM_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 intentHash, address vault)',
]);
const EVM_ROUTE_TUPLE = [{
type: 'tuple', components: [
{ name: 'salt', type: 'bytes32' }, { name: 'deadline', type: 'uint64' }, { name: 'portal', type: 'address' }, { name: 'nativeAmount', type: 'uint256' },
{ name: 'tokens', type: 'tuple[]', components: [{ name: 'token', type: 'address' }, { name: 'amount', type: 'uint256' }] },
{ name: 'calls', type: 'tuple[]', components: [{ name: 'target', type: 'address' }, { name: 'data', type: 'bytes' }, { name: 'value', type: 'uint256' }] },
],
}] as const;
const EVM_REWARD_TUPLE = [{
type: 'tuple', components: [
{ name: 'deadline', type: 'uint64' }, { name: 'creator', type: 'address' }, { name: 'prover', type: 'address' }, { name: 'nativeAmount', type: 'uint256' },
{ name: 'tokens', type: 'tuple[]', components: [{ name: 'token', type: 'address' }, { name: 'amount', type: 'uint256' }] },
],
}] as const;
/** keccak256 over the ABI-encoded EVM Route struct, as the EVM Portal hashes it. */
function evmRouteHash(route: RouteJson): Hex {
return keccak256(encodeAbiParameters(EVM_ROUTE_TUPLE, [{
salt: route.salt, deadline: BigInt(route.deadline), portal: getAddress(route.portal), nativeAmount: BigInt(route.nativeAmount),
tokens: route.tokens.map((t) => ({ token: getAddress(t.token), amount: BigInt(t.amount) })),
calls: route.calls.map((c) => ({ target: getAddress(c.target), data: c.data, value: BigInt(c.value) })),
}]));
}
/** Minimal Borsh writer for the Solana Portal's Route and Reward structs (u64 little-endian, Vec<u8> length-prefixed). */
class BorshWriter {
private parts: Uint8Array[] = [];
u64(value: bigint) { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, value, true); this.parts.push(b); }
u32(value: number) { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, value, true); this.parts.push(b); }
bytes(value: Uint8Array) { this.parts.push(value); }
vecBytes(value: Uint8Array) { this.u32(value.length); this.bytes(value); }
finish(): Uint8Array { const out = new Uint8Array(this.parts.reduce((n, p) => n + p.length, 0)); let o = 0; for (const p of this.parts) { out.set(p, o); o += p.length; } return out; }
}
function hexBytes(value: Hex): Uint8Array { return Uint8Array.from(Buffer.from(value.slice(2), 'hex')); }
/** keccak256 over the Borsh-encoded Solana Route, as the Solana Portal hashes it. */
function svmRouteHash(route: RouteJson): Hex {
const w = new BorshWriter();
w.bytes(hexBytes(route.salt)); w.u64(BigInt(route.deadline)); w.bytes(pubkeyBytes(route.portal, 'route.portal')); w.u64(BigInt(route.nativeAmount));
w.u32(route.tokens.length); for (const t of route.tokens) { w.bytes(pubkeyBytes(t.token, 'route token')); w.u64(BigInt(t.amount)); }
w.u32(route.calls.length); for (const c of route.calls) { w.bytes(pubkeyBytes(c.target, 'route call target')); w.vecBytes(hexBytes(c.data)); }
return keccak256(w.finish());
}
/** Solana Portal intent hash: keccak(destination as u64 big-endian || routeHash || rewardHash). */
function svmIntentHash(destination: bigint, routeHash: Uint8Array, rewardHash: Uint8Array): Hex {
const out = new Uint8Array(72);
new DataView(out.buffer).setBigUint64(0, destination, false);
out.set(routeHash, 8); out.set(rewardHash, 40);
return keccak256(out);
}
// --- verification ---------------------------------------------------------------------------------------------------
function signedIntentHashes(quote: Quote): Hex[] {
const hashes = [...new Set(
[quote.intentHash, ...quote.steps.flatMap((step) => step.intents.map((intent) => intent.intentHash))]
.filter((hash): hash is Hex => hash !== null)
.map((hash) => hash.toLowerCase() as Hex),
)].sort();
if (hashes.length === 0) throw new Error('Quote carries no intent hashes');
return hashes;
}
async function verifySignature(quote: Quote, quoteSigner: Address): Promise<void> {
const digest = 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: signedIntentHashes(quote), expiresAt: BigInt(quote.expiresAt) },
});
const signer = await recoverAddress({ hash: digest, signature: quote.signature });
if (signer.toLowerCase() !== quoteSigner.toLowerCase()) {
throw new Error(`Quote signature recovered ${signer}, expected quoteSigner ${quoteSigner}`);
}
}
function assertQuoteMatchesRequest(quote: Quote, source: Chain, destination: Chain): void {
const checks: [boolean, string][] = [
[quote.source.chainId === config.sourceChainId && quote.destination.chainId === config.destinationChainId, 'chain ids'],
[sameAddress(source.type as 'evm' | 'svm', quote.source.token, config.sourceToken), 'source token'],
[sameAddress(destination.type as 'evm' | 'svm', quote.destination.token, config.destinationToken), 'destination token'],
[quote.source.amount === config.amount, 'source amount'],
[sameAddress(source.type as 'evm' | 'svm', quote.source.funder, config.funder), 'funder'],
[sameAddress(destination.type as 'evm' | 'svm', quote.destination.recipient, config.recipient), 'recipient'],
[quote.execution.transaction.type === source.type, 'funding transaction VM matches the source chain'],
];
for (const [ok, label] of checks) if (!ok) throw new Error(`Quote does not match the request: ${label}`);
}
/** keccak256 over the Borsh-encoded Solana Reward, as the Solana Portal hashes it. */
function svmRewardHash(reward: RewardJson): Hex {
const w = new BorshWriter();
w.u64(BigInt(reward.deadline)); w.bytes(pubkeyBytes(reward.creator, 'reward.creator')); w.bytes(pubkeyBytes(reward.prover, 'reward.prover')); w.u64(BigInt(reward.nativeAmount));
w.u32(reward.tokens.length); for (const t of reward.tokens) { w.bytes(pubkeyBytes(t.token, 'reward token')); w.u64(BigInt(t.amount)); }
return keccak256(w.finish());
}
function evmRewardHash(reward: RewardJson): Hex {
return keccak256(encodeAbiParameters(EVM_REWARD_TUPLE, [{
deadline: BigInt(reward.deadline), creator: getAddress(reward.creator), prover: getAddress(reward.prover), nativeAmount: BigInt(reward.nativeAmount),
tokens: reward.tokens.map((t) => ({ token: getAddress(t.token), amount: BigInt(t.amount) })),
}]));
}
function routeHashOf(route: RouteJson): Hex { return chainType(route.destination) === 'evm' ? evmRouteHash(route) : svmRouteHash(route); }
function rewardHashOf(route: RouteJson, reward: RewardJson): Hex { return chainType(route.source) === 'evm' ? evmRewardHash(reward) : svmRewardHash(reward); }
/** Portal intent hash on both VMs: keccak(destination as 8-byte big-endian || routeHash || rewardHash). */
function intentHashOf(destination: bigint, routeHash: Hex, rewardHash: Hex): Hex {
return keccak256(encodePacked(['uint64', 'bytes32', 'bytes32'], [destination, routeHash, rewardHash]));
}
const SPL_TOKEN_PROGRAMS = ['TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'];
const ASSOCIATED_TOKEN_PROGRAM = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL';
/** Solana route calls carry Borsh `CalldataWithAccounts { calldata: { data: Vec<u8>, account_count: u8 }, accounts: Vec<{ pubkey, is_signer, is_writable }> }`. */
function decodeSvmRouteCall(call: { target: string; data: Hex }): { program: string; data: Uint8Array; accounts: string[] } {
const bytes = hexBytes(call.data); const view = new DataView(bytes.buffer, bytes.byteOffset); let o = 0;
const len = view.getUint32(o, true); o += 4; const data = bytes.subarray(o, o + len); o += len;
const count = bytes[o]; o += 1; const n = view.getUint32(o, true); o += 4;
if (n !== count) throw new Error('Solana route call account count mismatch');
const accounts: string[] = [];
for (let i = 0; i < n; i++) { accounts.push(base58Encode(bytes.subarray(o, o + 32))); o += 34; }
if (o !== bytes.length) throw new Error('Solana route call has trailing bytes');
return { program: call.target, data, accounts };
}
function base58Encode(bytes: Uint8Array): string {
let n = 0n; for (const b of bytes) n = (n << 8n) | BigInt(b);
let out = ''; while (n > 0n) { out = BASE58[Number(n % 58n)] + out; n /= 58n; }
for (const b of bytes) { if (b !== 0) break; out = '1' + out; }
return out;
}
/**
* A delivery route must carry the requested destination token, at least the quoted minimum, and a transfer of the
* route amount to the requested recipient: an ERC-20 `transfer` on EVM, an SPL `Transfer`/`TransferChecked` into the
* recipient's associated token account on Solana.
*/
async function verifyDeliveryRoute(route: RouteJson, minAmountOut: bigint): Promise<string | null> {
const vm = chainType(route.destination);
// A swap leg carries its INPUT token and produces the output inside an aggregator call; its recipient is not provable here.
if (route.tokens.length !== 1 || !sameAddress(vm, route.tokens[0].token, config.destinationToken)) return null;
const routeAmount = BigInt(route.tokens[0].amount);
if (routeAmount < minAmountOut) throw new Error('Delivery route amount is below the quoted minimum');
if (vm === 'evm') {
const transfer = route.calls.find((call) => getAddress(call.target) === getAddress(config.destinationToken));
if (!transfer) throw new Error('Delivery route has no call on the destination token');
const call = decodeFunctionData({ abi: erc20Abi, data: transfer.data });
if (call.functionName !== 'transfer' || getAddress(call.args[0] as Address) !== getAddress(config.recipient) || call.args[1] !== routeAmount) {
throw new Error('Delivery route does not transfer the route amount to the requested recipient');
}
return 'pays the requested recipient';
}
const { PublicKey } = await import('@solana/web3.js');
const transfers = route.calls.filter((call) => SPL_TOKEN_PROGRAMS.includes(call.target)).map(decodeSvmRouteCall);
if (transfers.length === 0) throw new Error('Delivery route has no SPL token transfer');
for (const ix of transfers) {
const kind = ix.data[0]; // 3 = Transfer [amount], 12 = TransferChecked [amount, decimals]
if (kind !== 3 && kind !== 12) throw new Error(`Delivery route token call is not a transfer (instruction ${kind})`);
const amount = new DataView(ix.data.buffer, ix.data.byteOffset + 1, 8).getBigUint64(0, true);
if (amount !== routeAmount) throw new Error('Delivery route SPL transfer amount differs from the route amount');
const destinationAccount = ix.accounts[kind === 12 ? 2 : 1];
const [ata] = PublicKey.findProgramAddressSync(
[new PublicKey(config.recipient).toBuffer(), new PublicKey(ix.program).toBuffer(), new PublicKey(config.destinationToken).toBuffer()],
new PublicKey(ASSOCIATED_TOKEN_PROGRAM));
if (destinationAccount !== ata.toBase58()) throw new Error("Delivery route SPL transfer does not pay the requested recipient's token account");
}
return "pays the requested recipient's token account";
}
/** Every intent listed in the quote must re-hash from its decoded route and reward to a hash inside the signed set. */
function verifyListedIntent(intent: Intent, signed: Hex[]): Hex {
const { route, reward } = intent.intent;
const hash = intentHashOf(BigInt(route.destination), routeHashOf(route), rewardHashOf(route, reward));
if (intent.intentHash === null || hash.toLowerCase() !== intent.intentHash.toLowerCase()) throw new Error(`Listed ${intent.role} intent does not re-hash to its intentHash`);
if (!signed.includes(hash.toLowerCase() as Hex)) throw new Error(`Listed ${intent.role} intent is not covered by the quote signature`);
return hash;
}
/** The funded intent, decoded from the funding transaction itself, must be signed, funded by the funder, and equal to the decoded intent. */
function verifyFundedEvm(quote: Quote, tx: EvmTransaction, source: Chain, signed: Hex[]): Hex {
const funded = quote.execution.intent;
if (getAddress(tx.to) !== getAddress(source.contracts.portal)) throw new Error('Funding transaction is not addressed to the source Portal');
const decoded = decodeFunctionData({ abi: PORTAL_EVM_ABI, data: tx.data });
if (decoded.functionName !== 'publishAndFund') throw new Error(`Unexpected funding function ${decoded.functionName}`);
const [dest, routeBytes, reward] = decoded.args;
if (Number(dest) !== funded.route.destination) throw new Error('Calldata destination differs from the funded intent');
const routeHash = keccak256(routeBytes);
if (routeHash.toLowerCase() !== routeHashOf(funded.route).toLowerCase()) throw new Error('Calldata route does not hash to the decoded funded route');
const intentHash = intentHashOf(dest, routeHash, keccak256(encodeAbiParameters(EVM_REWARD_TUPLE, [reward])));
if (!signed.includes(intentHash.toLowerCase() as Hex)) throw new Error('Funding calldata encodes an intent that is not covered by the quote signature');
if (getAddress(reward.creator) !== getAddress(config.funder)) throw new Error('Calldata reward creator is not the funder');
if (reward.tokens.length !== 1 || getAddress(reward.tokens[0].token) !== getAddress(config.sourceToken) || reward.tokens[0].amount !== BigInt(config.amount)) {
throw new Error('Calldata reward does not equal the requested source amount');
}
if (BigInt(tx.value) !== reward.nativeAmount) throw new Error('Transaction value differs from reward.nativeAmount');
return intentHash;
}
async function verifyFundedSvm(quote: Quote, tx: SvmTransaction, source: Chain, signed: Hex[]): Promise<Hex> {
const funded = quote.execution.intent;
if (tx.instructions.length !== 1) throw new Error(`Expected one Portal.fund instruction, got ${tx.instructions.length}`);
const [ix] = tx.instructions;
if (!bytesEqual(pubkeyBytes(ix.programId, 'programId'), pubkeyBytes(source.contracts.portal, 'portal'))) throw new Error('Instruction is not addressed to the source Portal program');
const data = Uint8Array.from(Buffer.from(ix.data, 'base64'));
const discriminator = createHash('sha256').update('global:fund').digest().subarray(0, 8);
if (!bytesEqual(data.subarray(0, 8), Uint8Array.from(discriminator))) throw new Error('Instruction is not Portal.fund');
// FundArgs { destination: u64, route_hash: [u8; 32], reward: Reward, allow_partial: bool }, Borsh little-endian.
const view = new DataView(data.buffer, data.byteOffset);
let offset = 8;
const dest = view.getBigUint64(offset, true); offset += 8;
const routeHash = toHex(data.subarray(offset, offset + 32)); offset += 32;
const rewardStart = offset;
const rewardDeadline = view.getBigUint64(offset, true); offset += 8;
const creator = data.subarray(offset, offset + 32); offset += 32;
offset += 32; // prover
const nativeAmount = view.getBigUint64(offset, true); offset += 8;
const tokenCount = view.getUint32(offset, true); offset += 4;
const tokens: { mint: Uint8Array; amount: bigint }[] = [];
for (let i = 0; i < tokenCount; i++) { tokens.push({ mint: data.subarray(offset, offset + 32), amount: view.getBigUint64(offset + 32, true) }); offset += 40; }
const rewardBytes = data.subarray(rewardStart, offset);
const allowPartial = data[offset]; offset += 1;
if (offset !== data.length) throw new Error('Unexpected trailing bytes in Portal.fund arguments');
if (Number(dest) !== funded.route.destination) throw new Error('Instruction destination differs from the funded intent');
if (routeHash.toLowerCase() !== routeHashOf(funded.route).toLowerCase()) throw new Error('Instruction route hash does not match the decoded funded route');
const intentHash = intentHashOf(dest, routeHash, keccak256(rewardBytes));
if (!signed.includes(intentHash.toLowerCase() as Hex)) throw new Error('Funding instruction encodes an intent that is not covered by the quote signature');
if (!bytesEqual(creator, pubkeyBytes(config.funder, 'funder'))) throw new Error('Instruction reward creator is not the funder');
if (tokens.length !== 1 || !bytesEqual(tokens[0].mint, pubkeyBytes(config.sourceToken, 'source token')) || tokens[0].amount !== BigInt(config.amount)) {
throw new Error('Instruction reward does not equal the requested source amount');
}
if (Number(rewardDeadline) !== funded.reward.deadline || nativeAmount !== BigInt(funded.reward.nativeAmount)) throw new Error('Instruction reward differs from the decoded intent reward');
if (allowPartial !== 0) throw new Error('Instruction allows partial funding');
const { PublicKey } = await import('@solana/web3.js');
const [vault] = PublicKey.findProgramAddressSync([Buffer.from('vault'), Buffer.from(hexBytes(intentHash))], new PublicKey(ix.programId));
if (ix.accounts[2]?.pubkey !== vault.toBase58()) throw new Error('Instruction vault account is not the PDA for the signed intent');
if (ix.accounts[1]?.pubkey !== config.funder || !ix.accounts[1].isSigner) throw new Error('Instruction funder account is not the funder');
return intentHash;
}
/**
* Verify the whole quote against the request:
* 1. the funded intent, decoded from the funding transaction, is signed and spends exactly the requested amount from the funder;
* 2. every listed intent re-hashes to a signed hash;
* 3. the intents that execute on the destination chain pay the requested recipient in the requested token.
* On stitched routes the final leg can be an opaque bridge call inside a candidate intent; then the recipient cannot be
* proven from route data and the result says so.
*/
async function verifyFunding(quote: Quote, source: Chain): Promise<{ summary: string; recipientVerified: boolean }> {
const signed = signedIntentHashes(quote);
const tx = quote.execution.transaction;
const fundedHash = tx.type === 'evm' ? verifyFundedEvm(quote, tx, source, signed) : await verifyFundedSvm(quote, tx, source, signed);
const listed = quote.steps.flatMap((step) => step.intents);
for (const intent of listed) verifyListedIntent(intent, signed);
const minAmountOut = BigInt(quote.destination.minAmountOut);
const deliveries: RouteJson[] = [];
if (quote.execution.intent.route.destination === config.destinationChainId) deliveries.push(quote.execution.intent.route);
for (const intent of listed) {
if (intent.intent.route.destination === config.destinationChainId && intent.intentHash?.toLowerCase() !== fundedHash.toLowerCase()) deliveries.push(intent.intent.route);
}
if (deliveries.length === 0) {
return { summary: `funded intent and ${listed.length} listed intent(s) signed; no listed intent executes on chain ${config.destinationChainId}, so the recipient is not provable from route data`, recipientVerified: false };
}
const results = new Set<string>();
let unprovable = 0;
for (const route of deliveries) {
const result = await verifyDeliveryRoute(route, minAmountOut);
if (result === null) unprovable += 1; else results.add(result);
}
if (results.size === 0) {
return { summary: `funded intent and ${listed.length} listed intent(s) signed; the ${unprovable} route(s) on chain ${config.destinationChainId} are swap legs, so the recipient is not provable from route data`, recipientVerified: false };
}
return { summary: `funded intent and ${listed.length} listed intent(s) signed; ${results.size ? deliveries.length - unprovable : 0} delivery route(s) ${[...results].join('; ')}`, recipientVerified: true };
}
function trackingHashes(quote: Quote): Hex[] {
const destination = quote.steps.flatMap((step) => step.intents)
.filter((intent) => intent.role === 'stitched-destination' && intent.intentHash !== null)
.map((intent) => intent.intentHash as Hex);
if (destination.length > 0) return destination;
if (quote.intentHash === null) throw new Error('No primary intent hash to track');
return [quote.intentHash];
}
async function waitForIntent(intentHash: Hex, timeoutMs = 10 * 60_000): Promise<string> {
const terminal = new Set(['filled', 'settled', 'refunded', 'expired', 'failed']);
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const page = await api<StatusPage>(`/v1/intents/status?intentHash=${intentHash}`);
const status = page.results[0]?.status ?? 'unknown';
console.log(` ${intentHash} -> ${status}`);
if (terminal.has(status)) return status;
await new Promise((resolve) => setTimeout(resolve, 5_000));
}
return 'timeout';
}
// --- funding --------------------------------------------------------------------------------------------------------
async function fundEvm(quote: Quote, tx: EvmTransaction): Promise<void> {
const account = privateKeyToAccount(env('ECO_PRIVATE_KEY') as Hex);
if (getAddress(account.address) !== getAddress(config.funder)) throw new Error('ECO_PRIVATE_KEY must control ECO_FUNDER');
const chain = defineChain({
id: config.sourceChainId, name: `chain-${config.sourceChainId}`,
nativeCurrency: { name: 'Native', symbol: 'NATIVE', decimals: 18 },
rpcUrls: { default: { http: [env('ECO_RPC_URL')] } },
});
const publicClient = createPublicClient({ chain, transport: http() });
const wallet = createWalletClient({ account, chain, transport: http() });
if ((await publicClient.getChainId()) !== config.sourceChainId) throw new Error('ECO_RPC_URL serves a different chain');
const assertFresh = () => { if (quote.expiresAt <= Math.floor(Date.now() / 1000)) throw new Error('Quote expired; request a new one'); };
for (const token of quote.execution.intent.reward.tokens) {
const needed = BigInt(token.amount);
const allowance = await publicClient.readContract({ address: getAddress(token.token), abi: erc20Abi, functionName: 'allowance', args: [account.address, tx.to] });
if (allowance < needed) {
assertFresh();
const hash = await wallet.writeContract({ address: getAddress(token.token), abi: erc20Abi, functionName: 'approve', args: [tx.to, needed] });
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== 'success') throw new Error(`Approval reverted: ${hash}`);
console.log({ approval: hash });
}
}
assertFresh();
const request = { account, to: tx.to, data: tx.data, value: BigInt(tx.value) };
const gas = await publicClient.estimateGas(request);
assertFresh();
const hash = await wallet.sendTransaction({ ...request, gas });
console.log({ funding: hash });
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== 'success') throw new Error(`Funding reverted: ${hash}`);
}
async function fundSvm(quote: Quote, tx: SvmTransaction): Promise<void> {
const { Connection, Keypair, PublicKey, Transaction, TransactionInstruction, sendAndConfirmTransaction } = await import('@solana/web3.js');
const secret = env('ECO_PRIVATE_KEY');
const keypair = Keypair.fromSecretKey(secret.trim().startsWith('[') ? Uint8Array.from(JSON.parse(secret) as number[]) : base58Decode(secret));
if (keypair.publicKey.toBase58() !== config.funder) throw new Error('ECO_PRIVATE_KEY must control ECO_FUNDER');
if (tx.feePayer !== config.funder) throw new Error('feePayer is not the funder; this script signs with the funder only');
const connection = new Connection(env('ECO_RPC_URL'), 'confirmed');
if (quote.expiresAt <= Math.floor(Date.now() / 1000)) throw new Error('Quote expired; request a new one');
const transaction = new Transaction();
for (const ix of tx.instructions) {
transaction.add(new TransactionInstruction({
programId: new PublicKey(ix.programId),
keys: ix.accounts.map((a) => ({ pubkey: new PublicKey(a.pubkey), isSigner: a.isSigner, isWritable: a.isWritable })),
data: Buffer.from(ix.data, 'base64'),
}));
}
transaction.feePayer = keypair.publicKey;
const signature = await sendAndConfirmTransaction(connection, transaction, [keypair], { commitment: 'confirmed' });
console.log({ funding: signature });
}
// --- main -----------------------------------------------------------------------------------------------------------
async function main(): Promise<void> {
config = loadConfig();
const { chains } = await api<{ chains: Chain[] }>('/v1/chains');
const source = chains.find((chain) => chain.chainId === config.sourceChainId && chain.status === 'live');
const destination = chains.find((chain) => chain.chainId === config.destinationChainId && chain.status === 'live');
if (!source || !destination) throw new Error('Source or destination chain is not live');
if (source.type === 'tvm' || destination.type === 'tvm') throw new Error('Tron is not handled by this script');
chainsById = new Map(chains.map((chain) => [chain.chainId, chain]));
const quote = await api<Quote>('/v1/quotes', {
type: 'exact-in',
source: { chainId: config.sourceChainId, token: config.sourceToken, amount: config.amount, funder: config.funder },
destination: { chainId: config.destinationChainId, token: config.destinationToken, recipient: config.recipient },
slippage: config.slippage,
dappId: config.dappId,
});
await verifySignature(quote, source.quoteSigner);
assertQuoteMatchesRequest(quote, source, destination);
const tx = quote.execution.transaction;
const funding = await verifyFunding(quote, source);
console.log({
quoteId: quote.id,
sourceVm: source.type,
amountOut: quote.destination.amountOut,
minAmountOut: quote.destination.minAmountOut,
fees: quote.fees.map((fee) => `${fee.type}: ${fee.amount} ${fee.token.symbol ?? ''}`.trim()),
expiresAt: new Date(quote.expiresAt * 1000).toISOString(),
funding: tx.type === 'evm'
? { to: tx.to, value: tx.value, dataBytes: (tx.data.length - 2) / 2 }
: { programId: tx.instructions[0].programId, feePayer: tx.feePayer, accounts: tx.instructions[0].accounts.length },
trackingHashes: trackingHashes(quote),
signature: 'verified against quoteSigner',
verification: funding.summary,
recipientVerified: funding.recipientVerified,
});
if (!config.execute) {
console.log('Dry run complete. Set ECO_EXECUTE=yes with ECO_PRIVATE_KEY and ECO_RPC_URL to fund this quote.');
return;
}
if (!funding.recipientVerified && process.env.ECO_ALLOW_UNVERIFIED_RECIPIENT !== 'yes') {
throw new Error('Recipient could not be verified from route data (stitched route). Set ECO_ALLOW_UNVERIFIED_RECIPIENT=yes to fund anyway.');
}
if (tx.type === 'evm') await fundEvm(quote, tx); else await fundSvm(quote, tx);
for (const intentHash of trackingHashes(quote)) {
const status = await waitForIntent(intentHash);
if (status !== 'filled' && status !== 'settled') throw new Error(`Intent ${intentHash} ended in ${status}`);
}
console.log('Delivered.');
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
package.json
{
"name": "eco-api-v1-example",
"private": true,
"type": "module",
"engines": {
"node": ">=22.18"
},
"scripts": {
"transfer": "node transfer.ts"
},
"dependencies": {
"viem": "^2.21.0",
"@solana/web3.js": "^1.95.0"
}
}
Machine-readable index
/llms.txt: page index with one-line descriptions./llms-full.txt: the full documentation in one file./api-v1.openapi.json: the OpenAPI document this reference is generated from.- Every page is available as Markdown by appending
.mdto its URL.
