Download this complete quickstart as Markdown
Build an MNX testnet order bot
This guide has two copy-and-run scripts. The first, after browser account setup and API-key creation, is a complete path to an accepted MNX order. It authenticates, requests test collateral, deposits it into the exchange, places a limit order worth the $5 minimum order value, reads the order back, and attempts to cancel it. Success requires a confirmed terminal order status. The second, under "Sign up from code" below, needs no browser: the wallet signs up with one signature, registers a trading key and signs its tokens, which then authenticate the bot instead of an API key.
Testnet only. Use a fresh private key that has never held real assets. Review and accept the applicable terms and risk disclaimer during browser account setup, or in the message you sign when you sign up from code.
Prefer the visual guide? Read the HTML version. For field-level details, see Authentication and signing and Placing orders.
Requirements
- Node.js 18 or newer
- npm
- Internet access to the public MNX and MegaETH testnet endpoints
Run it
Start in an empty directory:
Sign in to the testnet web app through Privy with a dedicated testnet wallet, complete account setup, and generate an API key in Settings. The bot needs both the wallet private key for signatures and the API key for request authentication.
npm init -y
npm install ethers@6
export PRIVATE_KEY="your-testnet-wallet-private-key"
export API_KEY="your-api-key-from-settings"
node testnet-order-bot.mjs
Save the following as testnet-order-bot.mjs. Reuse the same wallet and API key
when retrying. Never put either secret in source control.
import {
TypedDataEncoder,
Contract,
JsonRpcProvider,
Wallet,
formatUnits,
getBytes,
parseUnits,
toBeHex,
} from 'ethers'
const API = 'https://api.testnet.mnx.fi'
const RPC = 'https://carrot.megaeth.com/rpc'
const CHAIN_ID = 6343
const TARGET_DEPOSIT = '25'
const TERMINAL_STATUSES = new Set(['FILLED', 'CANCELLED', 'EXPIRED'])
const ACTIVE_STATUSES = new Set([
'OPEN',
'PENDING_TRIGGER',
'CANCEL_PENDING_FINALIZE',
])
const privateKey = process.env.PRIVATE_KEY
if (!privateKey) throw new Error('Set PRIVATE_KEY to a fresh testnet key')
const provider = new JsonRpcProvider(RPC)
const wallet = new Wallet(privateKey, provider)
const sleep = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds))
async function request(path, options = {}, timeoutMs = 10_000) {
const response = await fetch(API + path, {
...options,
signal: AbortSignal.timeout(timeoutMs),
headers: {
'Content-Type': 'application/json',
...(options.headers ?? {}),
},
})
const text = await response.text()
let data = null
try {
data = text ? JSON.parse(text) : null
} catch {
data = text
}
if (!response.ok) {
throw Object.assign(
new Error(
(options.method ?? 'GET') + ' ' + path + ' -> ' +
response.status + ': ' + JSON.stringify(data)
),
{
status: response.status,
code: data?.details?.code,
retryAfter: response.headers.get('Retry-After'),
}
)
}
return data
}
function readAfterPath(path, bookmark) {
if (!bookmark) return path
const query = new URLSearchParams({
read_after_shard_id: bookmark.shard_id,
read_after_shard_seq: String(bookmark.shard_seq),
})
return path + '?' + query
}
// These GET retries have a shared 60-second / 60-attempt budget. The API
// sends Retry-After as decimal seconds; never shorten a valid requested delay.
function retryDelayMs(value) {
if (!value || !/^\d+(\.\d+)?$/.test(value)) return 1000
return Math.max(1000, Number(value) * 1000)
}
async function waitForTerminalOrder(orderId, bookmark, authHeaders, readRetry) {
const deadline = Date.now() + 60_000
for (let attempt = 0; attempt < 60; attempt += 1) {
const remaining = deadline - Date.now()
if (remaining <= 0) break
// Keep the server's retry floor across cancellation/confirmation phases.
// If it cannot fit this phase, stop reads without weakening that floor.
const backoff = readRetry.notBeforeMs - Date.now()
if (backoff >= remaining) break
if (backoff > 0) await sleep(backoff)
const requestBudget = deadline - Date.now()
if (requestBudget <= 0) break
let delay = 1000
try {
const order = await request(
readAfterPath('/v0/orders/' + orderId, bookmark),
{ headers: authHeaders },
Math.min(10_000, requestBudget)
)
if (TERMINAL_STATUSES.has(order.status)) return order
if (!ACTIVE_STATUSES.has(order.status)) {
throw new Error('Unrecognized order status: ' + order.status)
}
} catch (error) {
if (error.status === 409 &&
error.code === 'HTV1_READ_AFTER_STALE_BOOKMARK' && bookmark) {
bookmark = null
} else if (
(error.status === 503 && (
error.code === 'HTV1_READ_AFTER_PENDING' ||
error.code === 'HTV1_READ_AFTER_UNAVAILABLE'
)) ||
(error.status === 429 && error.code === 'HTV1_READ_AFTER_CAPACITY')
) {
delay = retryDelayMs(error.retryAfter)
readRetry.notBeforeMs = Date.now() + delay
} else {
throw error
}
}
if (delay >= deadline - Date.now()) break
await sleep(delay)
}
throw new Error('Terminal order status was not confirmed within the read budget')
}
async function cleanupOrder(placedOrder, bookmark, authHeaders) {
// Log recovery identifiers before any cancellation or status read can fail.
// Never print the private key or API key.
console.log(JSON.stringify({
cleanup_order_id: placedOrder.order_id,
cleanup_order_hash: placedOrder.order_hash,
}))
// One initial cancel and, if confirmation fails, one deliberate cleanup
// retry for this same order. request() never automatically retries a POST.
const readRetry = { notBeforeMs: 0 }
for (let phase = 0; phase < 2; phase += 1) {
let cancellation = null
try {
cancellation = await request(
'/v0/orders/cancel/by-hash/' + placedOrder.order_hash,
{
method: 'POST',
headers: authHeaders,
body: JSON.stringify({ reason: 'Testnet quickstart cleanup' }),
}
)
bookmark = cancellation.read_after ?? bookmark
} catch (error) {
// A fill can race cancellation, or its acknowledgement can be lost.
// Only a subsequent terminal read establishes that no live order remains.
console.error('Cancel was not confirmed:', error.message)
}
try {
const finalOrder = await waitForTerminalOrder(
placedOrder.order_id, bookmark, authHeaders, readRetry
)
return { cancellation, finalOrder }
} catch (error) {
console.error('Cleanup status was not confirmed:', error.message)
}
}
throw new Error(
'Cleanup remains unconfirmed for order ' + placedOrder.order_id +
' (' + placedOrder.order_hash + '). Keep the same wallet and cancel/check' +
' this order before placing another. See "If a run stops early" below.'
)
}
const network = await provider.getNetwork()
if (network.chainId !== BigInt(CHAIN_ID)) {
throw new Error(
'Wrong chain: expected ' + CHAIN_ID + ', got ' + network.chainId
)
}
const apiKey = process.env.API_KEY
if (!apiKey) throw new Error('Set API_KEY to the key generated in Settings')
const authHeaders = { Authorization: 'Key ' + apiKey }
const user = await request('/v0/users/by-eoa/' + wallet.address)
// This private read also verifies that the API key owns the signing wallet's account.
await request('/v0/orders/by-user/' + user.user_id + '?limit=1', {
headers: authHeaders,
})
const contracts = await request('/v0/contracts/shared')
const collateral = new Contract(
contracts.addressCollateralToken,
[
'function decimals() view returns (uint8)',
'function balanceOf(address) view returns (uint256)',
'function allowance(address,address) view returns (uint256)',
'function approve(address,uint256) returns (bool)',
],
wallet
)
const marginBank = new Contract(
contracts.addressMarginBank,
[
'function bankBalances(address) view returns (uint256)',
'function depositToBank(address,uint128) returns (bool)',
],
wallet
)
const decimals = Number(await collateral.decimals())
const targetRaw = parseUnits(TARGET_DEPOSIT, decimals)
let bankRaw = await marginBank.bankBalances(wallet.address)
let walletRaw = await collateral.balanceOf(wallet.address)
let nativeRaw = await provider.getBalance(wallet.address)
const neededRaw = bankRaw < targetRaw ? targetRaw - bankRaw : 0n
let faucet = null
let approvalHash = null
let depositHash = null
// The faucet mints test USDM to the wallet and supplies testnet gas. It does
// not deposit into MarginBank; approve + depositToBank below are required.
if (neededRaw > 0n && (walletRaw < neededRaw || nativeRaw === 0n)) {
faucet = await request('/v0/testnet/faucet', {
method: 'POST',
headers: authHeaders,
body: '{}',
})
walletRaw = await collateral.balanceOf(wallet.address)
nativeRaw = await provider.getBalance(wallet.address)
}
if (neededRaw > 0n) {
if (walletRaw < neededRaw) throw new Error('Faucet balance is too small')
if (nativeRaw === 0n) throw new Error('Faucet did not supply testnet gas')
const allowance = await collateral.allowance(
wallet.address,
contracts.addressMarginBank
)
if (allowance < neededRaw) {
const approval = await collateral.approve(
contracts.addressMarginBank,
neededRaw
)
approvalHash = approval.hash
await approval.wait()
}
const deposit = await marginBank.depositToBank(wallet.address, neededRaw)
depositHash = deposit.hash
await deposit.wait()
bankRaw = await marginBank.bankBalances(wallet.address)
}
// Chain confirmation precedes the exchange read model. Wait until the API has
// materialized the deposit before trying to reserve order margin.
let margin = null
for (let attempt = 0; attempt < 90; attempt += 1) {
margin = await request('/v0/balance/' + user.user_id + '/margin')
if (margin.balance.total >= Number(TARGET_DEPOSIT)) break
await sleep(1000)
}
if (!margin || margin.balance.available <= 0) {
throw new Error('Deposit did not appear in the API balance within 90 seconds')
}
const markets = await request('/v0/markets')
const market = markets
.filter(
(item) =>
item.trading_enabled &&
item.type !== 'binary_future' &&
item.mark_price_e18_raw &&
item.mtb_long_e18_raw &&
item.min_order_size * item.mark_price < Number(TARGET_DEPOSIT) / 2
)
.sort((left, right) => right.mtb_long - left.mtb_long)[0]
if (!market) throw new Error('No suitable trading-enabled market is available')
// Start the bid below the mark, using half the long take buffer as an offset.
// This is a pricing heuristic, not the lower take bound (which uses mtb_short).
// Tick/minimum rounding still applies. There is no post-only guarantee;
// the order can fill even if its price is below the current mark.
const ONE_E18 = 10n ** 18n
const markRaw = BigInt(market.mark_price_e18_raw)
const tickRaw = BigInt(market.tick_size_e18_raw)
const halfBandRaw = BigInt(market.mtb_long_e18_raw) / 2n
let priceRaw =
((markRaw * (ONE_E18 - halfBandRaw)) / ONE_E18 / tickRaw) * tickRaw
const minimumRaw = BigInt(market.min_order_price_e18_raw)
if (priceRaw < minimumRaw) {
priceRaw = ((minimumRaw + tickRaw - 1n) / tickRaw) * tickRaw
}
const price = Number(formatUnits(priceRaw, 18))
const side = 'LONG'
const reduceOnly = false
const leverage = 1
// An order without reduce_only must be worth at least $5: quantity times
// limit price (a sell on a binary market: times one minus its price). Both are
// 18-decimal integers, so their product has 36 decimals. Round the $5
// quantity up to the step size, and to at least the market's minimum order
// size.
const minimumValueRaw = 5n * ONE_E18 * ONE_E18
const stepRaw = BigInt(market.step_size_e18_raw)
let sizeRaw = (minimumValueRaw + priceRaw - 1n) / priceRaw
sizeRaw = ((sizeRaw + stepRaw - 1n) / stepRaw) * stepRaw
const minimumSizeRaw = BigInt(market.min_order_size_e18_raw)
if (sizeRaw < minimumSizeRaw) sizeRaw = minimumSizeRaw
const quantityRaw = sizeRaw.toString()
const salt =
BigInt(Date.now()) * 1000n + BigInt(Math.floor(Math.random() * 1000))
if (salt >= 2n ** 53n) throw new Error('Salt must remain below 2^53')
const expiration = Math.floor(Date.now() / 1000) + 300
const buyBit = side === 'LONG' ? 1n : 0n
const reduceOnlyBit = reduceOnly ? 2n : 0n
const flags = toBeHex((salt << 4n) | buyBit | reduceOnlyBit, 8)
const domain = {
name: 'IsolatedTrader',
version: '2.0',
chainId: CHAIN_ID,
verifyingContract: market.trader_address,
}
const types = {
Order: [
{ name: 'flags', type: 'bytes8' },
{ name: 'quantity', type: 'uint128' },
{ name: 'price', type: 'uint128' },
{ name: 'triggerPrice', type: 'uint128' },
{ name: 'triggerCondition', type: 'uint8' },
{ name: 'leverage', type: 'uint128' },
{ name: 'maker', type: 'address' },
{ name: 'expiration', type: 'uint128' },
],
}
const message = {
flags,
triggerCondition: 0,
quantity: BigInt(quantityRaw),
price: priceRaw,
triggerPrice: 0n,
leverage: parseUnits(String(leverage), 18),
maker: wallet.address,
expiration: BigInt(expiration),
}
// Append 01 to the 65-byte ethers signature. This embedded signature-type
// byte selects personal-message prepend verification and is separate from
// signature_type: 1 in the JSON request.
const orderDigest = TypedDataEncoder.hash(domain, types, message)
const signature = (await wallet.signMessage(getBytes(orderDigest))) + '01'
const placement = await request('/v0/orders', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify({
market_id: market.market_id,
side,
order_type: 'LIMIT',
price,
quantity_e18_raw: quantityRaw,
leverage,
reduce_only: reduceOnly,
expiration_seconds: expiration,
salt: salt.toString(),
signature,
signature_type: 1,
}),
})
const placedOrder = placement.order
const { cancellation, finalOrder } = await cleanupOrder(
placedOrder, placement.read_after, authHeaders
)
console.log(
JSON.stringify(
{
wallet: wallet.address,
user_id: user.user_id,
faucet_tx: faucet?.tx_hash ?? null,
approval_tx: approvalHash,
deposit_tx: depositHash,
deposited: formatUnits(bankRaw, decimals),
market_id: market.market_id,
order_id: placedOrder.order_id,
order_hash: placedOrder.order_hash,
placed_status: placedOrder.status,
cancelled: cancellation?.cancelled ?? false,
cancel_status: cancellation?.status ?? null,
final_status: finalOrder.status,
live_order_remaining: ACTIVE_STATUSES.has(finalOrder.status),
},
null,
2
)
)
Read-after-write bookmarks
The placement and cancel responses carry a read_after bookmark. Passing it
back as read_after_shard_id and read_after_shard_seq on a GET, as
readAfterPath above does, makes the read wait until the read model has applied
that write. The server decides how long to wait — up to 5 seconds — so there is
no client-supplied server wait setting. The example separately limits each HTTP
request to 10 seconds and each status-polling phase to 60 seconds/60 attempts.
If freshness cannot be established, retry according to the response:
409withdetails.codeofHTV1_READ_AFTER_STALE_BOOKMARK— the bookmark cannot be placed (it names an owner this API does not serve, or its sequence is not a whole number). Discard it and read again without it.503withdetails.codeofHTV1_READ_AFTER_PENDINGand aRetry-Afterheader — the write has not landed in the read model yet. Keep the bookmark and ask again after the requested delay.503withdetails.codeofHTV1_READ_AFTER_UNAVAILABLEand aRetry-Afterheader (currently 2 seconds) — this process cannot establish projection freshness. Keep the bookmark and retry after the requested delay within the same bounded read budget.429withdetails.codeofHTV1_READ_AFTER_CAPACITY— the API is already holding as many waiting reads as it allows. Back off, then retry with the same bookmark.
A read sent without a bookmark never waits; it returns the latest data the read model already has. Dropping a stale bookmark does not prove cleanup: the script keeps polling until it sees a known terminal status. An exhausted read budget or another error triggers one more cancellation attempt for the same order and a fresh bounded confirmation phase. The read retry deadline survives that second cancellation: a status read waits for it, or stops if the requested delay cannot fit the remaining phase budget. If confirmation still fails, the script reports cleanup as unconfirmed. It never automatically retries placement.
Confirm success
The final JSON should contain:
- a nonzero
order_id - an
order_hash cancelled: truewhen the cancel took the whole order, so nothing more of it fills. It is false when a fill won the race: a batch had already matched part or all of the order,cancel_statusiscancel_pending_finalize, and the cancel response'ssealed_in_flight_at_cancel_e18_rawis the matched quantity still settling andcancelled_quantity_e18_rawthe unmatched rest it cancelled. It is also false when the order had already ended (the cancel is refused with 404, anddetails.terminal_statussays how) or the cancel acknowledgement was lost- a terminal
final_status, normallyCANCELLED, orFILLEDwhen a fill won the race live_order_remaining: false
An accepted placement proves that authentication, collateral, exact integer conversion, flag packing, EIP-712 signing, and the order request all agreed.
If a run stops early
Keep the same PRIVATE_KEY and account API key; do not create a replacement
wallet. The script checks existing wallet and MarginBank balances before
requesting or depositing more collateral, but rerunning it also places a new
order. First resolve any previous placement or cleanup uncertainty.
If the script stopped after order placement, cancel the order using
POST /v0/orders/cancel/by-hash/:order_hash, or cancel all orders for this
disposable account using POST /v0/orders/cancel/all. Authenticate with that
account's API key: Authorization: Key <api-key>. If placement timed out before
returning an order ID, inspect
GET /v0/orders/by-user/:user_id for that account and reconcile the submitted
order; an HTTP timeout does not establish that placement failed. Do not abandon
a run or rerun placement until no order has an active status. A fill can leave a
position even after every order is terminal; inspect the account's positions and
close any test exposure separately, with an order that sets reduce_only: true:
an order without it must be worth at least $5, so a smaller close is refused.
Sign up from code
A wallet can create its account without the browser: it signs one Sign-In with Ethereum message, and the bot then authenticates with signed tokens instead of an API key. This script funds the wallet with test USDM, signs up, waits until the account is ready, registers a trading key, and signs the key's first trade token. Wallet sign-up is open on testnet only for now.
Use two fresh keys: PRIVATE_KEY for the account wallet and TRADING_KEY
for the key that signs orders and tokens. A key can be generated with
node -e "console.log(require('ethers').Wallet.createRandom().privateKey)".
Keep both out of source control. REFERRAL_CODE is optional.
export PRIVATE_KEY="a-fresh-testnet-private-key"
export TRADING_KEY="another-fresh-private-key"
node testnet-sign-up.mjs
Signing up accepts the terms and risk disclaimer at the version the message
names, and confirms that you are not a Restricted Person as the terms define
it. The API does not check where you are: the signed statement is your
confirmation. Save the following as testnet-sign-up.mjs:
import {
Contract,
Interface,
JsonRpcProvider,
Wallet,
getAddress,
hexlify,
parseUnits,
randomBytes,
} from 'ethers'
const API = 'https://api.testnet.mnx.fi'
const SITE = 'testnet.mnx.fi'
const RPC = 'https://carrot.megaeth.com/rpc'
const CHAIN_ID = 6343
const HOUR = 60 * 60 * 1000
// Sign-up needs $5 of USDM in MarginBank, which counts 18 decimals.
const SIGN_UP_MINIMUM = parseUnits('5', 18)
const { PRIVATE_KEY, TRADING_KEY, REFERRAL_CODE } = process.env
if (!PRIVATE_KEY) throw new Error('Set PRIVATE_KEY to a fresh testnet key')
if (!TRADING_KEY) throw new Error('Set TRADING_KEY to another fresh key')
const provider = new JsonRpcProvider(RPC)
const wallet = new Wallet(PRIVATE_KEY, provider)
const tradingKey = new Wallet(TRADING_KEY)
const sleep = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds))
async function request(path, options = {}) {
const response = await fetch(API + path, {
...options,
signal: AbortSignal.timeout(10_000),
headers: {
'Content-Type': 'application/json',
...(options.headers ?? {}),
},
})
const text = await response.text()
let data = null
try {
data = text ? JSON.parse(text) : null
} catch {
data = text
}
if (!response.ok) {
throw Object.assign(
new Error(
(options.method ?? 'GET') + ' ' + path + ' -> ' +
response.status + ': ' + JSON.stringify(data)
),
{ status: response.status, code: data?.details?.code }
)
}
return data
}
// Asks for a challenge and signs it only if it names the testnet site, this
// wallet, chain and API, the expected statement and exactly the codes sent.
async function signChallenge(action, statementStart, codes = {}) {
const { message } = await request('/v0/auth/challenge', {
method: 'POST',
body: JSON.stringify({ userAddress: wallet.address, action, ...codes }),
})
const resources = [
...(codes.invite_code ? ['urn:mnx:invite:' + codes.invite_code] : []),
...(codes.referral_code ? ['urn:mnx:referral:' + codes.referral_code] : []),
]
const lines = message.split('\n')
const expected =
lines[0] === SITE + ' wants you to sign in with your Ethereum account:' &&
lines[1]?.toLowerCase() === wallet.address.toLowerCase() &&
lines[2] === '' &&
lines[3]?.startsWith(statementStart) &&
lines[4] === '' &&
lines[5] === 'URI: ' + API &&
lines[6] === 'Version: 1' &&
lines[7] === 'Chain ID: ' + CHAIN_ID &&
/^Nonce: [A-Za-z0-9]{8,}$/.test(lines[8] ?? '') &&
lines[9]?.startsWith('Issued At: ') &&
lines[10]?.startsWith('Expiration Time: ') &&
JSON.stringify(lines.slice(11)) ===
JSON.stringify(
resources.length === 0
? []
: ['Resources:', ...resources.map((resource) => '- ' + resource)]
)
if (!expected) throw new Error('Refusing to sign this challenge:\n' + message)
return { message, signature: await wallet.signMessage(message) }
}
// A token is its text in URL-safe base64, a dot, and the signature in
// lower-case hex without 0x. Times are written as toISOString() writes them.
async function signToken(signer, lines) {
const text = lines.join('\n')
const signature = await signer.signMessage(text)
return (
Buffer.from(text).toString('base64url') + '.' +
signature.slice(2).toLowerCase()
)
}
function newTokenTimes() {
const issuedAt = new Date()
return {
nonce: hexlify(randomBytes(16)).slice(2),
issuedAt: issuedAt.toISOString(),
expiresAt: new Date(issuedAt.getTime() + 8 * HOUR),
}
}
// The wallet's token reads the account and manages its trading keys. It
// never trades.
async function walletToken() {
const { nonce, issuedAt, expiresAt } = newTokenTimes()
return await signToken(wallet, [
SITE + ' wants you to sign in with your Ethereum account:',
wallet.address,
'',
'Sign in to the MNX API: read this account and manage its trading keys for up to 8 hours.',
'',
'URI: ' + API,
'Version: 1',
'Chain ID: ' + CHAIN_ID,
'Nonce: ' + nonce,
'Issued At: ' + issuedAt,
'Expiration Time: ' + expiresAt.toISOString(),
])
}
// The trading key signs its trade token in code. Reuse the token, and sign
// the next one 10 minutes before it expires.
let tradeToken = null
async function tradeAuth() {
if (
!tradeToken ||
Date.now() > tradeToken.expiresAt.getTime() - 10 * 60 * 1000
) {
const { nonce, issuedAt, expiresAt } = newTokenTimes()
const token = await signToken(tradingKey, [
'MNX trading key token',
'Account: ' + wallet.address,
'Key: ' + tradingKey.address,
'Scope: trade',
'URI: ' + API,
'Chain ID: ' + CHAIN_ID,
'Nonce: ' + nonce,
'Issued At: ' + issuedAt,
'Expiration Time: ' + expiresAt.toISOString(),
])
tradeToken = { token, expiresAt }
}
return { Authorization: 'Token ' + tradeToken.token }
}
const network = await provider.getNetwork()
if (network.chainId !== BigInt(CHAIN_ID)) {
throw new Error('Wrong chain: expected ' + CHAIN_ID + ', got ' + network.chainId)
}
// 1. Meet the sign-up minimum. testnet/faucet/wallet deposits test USDM
// straight into MarginBank for a wallet with no account, so no gas is needed.
const contracts = await request('/v0/contracts/shared')
const marginBank = new Contract(
contracts.addressMarginBank,
['function bankBalances(address) view returns (uint256)'],
provider
)
let funding = null
if ((await marginBank.bankBalances(wallet.address)) < SIGN_UP_MINIMUM) {
try {
funding = await request('/v0/testnet/faucet/wallet', {
method: 'POST',
body: JSON.stringify(
await signChallenge(
'testnet_funds',
"Send test USDM to this wallet's MNX margin balance on testnet."
)
),
})
} catch (error) {
// A wallet with an account tops up through POST /v0/testnet/faucet.
if (error.code !== 'ACCOUNT_EXISTS') throw error
}
for (let attempt = 0; funding; attempt += 1) {
if ((await marginBank.bankBalances(wallet.address)) >= SIGN_UP_MINIMUM) break
if (attempt >= 60) throw new Error('The test USDM did not arrive within a minute')
await sleep(1000)
}
}
// 2. Sign up. A wallet that already has an account is signed up already.
let signUp = null
try {
signUp = await request('/v0/users/sign-up', {
method: 'POST',
body: JSON.stringify(
await signChallenge('sign_up', 'Create an MNX account for this wallet.', {
referral_code: REFERRAL_CODE,
})
),
})
} catch (error) {
if (error.code !== 'ACCOUNT_EXISTS') throw error
}
// 3. Wait until the account is ready to trade.
const walletAuth = { Authorization: 'Token ' + (await walletToken()) }
let readiness = null
for (let attempt = 0; ; attempt += 1) {
readiness = await request('/v0/me/readiness', { headers: walletAuth })
if (readiness.ready) break
if (attempt >= 120) {
throw new Error('Not ready after 2 minutes; held by ' + readiness.holds.join(', '))
}
await sleep(1000)
}
// 4. Register the trading key: the wallet signs the prepared AccountManager
// approval as EIP-712 typed data, and the exchange relays it and pays the gas.
const prepared = await request('/v0/session-keys/prepare', {
method: 'POST',
headers: walletAuth,
body: JSON.stringify({
session_public_address: tradingKey.address,
operation: 'register',
}),
})
const approval = new Interface([
'function setSubAccount(address operator, bool approved)',
]).encodeFunctionData('setSubAccount', [tradingKey.address, true])
if (
getAddress(prepared.from) !== wallet.address ||
prepared.chain_id !== CHAIN_ID ||
getAddress(prepared.to) !== getAddress(prepared.account_manager_address) ||
prepared.data.toLowerCase() !== approval.toLowerCase()
) {
throw new Error('Refusing to sign a registration that does anything else')
}
const { from, to, gas, nonce, deadline, data } = prepared
const forwardRequest = { from, to, gas, nonce, deadline, data }
const registration = await wallet.signTypedData(
{
name: 'MinimalForwarder',
version: '0.0.2',
chainId: CHAIN_ID,
verifyingContract: prepared.minimal_forwarder_address,
},
{
ForwardRequest: [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'gas', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
{ name: 'data', type: 'bytes' },
],
},
forwardRequest
)
const relayed = await request('/v0/session-keys/relay', {
method: 'POST',
headers: walletAuth,
body: JSON.stringify({
...forwardRequest,
signature: registration,
session_public_address: tradingKey.address,
approved: true,
// lifetime_days: 180, // 1 to 180 days; without it the key lasts 30
}),
})
let key = null
for (let attempt = 0; attempt < 60 && key?.status !== 'ACTIVE'; attempt += 1) {
if (attempt > 0) await sleep(1000)
const keys = await request('/v0/session-keys', { headers: walletAuth })
key = keys.session_keys.find(
(item) =>
item.session_public_address.toLowerCase() ===
tradingKey.address.toLowerCase()
)
}
if (key?.status !== 'ACTIVE') {
throw new Error('The trading key is not ACTIVE: ' + JSON.stringify(relayed))
}
// 5. Authenticate as the trading key.
const checked = await request('/v0/me/readiness', { headers: await tradeAuth() })
console.log(
JSON.stringify(
{
wallet: wallet.address,
user_id: checked.user_id,
signed_up_now: signUp !== null,
terms_version: signUp?.terms_version ?? null,
funding_tx: funding?.tx_hash ?? null,
trading_key: tradingKey.address,
key_status: key.status,
key_expires_at: key.expires_at,
trade_token_expires_at: tradeToken.expiresAt.toISOString(),
},
null,
2
)
)
To trade as the key, change three things in the order program above:
authenticate with await tradeAuth() instead of authHeaders, sign each
order with the trading key (tradingKey.signMessage(getBytes(orderDigest)),
with the same 01 byte after it), and keep maker as wallet.address, the
account's wallet. The exchange's order and account routes accept the key's
tokens at once. The read servers accept them once they have caught up with
the registration, usually within seconds; a read refused with 401
Token refused. right after registering can be retried a few seconds later.
A token lasts at most 8 hours. Sign the next one before it expires, as
tradeAuth does, and reuse each one for its whole life: each signer has a
budget of 30 new tokens, refilled at one a minute. On a WebSocket, send the
next token in a SUBSCRIBE at least a minute before the old one expires, or
the connection closes.
Without lifetime_days a key lasts 30 days from its registration. A
registration can ask for 1 to 180 days; an app that registers one key per user
can send 180. The owner can register a
new key at any time with the wallet token; the account can register 10 keys a
day.
If the trading key leaks
Switch it off at once, with the key's own signature and the cancel of the account's open orders. This needs neither the wallet nor a login:
const ACCOUNT = getAddress(process.env.ACCOUNT_ADDRESS) // the account wallet
const message = [
'MNX trading key switch-off',
'Account: ' + ACCOUNT,
'Key: ' + tradingKey.address,
'Cancel orders: yes',
'URI: ' + API,
'Chain ID: ' + CHAIN_ID,
'Issued At: ' + new Date().toISOString(),
].join('\n')
const switched = await request('/v0/session-keys/switch', {
method: 'POST',
body: JSON.stringify({
message,
signature: await tradingKey.signMessage(message),
}),
})
console.log(switched.status, switched.cancel)
The key's tokens and new orders are refused from then on. Each signed command
works once: a 409 with details.code TRADING_KEY_SWITCH_REPLAYED means
this key switched within the last minute, and details.not_before
says when to sign a new command; send it once, not in a loop. The owner can
switch the key off too, with the wallet token: POST /v0/session-keys/switch-off
with { "session_public_address": "<key>", "cancel_orders": true }.
When switched.cancel shows counts rather than refused, and the account has
no open orders, revoke the key with the owner wallet or let it renounce itself
(POST /v0/session-keys/renounce). Revoking first would leave the orders it
signed open, to fail when they fill along with the account's other orders in
that batch. A refused cancel does not undo the switch-off; send a new signed
command to retry it. Only the owner can switch a key back on; see
Authentication and signing.
Easy-to-miss details
- The API key authenticates HTTP requests. The EIP-712 signature separately authorizes the exact order.
POST /v0/testnet/faucetsends test USDM and testnet gas to the wallet; it does not deposit collateral into the exchange.- Fetch contract addresses from
GET /v0/contracts/shared, then call tokenapproveand MarginBankdepositToBank. - Wait for
GET /v0/balance/:user_id/marginafter the deposit confirms. - Use exact
*_e18_rawstrings in the EIP-712 message and matching human-readable numbers in the JSON body. - Pack flags as
(salt << 4) | buyBit | reduceOnlyBit. Append01to the signature returned bywallet.signMessage(getBytes(orderDigest)). That appended byte selects personal-message recovery in the contract. JSONsignature_type: 1is a separate API field. - An order without
reduce_onlymust be worth at least $5: its quantity times its limit price, or for a market order the price the book clears its whole quantity at when it arrives, which also sets the margin it reserves (the mark price only when the book cannot fill it; a sell on a binary market: times one minus that price). Size a market order from the book,GET /v0/markets/:market_id/book, not from the mark: a buy clears at or above the best ask, and a sell at or above the bid where the bids, best first, cover its quantity. Leave some room for the book to move. The API refuses a smaller order with a400response. - If the API key leaks, revoke it with
POST /v0/me/api-key/revoke, authenticated with that key and an empty JSON body. No wallet signature is needed. It also closes the WebSocket connections the key authenticated (close code 4401). Generate a new key in Settings afterwards. - The wallet's token never trades: orders and cancels need a
tradetoken, which only a trading key signs. A wallet signature obtained by phishing therefore cannot trade. - If a trading key leaks, switch it off with the cancel first, by the key itself or by the owner, and revoke it only once the cancel has finished.