Account Demolisher
Protocol

Mediator forward protocol

Per-flow key derivation, the flow token and its terms, the one envelope shape the server co-signs, renewal, and recovery.

Most exchanges do not credit an ACCOUNT_MERGE as a deposit, and the memo that attributes a deposit to a customer rides on the transaction. For a destination that needs a memo, the close routes through an ephemeral mediator account: your account merges into the mediator, then a two-operation forward pays the exchange with your memo and merges the mediator into it.

The server co-signs that forward, and only a forward that meets the terms bound into the flow.

The routes

Both routes run on the Node.js runtime (node:crypto and the Stellar SDK), answer every response with cache-control: no-store, and add CORS headers only for an origin listed in MEDIATOR_ALLOWED_ORIGIN. Outside production, http://localhost:3000, :3001 and :3002 are allowed instead.

RoutePurposeRate limit
GET /api/mediator/signMint a flow30 per 60 s per client address
POST /api/mediator/signCo-sign one forward envelope10 per 60 s per flow; requests without an authentic token, 5 per 60 s per address
POST /api/mediator/reissueRenew a flow token for a mediator that exists on chain5 per 60 s per flow; requests without an authentic token, 5 per 60 s per address
OPTIONS on bothCORS preflight, 204

Each route advertises only the methods it serves, in access-control-allow-methods and in the allow header of a 405: GET, POST, OPTIONS on /api/mediator/sign and POST, OPTIONS on /api/mediator/reissue. Any other method answers 405 METHOD_NOT_ALLOWED. A rate-limited request answers 429 RATE_LIMITED with retry-after in seconds. The limits are keyed as described in Self-hosting; a deployment that cannot identify the client answers 500 PROXY_MISCONFIGURED.

A request body over 16,384 bytes answers 413 BODY_TOO_LARGE. Malformed JSON or a missing field answers 400 BAD_REQUEST. An unset or malformed MEDIATOR_SECRET answers 500 MEDIATOR_NOT_CONFIGURED; the detail goes to the server log, with any seed in it redacted.

1. Mint a flow

GET /api/mediator/sign?destination=G...&network=mainnet&memo=<base64 xdr>
ParameterRuleError
destinationA valid G... public keyINVALID_DESTINATION
Not one of the exchange internal wallets the registry refusesREFUSED_DESTINATION
networkmainnet or testnetINVALID_NETWORK
memoThe base64 XDR of exactly one Stellar memo, in canonical form; MemoNone when the destination takes no memoINVALID_MEMO

Success answers 200:

{ "ok": true, "flowToken": "v2....", "mediatorPublicKey": "G...", "expiresAt": 1760000000000 }

Each GET mints a fresh, independent flow. The browser client (src/lib/mediator/client.ts) checks that the returned token names the network, destination and memo it asked for, and that mediatorPublicKey is a valid public key, and refuses the flow otherwise (FLOW_TERMS_MISMATCH, INVALID_MEDIATOR).

2. Fund and merge into the mediator

Plan generation refuses a mediator plan without both the mediator public key and the flow token. The batch that carries the merge also carries a create_account that funds the mediator with 2 XLM, placed right before the account_merge into it. A close that already funded the mediator in an earlier phase leaves the funding operation out. See The classic batch.

The preview mints a flow so the plan can show and simulate the merge, and that token is never used for the merge. The executor mints a new flow right before the batch that funds the mediator, saves the recovery record, and only then signs. A mediator that an earlier phase or an earlier run already funded is adopted instead, and its token is renewed through /api/mediator/reissue when fewer than eight minutes (the merge's 300 seconds plus the forward's 180) remain. Execution and recovery covers the probe, adoption of an already-funded mediator, and the refusals.

3. Build and co-sign the forward

The browser builds the forward (src/lib/mediator/forward.ts):

  1. It reads the mediator's history until it sees the merge from your account. When the mediator was already merged out, it reports that forward instead of sending a new one.
  2. It waits until the Horizon account view includes the merge, so the payment is never sized from a view that shows only the 2 XLM funding.
  3. It pays everything above the mediator's minimum balance ((2 + subentries + sponsoring - sponsored) × base reserve), minus selling liabilities and the fee. The merge leg then carries only that minimum, two base reserves (1 XLM at the current 0.5 XLM base reserve), plus whatever part of the fee the network did not charge. That remainder reaches the exchange as an ACCOUNT_MERGE, and an exchange that credits only payments does not credit it.
  4. It asks the server to co-sign and checks that the signed transaction has the same hash as the one it built.
  5. It runs the SEP-29 memo check, submits, and reconciles by hash whenever the outcome is unknown.
POST /api/mediator/sign
Content-Type: application/json

{ "envelopeXdr": "...", "flowToken": "v2....", "network": "mainnet" }

The route authenticates the token, applies the rate limit, and then:

CodeStatusCondition
INVALID_FLOW_TOKEN400The token is malformed or its MAC does not verify
FLOW_TOKEN_EXPIRED400The token is authentic but expired; renew it and retry
INVALID_NETWORK400network is present and is not mainnet or testnet
NETWORK_MISMATCH400network differs from the network bound into the token
validator codes400See Validation failure codes
SIGNING_FAILED500Signing a validated envelope threw
COSIGN_LOG_FAILED500The co-signature could not be written to the co-sign log

Success answers { "ok": true, "signedXdr": "..." }.

The co-sign log

Before it returns a signature, the route appends one JSON line to cosign.jsonl in METRICS_DB_DIR and syncs it to disk. A signature leaves the server only once its line is on disk. When the write fails, the route answers COSIGN_LOG_FAILED and keeps the signature, so the log holds every forward the server has handed out, including one that never reached the ledger.

FieldValue
signedAtWhen the server signed, in Unix milliseconds
networkmainnet or testnet
txHashThe hash of the co-signed transaction, in hex
mediatorThe mediator account
destinationThe destination of both operations
memoXdrThe memo, as base64 XDR
amountThe payment, in XLM
feeThe fee, in stroops
maxTimeThe end of the time bounds, in Unix seconds

Every field is part of the signed envelope, and lands on the ledger once the forward is submitted. The log never holds the flow token or its nonce, because with the master seed they derive the mediator key.

Fee and attempts

The starting bid reads Horizon fee_stats: fee_charged.mode while ledgers run below 50% capacity, fee_charged.p90 above it, clamped between 100 and 1,000,000 stroops per operation. An unreadable answer fails the forward. The forward makes up to four attempts. A forward that expires unseen, or a tx_insufficient_fee refusal, triples the bid up to the 1,000,000-stroop ceiling. tx_bad_seq, tx_too_late and tx_no_source_account reload the mediator, which detects a forward another tab already landed. A 429, an unreachable mediator route or a COSIGN_LOG_FAILED answer is retried three times with backoff, honoring retry-after up to 60 seconds.

Key derivation

MEDIATOR_SECRET is a Stellar seed used only as an HMAC master key. It is never a signing key and never an account. The master is its 32-byte raw ed25519 seed, cached after first use.

PurposeHMAC-SHA-256 message
Per-flow keypair seedmediator-flow-key:<nonceHex>
Flow token MACmediator-flow-token-v2:v2.<nonceHex>.<expiryMillis>.<network>.<destination>.<memoXdr>

The 32-byte digest becomes the flow's ed25519 seed. Derivation is deterministic and stateless: the same nonce and master always give the same keypair, so no per-flow seed is stored and any server instance with the same master serves every flow.

The environment schema accepts an unset or empty MEDIATOR_SECRET, and requires an S prefix and 56 characters otherwise. The full ed25519 seed check runs on first use.

The flow token

Seven dot-separated parts:

v2.<nonceHex>.<expiryMillis>.<network>.<destination>.<memoXdr>.<macHex>

The nonce is 16 random bytes as 32 hex characters, and the MAC is 64 hex characters over the first six parts. A token lives for 15 minutes from its mint or renewal.

Verification parses the parts, checks the version, the nonce, expiry and MAC formats, compares the MAC in constant time, and only then checks that the network, destination and memo are values this build serves. An expired token is reported as expired only after its MAC verifies, so the client is pointed at the renewal route only for a token the server issued.

The token is a bearer credential: it can be used again until it expires, and the client keeps it in browser storage for recovery. What it unlocks is a forward to the committed destination with the committed memo on the committed network, and nothing else.

Renewing a token

POST /api/mediator/reissue
Content-Type: application/json

{ "flowToken": "v2...." }

The route accepts an authentic token, expired or not, and re-signs it for the same nonce, so the same mediator, with the same network, destination and memo and a new 15-minute expiry. Renewal never widens what the token allows. Success answers 200 with the same fields as a mint:

{ "ok": true, "flowToken": "v2....", "mediatorPublicKey": "G...", "expiresAt": 1760000000000 }

The route renews a token only while the mediator account exists:

CodeStatusCondition
INVALID_FLOW_TOKEN400The token is malformed or its MAC does not verify
MEDIATOR_NOT_FOUND404The mediator account does not exist on chain
HORIZON_UNAVAILABLE502Horizon could not be read

The forward renews automatically when the sign route answers FLOW_TOKEN_EXPIRED, and refuses a renewed token that names a different mediator.

The accepted envelope

Exactly one shape is signable:

op0: PAYMENT       native XLM, source omitted or the mediator, to destination D
op1: ACCOUNT_MERGE source omitted or the mediator, to destination D (the same D)
memo:              exactly the committed memo
fee:               at most 2,000,000 stroops (1,000,000 per operation)
preconditions:     time bounds only, maxTime within 3600 seconds

Binding both operations to one destination stops a tampered envelope from paying the balance to one address and merging the reserve to another. Binding the memo as tightly as the destination matters because an exchange destination is a shared address and the memo decides whose account is credited.

Validation failure codes

validateMediatorForwardEnvelope in src/lib/mediator/validator.ts returns the first failure, in this order:

#CodeCondition
1MALFORMED_XDRThe envelope does not parse for the flow's network
2FEE_BUMP_NOT_ALLOWEDThe envelope is a fee bump
3FORWARD_TX_SOURCE_NOT_MEDIATORThe transaction source is not the mediator
4WRONG_OPERATION_COUNTThe operation count is not exactly 2
5FORWARD_OP0_NOT_PAYMENTOperation 0 is not a payment
6FORWARD_OP0_ASSET_NOT_NATIVEThe payment asset is not native XLM
7FORWARD_OP0_SOURCE_NOT_MEDIATOROperation 0 names a source other than the mediator
8FORWARD_OP1_NOT_ACCOUNT_MERGEOperation 1 is not an account merge
9FORWARD_OP1_SOURCE_NOT_MEDIATOROperation 1 names a source other than the mediator
10FORWARD_DESTINATION_MISMATCHThe two operations target different destinations
11FORWARD_DESTINATION_NOT_COMMITTEDThe destination differs from the token's
12FORWARD_MEMO_NOT_COMMITTEDThe memo's XDR differs from the token's, in type or bytes
13FORWARD_FEE_TOO_HIGHThe fee is above 2,000,000 stroops
14FORWARD_PRECONDITION_NOT_ALLOWEDLedger bounds, a minimum sequence, sequence age or gap, or extra signers
15MISSING_TIME_BOUNDSNo time bounds, or a maxTime that is absent, zero or not positive
16TIME_BOUNDS_EXCESSIVEmaxTime is more than 3600 seconds in the future

Trust: what the server can do

An honest server co-signs only a forward that meets the flow's terms. The binding of destination, network and memo into the token limits what a token that leaks on its own, from browser storage or a log, can be used for. It does not limit these parties:

  • Whoever holds MEDIATOR_SECRET: the operator, or anyone who compromises the server. The holder derives every mediator key from the master and a nonce, so it can move any balance that sits in a mediator without a token.
  • A compromised server. The server serves the app code, so it can change the destination and memo the page builds against before you sign.
  • Script running in the page, from a cross-site scripting flaw or a malicious browser extension. It calls GET /api/mediator/sign like the app does and mints its own flow for a destination and memo of its choosing, so the binding does not constrain it.

The client checks above (the returned terms, a valid mediator key, the signed hash) catch a bug or a cache answering for other terms. They do not stop a malicious server. An exchange close trusts the operator with the balance while it is in transit. That exposure is bounded by time, not by cryptography: it lasts from the merge into the mediator to the forward out of it, and the two run back to back. A direct close, to an address that needs no memo, never uses the mediator.

Interrupted forwards

Before it signs the batch that funds the mediator, the app writes a recovery record to browser storage (account-demolisher:pending-mediator-forwards): the account, the mediator, the flow token, the destination, the memo and the network. Records are keyed by network, account and mediator, so one close never overwrites another's. A close whose record cannot be stored does not start.

On later visits with that account connected, the app reads each record against the chain:

What the chain showsWhat the app does
Your account merged in, balance still thereAn exchange forward was interrupted, with Resume the forward
The mediator merged out to the committed addressExchange forward delivered, and the record is removed
The mediator merged out anywhere elseExchange forward went to another address
No merge from your accountAn earlier exchange close stopped before the merge

Resume runs the same forward, renewing the token through the re-issue route when it has expired, so recovery has no time limit while the mediator exists. A record whose close is still running in another tab is left to that tab.

Records saved by builds before the memo and network were bound into the token hold a four-part token. The routes refuse those tokens, because renewing one would let whoever holds it choose the memo. The app asks you to contact the operator with the mediator account, your deposit memo and the token, and the operator finishes the forward with scripts/mediator-legacy-forward.mjs.

On this page