Build a testnet order bot
This is the shortest complete path from a new wallet to an accepted MNX order. The example uses Node.js 18 or newer, ethers v6, and public testnet endpoints only. It creates a wallet session, requests test collateral, deposits it into the exchange, places a minimum-size limit order away from the market, reads the accepted order back, and cancels it so no live order remains.
Testnet only: use a fresh private key that has never held real assets. The faucet and chain ID in this guide are not for production. Before setting the acceptance fields in the authorization request, the operator must review and accept the applicable terms and risk disclaimer.
Download this complete quickstart as Markdown — ideal for AI agents, terminal workflows, and offline reading.
Run it
Start in an empty directory. The generated key remains in your current shell so you can safely retry the script without creating a second account or losing the ability to clean up its order.
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)")"
node testnet-order-bot.mjsSave this as testnet-order-bot.mjs:
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
)
)What success means
The final JSON should contain a nonzero order_id, an order_hash, cancel_accepted: true, a terminal final_status (normally CANCELLED), and live_order_remaining: false. An accepted placement proves authentication, collateral, exact integer conversion, flag packing, EIP-712 signing, and the order request all agreed.
If the script stops after placement, rerun it with the same private key or call POST /v0/orders/cancel/all with its bearer token. The faucet is rate-limited, so reusing the same test wallet also makes retries faster.
Why each step exists
POST /v0/auth/authorizecreates the exchange account and returns the bearer token used for authenticated requests.POST /v0/faucetgives the wallet test USDM and native testnet gas. It intentionally does not move USDM into the exchange.- ERC-20
approvefollowed by MarginBankdepositToBankmakes collateral available for orders. Fetch both contract addresses fromGET /v0/contracts/shared; do not hardcode deployment addresses. - The API balance is a materialized view of chain events, so the bot waits after the deposit transaction confirms.
- The signed EIP-712 message uses exact
_e18_rawintegers, while the JSON body uses their human-readable values. Both forms must represent the same order.
For field-level signing rules, see Authentication & signing. For matching, order status, and cancel semantics, see Placing orders.