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:

  • 409 with details.code of HTV1_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.
  • 503 with details.code of HTV1_READ_AFTER_PENDING and a Retry-After header — the write has not landed in the read model yet. Keep the bookmark and ask again after the requested delay.
  • 503 with details.code of HTV1_READ_AFTER_UNAVAILABLE and a Retry-After header (currently 2 seconds) — this process cannot establish projection freshness. Keep the bookmark and retry after the requested delay within the same bounded read budget.
  • 429 with details.code of HTV1_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: true when 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_status is cancel_pending_finalize, and the cancel response's sealed_in_flight_at_cancel_e18_raw is the matched quantity still settling and cancelled_quantity_e18_raw the unmatched rest it cancelled. It is also false when the order had already ended (the cancel is refused with 404, and details.terminal_status says how) or the cancel acknowledgement was lost
  • a terminal final_status, normally CANCELLED, or FILLED when 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

  1. The API key authenticates HTTP requests. The EIP-712 signature separately authorizes the exact order.
  2. POST /v0/testnet/faucet sends test USDM and testnet gas to the wallet; it does not deposit collateral into the exchange.
  3. Fetch contract addresses from GET /v0/contracts/shared, then call token approve and MarginBank depositToBank.
  4. Wait for GET /v0/balance/:user_id/margin after the deposit confirms.
  5. Use exact *_e18_raw strings in the EIP-712 message and matching human-readable numbers in the JSON body.
  6. Pack flags as (salt << 4) | buyBit | reduceOnlyBit. Append 01 to the signature returned by wallet.signMessage(getBytes(orderDigest)). That appended byte selects personal-message recovery in the contract. JSON signature_type: 1 is a separate API field.
  7. An order without reduce_only must 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 a 400 response.
  8. 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.
  9. The wallet's token never trades: orders and cancels need a trade token, which only a trading key signs. A wallet signature obtained by phishing therefore cannot trade.
  10. 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.