BONDED

Documentation

BONDED is a mandate gate and reconciliation layer between an AI agent and a Binance Spot account. This page covers installation, the mandate schema, the tool contract and how to operate it.

Overview

The agent holds no Binance credential. Its entire capability is the five MCP tools below, so the constraint is structural rather than advisory. Every order is evaluated against a mandate before it is sent, and every order that reaches the exchange is reconciled against what BONDED actually authorised.

Three components, in the order an order passes through them:

Component Responsibility
Gate Pure evaluation of an order against the mandate. No I/O, fails closed, names the clause that refused.
Audit log Append-only, hash-chained record of every decision, fsync'd before the order is sent.
Reconciler Compares the exchange's order history against the audit log. Disagreement burns the bond.

Requirements

No system binaries, no root, no database. Runtime state is two files on disk.

Install

git clone https://github.com/Ritapossible/Bonded
cd Bonded
npm install
npm run build

cp .env.example .env      # fill in BINANCE_API_KEY and BINANCE_SECRET_KEY
openssl rand -hex 32      # -> BONDED_HMAC_SECRET in .env

mkdir -p data
cp examples/mandate.example.json data/mandate.json

npm start

The data/ directory is not in the repository - it holds runtime state, which is deliberately not committed - so mkdir -p data comes before the copy. BONDED creates the decision log itself; the mandate is the one file you supply.

Configuration

Copy .env.example to .env and fill it in. Configuration is validated once at startup - a missing value fails immediately with the field named, rather than at the first order.

Variable Default Purpose
BINANCE_API_KEY - Required. Spot Testnet key.
BINANCE_SECRET_KEY - Required. Never logged; wrapped so interpolation yields [redacted].
BONDED_HMAC_SECRET - Required, 32+ chars. Stamps authorised orders so a bypass is detectable. Generate with openssl rand -hex 32.
BINANCE_API_ENV testnet testnet or prod.
BONDED_ALLOW_PROD 0 Must be 1 for production. A misconfiguration fails at boot.
BONDED_ALLOW_PARTIAL_AUDIT 0 Permit trading when only symbol-scoped observation is live. See Limits.
BONDED_PRICE_SOURCE rest rest or binance-cli. The latter reads reference prices through @binance/binance-cli, Binance's own Agent OS tooling, which takes the same variables and supports testnet. Orders are always signed by BONDED itself. Either source fails closed.
BONDED_WATCH_SYMBOLS empty Extra symbols the polling backstop watches, comma separated, beyond the mandate's own.
BINANCE_SPOT_BASE_PATH https://testnet.binance.vision REST base URL. Validated against an allowlist of Binance hostnames and must match BINANCE_API_ENV.
BINANCE_STREAM_BASE_PATH wss://stream.testnet.binance.vision/ws User data stream endpoint.
BONDED_MANDATE_PATH ./data/mandate.json Mandate location.
BONDED_DECISION_LOG_PATH ./data/decisions.jsonl Hash-chained audit log.
BONDED_LOOKBACK_MS 86400000 How far back the first poll reaches, to catch orders placed while BONDED was down. A fresh instance with an empty decision log should not inherit history it has no records for - every order legitimately authorised in that window reads as UNKNOWN_AUTHENTIC and burns the bond at startup.
BONDED_POLL_INTERVAL_MS 15000 Reconciliation backstop interval.
BONDED_CONSOLE_PORT 7391 Owner console. 0 disables it. Always loopback.
BONDED_LOG_LEVEL info debug, info, warn, error.

Disable withdrawals on the API key. BONDED does not implement a withdrawal guard - it verifies the exchange enforces one, which survives BONDED being wrong about everything else. On testnet that check cannot run, and the guard reports WARN rather than claiming a pass.

Connect an agent

BONDED is an MCP server over stdio, so any MCP client can hold it. It is not tied to one agent - the point of the mandate is that whatever sits behind it gets the same limits. Every client below runs the same command:

node /absolute/path/to/Bonded/dist/cli.js

Use an absolute path, and pass the credentials in the config. An agent launches this process itself, often from a desktop app with no shell and a working directory you did not choose. BONDED reads a .env beside its own installation as well as in the working directory, but a GUI-launched client is the case most likely to find neither - so the env block below is the reliable route. Anything already set in the real environment always wins over the file.

Claude Code

claude mcp add bonded -- node /absolute/path/to/Bonded/dist/cli.js

Codex CLI

~/.codex/config.toml - TOML, and the table name is plural:

[mcp_servers.bonded]
command = "node"
args = ["/absolute/path/to/Bonded/dist/cli.js"]

[mcp_servers.bonded.env]
BINANCE_API_KEY = "..."
BINANCE_SECRET_KEY = "..."
BINANCE_API_ENV = "testnet"
BONDED_HMAC_SECRET = "..."

Claude Desktop

~/Library/Application Support/Claude/claude_desktop_config.json on macOS, %APPDATA%\Claude\claude_desktop_config.json on Windows:

{
  "mcpServers": {
    "bonded": {
      "command": "node",
      "args": ["/absolute/path/to/Bonded/dist/cli.js"],
      "env": {
        "BINANCE_API_KEY": "...",
        "BINANCE_SECRET_KEY": "...",
        "BINANCE_API_ENV": "testnet",
        "BONDED_HMAC_SECRET": "..."
      }
    }
  }
}

Cursor and Windsurf

Same JSON shape as Claude Desktop, in .cursor/mcp.json for one project or ~/.cursor/mcp.json for all of them; Windsurf reads ~/.codeium/windsurf/mcp_config.json.

VS Code

.vscode/mcp.json. Note the key is servers, not mcpServers:

{
  "servers": {
    "bonded": {
      "type": "stdio",
      "command": "node",
      "args": ["/absolute/path/to/Bonded/dist/cli.js"]
    }
  }
}

Anything else

Any client that speaks MCP over stdio works. It needs three things: the command (node), one argument (the absolute path to dist/cli.js), and the environment. Config file locations move between versions - if one of the paths above has changed, check that client's own documentation for where its MCP config lives; the server side is identical either way.

All logging goes to stderr - stdout carries the JSON-RPC frames, and a single stray line there would corrupt the transport. If a client reports the server as failed, run the command by hand first: the boot-guard banner prints the reason, and a guard that fails is a refusal to start rather than a crash.

Alongside Binance's own MCP server

BONDED does not replace Binance's MCP server. It sits next to it. Register both and the agent reads from Binance and writes through the mandate:

claude mcp add binance-mcp-server --transport http https://agent.binance.com/mcp/agentic
claude mcp add bonded -- node /absolute/path/to/Bonded/dist/cli.js

Grant Binance's server the Market data scope and withhold Trade. That division is the whole pairing: market data is public and needs no credentials, so the agent gets tickers, order books and candles from Binance directly, and every order it decides to place goes through BONDED - checked against the mandate and written to the hash-chained log before it is signed.

The two reach different accounts, and it is worth being exact about why. Binance's MCP server trades a dedicated Agentic sub-account on mainnet, over OAuth, with every trade confirmed by a human and no withdrawal scope in existence. That path is already guarded, and BONDED has nothing to add to it. BONDED guards the other path - raw API keys, no confirmation step - which is the one an unattended agent actually runs on, and the one Binance left open. Composed, the agent has Binance's market data on one side and a bound, audited account on the other.

Mandate

The complete statement of what the agent may do. It is data, not code: compiled once, validated, grounded against live exchangeInfo, then hashed. The mandateHash is stamped into every audit record, so a record always cites one exact ruleset.

{
  "version": 1,
  "env": "testnet",
  "symbols": ["BTCUSDT", "ETHUSDT"],
  "orderTypes": ["LIMIT", "MARKET"],
  "sides": ["BUY", "SELL"],
  "maxNotionalUsd": "500",
  "maxOpenOrders": 3,
  "dailyLossLimitUsd": "50",
  "maxDrawdownPct": "5",
  "tradingWindowUtc": ["00:00", "23:59"],
  "expiresAt": "2027-09-08T23:59:00.000Z"
}

All fractional values are decimal strings, matching Binance's own convention - a float loses precision that a limit check depends on. Only counts and the version are numbers.

An unrecognised key is a hard error, not an ignored field. A typo'd clause name must fail loudly: otherwise the operator believes a limit is in force while nothing enforces it.

Gate clauses

Seventeen clauses evaluated in a fixed order, plus two denials raised before them when the gate cannot evaluate an order at all - nineteen names in total, all listed below. The four that read nothing but the mandate and BONDED's own state (environment, scope, auditPath, expiry) are evaluated first, so a revoked scope is never reported as a stale snapshot. After those, the first denial wins. Every clause lives in a single file, so the whole enforcement surface is auditable at once.

Clause Denies when
environment The mandate's env differs from the runtime env.
scope Trade scope has been revoked by a finding.
auditPath No order source is delivering - reconciliation is blind.
expiry The mandate has expired.
symbolAllowlist The symbol is outside the allowlist or unresolvable.
symbolTradable The symbol is not currently trading.
orderType The order type is not permitted.
side The side is not permitted.
tradingWindowUtc Outside the mandate's UTC window.
accountTradingDisabled The exchange account cannot trade.
maxOpenOrders The open-order limit is already reached.
priceFilter Price breaches tick size or price bounds.
lotSize Quantity breaches step size or quantity bounds.
minNotional Notional is below the symbol's minimum.
maxNotionalUsd Notional exceeds the mandate's maximum.
dailyLossLimitUsd The day's realised loss reaches the absolute cap.
maxDrawdownPct The day's realised loss reaches the proportional cap.
referencePrice A base-sized market order has no live price to bound it.
stateFreshness Exchange state is stale or timestamped in the future.

Fail closed. Anything the gate cannot evaluate is a denial, never a pass. An outage must not silently convert a bounded mandate into an unbounded one.

Realised PnL

Both loss clauses compare against realised PnL computed with an average cost basis per symbol, walking Binance's own trade history in chronological order. A buy moves the average cost; a sell realises (price − averageCost) × quantity. Quote-asset commission is subtracted; base-asset commission reduces the quantity acquired.

dailyLossLimitUsd is absolute. maxDrawdownPct is the same loss as a percentage of the account's quote-asset balance. Whichever is tighter fires first.

A position opened before the seven-day basis window has no known cost. Selling it realises an unknown amount, so that quantity is excluded and reported rather than guessed at. The figure therefore understates activity; it never overstates it.

Reconciliation

The gate only sees orders that pass through it. Anything holding the API key can go around it, and the gate is blind to that by construction - so BONDED keeps a second, independent account of reality and treats disagreement as the finding.

Every authorised order is stamped with a client order id of the form bnd_<mandateHash8>_<seq>_<hmac>. Knowing the format is not enough to mint one without the secret.

Outcome Meaning Action
AUTHORISED Executed exactly as authorised. -
MISMATCHED Authorised, but not for the order that executed. Bond burns
FOREIGN No BONDED identifier. A plain bypass. Bond burns
FORGED Wears the namespace without a valid tag. Bond burns
UNKNOWN_AUTHENTIC Valid tag, no matching record. Bond burns

Findings carry four things, not one:

  1. A plain explanation
  2. The verbatim exchange payload
  3. A Binance orderId, checkable without trusting BONDED
  4. An explicit list of what could not be determined

It is designed around two overlapping sources: the account-wide user data stream for near-instant detection, and a polling backstop that is a hard startup dependency. Seeing an order twice costs nothing; seeing it zero times is the failure this exists to prevent. Since February 2026 the stream is unavailable - Binance removed the listen-key endpoints and they answer 410 - so polling is currently the only source, which is why the boot banner reports coverage as DEGRADED rather than quietly carrying on.

Audit log

Every decision - allows as well as denials - is appended to a hash-chained JSONL file and fsync'd before the order is sent. That ordering is the safety argument: an order can never carry an authorisation a crash could erase.

Each record carries the SHA-256 of the previous record's canonical form. Altering or removing any record breaks every link after it. This does not stop an operator rewriting the whole file - that needs an external anchor - but it makes a silent edit impossible, which is the realistic threat.

Verifying a run

The demo shows a bypass being caught, and you have no reason to believe it - every frame could be staged. So the log is checkable by anyone, without running BONDED, without an API key, and without trusting whoever recorded the video. A sample log ships in the repository, written by the real writer, so the command can be tried offline:

node dist/cli.js verify examples/sample-decisions.jsonl

Holding nothing but the file, that verifies the hash chain from genesis, that sequence numbers are contiguous and that every record cites one mandate hash - and prints the exchange order ids, which are checkable against Binance independently of this project.

What a chain walk cannot do

The chain hash is unkeyed, so anyone can compute it. Walking the chain pins each record against the one after it, which leaves the last record pinned by nothing. Editing the final record, or appending one with a correctly computed prevHash, therefore survives a chain walk - and both are exactly what faking a demo would look like. The command reports them as unchecked rather than passing them off as verified.

Closing that needs no secret, only a commitment made in advance. The head hash commits to the whole history: publish it - say it on camera, put it in a README - and pass it back.

node dist/cli.js verify examples/sample-decisions.jsonl \
  --head 54aaa41cc363c850f66d81d583dbe0dbaa15587c00679e65369890b644669ee8

That is the head of the sample log, published here so the check means something. Change any byte of that file, the tail included, and the command exits non-zero.

What it still does not claim. Whether each stamped client order id is authentic needs the HMAC secret, which is the operator's - pass --secret if you hold it. An authentic tag covers the mandate hash and the sequence number, not the order's symbol, side, quantity or price. And a log is a claim about what BONDED authorised, never proof of what the exchange did.

MCP tools

place_order

Submit a Spot order for evaluation.

Field Type Notes
symbol string Required. e.g. ETHUSDT
side BUY | SELL Required.
type LIMIT | MARKET Required.
quantity string Base asset. Required for LIMIT.
price string Required for LIMIT.
quoteOrderQty string Quote asset. MARKET only, never with quantity.

Returns PLACED, DENIED or FAILED. A denial:

{
  "status": "DENIED",
  "seq": 412,
  "clause": "maxNotionalUsd",
  "clauseText": "Order notional must not exceed the mandate's maximum.",
  "observed": "900",
  "limit": "500"
}

A denial is a successful tool call, not an error. Three rules:

  1. Do not retry the same order. The mandate will not change between attempts.
  2. Read clause and adjust. It names exactly what to change.
  3. clause: "scope" means stop. The bond has burned. Report it rather than working around it.

check_order

Same input as place_order. Evaluates without contacting the exchange and without consuming an audit-log entry. Returns WOULD_ALLOW or WOULD_DENY.

cancel_order

Takes symbol and clientOrderId. Never refused. Every clause exists to limit exposure and cancelling only reduces it - a gate able to refuse a cancellation could trap an agent in a position it is not allowed to close.

get_mandate_summary

Returns the mandate hash, expiry, clause names and scope status. Thresholds are deliberately withheld: an agent that can read its limits can shape its behaviour to sit exactly inside them. The agent learns the rules by being refused, and each refusal reports the limit for that clause.

get_account

Balances and open-order count as most recently observed, with the observation timestamp so staleness is visible.

Boot guards

BONDED refuses to start unless the guarantees it advertises are in place, and prints what each guard actually verified. A guard that cannot perform its check reports WARN and says what it checked instead - it never claims a pass it did not earn.

BONDED boot guards
  [PASS] environment           testnet confirmed, base URL https://testnet.binance.vision
  [PASS] mandate               mandate 8b80eb8e valid until 2027-09-08T23:59:00.000Z
  [WARN] withdrawalPermission  not verified: apiRestrictions is unavailable on Spot Testnet
  [PASS] decisionLog           chain verified over 128 records, head 4f2ab910
  [PASS] clockSkew             clock within 42 ms of exchange
  [PASS] symbolGrounding       2 symbols resolved from exchangeInfo
  [PASS] authorisationIndex    128 prior authorisations replayed
  [PASS] auditPath             order history reconciled every 15000 ms

Console

A read-only owner view at http://127.0.0.1:7391, pushed over SSE. Shows the bond state, the mandate's thresholds, reconciliation counters, findings and a live decision feed.

Unlike the agent's view, the console does show thresholds. The owner wrote them; hiding them would be theatre.

Exit codes

Code Meaning
0 Clean shutdown.
1 A boot guard failed - the banner names which. From bonded verify: the log did not verify, or a pinned head did not match.
70 Uncaught exception or unhandled rejection. The audit log is flushed first.
78 Invalid configuration, with the failing fields listed. From bonded verify: a missing or malformed argument.

Limits

Every one of these is a real weakness. They are here because a security design that does not name its own limits has not been examined.

Spot only - no futures, no margin

Every endpoint BONDED calls is /api/v3/*. There is no fapi, no dapi and no margin surface in the code. A mandate cannot express a leverage cap or a position side, because BONDED has no concept of either.

A scope decision rather than an omission, and extending it is not a configuration change. The gate's clauses are Spot-shaped - notional, lot size, tick size, open orders, realised PnL from trade history. Futures needs a different clause set: leverage, liquidation distance, funding, reduce-only, position side. Reconciliation would also have to account for position changes that no order explains, such as a liquidation.

The guarantee rests on key custody

BONDED is not a TEE and not a ZK circuit. It holds the credential and the agent does not, which makes the constraint structural - but compromise the host and that constraint is gone.

Reconciliation detects; it does not prevent

A bypass order reaches the exchange and fills before BONDED sees it. What is guaranteed is that it will not go unnoticed: attributed, bond burned, scope revoked.

It audits compliance, not quality

The gate can prove an order was inside the mandate. It has no opinion on whether the trade was sensible, and cannot acquire one.

The withdrawal guarantee is not verifiable on testnet

GET /sapi/v1/account/apiRestrictions does not exist on Spot Testnet, so the guard reports WARN and states that it asserted the environment instead.

The gate is only as good as the mandate

A mandate that compiles to weaker rules than intended fails silently. The mitigations are human: clauses render as numbered text before anything runs, and an unrecognised clause name is a hard error.

Testnet fills are not real fills

Testnet liquidity does not resemble production. No PnL figure produced there means anything.

Detection is only as wide as the stream

GET /api/v3/allOrders requires a symbol - there is no account-wide REST listing - so the polling backstop only ever sees the symbols it was given. The user data stream is the only account-wide source, and with it down an order on a symbol the mandate never named cannot be observed at all.

BONDED treats that as a loss of the audit path rather than a degraded one: without an account-wide source, trading stops. BONDED_ALLOW_PARTIAL_AUDIT=1 accepts symbol-scoped coverage and keeps trading, and the boot banner says which is in force. BONDED_WATCH_SYMBOLS widens the poller beyond the mandate's own symbols.

As of 2026-09-06 this is not hypothetical. Binance removed the listen-key REST endpoints in February 2026: POST /api/v3/userDataStream answers 410 Gone on Spot Testnet, verified on a real machine. The account-wide source is unavailable, so BONDED refuses to start unless BONDED_ALLOW_PARTIAL_AUDIT=1 says symbol-scoped coverage is acceptable - the guard behaving as designed rather than trading with less observation than it claims. A removed endpoint is recognised as permanent: reported once on the banner, never retried.

The replacement is POST /sapi/v1/userListenToken plus userDataStream.subscribe.listenToken over the WebSocket API, and it is not implemented here. Until it is, run with BONDED_ALLOW_PARTIAL_AUDIT=1 and a short BONDED_POLL_INTERVAL_MS (3000 works): detection then comes from polling the mandate's symbols rather than from the stream.

Aggregate limits bind against a snapshot, not the exchange's clock

The order path is serialised, and orders placed since the current account observation count toward maxOpenOrders, so a burst cannot slip several orders through one stale count. That closes the concurrency hole; it does not make the caps instantaneous.

The loss limits compare against realised PnL refreshed on a 30-second budget. A loss is realised when a position closes, and BONDED learns of it at the next refresh, so a fast sequence of losing trades can breach a limit and keep trading until that refresh sees it. This is a lag rather than a hole - the cap binds as soon as the loss is observable - and it is inherent to enforcing a limit against an exchange that fills orders without asking. Open positions are not counted at all; that is what "realised" means.

Why this runs on testnet, deliberately

Not a limitation worked around - a choice, and it would be the same choice with more time. The demo places an order that defeats a safety control. That is the whole argument: an order goes around BONDED, fills, and is caught. On mainnet that is real money being deliberately misused on camera, repeatedly, across takes. A security tool whose demonstration requires misusing a funded account has not thought about what it is asking its audience to do.

The code enforces this rather than trusting the operator to remember. BONDED refuses to start against production unless BONDED_ALLOW_PROD=1 is set explicitly, and the two scripts that exist to defeat it - scripts/bypass-order.mjs and scripts/redteam.mjs - refuse production with no override at all.

It is also the only place the argument can be made. Binance's hosted MCP server has no testnet: it trades a funded Agentic sub-account on mainnet, over OAuth, with a human confirming every trade. That path is already guarded. The path BONDED guards - raw API keys, no confirmation, what an unattended agent actually runs on - is the one with a testnet, and binance-cli supports testnet alongside prod and demo for exactly that reason.

What has and has not been run live. The full sequence - allow, refuse, bypass, detect, revoke - has run against Binance Spot Testnet from a real machine. An agent's 900 USD order was refused by maxNotionalUsd; a 450 USD order filled, stamped with a BONDED client id; a forged id and a raw API order both filled and were then classified FORGED and FOREIGN, burning the bond; every order after that was refused by scope. npm run redteam reports eight of eight held.

Those live runs are also what found the removed listen-key endpoint, a clock-skew guard whose symmetric limit passed a clock Binance rejects, and a startup lookback that made a fresh instance burn its own bond on the operator's earlier orders.

Not run: anything on mainnet, deliberately. The withdrawalPermission guard, which needs an endpoint testnet does not have. The daily-loss and drawdown clauses, which no live run has yet lost enough to trip. Multi-day operation. And testnet fills are simulated - a fill there says the request was well-formed and accepted, not that it would have found a counterparty on a real book.

Development

npm run check      # typecheck, lint, format, tests
npm test           # 338 tests
npm run build      # compile to dist/

The gate is a pure function, so it is tested exhaustively without mocks. The Binance client is tested against a scripted fetch, including that the signature covers exactly the string sent and that order placement is never retried. CI runs the full gate plus a check that the published entry point resolves.