commitFirmQuote from your wallet, and confirms settlement with GET /v1/executions. It also defines the signRequest helper used in the other code samples.
The script sends real on-chain transactions from your wallet. Run it against the Sandbox (Kaia Kairos testnet) with test tokens.
What it does
1
Discover corridors
GET /v1/corridors — finds the corridor for SWAP_PAIR and resolves corridor_id, from_token and to_token.2
Check rates
GET /v1/rates — prints the current mid-market rate for every corridor.3
Indicative quote
POST /v1/quote — prints the output amount and fee breakdown.4
Firm quote
POST /v1/firm-quote — returns the signed quote and execution calldata.5
Approve
Checks the input token’s allowance for the FxEngine contract and approves it if needed.
6
Execute
Sends the transaction to
execution.contract with execution.calldata.7
Verify
Polls
GET /v1/executions?quote=… until the swap is indexed as SETTLED.Approval is one-time per token. The script approves after receiving the firm quote to stay self-contained; in production, approve the FxEngine contract ahead of time so a firm quote never waits on an approval (firm quotes expire after 60 seconds).
Requirements
- Node.js 18 or later and ethers v6:
npm install ethers@6 - Sandbox API credentials (API key and secret)
- A wallet registered to your credential with swap permission, on a corridor where
accessibleistrue - Enough of the input token in that wallet, and KAIA for gas
Configuration
Set these environment variables:| Variable | Required | Description |
|---|---|---|
RATIO_API_KEY / RATIO_API_SECRET | Yes | Your API credentials |
PRIVATE_KEY | Yes | Private key of your registered wallet |
RATIO_API_BASE | No | Defaults to Sandbox (https://sbx.ratiofx.com/api/router). For Production use https://app.ratiofx.com/api/router |
RPC_URL | No | Kaia RPC for the same chain as the API. Defaults to Kairos (https://public-en-kairos.node.kaia.io); Production uses Kaia mainnet (e.g. https://public-en.node.kaia.io) |
SWAP_PAIR | No | Pair as shown in GET /v1/corridors. Default USDT-JPYC |
FROM_TOKEN / TO_TOKEN | No | Token addresses. If either is empty, both are resolved as quote token → base token (e.g. JPYC → USDT) |
SWAP_AMOUNT | No | Input amount for the quotes. Default 2000 |
ratio-swap.mjs and run:
RATIO_API_KEY=rk_... RATIO_API_SECRET=... PRIVATE_KEY=0x... node ratio-swap.mjs
Script
ratio-swap.mjs
import crypto from "crypto";
let ethers;
async function loadEthers() {
if (!ethers) {
try {
ethers = (await import("ethers")).ethers;
} catch {
throw new Error("ethers is required. Run: npm install ethers");
}
}
return ethers;
}
async function getSignerAddress() {
const { computeAddress } = await loadEthers();
return computeAddress(CONFIG.PRIVATE_KEY);
}
// ─── Configuration ───────────────────────────────────────────────
const CONFIG = {
// Ratio API base URL (Sandbox by default). The "/api/router" prefix is not part
// of the HMAC string-to-sign — requests are signed over "/v1/..." paths.
API_BASE: process.env.RATIO_API_BASE || "https://sbx.ratiofx.com/api/router",
API_KEY: process.env.RATIO_API_KEY || "",
API_SECRET: process.env.RATIO_API_SECRET || "",
// Blockchain
RPC_URL: process.env.RPC_URL || "https://public-en-kairos.node.kaia.io",
PRIVATE_KEY: process.env.PRIVATE_KEY || "",
// Swap parameters (override via env or change here)
// PAIR is used to find the corridor; FROM_TOKEN/TO_TOKEN determine swap direction.
PAIR: process.env.SWAP_PAIR || "USDT-JPYC",
// Set FROM_TOKEN and TO_TOKEN to the ERC-20 contract addresses.
// These are resolved automatically from the corridor if left empty.
FROM_TOKEN: process.env.FROM_TOKEN || "",
TO_TOKEN: process.env.TO_TOKEN || "",
AMOUNT: process.env.SWAP_AMOUNT || "2000",
};
// ─── HMAC Authentication ─────────────────────────────────────────
function signRequest(method, path, body = null) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const bodyStr = body ? JSON.stringify(body) : "";
// POST requests require x-ratio-request-id for replay protection.
// It is included in the HMAC payload between timestamp and method.
const isMutating = method !== "GET";
const requestId = isMutating ? `req-${timestamp}-${crypto.randomUUID()}` : null;
const payload = requestId
? timestamp + requestId + method + path + bodyStr
: timestamp + method + path + bodyStr;
const signature = crypto
.createHmac("sha256", CONFIG.API_SECRET)
.update(payload)
.digest("hex");
const headers = {
"x-ratio-api-key": CONFIG.API_KEY,
"x-ratio-timestamp": timestamp,
"x-ratio-signature": signature,
"Content-Type": "application/json",
};
if (requestId) headers["x-ratio-request-id"] = requestId;
return { headers, bodyStr };
}
async function ratioRequest(method, path, body = null) {
const { headers, bodyStr } = signRequest(method, path, body);
const url = CONFIG.API_BASE + path;
const opts = { method, headers };
if (method === "POST") opts.body = bodyStr;
const res = await fetch(url, opts);
const text = await res.text();
let json;
try {
json = JSON.parse(text);
} catch {
throw new Error(`${method} ${path} → ${res.status} (non-JSON): ${text.slice(0, 300)}`);
}
if (!json.success) {
throw new Error(`${method} ${path} → ${res.status}: ${json.error?.code} — ${json.error?.message}`);
}
return json;
}
async function ratioGet(path) { return ratioRequest("GET", path); }
async function ratioPost(path, body) { return ratioRequest("POST", path, body); }
// ─── Step 1: Check available corridors & resolve swap tokens ─────
async function checkCorridors() {
console.log("\n=== Step 1: Checking available corridors ===");
const { data } = await ratioGet("/v1/corridors");
let target = null;
for (const c of data.corridors) {
console.log(` ${c.pair} state=${c.state ?? "N/A"} status=${c.state_status} directions=${JSON.stringify(c.allowed_directions)}`);
console.log(` limits: ${c.limits.min_amount_usd}–${c.limits.max_amount_usd} USD`);
console.log(` spread: base=${c.spread_config.base_spread_bps}bps liquidity=${c.spread_config.liquidity_bps}bps`);
if (c.pair === CONFIG.PAIR) target = c;
}
if (!target) throw new Error(`Corridor ${CONFIG.PAIR} not found`);
if (!CONFIG.FROM_TOKEN || !CONFIG.TO_TOKEN) {
CONFIG.FROM_TOKEN = target.tokens.quote.asset;
CONFIG.TO_TOKEN = target.tokens.base.asset;
console.log(`\n Auto-resolved tokens for ${CONFIG.PAIR}:`);
console.log(` from_token (${target.tokens.quote.symbol}): ${CONFIG.FROM_TOKEN}`);
console.log(` to_token (${target.tokens.base.symbol}): ${CONFIG.TO_TOKEN}`);
}
CONFIG.CORRIDOR_ID = target.corridor_id;
console.log(` corridor_id: ${CONFIG.CORRIDOR_ID}`);
return data.corridors;
}
// ─── Step 2: Check live rates ────────────────────────────────────
async function checkRates() {
console.log("\n=== Step 2: Checking live rates ===");
const { data } = await ratioGet("/v1/rates");
for (const r of data.rates) {
console.log(` ${r.pair} mid=${r.mid} age=${r.oracle_age_ms}ms updated=${r.updated_at}`);
}
return data.rates;
}
// ─── Step 3: Get indicative quote ────────────────────────────────
async function getIndicativeQuote(signerAddr) {
console.log("\n=== Step 3: Getting indicative quote ===");
const body = {
corridor_id: CONFIG.CORRIDOR_ID,
from_token: CONFIG.FROM_TOKEN,
to_token: CONFIG.TO_TOKEN,
amount: CONFIG.AMOUNT,
signer: signerAddr,
};
const { data } = await ratioPost("/v1/quote", body);
console.log(` ${data.amount.in.display} ${data.amount.in.token} → ${data.amount.out.display} ${data.amount.out.token}`);
console.log(` Fee tier: ${data.fee_breakdown.tier}`);
console.log(` Fixed fee: ${data.fee_breakdown.fixed.charged.display} ${data.fee_breakdown.fixed.charged.token}`);
console.log(` Variable fee: ${data.fee_breakdown.variable.charged.display} ${data.fee_breakdown.variable.charged.token} (${data.fee_breakdown.variable.bps}bps)`);
console.log(` Spread: ${data.fee_breakdown.spread.total_bps}bps`);
console.log(` Risk state: ${data.state_flag} expires: ${new Date(data.expires_at * 1000).toISOString()}`);
return data;
}
// ─── Step 4: Get firm quote ──────────────────────────────────────
async function getFirmQuote(signerAddr) {
console.log("\n=== Step 4: Requesting firm quote ===");
const clientRef = `TEST-${Date.now()}`;
const body = {
corridor_id: CONFIG.CORRIDOR_ID,
from_token: CONFIG.FROM_TOKEN,
to_token: CONFIG.TO_TOKEN,
amount: CONFIG.AMOUNT,
signer: signerAddr,
quote_type: "FIRM",
client_ref: clientRef,
};
const { data } = await ratioPost("/v1/firm-quote", body);
console.log(` Quote ID: ${data.quote.quote_id}`);
console.log(` ${data.amount.in.display} ${data.amount.in.token} → ${data.amount.out.display} ${data.amount.out.token}`);
console.log(` Expires: ${new Date(data.expires_at * 1000).toISOString()}`);
console.log(` Contract: ${data.execution.contract}`);
console.log(` Chain ID: ${data.execution.chain_id}`);
console.log(` Client ref: ${clientRef}`);
return data;
}
// ─── Step 5: Approve token spend ─────────────────────────────────
async function ensureAllowance(firmQuote) {
console.log("\n=== Step 5: Ensuring ERC-20 allowance ===");
const { JsonRpcProvider, Wallet, Contract, MaxUint256 } = await loadEthers();
const provider = new JsonRpcProvider(CONFIG.RPC_URL);
const wallet = new Wallet(CONFIG.PRIVATE_KEY, provider);
const fromToken = CONFIG.FROM_TOKEN;
const spender = firmQuote.execution.contract;
const needed = BigInt(firmQuote.execution.source_amount_raw || firmQuote.amount.in.raw);
const erc20 = new Contract(fromToken, [
"function allowance(address,address) view returns (uint256)",
"function approve(address,uint256) returns (bool)",
], wallet);
const current = await erc20.allowance(wallet.address, spender);
console.log(` Token: ${fromToken}`);
console.log(` Spender (FxEngine): ${spender}`);
console.log(` Current allowance: ${current}`);
console.log(` Needed: ${needed}`);
if (current >= needed) {
console.log(" Allowance sufficient — skipping approve.");
return;
}
console.log(" Approving max allowance...");
const tx = await erc20.approve(spender, MaxUint256);
console.log(` Approve tx: ${tx.hash}`);
await tx.wait();
console.log(" Approved.");
}
// ─── Step 6: Execute on-chain ────────────────────────────────────
async function executeOnChain(firmQuote) {
console.log("\n=== Step 6: Executing on-chain ===");
const { JsonRpcProvider, Wallet } = await loadEthers();
const provider = new JsonRpcProvider(CONFIG.RPC_URL);
const wallet = new Wallet(CONFIG.PRIVATE_KEY, provider);
const { execution } = firmQuote;
console.log(` Sending tx to ${execution.contract}...`);
const tx = await wallet.sendTransaction({
to: execution.contract,
data: execution.calldata,
value: execution.value,
chainId: execution.chain_id,
});
console.log(` Submitted: ${tx.hash}`);
console.log(" Waiting for confirmation...");
const receipt = await tx.wait();
console.log(` Confirmed in block ${receipt.blockNumber} (status=${receipt.status})`);
return tx.hash;
}
// ─── Step 7: Verify execution ────────────────────────────────────
async function verifyExecution(quoteId, txHash) {
console.log("\n=== Step 7: Verifying execution ===");
// Wait for indexer to catch up
for (let attempt = 1; attempt <= 10; attempt++) {
try {
const { data } = await ratioGet(`/v1/executions?quote=${quoteId}`);
console.log(` Status: ${data.status}`);
console.log(` Filled rate: ${data.filled_rate}`);
console.log(` Source: ${data.source_amount} Destination: ${data.destination_amount}`);
console.log(` Total fee (USD): ${data.total_fee_usd}`);
console.log(` Tx: ${data.tx_hash}`);
console.log(` Settled at: ${data.settled_at}`);
return data;
} catch (e) {
if (attempt < 10) {
console.log(` Not indexed yet (attempt ${attempt}/10), waiting 3s...`);
await new Promise((r) => setTimeout(r, 3000));
} else {
console.log(` Could not verify after ${attempt} attempts. Check tx: ${txHash}`);
}
}
}
}
// ─── Main ────────────────────────────────────────────────────────
async function main() {
console.log("Ratio FX — Partner Integration Test");
console.log(` Pair: ${CONFIG.PAIR} Amount: ${CONFIG.AMOUNT}`);
// Validate config
if (!CONFIG.API_KEY || !CONFIG.API_SECRET || !CONFIG.PRIVATE_KEY) {
console.error("\nMissing required env vars. Set:");
console.error(" RATIO_API_KEY, RATIO_API_SECRET, PRIVATE_KEY");
console.error("\nOptional:");
console.error(" RATIO_API_BASE, RPC_URL, SWAP_PAIR, FROM_TOKEN, TO_TOKEN, SWAP_AMOUNT");
process.exit(1);
}
const signerAddr = await getSignerAddress();
console.log(` Signer wallet: ${signerAddr}`);
await checkCorridors();
await checkRates();
const indicative = await getIndicativeQuote(signerAddr);
// Execution steps (requires funded wallet)
const firmQuote = await getFirmQuote(signerAddr);
await ensureAllowance(firmQuote);
const txHash = await executeOnChain(firmQuote);
await verifyExecution(firmQuote.quote.quote_id, txHash);
console.log("\n=== Done ===");
}
main().catch((e) => {
console.error("\nFatal:", e.message);
process.exit(1);
});
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
401 on every request | Wrong key or secret, clock more than 30 s off, or signing a path that includes /api/router | Check credentials and system time; sign /v1/... paths only |
403 on quote | Wallet not registered with swap permission, or corridor not enabled for your credential | Check accessible in GET /v1/corridors; contact your account manager |
could not decode result data (value="0x") at the approve step | The RPC is on a different chain than the API environment, or the RPC node is lagging | Check RPC_URL; retry or switch RPC |
| Transaction reverts | Quote expired, insufficient balance or allowance, or the wallet is not quote.partner | Request a new firm quote and check balance and allowance |