Node.js Agent
A working script that discovers a market, deposits tokens, places a trade, and withdraws — using @dfinity/agent. Copy, adapt, extend into a persistent engine.
Project Setup
mkdir partydex-agent && cd partydex-agent
npm init -y
npm install @dfinity/agent @dfinity/candid @dfinity/identity @dfinity/principal
Identity
import { Ed25519KeyIdentity } from '@dfinity/identity';
import { createHash } from 'crypto';
// Deterministic — same seed = same identity every time
const seed = createHash('sha256').update('my-trading-agent-v1').digest();
const identity = Ed25519KeyIdentity.generate(new Uint8Array(seed));
console.log('Principal:', identity.getPrincipal().toText());
For production, load a PEM file or hardware key instead of a seed.
Connect to IC
import { HttpAgent, Actor } from '@dfinity/agent';
import { IDL } from '@dfinity/candid';
const agent = HttpAgent.createSync({
host: 'https://ic0.app', // mainnet
identity,
});
No fetchRootKey() on mainnet. For local dfx development, use http://127.0.0.1:4943 and call await agent.fetchRootKey() once.
IDL Pattern
Each canister needs an IDL factory. Define only the methods you call — you don't need the full interface. Here's the pattern:
// Reusable building blocks
const Tick = IDL.Int32;
const Side = IDL.Variant({ buy: IDL.Null, sell: IDL.Null });
const TokenId = IDL.Variant({ base: IDL.Null, quote: IDL.Null });
const ApiError = IDL.Record({
category: IDL.Variant({
validation: IDL.Null, authorization: IDL.Null, state: IDL.Null,
resource: IDL.Null, rate_limit: IDL.Null, external: IDL.Null,
admin: IDL.Null, other: IDL.Null,
}),
code: IDL.Text, message: IDL.Text,
metadata: IDL.Opt(IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text))),
});
const Result = (ok) => IDL.Variant({ ok, err: ApiError });
const PollVersions = IDL.Record({
platform: IDL.Nat, orderbook: IDL.Nat, candle: IDL.Nat, user: IDL.Nat,
});
const BookOrderSpec = IDL.Record({
side: Side, input_amount: IDL.Nat, limit_tick: Tick, immediate_or_cancel: IDL.Bool,
});
const PoolSwapSpec = IDL.Record({
side: Side, input_amount: IDL.Nat, limit_tick: Tick, fee_pips: IDL.Nat32,
});
// Factory — only include methods you need
const spotIdl = ({ IDL: _ }) => IDL.Service({
deposit: IDL.Func([TokenId, IDL.Nat], [Result(IDL.Record({
versions: PollVersions, credited: IDL.Nat, new_balance: IDL.Nat, block_index: IDL.Nat,
}))], []),
withdraw: IDL.Func([TokenId, IDL.Nat], [Result(IDL.Record({
versions: PollVersions, withdrawn: IDL.Nat, remaining: IDL.Nat, block_index: IDL.Opt(IDL.Nat),
}))], []),
quote_order: IDL.Func([Side, IDL.Nat, IDL.Opt(Tick), IDL.Opt(IDL.Nat32)], [Result(IDL.Record({
input_amount: IDL.Nat, output_amount: IDL.Nat,
pool_swaps: IDL.Vec(PoolSwapSpec), book_order: IDL.Opt(BookOrderSpec),
total_fees: IDL.Nat, effective_tick: Tick, reference_tick: Tick,
venue_breakdown: IDL.Vec(IDL.Record({
venue_id: IDL.Variant({ book: IDL.Null, pool: IDL.Nat32 }),
input_amount: IDL.Nat, output_amount: IDL.Nat, fee_amount: IDL.Nat,
})),
}))], ['query']),
create_orders: IDL.Func([IDL.Vec(IDL.Nat), IDL.Vec(BookOrderSpec), IDL.Vec(PoolSwapSpec)], [Result(IDL.Record({
versions: PollVersions,
cancel_results: IDL.Vec(IDL.Record({ order_id: IDL.Nat, result: IDL.Variant({ ok: IDL.Null, err: ApiError }) })),
cancel_summary: IDL.Record({ succeeded: IDL.Nat32, failed: IDL.Nat32 }),
order_results: IDL.Vec(IDL.Record({ index: IDL.Nat32, result: IDL.Variant({ ok: IDL.Record({ order_id: IDL.Nat }), err: ApiError }) })),
order_summary: IDL.Record({ succeeded: IDL.Nat32, failed: IDL.Nat32 }),
swap_results: IDL.Vec(IDL.Record({ index: IDL.Nat32, result: IDL.Variant({ ok: IDL.Record({ input_amount: IDL.Nat, output_amount: IDL.Nat, fee: IDL.Nat }), err: ApiError }) })),
available_base: IDL.Nat, available_quote: IDL.Nat,
}))], []),
get_versions: IDL.Func([], [PollVersions], ['query']),
get_routing_state: IDL.Func([], [IDL.Record({
system_state: IDL.Variant({ normal: IDL.Null, degraded: IDL.Null, halted: IDL.Null }),
base: IDL.Record({ ledger: IDL.Principal, decimals: IDL.Nat8, fee: IDL.Nat }),
quote: IDL.Record({ ledger: IDL.Principal, decimals: IDL.Nat8, fee: IDL.Nat }),
reference_tick: IDL.Opt(Tick),
pools: IDL.Vec(IDL.Record({ fee_pips: IDL.Nat32, tick: Tick, liquidity: IDL.Nat, tick_spacing: IDL.Nat, sqrt_price_x96: IDL.Nat, base_reserve: IDL.Nat, quote_reserve: IDL.Nat, initialized_ticks: IDL.Vec(IDL.Record({ tick: Tick, liquidity_net: IDL.Int, liquidity_gross: IDL.Nat })) })),
last_book_tick: IDL.Opt(Tick), last_trade_tick: IDL.Opt(Tick), last_trade_sqrt_price_x96: IDL.Opt(IDL.Nat),
maker_fee_pips: IDL.Nat32, taker_fee_pips: IDL.Nat32, quote_usd_rate_e12: IDL.Nat, current_price_usd_e12: IDL.Nat,
book: IDL.Record({ bids: IDL.Vec(IDL.Record({ tick: Tick, total: IDL.Nat })), asks: IDL.Vec(IDL.Record({ tick: Tick, total: IDL.Nat })) }),
})], ['query']),
add_liquidity: IDL.Func([IDL.Nat32, Tick, Tick, IDL.Nat, IDL.Nat, IDL.Opt(Tick), IDL.Opt(IDL.Nat64), IDL.Nat, IDL.Nat], [Result(IDL.Record({
versions: PollVersions, position_id: IDL.Nat64,
actual_amt_base: IDL.Nat, actual_amt_quote: IDL.Nat,
available_base: IDL.Nat, available_quote: IDL.Nat,
}))], []),
increase_liquidity: IDL.Func([IDL.Nat64, IDL.Nat, IDL.Nat, IDL.Nat, IDL.Nat], [Result(IDL.Record({
versions: PollVersions, liquidity_delta: IDL.Nat,
actual_amt_base: IDL.Nat, actual_amt_quote: IDL.Nat,
available_base: IDL.Nat, available_quote: IDL.Nat,
}))], []),
decrease_liquidity: IDL.Func([IDL.Nat64, IDL.Nat, IDL.Nat, IDL.Nat], [Result(IDL.Record({
versions: PollVersions,
amount_base: IDL.Nat, amount_quote: IDL.Nat,
available_base: IDL.Nat, available_quote: IDL.Nat,
}))], []),
collect_fees: IDL.Func([IDL.Nat64], [Result(IDL.Record({
versions: PollVersions,
collected_amt_base: IDL.Nat, collected_amt_quote: IDL.Nat,
available_base: IDL.Nat, available_quote: IDL.Nat,
}))], []),
close_all_positions: IDL.Func([], [Result(IDL.Record({
versions: PollVersions, closed: IDL.Nat32,
amount_base: IDL.Nat, amount_quote: IDL.Nat,
available_base: IDL.Nat, available_quote: IDL.Nat,
}))], []),
get_user: IDL.Func([], [IDL.Opt(IDL.Record({
versions: PollVersions,
available: IDL.Record({ base: IDL.Nat, quote: IDL.Nat }),
locked: IDL.Record({ orders: IDL.Record({ base: IDL.Nat, quote: IDL.Nat }), triggers: IDL.Record({ base: IDL.Nat, quote: IDL.Nat }), positions: IDL.Record({ base: IDL.Nat, quote: IDL.Nat }) }),
fees: IDL.Record({ base: IDL.Nat, quote: IDL.Nat }),
cumulative_lp_fees: IDL.Record({ base: IDL.Nat, quote: IDL.Nat }),
net_flows: IDL.Record({ external: IDL.Record({ base: IDL.Int, quote: IDL.Int }), swap: IDL.Record({ base: IDL.Int, quote: IDL.Int }), lp: IDL.Record({ base: IDL.Int, quote: IDL.Int }) }),
orders: IDL.Vec(IDL.Record({ order_id: IDL.Nat64, side: Side, tick: Tick, base_amount: IDL.Nat, quote_amount: IDL.Nat, base_filled: IDL.Nat, quote_filled: IDL.Nat, fee: IDL.Int, immediate_or_cancel: IDL.Bool, quote_usd_rate_e12: IDL.Nat, timestamp: IDL.Nat64, status: IDL.Variant({ pending: IDL.Null, partial: IDL.Null, filled: IDL.Null, cancelled: IDL.Null }) })),
triggers: IDL.Vec(IDL.Record({ trigger_id: IDL.Nat, owner: IDL.Principal, side: Side, trigger_type: IDL.Variant({ above: IDL.Null, below: IDL.Null }), trigger_tick: Tick, input_amount: IDL.Nat, limit_tick: Tick, immediate_or_cancel: IDL.Bool, status: IDL.Variant({ active: IDL.Null, triggered: IDL.Null, cancelled: IDL.Null, activation_failed: IDL.Null }), timestamp: IDL.Nat64, quote_usd_rate_e12: IDL.Nat })),
positions: IDL.Vec(IDL.Record({ position_id: IDL.Nat64, fee_pips: IDL.Nat32, owner: IDL.Principal, tick_lower: Tick, tick_upper: Tick, liquidity: IDL.Nat, amount_base: IDL.Nat, amount_quote: IDL.Nat, fees_base: IDL.Nat, fees_quote: IDL.Nat, usd_value_e6: IDL.Nat, fees_usd_value_e6: IDL.Nat, apr_bps: IDL.Nat, locked_until: IDL.Opt(IDL.Nat64) })),
}))], ['query']),
});
The get_user and get_routing_state definitions are dense — they match the type shapes in Trading Overview. For the full interface covering all endpoints, see the Spot API reference.
- Variants:
{ buy: null }to construct,'ok' in resultto match - Optionals:
[]for absent,[value]for present - Nat/Int: JavaScript
BigInt— use1_000_000nliterals - ICRC results: Capitalized
Ok/Err. Spot canister results: lowercaseok/err
Complete Flow: First Trade
This script discovers a market, approves + deposits quote tokens, places a buy order, polls for fill, and withdraws.
// --- Config ---
const INDEXER = 'gx3we-baaaa-aaaab-afaia-cai';
const CKUSDT_LEDGER = 'cngnf-vqaaa-aaaar-qag4q-cai';
// ICRC-2 approve IDL (works for any ICRC-2 ledger)
const Account = IDL.Record({ owner: IDL.Principal, subaccount: IDL.Opt(IDL.Vec(IDL.Nat8)) });
const icrc2Idl = ({ IDL: _ }) => IDL.Service({
icrc2_approve: IDL.Func([IDL.Record({
spender: Account, amount: IDL.Nat, fee: IDL.Opt(IDL.Nat),
memo: IDL.Opt(IDL.Vec(IDL.Nat8)), from_subaccount: IDL.Opt(IDL.Vec(IDL.Nat8)),
created_at_time: IDL.Opt(IDL.Nat64), expected_allowance: IDL.Opt(IDL.Nat),
expires_at: IDL.Opt(IDL.Nat64),
})], [IDL.Variant({
Ok: IDL.Nat, Err: IDL.Variant({
BadFee: IDL.Record({ expected_fee: IDL.Nat }),
InsufficientFunds: IDL.Record({ balance: IDL.Nat }),
AllowanceChanged: IDL.Record({ current_allowance: IDL.Nat }),
TooOld: IDL.Null, TemporarilyUnavailable: IDL.Null,
GenericError: IDL.Record({ error_code: IDL.Nat, message: IDL.Text }),
}),
})], []),
});
// Indexer IDL (just discovery)
const indexerIdl = ({ IDL: _ }) => IDL.Service({
get_market_by_symbols: IDL.Func([IDL.Text, IDL.Text], [IDL.Opt(IDL.Principal)], ['query']),
});
// --- 1. Discover market ---
const indexer = Actor.createActor(indexerIdl, { agent, canisterId: INDEXER });
const [marketId] = await indexer.get_market_by_symbols('ckBTC', 'ckUSDT');
if (!marketId) throw new Error('Market not found');
console.log('Market:', marketId.toText());
// --- 2. Read market state ---
const spot = Actor.createActor(spotIdl, { agent, canisterId: marketId });
const state = await spot.get_routing_state();
const refTick = state.reference_tick[0]; // unwrap Opt
const quoteFee = BigInt(state.quote.fee);
console.log('Reference tick:', refTick, '| Quote fee:', quoteFee);
// --- 3. Approve canister to pull quote tokens (ICRC-2) ---
const depositAmount = 10_000_000n; // 10 ckUSDT
const approveAmount = depositAmount + quoteFee; // cover ledger fee
const ledger = Actor.createActor(icrc2Idl, { agent, canisterId: CKUSDT_LEDGER });
const approveResult = await ledger.icrc2_approve({
spender: { owner: marketId, subaccount: [] },
amount: approveAmount,
fee: [], memo: [], from_subaccount: [],
created_at_time: [], expected_allowance: [], expires_at: [],
});
if ('Err' in approveResult) throw new Error('Approve failed: ' + JSON.stringify(approveResult.Err));
console.log('Approved, block:', Number(approveResult.Ok));
// --- 4. Deposit into trading balance ---
const depositResult = await spot.deposit({ quote: null }, depositAmount);
if ('err' in depositResult) throw new Error(`Deposit: [${depositResult.err.code}] ${depositResult.err.message}`);
console.log('Deposited:', depositResult.ok.credited, '| Balance:', depositResult.ok.new_balance);
let lastVersions = depositResult.ok.versions;
// --- 5. Quote a buy order ---
const buyAmount = 5_000_000n; // 5 ckUSDT of quote to spend
const quoteResult = await spot.quote_order({ buy: null }, buyAmount, [refTick], [50]); // opt limit_tick, 50 bps slippage
if ('err' in quoteResult) throw new Error(`Quote: [${quoteResult.err.code}] ${quoteResult.err.message}`);
const quote = quoteResult.ok;
console.log('Quoted:', quote.input_amount, 'in →', quote.output_amount, 'out');
// --- 6. Execute via create_orders ---
const orderResult = await spot.create_orders(
[], // no cancels
quote.book_order.length > 0 ? [quote.book_order[0]] : [], // unwrap Opt
quote.pool_swaps,
);
if ('err' in orderResult) throw new Error(`Order: [${orderResult.err.code}] ${orderResult.err.message}`);
const res = orderResult.ok;
lastVersions = res.versions;
for (const r of res.order_results) {
if ('ok' in r.result) console.log('Order placed, ID:', Number(r.result.ok.order_id));
else console.log('Order failed:', r.result.err.code);
}
for (const r of res.swap_results) {
if ('ok' in r.result) console.log('Swap filled:', r.result.ok.input_amount, '→', r.result.ok.output_amount);
else console.log('Swap failed:', r.result.err.code);
}
console.log('Available:', res.available_base, 'base |', res.available_quote, 'quote');
// --- 7. Poll for external changes ---
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
let versions = await spot.get_versions();
while (BigInt(versions.user) <= BigInt(lastVersions.user)) {
await sleep(2000);
versions = await spot.get_versions();
}
const userData = await spot.get_user();
if (userData[0]) {
const u = userData[0];
console.log('Base:', u.available.base, '| Quote:', u.available.quote);
console.log('Orders:', u.orders.length, '| Positions:', u.positions.length);
}
// --- 8. Withdraw everything ---
if (userData[0]) {
const u = userData[0];
if (BigInt(u.available.base) > 0n) {
const wb = await spot.withdraw({ base: null }, BigInt(u.available.base));
if ('ok' in wb) console.log('Withdrew base:', wb.ok.withdrawn);
}
if (BigInt(u.available.quote) > 0n) {
const wq = await spot.withdraw({ quote: null }, BigInt(u.available.quote));
if ('ok' in wq) console.log('Withdrew quote:', wq.ok.withdrawn);
}
}
console.log('Done.');
From Script to Engine
The script above is a single pass. To build a persistent trading engine, wrap steps 5–8 in the agent loop:
while (true) {
const versions = await spot.get_versions();
if (BigInt(versions.platform) > BigInt(lastVersions.platform)) {
state = await spot.get_routing_state();
}
if (BigInt(versions.user) > BigInt(lastVersions.user)) {
userData = await spot.get_user();
}
lastVersions = versions;
// Decide + Act (your strategy here)
if ('normal' in state.system_state) {
// ... trading logic ...
}
await sleep(2000);
}
See Error Handling and Rate Limits before adding retry logic.