# Build an MNX testnet order bot

This is a complete copy-and-run path from a new wallet to an accepted MNX
order. It authenticates, requests test collateral, deposits it into the
exchange, places a minimum-size limit order away from the market, reads the
order back, and cancels it so no live order remains.

> **Testnet only.** Use a fresh private key that has never held real assets.
> Before setting the acceptance fields in the authorization request, the
> operator must review and accept the applicable terms and risk disclaimer.

Prefer the visual guide? Read the
[HTML version](https://docs.mnx.fi/guides/bot-quickstart). For field-level
details, see [Authentication and signing](https://docs.mnx.fi/guides/authentication)
and [Placing orders](https://docs.mnx.fi/guides/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:

```sh
npm init -y
npm install ethers@6
export MNX_PRIVATE_KEY="$(node --input-type=module -e "import { Wallet } from 'ethers'; console.log(Wallet.createRandom().privateKey)")"
```

Keep that shell open so a retry uses the same disposable wallet. Save the
following as `testnet-order-bot.mjs`, then run:

```sh
node testnet-order-bot.mjs
```

```js
import {
  Contract,
  JsonRpcProvider,
  Wallet,
  formatUnits,
  getBytes,
  keccak256,
  parseUnits,
  toBeHex,
  toUtf8Bytes,
} 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 ACTIVE_STATUSES = new Set([
  'OPEN',
  'PENDING_TRIGGER',
  'CANCEL_PENDING_FINALIZE',
])

const privateKey = process.env.MNX_PRIVATE_KEY
if (!privateKey) throw new Error('Set MNX_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 = {}) {
  const response = await fetch(API + path, {
    ...options,
    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 new Error(
      (options.method ?? 'GET') +
        ' ' +
        path +
        ' -> ' +
        response.status +
        ': ' +
        JSON.stringify(data)
    )
  }
  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),
    timeout_ms: '10000',
  })
  return path + '?' + query
}

const network = await provider.getNetwork()
if (network.chainId !== BigInt(CHAIN_ID)) {
  throw new Error(
    'Wrong chain: expected ' + CHAIN_ID + ', got ' + network.chainId
  )
}

// The API expects an Ethereum personal signature of keccak256(API base URL).
const onboardingHash = keccak256(toUtf8Bytes(API))
const onboardingSignature = await wallet.signMessage(getBytes(onboardingHash))
const authorization = await request('/v0/auth/authorize', {
  method: 'POST',
  body: JSON.stringify({
    signature: onboardingSignature,
    userAddress: wallet.address,
    isTermAccepted: true,
    accepted_risk_disclaimer: true,
  }),
})
const token = authorization.token
const authHeaders = { Authorization: 'Bearer ' + token }

await request('/v0/me/client/session', { headers: authHeaders })
const user = await request('/v0/users/by-eoa/' + wallet.address)
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')

// Put the limit halfway between the mark and the lower market-price band.
// This is deliberately non-marketable, reducing the chance of a test fill.
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 quantity = market.min_order_size
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: '1.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: 'leverage', type: 'uint128' },
    { name: 'maker', type: 'address' },
    { name: 'expiration', type: 'uint128' },
  ],
}
const message = {
  flags,
  quantity: BigInt(market.min_order_size_e18_raw),
  price: priceRaw,
  triggerPrice: 0n,
  leverage: parseUnits(String(leverage), 18),
  maker: wallet.address,
  expiration: BigInt(expiration),
}

// Append 00 to the 65-byte ethers signature. This embedded recovery-mode
// byte is separate from signature_type: 1 in the JSON request.
const signature = (await wallet.signTypedData(domain, types, message)) + '00'
const placement = await request('/v0/orders', {
  method: 'POST',
  headers: authHeaders,
  body: JSON.stringify({
    market_id: market.market_id,
    side,
    order_type: 'LIMIT',
    price,
    quantity,
    leverage,
    reduce_only: reduceOnly,
    post_only: true,
    expiration_seconds: expiration,
    salt: salt.toString(),
    signature,
    signature_type: 1,
  }),
})
const placedOrder = placement.order

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' }),
    }
  )
} catch (error) {
  // A very unlikely fill can race the cancel; the status read below decides
  // whether any live remainder still needs cleanup.
  console.error(String(error))
}

let finalOrder = placedOrder
const statusPath = readAfterPath(
  '/v0/orders/' + placedOrder.order_id,
  cancellation?.read_after ?? placement.read_after
)
for (let attempt = 0; attempt < 60; attempt += 1) {
  finalOrder = await request(statusPath, { headers: authHeaders })
  if (!ACTIVE_STATUSES.has(finalOrder.status)) break
  await sleep(1000)
}

if (ACTIVE_STATUSES.has(finalOrder.status)) {
  await request('/v0/orders/cancel/all', {
    method: 'POST',
    headers: authHeaders,
    body: JSON.stringify({
      market_id: market.market_id,
      reason: 'Testnet quickstart fallback cleanup',
    }),
  })
  throw new Error('Cleanup was submitted but the order is not terminal yet')
}

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
  )
)
```

## Confirm success

The final JSON should contain:

- a nonzero `order_id`
- an `order_hash`
- `cancel_accepted: true`
- 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

Retry with the same `MNX_PRIVATE_KEY`. The script checks existing wallet and
MarginBank balances before requesting or depositing more collateral.

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`. Do not abandon a run
until no order has an active status.

## Easy-to-miss details

1. The bearer token 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 `00` to the
   ethers signature and also send JSON `signature_type: 1`.
