The intent object
An intent describes an outcome: what leaves, what arrives, and where. It says nothing about route, bridge, or venue — that's the solvers' problem. This page is the full schema of what you forward to us.
Addresses are ERC-7930 encoded
This is the single thing most integrations get wrong, so it comes first.
RecoilPay does not take plain 0x addresses. Every address in an intent is an ERC-7930 interop address — the chain id and the address packed into one hex string:
0x [version:2] [chainType:2] [chainRefLen:1] [chainRef:n] [addrLen:1] [address:20]
0x0001 0x0000 e.g. 0x03 e.g. 0x014a34 0x14
version— always0x0001chainType—0x0000for EIP-155 (EVM) chainschainRef— the chain id as minimal big-endian bytes (no leading zero padding)address— the 20 raw address bytes
Worked example — USDC on Base Sepolia (84532 = 0x014a34), token 0x73c83DAcc74bB8a704717AC09703b959E74b9705:
0001 | 0000 | 03 | 014a34 | 14 | 73c83dacc74bb8a704717ac09703b959e74b9705
0x0001000003014a341473c83dacc74bb8a704717ac09703b959e74b9705
The aggregator won't tell you your address was malformed. You'll get quotes: [] and a metadata block that looks like nobody wanted your business. If you're getting no quotes, verify your encoder first — round-trip an address through encode/decode and assert you get back what you put in.
A reference encoder
Copy this. It's the encoder the RecoilPay app uses, and it reproduces the aggregator's own published examples exactly.
/** Minimal big-endian bytes for a chain id. */
// BigInt, not `&` and `>>`: JavaScript's bitwise operators are 32-bit, so
// they corrupt any chain id at or above 2^31.
function chainIdToBytes(chainId: number): number[] {
const bytes: number[] = [];
let v = BigInt(chainId);
while (v > 0n) { bytes.unshift(Number(v & 0xffn)); v >>= 8n; }
return bytes.length ? bytes : [0];
}
const hex = (bytes: number[]) => bytes.map(b => b.toString(16).padStart(2, '0')).join('');
/** Encode (chainId, EVM address) → ERC-7930 interop address. */
export function interopAddress(chainId: number, address: string): `0x${string}` {
const chainRef = chainIdToBytes(chainId);
const addr = address.replace(/^0x/, '').match(/.{2}/g)!.map(b => parseInt(b, 16));
return `0x0001` + // version
`0000` + // chainType: EIP-155
chainRef.length.toString(16).padStart(2, '0') + // chainRefLen
hex(chainRef) + // chainRef
addr.length.toString(16).padStart(2, '0') + // addrLen
hex(addr) as `0x${string}`;
}
/** Decode back — use this in a test to prove your encoder round-trips. */
export function fromInteropAddress(interop: string): { chainId: number; address: `0x${string}` } {
const h = interop.replace(/^0x/, '');
let o = 8; // skip version + chainType
const crLen = parseInt(h.slice(o, o + 2), 16); o += 2;
const chainId = parseInt(h.slice(o, o + crLen * 2), 16); o += crLen * 2;
const aLen = parseInt(h.slice(o, o + 2), 16); o += 2;
return { chainId, address: `0x${h.slice(o, o + aLen * 2)}` };
}
Pre-computed values for the five live testnets, if you want fixtures to test against:
| Chain | Chain id | chainRef prefix |
|---|---|---|
| Optimism Sepolia | 11155420 | 0x0001000003aa37dc14… |
| Base Sepolia | 84532 | 0x0001000003014a3414… |
| Polygon Amoy | 80002 | 0x000100000301388214… |
| Ethereum Sepolia | 11155111 | 0x0001000003aa36a714… |
| BNB Chain Testnet | 97 | 0x00010000016114… |
The chain reference is minimal big-endian, so its length depends on the chain id: 3 bytes for the Sepolias and Amoy, but 1 byte for BNB Chain (56 and 97) and 2 for opBNB Testnet (5611). An encoder that pads every chain id to the same width works on the first four chains and silently gets zero quotes on BNB Chain. Include a short chain id in your tests — for example, USDC 0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d on BNB Chain mainnet encodes to exactly:
0x000100000138148ac76a51cc950d9822d68b83fe1ad97b32cd580d
Solana addresses
Solana devnet works on either side of a swap: an intent can deliver USDC there, or pay from it. So an outputs[] receiver and asset can be Solana addresses, or an inputs[] user and asset can be, but not both sides of one intent. Solana uses its own ERC-7930 profile (CAIP-350):
chainType—0x0002chainRef— the base58 decoding of the CAIP-2 reference (the first 32 characters of the genesis hash): 24 bytes for devnet,EtWTRABZaYq6iMfeYKouRu166VU2xqa1address— the 32-byte public key, base58-decoded
Devnet USDC, mint 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU, encodes to:
0001 | 0002 | 18 | 02a06772aa71dc7b1a1648ee7c9d5ca45459c022b2f44a42 | 20 | 3b442cb3912157f13a933d0134282d032b5ffecd01a2dbf1b7790608df002ea7
RecoilPay's API and SDK name Solana devnet by a virtual chain id, 9000000002; the encoding carries the genesis reference, not that number. You don't need to write this encoder: interopAddress(9000000002, '<base58>') from @recoilpay/intent-core does it, and decodeInteropAddress reads either family. These vectors, and the EVM ones, are in the repository's test-vectors/erc7930.json, which the SDK and the aggregator are both tested against.
The aggregator refuses a 32-byte address under chainType 0x0000, and eip155:9000000002. Encode each side with the addresses of its own chain: Solana addresses on the Solana side, EVM addresses on the EVM side.
Amounts
Base units, as a decimal string. Not a number, not hex, not a decimal fraction.
- Decimals are per token, per chain. On every live testnet USDC and USDT have 6 →
1 USDCis"1000000". On BNB Chain mainnet they have 18 →1 USDCis"1000000000000000000". Hardcoding 6 is off by a factor of 10¹² there. - Must be greater than zero;
"0"is rejected at validation
Use a string because amounts exceed IEEE-754 safe integers. JSON.parse-ing an amount into a JS number is a real bug waiting for a large-value transfer — keep it a string or a bigint end to end.
Always read decimals from GET /api/v1/chains or from the solver's supportedAssets rather than assuming 6.
The quote request
{
user: InteropAddress, // who is spending — the signer
intent: {
intentType: 'oif-swap',
inputs: Input[],
outputs: Output[],
swapType?: 'exact-input' | 'exact-output',
preference?: 'price' | 'speed' | 'inputPriority' | 'trustMinimization',
partialFill?: boolean,
minValidUntil?: number, // ⚠️ omit — see below
failureHandling?: ('refund-automatic' | 'refund-claim' | 'needs-new-signature')[],
originSubmission?: { mode: 'user' | 'protocol', schemes?: Scheme[] },
metadata?: unknown,
},
supportedTypes: string[], // ['oif-escrow-v0']
solverOptions?: SolverOptions,
metadata?: unknown,
}
inputs[] — what leaves
{ user: InteropAddress, asset: InteropAddress, amount?: string, lock?: AssetLockReference }
user is the funding address on the source chain; asset is the input token on that same chain. At least one input is required.
For a swap from Solana devnet, both are Solana addresses (see Solana addresses): user is the user's Solana wallet, which pays, and asset is the mint. The quote that comes back is then an oif-generic-v0 order that the Solana wallet opens in RecoilPay's escrow program, not EIP-712 typed data to sign: see Orders from Solana.
outputs[] — what arrives
{ receiver: InteropAddress, asset: InteropAddress, amount?: string, calldata?: string }
receiver is the destination address on the destination chain; asset is the token there. At least one output is required.
The receiver is what makes this general: set it equal to user and you have a swap; set it to someone else and you have a cross-chain send or payment. There's no separate endpoint or intent type for paying a third party.
For Solana devnet, both are Solana addresses (see Solana addresses): the receiver is the user's Solana wallet, never their EVM address, so a Solana "swap" always has a receiver different from user. The quote then names RecoilPay's fill program on Solana as the output's settler and oracle, and the escrow releases only on a proof of delivery carried over Hyperlane. Nothing else about the request changes.
swapType
exact-input— fixinputs[].amount, leaveoutputs[].amountunset, solvers compete on delivery. Use this by default.exact-output— fixoutputs[].amount, leaveinputs[].amountunset, solvers compete on cost. Right for invoices and checkouts where the amount due is fixed.
preference
A hint about how to rank: price, speed, inputPriority, trustMinimization. You get all the quotes regardless, so you can always rank them yourself from preview and eta.
supportedTypes
Which settlement types your client can sign. Send ["oif-escrow-v0"] — see Signing.
originSubmission
How the input gets custodied. { mode: "user", schemes: ["permit2"] } means the user signs and Permit2 moves the funds.
permit2 explicitlyIf you offer eip3009 here, a solver may prefer it — and the EIP-3009 open path currently reverts on-chain with SignatureAndInputsNotEqual on this deployment. Listing ["permit2"] alone is what keeps you on the working route.
minValidUntil — do not send it
Intended as "keep this valid for at least N seconds". This deployment copies the value verbatim into the authorization's absolute validBefore timestamp, so 600 becomes a Unix time in 1970 and the order is born expired.
Omit the field. The solver then sets a sensible absolute deadline (~5 minutes) and returns it as validUntil on the quote. Use that value for your own expiry countdown.
solverOptions
{
timeout?: number, // ms, whole request
solverTimeout?: number, // ms, per solver
minQuotes?: number, // respond as soon as this many arrive (≥ 1)
solverSelection?: 'all' | 'sampled' | 'priority',
includeSolvers?: string[],
excludeSolvers?: string[],
sampleSize?: number, // with 'sampled'
priorityThreshold?: number, // 0–100, with 'priority'
}
{ timeout: 8000, solverTimeout: 5000, minQuotes: 1 } is a good interactive default: ~5s for any one solver, 8s ceiling overall, return as soon as one real quote lands. timeout must be ≥ solverTimeout or the request is rejected.
Trade-off worth being deliberate about: minQuotes: 1 optimises for latency and returns the first quote that arrives; a higher value waits for genuine competition and gets a better price. Interactive UI wants the former, batch settlement wants the latter.
metadata
Free-form JSON, passed through to adapters. Convenient for your own correlation id.
Don't build partner attribution or revenue-sharing on metadata — nothing in the pipeline reads it for that purpose today. Attribution will ride on partner API keys. Talk to us if you need it.
A complete, valid body
Swap 25 USDC on Optimism Sepolia for USDC on Polygon Amoy:
{
"user": "0x0001000003aa37dc14632bf0d0d6468908378c3ccfac4e788b115e0e55",
"intent": {
"intentType": "oif-swap",
"inputs": [{
"user": "0x0001000003aa37dc14632bf0d0d6468908378c3ccfac4e788b115e0e55",
"asset": "0x0001000003aa37dc14191688b2ff5be8f0a5bcab3e819c900a810faaf6",
"amount": "25000000"
}],
"outputs": [{
"receiver": "0x000100000301388214632bf0d0d6468908378c3ccfac4e788b115e0e55",
"asset": "0x0001000003013882148c1963ba445dd562da0b6c6fbca070921b3fa8e6"
}],
"swapType": "exact-input",
"originSubmission": { "mode": "user", "schemes": ["permit2"] }
},
"supportedTypes": ["oif-escrow-v0"],
"solverOptions": { "timeout": 8000, "solverTimeout": 5000, "minQuotes": 1 }
}
Validation
Rejected with 400 before any solver is contacted if: inputs or outputs are empty, any interop address is malformed, any stated amount is zero or negative, supportedTypes is empty, or solverOptions are inconsistent. Unknown top-level fields are also rejected — the schema is strict, so a typo like solverOption is an error rather than a silently ignored field.
Note that a well-formed intent for an unsupported route is not a validation error. It returns 200 with quotes: [].