---
name: eco-api-v1
description: "Integrate the Eco API (api.eco.com/v1): quote, fund, and track stablecoin transfers across chains, verify quote signatures, submit gasless funding, and create Circle Gateway deposit addresses. Triggers on: 'Eco API', 'api.eco.com', 'POST /v1/quotes', 'Eco quote', 'intentHash', 'quoteSigner', 'EcoQuoteV1', 'x-api-key for Eco', 'Permit3 / Permit2 / ERC-3009 submit', 'Circle Gateway deposit address', 'Eco intent status', 'move USDC from Base to Optimism with Eco'. Do NOT trigger for the legacy quotes.eco.com v3 endpoints, solver registration, Eco smart-contract development, or generic bridge questions that do not name Eco."
compatibility: Any HTTP client. The reference script needs Node 22.18 or later and viem.
metadata:
  author: Eco
  version: "1.0.0"
  docs: https://docs.eco.com/api-reference/introduction
---

# Eco API v1

Base URL `https://api.eco.com`. Full request and response schemas: [OpenAPI document](https://docs.eco.com/api-v1.openapi.json).

## Access

| Rule | Detail |
|---|---|
| Header | `x-api-key: <key>` on every request. Keys start with `eco_live_` (production) or `eco_test_` (staging). |
| Required | All endpoints except `/v1/circle-gateway/*`. |
| No key on a required endpoint | `403` with body `{"Message": "User is not authorized ..."}` (API Gateway, no `code`). |
| Unknown, revoked, or non-v1 key | `401 invalid-api-key` on every endpoint, including `/v1/circle-gateway/*`. A key issued for the older `/quotes` service is not a v1 key. |
| v1 key not mapped to a partner | Quotes and reads succeed; submit endpoints return `401 invalid-api-key` with `detail: "The request could not be attributed to an authorized API key."`. Escalate to Eco; nothing client-side fixes this. |
| Key handling | Server-side only. Never place the key in a browser, mobile app, URL, or log. |

Do not fall back to a placeholder key. A wrong key fails where no key would succeed.

## Integration flow

1. `GET /v1/chains` and `GET /v1/tokens` once; cache them. Use `quoteSigner` from the chain entry for step 3.
2. `POST /v1/quotes`.
3. Verify `signature` (below). Reject the quote if it does not recover to `quoteSigner`.
4. Fund: send `execution.transaction` from `source.funder`, or submit a signed authorization to a `/v1/intents/submit/*` endpoint.
5. `GET /v1/intents/status?intentHash=<hash>` until `status` is `filled` or a terminal failure.

## Request conventions

- JSON body, `Content-Type: application/json`. Unknown or misplaced keys are rejected with `400 invalid-request`; the response `errors[]` names each field.
- Amounts are base-unit decimal strings. Use `bigint`, never `number`.
- `slippage` is a decimal fraction in `[0.0001, 1]`. `0.005` = 0.5%. Values above `1` return `400 slippage-out-of-bounds`.
- Timestamps are Unix seconds.
- All discriminators are named `type`.
- IDs are prefixed (`quote:`, `gasless:`, `intent:`). Status query parameters `quoteId` and `jobId` take the bare UUID. Submit bodies take the full prefixed `target.quoteId`.
- Page size `limit` is 1 to 50 (default 20).

## Quote

Request (`type` fixes which amount is given):

```json
{
  "type": "exact-in",
  "source": { "chainId": 8453, "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "amount": "1000000", "funder": "<funder>" },
  "destination": { "chainId": 10, "token": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", "recipient": "<recipient>" },
  "slippage": 0.005,
  "dappId": "<integration-id>"
}
```

| `type` | Provide | Response |
|---|---|---|
| `exact-in` | `source.amount` | `destination.amountOut` expected, `destination.minAmountOut` guaranteed |
| `exact-out` | `destination.amount` | `source.amount` is the required input |
| `custom` | `source.amount` and `destination.calls[]` (`target`, `data`, `value`) | Calls execute atomically or funds refund. Any minimum-output check belongs inside the calls. |

Required: `type`, `source.{chainId,token,funder}`, `destination.{chainId,token,recipient}`, `dappId`. Optional: `refundRecipient` (defaults to `source.funder`), `options.allQuotes` (returns every candidate in `quotes[]`, each signed), `options.deadlineSec`.

Response fields that drive the integration:

| Field | Use |
|---|---|
| `id` | `quote:<uuid>` |
| `execution.transaction` | EVM: `{ type: "evm", chainId, to, data, value }`. Solana: `{ type: "svm", chainId, feePayer, instructions }`. Send as-is from `source.funder`. |
| `execution.vault` | Counterparty for gasless funding (Permit2 `spender`, Permit3 `account`, ERC-3009 `to`). |
| `intentHash` | Primary intent; the status handle. |
| `steps[].intents[]` | Every intent in the route with `role` (`local`, `stitched-destination`, `bucket-candidate`) and the decoded intent. On a multi-hop route, track the `stitched-destination` hash for delivery. |
| `fees[]` | `{ type: gas \| proving \| protocol \| gateway, amount, token, estimate }` |
| `expiresAt` | Fund before this; afterwards request a new quote. |
| `signature` | Verify before funding. |

Quote-time failures: `422 chain-not-supported` for an unsupported chain; `502 solver-error` when no solver quoted the request (unlisted token, no liquidity, unfillable amount). A `502` on a quote is not an outage. Check the pair against `/v1/tokens`, then retry once after a short delay.

## Quote signature verification

EIP-712. Domain `{ name: "EcoQuoteV1", version: "1", chainId: source.chainId }`. Type `Quote(string id, bytes32[] intentHashes, uint64 expiresAt)`.

`intentHashes` = `intentHash` plus every `steps[].intents[].intentHash`, lowercased, de-duplicated, sorted ascending as strings.

```typescript
import { hashTypedData, recoverAddress } from 'viem';
const hashes = [...new Set([quote.intentHash, ...quote.steps.flatMap(s => s.intents.map(i => i.intentHash))]
  .filter(Boolean).map(h => h.toLowerCase()))].sort();
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: hashes, expiresAt: BigInt(quote.expiresAt) },
});
const signer = await recoverAddress({ hash: digest, signature: quote.signature });
// signer must equal chains[source.chainId].quoteSigner, case-insensitive
```

A match proves origin, the exact intent set, and expiry. It does not cover `execution.transaction` bytes and does not prove best price. Read `quoteSigner` from `/v1/chains` at runtime; do not hardcode it.

## Gasless funding

| Endpoint | Body | Binding rule |
|---|---|---|
| `POST /v1/intents/submit/permit3` | `target.quoteId`, `permit3` (`owner`, `permitContract`, `salt`, `deadline`, `timestamp`, `merkleRoot`, `permits[]`), `signature` | each `permits[].account` = `execution.vault` |
| `POST /v1/intents/submit/permit2` | `chainId`, `target.quoteId`, `permit2` (`details.{token,amount,expiration,nonce}`, `spender`, `sigDeadline` as string), `signature` | `spender` = `execution.vault`; AllowanceTransfer `PermitSingle`, token pre-approved to Permit2 |
| `POST /v1/intents/submit/erc-3009` | `chainId`, `target.quoteId`, `authorization` (`from`,`to`,`value`,`validAfter`,`validBefore`,`nonce`), `signature` | `to` = `execution.vault`; `from` = `source.funder` |

Responses: first submission `202` with a job `{ id: "gasless:<uuid>", status, signatureHash, subStatuses[] }`; the same signature resent to the same target returns the existing job with `200`. Job `status` is one of `pending`, `processing`, `published`, `partial`, `failed`, `unknown`. `published` hands off to intent tracking; it is not delivery.

Rules: keep the exact signed payload for retries; a new signature is a new operation. `409 signature-already-bound` means the signature was used for another target. `410 quote-expired` means request a new quote. `401 invalid-signature` means the signature does not recover to the expected signer.

## Status

`GET /v1/intents/status` with exactly one of: `intentHash`, `sourceTxHash`, `destinationTxHash`, `quoteId` (bare UUID), `jobId` (bare UUID), or `wallet` (paged; `status` filter allowed only with `wallet`). No filter returns `400`.

Response `{ results: [...], nextCursor }`. Exact lookups always return `200`; an unknown ID returns one result with `status: "unknown"`. Result `type` is `intent`, `quote`, or `gasless`.

Intent statuses: `pending`, `filled`, `settled`, `refunded`, `refundable`, `expired`, `failed`, `unknown`. Only `filled` and `settled` mean the recipient has funds. `refundable` and `expired` do not mean funds have returned. A consumed quote reports `submitted` with `intentHashes[]`, per-step `steps[]`, and `sourceTx` / `destinationTx` when known.

Poll with a bounded budget. Treat any unrecognized future status as not-final.

## Circle Gateway deposits

No API key required. Responses use a `{ data: ... }` envelope and the deposit-address service's error format (`{ statusCode, createdBy, validationErrors }` or `{ statusCode, createdBy, details }`).

| Step | Call |
|---|---|
| Create | `POST /v1/circle-gateway/deposit-addresses` `{ sourceChainId, amount, recipient, depositor, refundRecipient? }` → `201 { data: { vaultAddress, amount, deadline } }`. Same pending request returns the same address. |
| Fund | Transfer at least `amount` to `vaultAddress` before `deadline`, or submit gaslessly to `POST /v1/circle-gateway/deposit-addresses/submit/erc-3009` (`authorization.to` = `target.depositAddress`) or `/erc-2612` (`permit.spender` = `target.depositAddress`). |
| Check deposit | `GET /v1/circle-gateway/deposit-addresses/{vaultAddress}?sourceChainId=` → `data.state` (`PENDING`, `PUBLISHED`, ...), `data.intentHash`. Unknown address → `404`. |
| Check job | `GET /v1/circle-gateway/deposit-addresses/status?jobId=` or `?address=`. Always `200`; unknown → `status: "unknown"`. |

`PUBLISHED` means the deposit intent was published, not that the Gateway balance is credited. Track `data.intentHash` (or `data.stitched.destinationIntentHash` when present) with `/v1/intents/status`.

## Error handling

Core errors are `application/problem+json`: `{ type, title, status, code, detail?, errors?[], requestId }`. Branch on `status` and `code`. Log `requestId`.

| Status | Action |
|---|---|
| `400` | Fix the request per `errors[]`. Do not retry unchanged. |
| `401`, `403` | Fix the key or signature. Do not retry unchanged. |
| `404` | Wrong path. Status lookups never return `404`. |
| `409`, `410` | Inspect the original job or request a new quote. Do not resign blindly. |
| `422` | Change the request; every solver rejected it for the stated reason. |
| `429` | Back off; honor `Retry-After`. |
| `500`, `502`, `503` | Reads and quotes: bounded retry with jitter. Submits: resend the identical signed payload to the same endpoint; the API returns the existing job with `200` instead of creating a second one. |

## Do not

- Do not send retired field names from earlier previews (`swapType`, root `funder`, `relatedIntents`, `visibility`, `guarantee`, `encodedRoute`). They fail with `400`.
- Do not fund a quote whose signature did not verify or whose `expiresAt` has passed.
- Do not rebuild the intent or reward from price fields; send `execution.transaction` as returned.
- Do not treat `202`, `published`, or Gateway `PUBLISHED` as delivery.
- Do not call `quotes.eco.com`; it is a separate legacy service with a different contract.

## End-to-end example

`scripts/transfer.ts` implements the full flow for EVM and Solana source chains: load chains, request a quote, verify the signature, verify the funded intent (decoded from the EVM `publishAndFund` calldata or the Solana `Portal.fund` instruction) and every listed intent against the signed set, confirm the funded reward is the requested amount from the funder, confirm the delivery route pays the requested recipient (ERC-20 `transfer` on EVM, SPL transfer into the recipient's token account on Solana), then fund and poll status until `filled`. When this skill was installed from the docs domain, the script is not included; copy it from https://docs.eco.com/api-reference/agent-integration.md.

```bash
cd scripts && npm install
ECO_API_KEY=eco_live_... ECO_FUNDER=0x... ECO_RECIPIENT=0x... node transfer.ts
```

| Variable | Default | Purpose |
|---|---|---|
| `ECO_API_KEY`, `ECO_FUNDER`, `ECO_RECIPIENT` | required | Key, source wallet (EVM `0x…` or Solana base58), destination wallet |
| `ECO_SOURCE_CHAIN`, `ECO_SOURCE_TOKEN` | `8453`, Base USDC | Source leg (`1399811149` and a mint address for Solana) |
| `ECO_DESTINATION_CHAIN`, `ECO_DESTINATION_TOKEN` | `10`, OP Mainnet USDC | Destination leg |
| `ECO_AMOUNT`, `ECO_SLIPPAGE`, `ECO_DAPP_ID` | `1000000`, `0.005`, `eco-api-v1-example` | Amount in base units, slippage fraction, attribution |
| `ECO_EXECUTE=yes`, `ECO_PRIVATE_KEY`, `ECO_RPC_URL` | unset | Broadcast on mainnet from the funder's key over the given RPC. EVM: `0x` hex key. Solana: base58 secret key or the JSON byte array from a keypair file. |
| `ECO_ALLOW_UNVERIFIED_RECIPIENT=yes` | unset | Permit broadcasting a quote whose recipient is not provable from route data (see below). |

Without `ECO_EXECUTE=yes` the script stops after printing the verified quote and its funding transaction; no funds move. It exits non-zero if the signature does not recover to `quoteSigner`, if any listed intent does not re-hash to a signed hash, if the funded intent does not spend the requested amount from the funder, if a delivery route pays anyone but the requested recipient, or if the quote expires before broadcast.

On stitched routes (a source-chain swap followed by a bridge) and same-chain swaps, the final transfer happens inside an aggregator or bridge call whose ABI the script does not decode, so the recipient is not provable from route data. The output reports `recipientVerified: false`, and broadcasting requires `ECO_ALLOW_UNVERIFIED_RECIPIENT=yes`. Tron source chains are not handled.

## References

- Reference: https://docs.eco.com/api-reference/introduction
- OpenAPI: https://docs.eco.com/api-v1.openapi.json
- Docs MCP server (search and read pages): `https://docs.eco.com/mcp`
