An MCP server that lets AI agents trade DreamDEX Event Contracts on the Somnia Shannon testnet through natural language, with server-side risk limits and per-session custodial wallets.
Status: hackathon MVP. Deployed against Somnia Shannon testnet only. Wallets generated by AgentRail are custodial: AgentRail generates and holds the private key server-side, so it can move those funds, and nothing on-chain prevents it. The protection is exposure-limiting (a purpose-only wallet holding only what the user deposited), not cryptographic against AgentRail itself. Do not confuse this with the non-custodial operator-delegation design evaluated during research; that path is blocked on-chain by an undocumented permission gate (see Somnia field notes).
Other deliberate scoping decisions, stated up front:
- Keys are encrypted at rest with AES-256-GCM, not backed by KMS/HSM. The decryption master key still lives in the server process environment and briefly in memory during signing. KMS/HSM-backed signing (where key material never enters the process) is the explicit next step before any real, non-testnet funds would be involved. It is documented as the target in the code itself (
build/wallet.mjs) and was not overlooked. - The only fill path proven live end to end is a YES-direction bet on a 300-second window. The direct NO-vs-NO fill path (
DIRECT_NO) has never been observed on this venue; NO fills happen via mint-a-pair against resting BUY_YES orders, with a crossing rule inferred from real on-chain fills, not read from verified contract source. - No real funds are ever at risk: the chain is a testnet, the collateral is tUSDC, and gas is SOMI from a faucet.
- Overview
- Architecture
- Prerequisites
- Installation
- Configuration
- Usage
- API reference
- Project structure
- Testing
- Deployment
- Troubleshooting and known issues
- Somnia field notes
- Post-hackathon roadmap
- Contributing
- Conclusion
- License
AgentRail is a Model Context Protocol (MCP) server that turns DreamDEX Event Contracts (binary up/down markets on the Somnia Shannon testnet) into tools any MCP client, such as Claude Desktop or a scripted agent, can call. It is for AI agent developers who want an agent to place small, risk-capped directional bets by natural language.
Every session gets a dedicated trading wallet generated server-side; the user deposits only what they intend to trade, and the user's main wallet is never touched. Server-side guardrails cap stake per window and daily loss, every refusal is logged as prominently as every fill, and every order's outcome is confirmed by on-chain balance deltas, never by transaction status alone.
Single entry point: node build/mcp-server.mjs. It is a thin tool-registration layer over build/mcp-core.mjs, which contains everything that touches the chain. All persistent state lives in disk-backed modules, so both transports share one source of truth.
- MCP server, two independent transport lives in one process.
- stdio: a persistent
McpServerconnected to aStdioServerTransport, as always. WithAGENTRAIL_HTTP_PORTunset, the file behaves exactly like the original stdio-only server. - Streamable HTTP: opt-in via
AGENTRAIL_HTTP_PORT. Each HTTP POST builds a freshMcpServerplus a fresh statelessStreamableHTTPServerTransport(stateless per-request transports are the SDK's own documented pattern; a persistent HTTP transport failed empirically on SDK 1.30.0 + Node 24, see the comment block inbuild/mcp-server.mjs). Responses are plain JSON-RPC JSON (enableJsonResponse), POST only, at/mcp. A DNS-rebinding guard refuses non-loopbackHostheaders when bound to localhost. There is no transport-level auth on purpose;api_keyauthenticates at the tool layer, hence the TLS warning the server prints on startup.
- stdio: a persistent
- Per-session wallet store (
build/wallet.mjs): one dedicated keypair persession_id, keys encrypted at rest (AES-256-GCM) underAGENTRAIL_WALLET_MASTER_KEY, written atomically via temp+rename, protected by a cross-process file lock. Private keys are never returned by any tool. - Accounts/auth store (
build/accounts.mjs):api_keycredentials, stored as salted SHA-256 hashes, verified withcrypto.timingSafeEqual. Keys use thear_sk_prefix, are shown exactly once, and have no recovery path by design. - Risk ledger (
build/risk.mjs): persisted per-session spend and drawdown tracking across restarts.place_orderreserves before broadcast and commits or releases after, so a crash mid-order cannot leak a reservation. - Rate limiter (
build/rate-limit.mjs): fixed-window per-IP limiter oncreate_accountonly (default 5 requests / 600 s). Client identity comes from the socket peer;X-Forwarded-Foris honored only from loopback peers, so it is spoof-proof behind Caddy. Fail-closed: if the limiter throws, the call is refused. - Trade log (
build/trade-log.mjs): append-only JSON Lines, one file per session, never rewritten. Records orders (FILLED / PENDING / NOT_FILLED kept distinct), per-leg redemptions, wallet generation, observed deposits, and refusals as first-class entries. - File locking (
build/filelock.mjs): cross-process lock used by the wallet and accounts stores so two racing processes cannot clobber each other's writes. - Chain access (
build/mcp-core.mjs):@somnia-chain/markets-sdk(SomniaMarkets) plus viem clients againsthttps://api.infra.testnet.somnia.network(RPC) andhttps://dev.smk.somnia.host/v1/graphql(indexer), usingSDK.SOMNIA_TESTNET_ADDRESSES. Orders are hand-encoded against the raw ABI via the selfplaceBinaryOrderpath; fills are confirmed by ERC6909 + collateral balance deltas and, when the receipt-time read shows no fill, polled every 5 s for up to 60 s.
- Node.js v24.16.0 (the version developed and deployed against;
package.jsonhas noenginesfield, so this is a documented expectation, not an enforced one). - npm (ships with Node).
- For trading: a funded session wallet. AgentRail generates the key; you must send it tUSDC (collateral) and SOMI (gas) on the Somnia Shannon testnet from a faucet.
- For HTTP transport and deployment: nothing else locally. The deployed setup additionally uses Caddy and a DuckDNS domain (see Deployment).
From a clean clone (there is no build step; the source is the runtime, all files are plain .mjs):
git clone https://github.com/phllp-tanstic/agentrail.git
cd agentrail
npm ci # or npm install
node build/mcp-server.mjsNote that npm test is not wired up: package.json still has the placeholder echo "Error: no test specified" && exit 1 script. Run the test suites directly instead (see Testing).
To also enable the HTTP transport:
AGENTRAIL_HTTP_PORT=8787 node build/mcp-server.mjsAll configuration is via environment variables. Defaults apply when a variable is unset.
| Variable | Required | Default | Description |
|---|---|---|---|
AGENTRAIL_WALLET_MASTER_KEY |
Yes for any operation touching private keys | none (refuses rather than degrading) | 64 hex chars (32 bytes), used to encrypt/decrypt private keys in the wallet store. Generate with node -e "console.log(require('crypto').randomBytes(32).toString('hex'))". Changing it orphans existing records; there is no rotation for the master key itself. |
AGENTRAIL_HTTP_PORT |
No | unset (HTTP transport off) | When set, additionally starts the Streamable HTTP listener on this port. |
AGENTRAIL_HTTP_HOST |
No | 127.0.0.1 |
HTTP bind address. Defaults to localhost only, never 0.0.0.0. |
AGENTRAIL_WALLET_STORE |
No | build/.wallet-store.json (gitignored) |
Path to the encrypted per-session wallet store. |
AGENTRAIL_ACCOUNTS_STORE |
No | build/.accounts-store.json (gitignored) |
Path to the API-key hash store. |
AGENTRAIL_RISK_STORE |
No | build/.risk-store.json (gitignored) |
Path to the persisted risk ledger. |
AGENTRAIL_TRADE_LOG_DIR |
No | build/.trade-log/ (gitignored) |
Directory for per-session append-only trade logs. |
AGENTRAIL_SESSION_ID |
No | default |
Session id used by get_trade_log when no session is passed. |
AGENTRAIL_MAX_STAKE_USD |
No | 5 |
Max collateral committable to a single market window, in USD (tUSDC). An order above it is refused (reason max_stake_per_window_exceeded), never trimmed. |
AGENTRAIL_MAX_DAILY_LOSS_USD |
No | 10 |
Max realized drawdown per UTC day before all further orders are refused. |
AGENTRAIL_RATE_CREATE_ACCOUNT_MAX |
No | 5 |
Max create_account calls per IP per window. |
AGENTRAIL_RATE_CREATE_ACCOUNT_WINDOW_SECONDS |
No | 600 |
Length of that fixed window. |
AGENTRAIL_WALLET_MASTER_KEY_NEW |
No | unset | Used only by build/rotate-wallet-master-key.mjs. |
SOMNIA_RPC_URL |
No | Somnia Shannon public RPC (https://api.infra.testnet.somnia.network) |
RPC endpoint override, used by build/fund-tusdc.mjs (tUSDC faucet funding). |
AGENTRAIL_OPERATOR_KEY |
No | unset | Private key used only by the research/onchain-proof/ probe scripts (pays gas for probe transactions). Never read by build/. |
AGENTRAIL_OWNER_KEY |
No | unset | Legacy owner private key. Standalone build/ scripts (funding, research probes) sign with it directly, and the core's ctx() special-cases the reserved session_id __legacy_owner_key__ to it for those direct calls. create_account and generate_wallet refuse that literal (reason reserved_session_id), so it cannot be minted into a credential through the MCP tool layer. |
The deployed systemd unit uses AGENTRAIL_WALLET_STORE=/var/lib/agentrail/.wallet-store.json and similar paths under /var/lib/agentrail, with the master key in /etc/agentrail/agentrail.env (chmod 640 root:agentrail), never in the repo.
Every tool returns JSON text. All write tools require session_id plus api_key. Refusals return ok: false with a stable machine-readable reason, and the MCP layer marks them isError: true.
Minimal example, create_account (no authentication possible yet, by definition):
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "create_account",
"arguments": { "session_id": "my_agent_1" }
}
}Real response shape (key value abbreviated):
{
"ok": true,
"created": true,
"sessionId": "my_agent_1",
"apiKey": "ar_sk_9f2c...",
"warning": "THIS IS THE ONLY TIME THIS KEY IS SHOWN. AgentRail stores only a salted hash and cannot recover or redisplay it — save it now.",
"nextStep": "Call generate_wallet with this session_id and api_key to create your dedicated trading wallet."
}Calling create_account again for the same session refuses with reason: "account_already_exists"; the key is never reissued.
Full flow: create account, generate wallet, fund it, trade, redeem.
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"generate_wallet","arguments":{"session_id":"my_agent_1","api_key":"ar_sk_..."}}}Real response shape (from generate_wallet):
{
"ok": true,
"created": true,
"address": "0x...",
"privateKeyReturned": false,
"custody": "CUSTODIAL over this wallet. AgentRail generated and holds the private key server-side, so it CAN move these funds — nothing on-chain prevents it.",
"storage": "Keys are stored ENCRYPTED AT REST (AES-256-GCM) as JSON on the server's local disk...",
"nextStep": "Deposit tUSDC (and SOMI for gas) to this address, then call get_wallet_balance to confirm the deposit landed before trading.",
"custodySigning": "place_order, redeem, and withdraw all sign with THIS wallet's own key for this session_id — not a shared owner key."
}Then: fund the address with tUSDC and SOMI, confirm with get_wallet_balance, pick a market with list_markets (or pre-validate with parse_intent), and place an order:
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"place_order","arguments":{
"session_id":"my_agent_1","api_key":"ar_sk_...",
"market_id":"0x...","direction":"YES","targetDollarAmount":2,"window_seconds":300
}}}A real observed filled order, from the trade log (build/.trade-log/live_test_1.jsonl, tx hashes and market id as actually broadcast, hashes elided here for width):
{
"seq": 4,
"kind": "ORDER",
"event": "PLACE_ORDER",
"outcome": "FILLED",
"ok": true,
"actor": "AGENTRAIL",
"summary": "FILLED — bought 2.09 units of ETH YES for 2.04193 tUSDC. Target was $2, actual spend 2.04193 (variance 2.0965%). Market 0x000000…00e651, 300s window, requested $2 of cash.",
"fill": {
"fillStatus": "FILLED",
"filled": true,
"filledUnits": "2.09",
"collateralSpent": "2.04193",
"confirmedBy": "ERC6909 balance delta + collateral delta, NOT transaction status",
"latencySecondsObserved": 28.5
},
"dollarSizing": { "requestedUsd": 2, "actualUsd": 2.04193, "variancePct": 2.0965 },
"slippage": { "observedPct": 1.1494, "maxPct": 5, "clamped": false },
"riskReservation": "COMMITTED",
"tx": {
"a1_approve": { "status": "success", "block": 475635860, "gasUsed": "259745", "logs": 1 },
"a1_placeBinaryOrder_BUY_YES": { "status": "success", "block": 475635881, "gasUsed": "486276", "logs": 4 }
}
}After the window settles, redeem scans finalized markets for held winning positions, runs the redeem guard on every leg before broadcasting (a losing redeem does not revert: it burns the position and pays zero, so a guard BLOCK refuses and leaves the position intact), and confirms payout by tUSDC balance delta. get_trade_log returns the whole history, including refusals, with refusals_only: true for just the declined actions.
Thirteen tools. Every tool returns a JSON object; refusals use {ok: false, refused: true, reason, detail}. session_id + api_key are required by every write-capable tool and by get_position / get_wallet_balance on the session path.
| Tool | Params | Returns |
|---|---|---|
create_account |
session_id (required), label (optional) |
{ok, created, sessionId, apiKey, warning, nextStep}. apiKey shown exactly once. Not authenticated (it mints the credential); rate limited per IP. Refuses account_already_exists, session_id_required. |
rotate_api_key |
session_id, current_api_key (both required) |
New apiKey, shown once; the previous key is invalidated, not merely superseded. Refuses account_not_found, invalid_current_api_key, current_api_key_required. No recovery path that skips proof of ownership exists. |
list_accounts |
none | {ok, count, accounts: [{sessionId, createdAt, rotatedAt, label}], storePath}. Metadata only, never key material or hashes. Read-only, no authentication required. |
generate_wallet |
session_id, api_key; force_new (optional), label (optional) |
{ok, created, address, privateKeyReturned: false, custody, storage, nextStep, custodySigning}; with force_new, also replacedPrevious (the old record is re-keyed under a suffixed id, never deleted). Refuses if no account exists, and refuses a missing or malformed master key. |
get_wallet_balance |
session_id + api_key, or raw address |
tUSDC and SOMI balances reported separately (collateral vs gas; a wallet with collateral but no SOMI cannot broadcast an order). An address not in the store is still reported, flagged known: false. Direct RPC read, so a deposit appears as soon as it is mined. |
list_wallets |
none | {ok, count, wallets, storePath}. Addresses and metadata only; private keys are never returned by any tool. |
list_markets |
window_seconds (default 300), require_yes_liquidity (default true), min_seconds_to_expiry (default 25), max_seconds_to_expiry (default 290) |
Markets that are 300 s windows, OPEN on the on-chain status gate, and (by default) have resting YES ask depth. Reports resting depth on both crossing sides (yesAskDepth*, yesBidDepth*, best prices and implied probabilities), sorted soonest settlement first. Non-300 windows refused. |
place_order |
market_id (required), direction (YES/NO, default YES), stake_units OR targetDollarAmount (mutually exclusive; defaults to 1.0 unit if neither), maxSlippagePct (default 5, clamped server-side to 50, only enforced with dollar sizing), cross_ticks (default 20), window_seconds (default 300, asserted against the market), session_id, api_key |
Fill outcome as three distinct states: FILLED (balance-delta confirmed, with observed latency), PENDING (resting, unresolved at the 60 s deadline; filled is null, not false), NOT_FILLED (terminal). A reverted broadcast returns {ok: false, reason: "reverted"} and is auto-retried exactly once. Risk limits are server-side env config, deliberately not tool parameters. Refusal/rejection reasons include max_stake_per_window_exceeded, max_daily_loss_exceeded, slippage_exceeded, status_gate_closed, window_mismatch, zero_quantity, allowance_failed, reverted, send_error. |
get_position |
market_id, session_id, api_key |
The session wallet's ERC6909 balance in both outcome token ids plus on-chain market status (Trading / Finalized). winningOutcome withheld until the market is finalized (a pre-settlement 0 would otherwise misread as "YES won"). |
redeem |
market_id (optional; omit to scan all finalized markets for held positions), dry_run (default false), session_id, api_key |
Per-leg entries: payout confirmed by tUSDC balance delta into the same session wallet; a guard block refuses, is reported, and leaves the position intact. Each leg gets its own trade-log entry. |
withdraw |
session_id, api_key, to_address (required, no default), asset (tUSDC or SOMI), amount (number or "max", default "max") |
Sends out of the caller's own dedicated wallet; tUSDC and SOMI are separate balances withdrawn separately. A tUSDC withdrawal is refused up front if the wallet lacks SOMI for its own gas. Confirmed by receipt status plus balance delta. Does not touch open positions or risk state. |
parse_intent |
direction, asset, window_seconds, targetDollarAmount/stake_units, maxSlippagePct, cross_ticks, resolve_market (default true), min_seconds_to_expiry (default 60), raw_text (echoed for audit only, never parsed) |
A placeOrderArgs object plus a confirmation block (estimated units, cost, max payout, payout multiple) to show the user before executing. Does no natural-language understanding and calls no LLM: the calling agent extracts the fields, this validates and normalizes them (synonyms accepted: up/long to YES, bitcoin to BTC, "5m" to 300, "$10" to 10). Places nothing. Refusal codes: direction_required, direction_unrecognized, window_not_supported, asset_not_supported, ambiguous_sizing, sizing_required, invalid_target_dollar_amount, no_tradeable_market, no_market_with_adequate_runway. |
get_trade_log |
session_id (optional, defaults to AGENTRAIL_SESSION_ID or default), limit (default 50), kind (ORDER/REDEEM/WALLET/WITHDRAWAL), outcome, refusals_only (default false), include_dry_runs (default true) |
{ok, entries, total, returned, elided, sessionsKnown, integrity}. Entries carry a self-contained summary and an actor of AGENTRAIL (what AgentRail did) vs OBSERVED (on-chain facts it noticed, e.g. a deposit). Refusals, guard blocks, and reverts are first-class entries. No entry contains private key material. |
Authentication failure reasons (from accounts.mjs): missing_credentials, account_not_found, malformed_api_key, invalid_api_key. Rate limiting on create_account returns rate_limited with a truthful retryAfterSeconds.
build/ The server and everything it imports (plain ESM .mjs, no build step)
mcp-server.mjs Entry point: tool registration, stdio + optional Streamable HTTP
mcp-core.mjs Chain interaction, tool implementations, scope fences, refusals
wallet.mjs Per-session custodial wallets, AES-256-GCM at rest
accounts.mjs API-key store (salted SHA-256 hashes, timing-safe compare)
risk.mjs Risk config, persisted ledger, reservation lifecycle
rate-limit.mjs Per-IP fixed-window limiter for create_account
trade-log.mjs Append-only JSONL audit log
filelock.mjs Cross-process file locking
intent.mjs parse_intent normalization and validation
redeem-guard.mjs Pre-broadcast redeem guard (every redeem leg gated before any broadcast)
tick-snap.mjs Price-to-tick snapping
*-test.mjs Test suites (run with node directly)
migrate-*.mjs One-off store migration scripts (plaintext to encrypted wallet store)
rotate-wallet-master-key.mjs
PHASE-*.md, *.json Build-phase logs and captured on-chain proof artifacts
deploy/ Real deployment kit (see Deployment)
agentrail.service systemd unit (hardened; the MemoryDenyWriteExecute trade-off is documented in-file)
Caddyfile Reverse proxy config (the header_up Host rewrite is load-bearing)
bootstrap-agentrail.sh Run on the EC2 box as root: Node, user, stores, systemd, Caddy
RUNBOOK.md Step-by-step EC2 to DuckDNS to Caddy to verify to commit runbook
site/ Landing page (static index.html)
research/ On-chain research (probe scripts + proof artifacts) — AND a runtime dependency: mcp-core.mjs reads onchain-proof/error-selectors.json at startup
AgentRail-Build-Spec.md Build spec: architecture, custody model, scope fences
NO-side-fill-paths.md Investigation of how NO-side orders actually fill
PROOF-LOG.md Raw proof log with real transactions
onchain-proof/, pool-precompute/ Reproducible probe scripts and JSON artifacts
Eight assertion-counted suites, run directly with Node, plus mcp-test.mjs for protocol-level verification (see below). As of this writing, all pass at these counts (re-run them yourself; they take seconds):
node build/accounts-test.mjs # 22/22
node build/auth-gate-test.mjs # 16/16 (API-key auth: wrong key, missing key, cross-session)
node build/filelock-test.mjs # 13/13 (cross-process lock races)
node build/intent-test.mjs # 29/29
node build/rate-limit-test.mjs # 32/32 (thresholds, retryAfterSeconds, spoof rejection, fail-closed)
node build/risk-test.mjs # 28/28
node build/trade-log-test.mjs # 31/31
node build/wallet-crypto-test.mjs # 19/19 (AES-256-GCM round-trip, wrong-key refusal)Total: 190 assertions. node build/mcp-test.mjs additionally calls the tool functions directly and has an --mcp --list mode that verifies the tools are advertised over the real stdio MCP protocol; with --expect-count=N / --expect-tool=<name> it asserts the advertised count and that the named tools are present, exiting nonzero on mismatch. Run live this session, output as actually produced:
$ node build/mcp-test.mjs --mcp --list --expect-count=13 --expect-tool=list_accounts
[+3.7s] MCP connected to build/mcp-server.mjs over stdio
[+3.8s] server advertises 13 tools: list_markets, place_order, get_position, redeem, withdraw, create_account, rotate_api_key, generate_wallet, get_wallet_balance, list_wallets, list_accounts, parse_intent, get_trade_log
[+3.8s] assertions OK — count == 13, present: list_accounts
Thirteen tools over the wire, matching the thirteen registerTool calls and the API reference above.
Warning: risk-test.mjs must be run with AGENTRAIL_RISK_STORE redirected to a temp file. Run unredirected, it wipes the real risk ledger at the configured store path, because its reset helper persists. Example:
AGENTRAIL_RISK_STORE="$(mktemp -u).json" node build/risk-test.mjsWhat is covered: authentication and key handling, encryption round-trips, cross-process file-lock races, rate limiting (including XFF spoof rejection and fail-closed behavior), risk reservation release on every path, trade-log append integrity, intent validation.
What is not covered: real network fills. The DIRECT_NO fill path has never been observed live (see Somnia field notes); the trade-log adapter for a live redeem-guard BLOCK and for ZERO_PAYOUT/PENDING/REVERTED entry shapes is proven offline only, where the guard's output is controlled directly. The underlying redeem guard itself was proven live in both directions during Phase B (see build/PHASE-B-LOG.md).
The actual deployment path used, all files in deploy/:
- EC2: Ubuntu 22.04 t3.micro, security group with 22 open to your IP only, 80/443 open, and 8787 never exposed. Elastic IP allocated and verified as attached (an unattached EIP bills even on the free tier).
- DuckDNS: domain (e.g.
agentrail.duckdns.org) pointed at the Elastic IP, resolution verified before continuing. - bootstrap-agentrail.sh: run on the box as root. Installs Node v24.16.0 from a pinned tarball (no nvm), creates the
agentrailsystem user and/var/lib/agentrailstate dirs, clones the repo and runsnpm ci(dependencies are not vendored; this was found live asERR_MODULE_NOT_FOUND), writes an/etc/agentrail/agentrail.envtemplate for the master key, installs the systemd unit and Caddy. - systemd:
agentrail.serviceruns as theagentrailuser, binds HTTP to127.0.0.1:8787, points all store paths at/var/lib/agentrail, and hardens the service (ProtectSystem=strict,NoNewPrivileges,PrivateTmp, and others).MemoryDenyWriteExecuteis deliberately left off: V8 cannot initialize its code range under W^X and crashes before any code runs (documented in the unit file itself). - Caddy: terminates TLS on 443 with an automatic certificate (Let's Encrypt via the HTTP-01 challenge against the DuckDNS domain) and proxies to
localhost:8787withheader_up Host localhost:8787. That rewrite is required: the server's loopback DNS-rebinding guard would otherwise 400 every proxied request carrying the public host header. - Verify, then commit: the runbook mandates proving a real round trip (
create_accounttogenerate_wallettoget_wallet_balance) over the public HTTPS endpoint from a separate machine before pushing.
The full command sequence is in deploy/RUNBOOK.md.
Issues actually hit while building and running this, on Windows and on the EC2 box:
- PowerShell shell-quoting inconsistency. JSON-RPC payloads piped to the stdio server from PowerShell quote differently than from bash; payloads that work in bash can arrive mangled under
pwsh. Test raw stdio calls from bash or a script, not from an interactive PowerShell pipeline. (Source: the original project handover document, which recorded the exact failure mode.) - Windows ESM
pathToFileURLrequirement. On Windows, constructing file URLs by string concatenation breaks ESM imports;import.meta.url/fileURLToPathmust be used. Store paths throughout this repo are resolved fromimport.meta.urlfor this reason — visible inwallet.mjs,accounts.mjs, andtrade-log.mjs, all of which usefileURLToPath. (Source: the original project handover document, plus this consistent in-code pattern.) MemoryDenyWriteExecutebreaks V8. Enabling this systemd hardening knob crashes Node atIsolate::Initialize(errno 12 / ENOMEM) before any code runs. It must stay off for this service; documented as an explicit trade indeploy/agentrail.service.fs.unlinkSyncon Windows can steal the file lock. Deleting a locked file does not fail on Windows the way POSIX does, so a test's cleanup helper can break another process's lock. The lock module accounts for this —filelock.mjstreats anunlinkSyncof an already-removed lock as benign and handles stale-lock steals explicitly; do not assume POSIX unlink semantics when adding tests. (Source: the original project handover document, plusbuild/filelock-test.mjscovering the races.)git checkoutdoes not fetch. Checking out a branch name after the remote moved leaves the working tree on stale code. The bootstrap script therefore runsgit fetch --allfollowed bygit reset --hard origin/main, not a plain checkout.- HTTP transport, Windows shutdown artifact. A
UV_HANDLE_CLOSINGlibuv assertion can appear at process exit on Windows when testing the HTTP path. It is a shutdown artifact, not a request-path failure; the per-request transport rationale is documented inbuild/mcp-server.mjs. - The HTTP transport has no TLS. The server prints a startup warning to stderr. Put Caddy (or any TLS terminator) in front of it, and keep
AGENTRAIL_HTTP_HOSTat127.0.0.1unless you know better.
Genuine friction encountered building against DreamDEX Event Contracts on Somnia, documented because it is not discoverable from the public docs alone.
The delegated execution path is closed, for everyone. The intended non-custodial design (build spec section 3: an operator key scoped by OperatorPermissionsRegistry that can place and reduce orders but architecturally cannot move funds) turned out to be unreachable: placeBinaryOrderFor, the delegated entrypoint, reverts OnlyApprovedContracts() for every caller tested (plain EOA, MultiSend, a real Safe, a SafeProxyFactory), on both testnet and mainnet, regardless of any operator grant. Deep tracing (state diffs, call trees, event logs, public selector databases) shows the gate reads from opaque, unverified pool and module implementation contracts, not from OperatorPermissionsRegistry, and no successful call to that entrypoint exists anywhere in the venue's transaction history. A further trap: OperatorPermissionsRegistry's placeOrderFor selector (0x80054449) belongs to spot pools; calling it against a binary pool reverts OnlyApprovedContracts() even though it is not the relevant permission check for that call path. Delegated third-party execution on this venue requires DreamDEX-side allowlist access; no client-side workaround reaches it. This is why AgentRail ships with custodial per-session wallets instead, and says so plainly rather than borrowing the non-custodial framing.
NO-side fills happen only via mint-a-pair, and the crossing rule is inferred, not specified. The four "fill paths" in DreamDEX's docs are fill-routing outcomes inside the matching engine, not four entry functions. A NO bet is the same placeBinaryOrder call with kind = 2 (BUY_NO), and the price argument is always the YES-side price (price = 1 - downPrice), which the project's first two NO attempts got wrong (they were priced in NO terms and could never cross). The mint-a-pair crossing rule (maker price at or above the taker's YES-terms limit, equivalently the two buyers' escrows summing to at least 1) was inferred from six decoded real fills plus both historical non-fills, because the pool implementation (0x48e523c9...) is not source-verified on either explorer. The exact boundary (inclusive vs exclusive) is therefore unconfirmed by source. Full analysis in research/NO-side-fill-paths.md.
DIRECT_NO has never been observed. Zero BUY_NO x SELL_NO fills appear in the decoded fill samples; sell-NO resting liquidity is essentially absent on these venues. NO orders do fill (via mint-a-pair against a resting BUY_YES), but the direct-cross path remains unproven and would need a real BUY_NO x SELL_NO fill to validate. NO orders placed against a book with no BUY_YES depth will rest (PENDING) or expire (NOT_FILLED) rather than revert, which is why place_order returns three distinct fill states.
Other friction worth knowing before building on this venue:
- The SDK's convenient
createOrder()method does not work for delegated execution with anownerparameter on binary markets; the real dispatched selector (0x5d97c566) had to be hand-encoded against the raw ABI, validated with a positive control (empty calldata returns nothing; real calldata reaches real downstream checks likeERC20InsufficientAllowance, proving it is not swallowed by an unknown-selector revert). - Market discovery via the SDK's default listing is a disjoint set from what matters at settlement:
redeemmust scanlistBinaryMarkets({status: "Finalized"})explicitly, or it silently reports nothing owed. - A losing redeem does not revert. It burns the position and pays zero with a success receipt. Any redemption flow must be guarded before broadcast, not detected after.
- Fill confirmation by transaction status alone is unreliable; orders resting at receipt time can fill later. AgentRail confirms by balance delta and polls for up to 60 s.
Everything below was explicitly deferred during the build; nothing here is invented scope.
- KMS/HSM-backed signing. The explicit next step before any real (non-testnet) funds are involved. Today keys are AES-256-GCM at rest with the master key in the server process environment; the goal is that key material never enters the process during signing. AWS KMS was already scoped via IAM — the blocker is a cloud-provider decision, deferred purely on cost and timing, not on any technical unknown.
DIRECT_NOfill path. Opportunistic and cannot be forced: validating it requires a realBUY_NO × SELL_NOcross to occur on the venue. It remains watched; an observed fill would pin down the inferred crossing-rule boundary (see Somnia field notes).- Held-aside Tier 2/3 hardening (per the original handover): monitoring and alerting, RPC redundancy, rate limiting broader than
create_accountalone, CI/CD, independent human code review, a web onboarding UI, an SDK, billing, compliance. - Real database migration. State is currently per-module JSON files on disk (wallet store, accounts store, risk ledger, trade log) — a deliberate current-scale choice per the handover, not an oversight. A real database has not been started and is not needed at current scale.
There is no CONTRIBUTING.md. Expectations for now:
- Issues: welcome, especially for factual corrections (a wrong claim in this README or in the research docs) and reproducible testnet failures.
- Pull requests: keep the existing discipline. Every refusal path keeps its machine-readable
reason; every write tool keepssession_id+api_keygating; risk limits stay server-side env config, not tool parameters; the trade log keeps recording refusals as first-class entries. Run the offline test suites — eight assertion-counted suites plusmcp-test.mjsfor the protocol-level check (with theAGENTRAIL_RISK_STOREredirect forrisk-test) — and include their output. - No new dependencies without a reason in the PR description.
AgentRail is a working, live-proven MCP server for natural-language trading of DreamDEX Event Contracts on the Somnia Shannon testnet. Its current state is real, not aspirational: thirteen registered tools verified over the actual MCP protocol, eight offline assertion-counted suites all passing (190 assertions), a fill path proven end to end on-chain with balance-delta confirmation, server-side risk limits and refusal logging exercised by real traffic, and a deployed HTTPS path. Custody and scope limits are documented rather than papered over, and the roadmap above is the documented path to production hardening. It is not a demo shell — and it is not production-hardened either; both halves of that sentence are the honest summary.
MIT. A LICENSE file with the standard MIT license text now exists at the repository root (copyright 2026 phllp-tanstic), and package.json declares "license": "MIT" to match.