Download this complete quickstart as Markdown

Build an MNX testnet order bot

After browser account setup and API-key creation, this is a complete copy-and-run path to an accepted MNX order. It authenticates, requests test collateral, deposits it into the exchange, places a minimum-size limit order, reads the order back, and attempts to cancel it. Success requires a confirmed terminal order status.

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.

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 = '10'
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/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
const quantityRaw = market.min_order_size_e18_raw
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,
      cancel_accepted: cancellation?.cancelled ?? false,
      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 (unknown owner, or a rebuilt read model). 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
  • cancel_accepted: true when the final cancellation was acknowledged; it may be false if a fill raced cancellation or the cancel acknowledgement was lost
  • a terminal final_status, normally CANCELLED
  • 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.

Easy-to-miss details

  1. The API key authenticates HTTP requests. The EIP-712 signature separately authorizes the exact order.
  2. POST /v0/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.