Direct Execution: Signing the Swap Yourself
This guide walks through a complete direct execution test: create an eligible DeFi order, fetch its unsigned transaction, check the calldata before signing, simulate it, send it from your own wallet and report it. Field and error definitions live in the API reference under DeFi direct execution; this page shows how to use them end to end.
With direct execution the user's wallet sends one transaction that pulls the deposit, swaps it and delivers the output to withdraw_address. Either all of it happens or the transaction reverts and only gas is spent. The proxy deposit_address keeps working as a fallback.
What you need
- An API key and a referral token whose referral code has direct execution enabled. Ask your account manager to enable it; without it, orders never carry
unsigned_transactionand the refresh action returnsdirect_execution_not_enabled. - The API key must belong to the owner of that referral code. The refresh and submit actions return
403for an order your key does not own. - An EVM wallet on the chain you test, holding the deposit plus native gas. In our tests a direct execution used roughly 350,000 to 400,000 gas.
- An RPC URL for that chain, to simulate and send.
- For the script below: Node.js 18 or later and
npm install ethers@6.
What is supported
| Supported | Not supported |
|---|---|
DeFi fixed-rate orders (is_defi: true, is_fixed_rate: true) | Floating-rate and limit orders |
| Deposit and withdrawal on the same EVM chain: Ethereum, Arbitrum, Base, Optimism, Polygon, BNB Smart Chain, Avalanche C-Chain | Cross-chain routes, and routes to or from Solana, Tron or Bitcoin |
| Native coin or ERC-20 token as the deposit | Orders whose deposit already reached deposit_address |
The transaction is always sent to the DirectSwapEntry contract, which has the same address on every supported chain:
0x7d27e5B75Fa1E506a31250047c778a7c3e5b947B
If unsigned_transaction.to is anything else, do not sign it and contact support@n.exchange.
1. Get a fixed rate
Fetch the pair you want to test with the DeFi fixed-rate filters. The example uses ETH to USDC on Arbitrum: native input needs no token approval, and gas is cheap.
curl -X GET "https://api.n.exchange/en/api/v2/rate/?pairs=USDCARBETHARB&is_defi=true&is_fixed_rate=true" \
-H "Accept: application/json" \
-H "Authorization: ApiKey YOUR_API_KEY" \
-H "x-referral-token: YOUR_REFERRAL_TOKEN"
Keep the row's rate_id and pick a deposit amount of at least min_deposit_amount (DeFi orders also have a $20 equivalent minimum). Quotes expire within tens of seconds, so go straight to the next step (Rates & Pricing).
2. Create the order
Use your test wallet as both withdraw_address and refund_address, so every outcome comes back to you.
curl -X POST "https://api.n.exchange/en/api/v2/orders/" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "Authorization: ApiKey YOUR_API_KEY" \
-H "x-referral-token: YOUR_REFERRAL_TOKEN" \
-d '{
"deposit_currency": "ETHARB",
"withdraw_currency": "USDCARB",
"deposit_amount": "0.01",
"withdraw_address": "0xYourTestWallet",
"refund_address": "0xYourTestWallet",
"is_defi": true,
"is_fixed_rate": true,
"rate_id": "RATE_ID_FROM_STEP_1"
}'
The 201 response is a normal DeFi order plus an unsigned_transaction object. Note unique_reference and fixed_rate_deadline: the transaction can be refreshed until that deadline.
If unsigned_transaction is missing, direct execution was not offered for this order (not enabled for the referral code, route not eligible, or no quote at that moment). The order is still valid through deposit_address, and you can ask for the transaction with the refresh action in the next step.
3. Refresh right before signing
The calldata in the create response is only valid for a short time (around 40 seconds in our tests). Always fetch a fresh one just before the wallet prompt:
curl -X POST "https://api.n.exchange/en/api/v2/orders/ABC123/unsigned_transaction/" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "Authorization: ApiKey YOUR_API_KEY" \
-H "x-referral-token: YOUR_REFERRAL_TOKEN" \
-d '{"sender_address": "0xYourTestWallet"}'
Passing sender_address makes the response check that wallet's token allowance (approval is null once it covers amount_in) and fill gas_estimate. When the deposit is an ERC-20 token and approval is set, send approve(spender, amount) on approval.token first, then refresh again.
4. Check the calldata before signing
Never forward the transaction to a wallet unchecked. Decode data and confirm that it does what the order says:
| Check | How |
|---|---|
| Right contract and chain | to equals the DirectSwapEntry address above; chain_id is the chain your wallet is on |
| Output goes to the user | The decoded intent's destinationAddress equals the order's withdraw_address |
| Refunds go to the user | The intent's refundAddress equals the order's refund_address |
| Same chain | The intent's sourceChainId and chainIdOut both equal chain_id |
| Right amount | amountIn equals summary.amount_in and the order's deposit_amount in smallest units; value equals amountIn for native input and is "0" for a token |
| Right floor | guaranteedAmountOut equals summary.min_amount_out, and that amount is acceptable to the user |
| No gas taken from the deposit | The low 128 bits of gasFeeAmount are zero (the wallet pays gas itself) |
| Bound to this order | On-chain, the entry's handler() and the handler's directEntry() point at each other, and calculateMultiChainDepositAddress(intent) on the handler returns this order's deposit_address |
| Still valid | deadline is in the future (it is the quote's expiry, quote_expires_at) |
The calldata carries our signature over every argument, valid until deadline. Send data exactly as returned: changing any byte makes the contract revert with InvalidSignature.
Then simulate with eth_call from the sending wallet. A revert in simulation means the transaction would revert on-chain too; refresh and try again, or fall back to deposit_address.
The decoding ABI:
intent = (address tokenIn, string tokenOut, uint256 chainIdOut, string destinationAddress,
address refundAddress, uint256 sourceChainId, bytes32 expectedDestAddress,
uint16 expectedDestChainId, bytes32 expectedQuoteHash, uint16 protocolFeeBps,
address bridgeProtocol)
swapDirect(intent, uint256 amountIn, uint256 guaranteedAmountOut, uint256 gasFeeAmount,
bytes swapCalldata, address swapTarget, address approveTo,
uint256 deadline, bytes signature) payable
swapDirectMultiLeg(intent, uint256 amountIn, uint256 guaranteedAmountOut, uint256 gasFeeAmount,
(bytes swapCalldata, address swapTarget, address approveTo, uint256 legInput)[] legs,
uint256 deadline, bytes signature) payable
5. Send and report
Send to, data and value exactly as returned, from the wallet you passed as sender_address. Then report the hash so the order completes without waiting for our chain scan:
curl -X POST "https://api.n.exchange/en/api/v2/orders/ABC123/unsigned_transaction/submitted/" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "Authorization: ApiKey YOUR_API_KEY" \
-H "x-referral-token: YOUR_REFERRAL_TOKEN" \
-d '{"tx_hash": "0xYourTransactionHash"}'
{
"status": "pending",
"tx_hash": "0xYourTransactionHash",
"detail": "transaction not mined yet; retry"
}
{
"status": "completed",
"tx_hash": "0xYourTransactionHash",
"already_recorded": false,
"amounts": {
"deposited": "10000000000000000",
"protocol_fee": "22000000000000",
"swap_input": "9978000000000000",
"output": "26435469"
}
}
Reporting is optional: a mined direct transaction is picked up on its own within a few minutes. Either way, track the order with GET /orders/{unique_reference}/ as usual; it moves to RELEASED and then COMPLETED once the chain confirms. The user can receive more than min_amount_out, never less.
Reproducible test script
The script below runs steps 3 to 5 for an order created in step 2 and prints PASS or FAIL for every check in step 4. Without SEND=1 it is a dry run: it refreshes, checks and simulates, and moves no funds. With SEND=1 it approves if needed, sends the swap from PRIVATE_KEY, reports the hash and follows the order to completion.
Keys are read from the environment only. Use a dedicated test wallet holding just what the test needs, and never paste a key into a file you share.
export API_KEY=... # your API key
export REFERRAL_TOKEN=... # referral token with direct execution enabled
export ORDER_REF=ABC123 # unique_reference from step 2
export RPC_URL=https://arb1.arbitrum.io/rpc
export SENDER=0xYourTestWallet # dry run: the wallet that will send (must hold the deposit)
node direct-execution-check.mjs # dry run
SEND=1 PRIVATE_KEY=... node direct-execution-check.mjs # sign and send
import { ethers } from "ethers";
const API = process.env.API_BASE || "https://api.n.exchange/en/api/v2";
const { API_KEY, REFERRAL_TOKEN, ORDER_REF, RPC_URL } = process.env;
const SEND = process.env.SEND === "1";
const ENTRY_ADDRESS = "0x7d27e5B75Fa1E506a31250047c778a7c3e5b947B";
const required = ["API_KEY", "REFERRAL_TOKEN", "ORDER_REF", "RPC_URL", SEND ? "PRIVATE_KEY" : "SENDER"];
const missing = required.filter((name) => !process.env[name]);
if (missing.length) throw new Error(`set these environment variables: ${missing.join(", ")}`);
if (!SEND && !ethers.isAddress(process.env.SENDER)) throw new Error("SENDER is not an EVM address");
const INTENT =
"(address tokenIn,string tokenOut,uint256 chainIdOut,string destinationAddress," +
"address refundAddress,uint256 sourceChainId,bytes32 expectedDestAddress," +
"uint16 expectedDestChainId,bytes32 expectedQuoteHash,uint16 protocolFeeBps," +
"address bridgeProtocol)";
const ENTRY = new ethers.Interface([
`function swapDirect(${INTENT} intent,uint256 amountIn,uint256 guaranteedAmountOut,uint256 gasFeeAmount,bytes swapCalldata,address swapTarget,address approveTo,uint256 deadline,bytes signature) payable`,
`function swapDirectMultiLeg(${INTENT} intent,uint256 amountIn,uint256 guaranteedAmountOut,uint256 gasFeeAmount,(bytes swapCalldata,address swapTarget,address approveTo,uint256 legInput)[] legs,uint256 deadline,bytes signature) payable`,
"function handler() view returns (address)",
"error QuoteExpired()",
"error OutputBelowMinimum()",
"error ProxyNotEmpty()",
"error ValueMismatch()",
"error ZeroAmount()",
"error InsufficientAfterFees()",
"error FeeAboveCommitted()",
"error InvalidSignature()",
"error SignatureExpired()",
]);
const HANDLER = new ethers.Interface([
`function calculateMultiChainDepositAddress(${INTENT} intent) view returns (address)`,
"function directEntry() view returns (address)",
]);
const ERC20 = new ethers.Interface([
"function decimals() view returns (uint8)",
"function approve(address,uint256) returns (bool)",
]);
const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = SEND ? new ethers.Wallet(process.env.PRIVATE_KEY, provider) : null;
const sender = wallet ? wallet.address : process.env.SENDER;
const { chainId } = await provider.getNetwork();
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const same = (a, b) => String(a).toLowerCase() === String(b).toLowerCase();
let failures = [];
function check(name, ok, detail = "") {
console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? ` (${detail})` : ""}`);
if (!ok) failures.push(name);
}
async function api(path, body) {
const res = await fetch(`${API}${path}`, {
method: body ? "POST" : "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `ApiKey ${API_KEY}`,
"x-referral-token": REFERRAL_TOKEN,
},
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => null) };
}
// Decimal string -> smallest units, rounding down like the API does.
function toUnits(text, decimals) {
const [whole, frac = ""] = String(text).split(".");
return BigInt(whole + frac.padEnd(decimals, "0").slice(0, decimals));
}
async function read(iface, to, fn, args = []) {
const data = await provider.call({ to, data: iface.encodeFunctionData(fn, args) });
return iface.decodeFunctionResult(fn, data)[0];
}
function revertReason(err) {
const data = err?.data ?? err?.info?.error?.data;
try {
return ENTRY.parseError(data).name;
} catch {
return err?.shortMessage || String(data);
}
}
async function refreshAndCheck(order) {
failures = [];
const r = await api(`/orders/${ORDER_REF}/unsigned_transaction/`, { sender_address: sender });
if (r.status !== 200) throw new Error(`refresh failed: HTTP ${r.status} ${JSON.stringify(r.body)}`);
const tx = r.body;
const s = tx.summary;
console.log(`\nunsigned transaction to ${tx.to}, valid until ${tx.expires_at}`);
// null for an unknown selector; throws when the arguments do not decode.
let call = null;
try {
call = ENTRY.parseTransaction({ data: tx.data, value: BigInt(tx.value) });
} catch {
call = null;
}
if (!call) {
throw new Error(`data (selector ${String(tx.data).slice(0, 10)}) is not a DirectSwapEntry call: do not sign it`);
}
const [intent, amountIn, guaranteedOut, gasFeeAmount] = call.args;
const deadline = call.args[call.name === "swapDirect" ? 7 : 5];
const native = intent.tokenIn === ethers.ZeroAddress;
check("signed calldata still valid", Number(deadline) * 1000 > Date.now(), `deadline ${deadline}`);
check("to is the DirectSwapEntry contract", same(tx.to, ENTRY_ADDRESS), tx.to);
check("chain_id is the RPC's chain", BigInt(tx.chain_id) === chainId, String(tx.chain_id));
check("same-chain intent", intent.sourceChainId === chainId && intent.chainIdOut === chainId);
check("output goes to withdraw_address", same(intent.destinationAddress, order.withdraw_address), intent.destinationAddress);
if (order.refund_address) check("refunds go to refund_address", same(intent.refundAddress, order.refund_address));
check("token_in and amount_in match the summary", same(intent.tokenIn, s.token_in) && amountIn === BigInt(s.amount_in));
if (order.deposit_amount) {
const decimals = native ? 18 : Number(await read(ERC20, intent.tokenIn, "decimals"));
const expected = toUnits(order.deposit_amount, decimals);
check("amount_in is the order's deposit_amount", amountIn === expected, `${amountIn} vs ${expected}`);
}
check("value matches the input type", BigInt(tx.value) === (native ? amountIn : 0n), `value ${tx.value}`);
check("floor matches summary.min_amount_out", guaranteedOut === BigInt(s.min_amount_out), String(guaranteedOut));
check("no gas fee taken from the deposit", (gasFeeAmount & ((1n << 128n) - 1n)) === 0n);
const handler = await read(ENTRY, tx.to, "handler");
const paired = await read(HANDLER, handler, "directEntry");
check("entry and handler are paired", same(handler, s.handler_address) && same(paired, tx.to), handler);
const proxy = await read(HANDLER, handler, "calculateMultiChainDepositAddress", [intent]);
check("calldata belongs to this order's deposit_address", same(proxy, order.deposit_address), proxy);
if (tx.approval) check("approval spender is the entry contract", same(tx.approval.spender, tx.to));
return tx;
}
async function simulate(tx) {
const req = { from: sender, to: tx.to, data: tx.data, value: BigInt(tx.value) };
try {
await provider.call(req);
const gas = await provider.estimateGas(req);
check("simulation succeeds", true, `estimated gas ${gas}`);
return gas;
} catch (err) {
check("simulation succeeds", false, revertReason(err));
return null;
}
}
const orderRes = await api(`/orders/${ORDER_REF}/`);
if (orderRes.status !== 200) throw new Error(`order lookup failed: HTTP ${orderRes.status}`);
const order = orderRes.body;
console.log(`order ${ORDER_REF}: ${order.status}, deposit ${order.deposit_amount} ${order.deposit_currency}`);
let tx = await refreshAndCheck(order);
if (tx.approval && !SEND) {
console.log(`\nApproval needed first: approve(${tx.approval.spender}, ${tx.approval.amount}) on ${tx.approval.token}.`);
console.log("Simulation skipped: it would fail until the allowance is in place.");
} else if (!tx.approval) {
await simulate(tx);
}
if (!SEND) {
console.log(`\nDry run finished: ${failures.length ? `FAILED (${failures.join("; ")})` : "all checks passed"}. No funds moved.`);
process.exit(failures.length ? 1 : 0);
}
if (failures.length) throw new Error(`not sending, failed checks: ${failures.join("; ")}`);
if (tx.approval) {
const approveTx = await wallet.sendTransaction({
to: tx.approval.token,
data: ERC20.encodeFunctionData("approve", [tx.approval.spender, tx.approval.amount]),
});
console.log(`\napprove sent: ${approveTx.hash}`);
await approveTx.wait();
tx = await refreshAndCheck(order); // the first calldata may have expired meanwhile
if (failures.length) throw new Error(`not sending, failed checks: ${failures.join("; ")}`);
}
const gas = await simulate(tx);
if (!gas) throw new Error("not sending: simulation reverted");
if (Date.parse(tx.expires_at) - Date.now() < 10_000) throw new Error("calldata about to expire, run again");
const sent = await wallet.sendTransaction({
to: tx.to,
data: tx.data,
value: BigInt(tx.value),
gasLimit: (gas * 13n) / 10n,
});
console.log(`\nswap sent: ${sent.hash}`);
const receipt = await sent.wait();
console.log(`mined in block ${receipt.blockNumber}, status ${receipt.status}, gas used ${receipt.gasUsed}`);
for (;;) {
const r = await api(`/orders/${ORDER_REF}/unsigned_transaction/submitted/`, { tx_hash: sent.hash });
if (r.status !== 202) {
console.log(`submit: HTTP ${r.status} ${JSON.stringify(r.body)}`);
break;
}
await sleep(5_000);
}
for (let i = 0; i < 30; i++) {
const r = await api(`/orders/${ORDER_REF}/`);
console.log(`order status: ${r.body?.status}`);
if (["COMPLETED", "REFUNDED", "CANCELED"].includes(r.body?.status)) break;
await sleep(10_000);
}
A passing dry run looks like this (addresses shortened):
order ABC123: INITIAL, deposit 0.01000000 ETHARB
unsigned transaction to 0x7d27...947B, valid until 2026-10-05T10:12:44Z
PASS signed calldata still valid (deadline 1791195194)
PASS to is the DirectSwapEntry contract (0x7d27...947B)
PASS chain_id is the RPC's chain (42161)
PASS same-chain intent
PASS output goes to withdraw_address (0xYourTestWallet)
PASS refunds go to refund_address
PASS token_in and amount_in match the summary
PASS amount_in is the order's deposit_amount (10000000000000000 vs 10000000000000000)
PASS value matches the input type (value 10000000000000000)
PASS floor matches summary.min_amount_out (25611482)
PASS no gas fee taken from the deposit
PASS entry and handler are paired (0xF1eb...D77E)
PASS calldata belongs to this order's deposit_address (0x5a1c...)
PASS simulation succeeds (estimated gas 397412)
Dry run finished: all checks passed. No funds moved.
When something fails
API errors on the refresh and submit actions carry a code; the full list is in the error table. Reverts you may see in simulation or on-chain:
| Revert | Meaning | What to do |
|---|---|---|
QuoteExpired | The quote inside the calldata expired before inclusion | Refresh and send again sooner |
OutputBelowMinimum | The market moved and the swap would deliver less than min_amount_out | Refresh; if it keeps failing, use deposit_address |
ProxyNotEmpty | Funds already reached deposit_address; that deposit is processed through it | Do not send; track the order |
ValueMismatch | value does not match amount_in | Send value exactly as returned |
InvalidSignature | data was changed after we built it | Send data exactly as returned; refresh if in doubt |
SignatureExpired | The transaction was included after deadline | Refresh and send again sooner |
| ERC-20 allowance or balance error | The wallet has not approved the entry contract, or lacks the deposit | Approve as described in step 3, or fund the wallet |
A reverted transaction costs gas only; nothing leaves the wallet otherwise. Some RPC providers report a zero priority fee on Ethereum mainnet, which leaves a transaction pending forever; set the priority fee explicitly if your wallet library takes it from such a provider.