Account Demolisher
Protocol

Signing relay API

The four routes that collect signatures for a shared-account close, their guards, and how signatures are verified, merged and trimmed.

A shared-account close needs enough signer weight on one transaction. The relay in src/server/signing-relay.ts holds that transaction, merges each co-signer's signature into it, and streams every change to the people watching.

The relay never sees a secret key. It holds only the transaction envelope, a public artifact, in process memory, until the request expires.

Routes

All four run on the Node.js runtime, are force-dynamic, and rate-limit per client address, keyed as described in Self-hosting.

RouteMethodPurposeRate limit
/api/planPOSTPublish a close transaction10 / 60 s
/api/plan/[id]GETFetch the current state of a request60 / 60 s
/api/plan/[id]/signPOSTAdd a co-signer's signature30 / 60 s
/api/plan/[id]/eventsGETServer-sent stream of the request's state20 / 60 s

The id is the transaction hash, hex-encoded, so a co-signer can check that the transaction they fetch is the one the link names.

Publish

POST /api/plan
{ "network": "mainnet", "xdr": "<base64 envelope signed by one signer>" }

Success answers { "ok": true, "id": "<64-hex hash>" }. CAPACITY answers 503 and every other refusal 400 with code and reason. A body over 32,768 bytes answers 413, and a throttled request 429.

Fetch

GET /api/plan/[id]

Success answers the request's public state:

{
  "ok": true,
  "network": "mainnet",
  "xdr": "<base64>",
  "account": "G...",
  "signers": [{ "key": "G...", "weight": 1 }],
  "thresholds": { "low": 0, "med": 2, "high": 2 },
  "expiresAt": 1760000000000
}

signers and thresholds are the account's, read from Horizon at publish. A missing or expired request answers 404 NOT_FOUND.

Sign

POST /api/plan/[id]/sign
{ "xdr": "<base64 envelope carrying the co-signer's signature>" }

Only xdr is read; the network comes from the stored request. Success answers the full public state after the merge, in the same shape as Fetch. NOT_FOUND answers 404, and BAD_XDR, REJECTED and TOO_MANY_SIGNATURES answer 400.

Events

GET /api/plan/[id]/events

A text/event-stream. Each event carries the full public state as JSON: one on open, and one whenever a signature is accepted. A : ping comment keeps the stream open every 25 seconds. The stream closes when the request expires.

Refusals are plain text: 404 signing request not found, and 503 too many live listeners for this signing request with retry-after: 30 when the request already has 50 listeners, or 3 from the same client address. On a refused stream the browser client (src/lib/multisig/plan-client.ts) polls GET /api/plan/[id], starting at 5 seconds and backing off to 60, and tries the stream again every 60 seconds.

Publish guards

createPlan sweeps expired requests, then checks in this order:

#GuardCode
1The XDR is a non-empty string of at most 24,576 charactersBAD_XDR
2The network is mainnet or testnetBAD_NETWORK
3A classic transaction with an account merge, whose source and every operation's source is the closing accountNOT_A_CLOSE
4The transaction has an upper time boundNO_EXPIRY
5That bound is within 72 hours (plus 5 minutes of clock skew)EXPIRY_TOO_FAR
6The lower time bound is not more than 60 seconds in the futureNOT_YET_VALID
7The request stays open for more than 10 minutesEXPIRED
8A transaction already published merges into its request insteadREJECTED, TOO_MANY_SIGNATURES
9The closing account is readable from HorizonACCOUNT_UNREACHABLE
10The transaction uses the account's next sequence numberSTALE_SEQUENCE
11The envelope holds no more than 20 signaturesTOO_MANY_SIGNATURES
12The envelope carries a verified signature from one of the account's signersUNSIGNED
13The network has room: 400 open requests on mainnet, 100 on testnetCAPACITY

Guard 3 identifies the account by the account merge's effective source, and refuses a transaction whose operations act on any other account, so a publisher cannot read one account's signer set while merging another.

Guard 12 is the proof of control: only an account's own signer can open a request to close it.

Between guards 12 and 13, the relay manages the account's other open requests. Requests whose sequence number is already used can never be submitted and are dropped. An account holds at most 3 open requests: at the cap, the request with the least signer weight collected (the oldest among equals) gives way to the new one. All of an account's open requests share one sequence number, so at most one of them can land.

A publish that races another publish of the same transaction merges into the request that won, instead of replacing it.

Signatures

On publish

The published envelope is rebuilt with exactly one verified signature per signer, in signer order, and then trimmed to the close threshold. Junk, duplicates and signatures from other keys are dropped, so a padded envelope cannot block later co-signers.

The close threshold

thresholdForClose in src/lib/multisig/partial-xdr.ts is the largest of the account's low, medium and high thresholds, and at least 1. The bundle holds high operations (the merge, signer cleanup) and medium ones (the close marker, trustline and offer cleanup), and the network checks the low threshold for the transaction as a whole. The relay and the app count with this one rule.

On sign

mergeNewSignatures merges a co-signer's envelope into the stored one and throws on any failure, which the relay answers as REJECTED:

  1. The stored and incoming envelopes decode as classic transactions, not fee bumps.
  2. The incoming transaction hash equals the stored one, so a signature over a different transaction is never merged.
  3. Every new signature verifies over that hash under one of the account's signer keys, read from Horizon at publish. The 4-byte hint only decides which key is tried first; a signature from a wallet with an unusual hint is still matched by verifying against every signer.

Signatures are deduplicated by the recovered signer key, not by their wire bytes. The hint is chosen by whoever sends the envelope, so keying on wire bytes would let one captured signature be replayed under many hints until the envelope hits the 20-signature limit. A merged signature is stored with its signer's canonical hint.

Once the stored envelope meets the threshold it is final: a later valid signature is acknowledged but not added, because the network rejects a transaction that carries a signature it did not need (tx_bad_auth_extra). An envelope that brings several new signatures at once is trimmed to the threshold in envelope order, as the network counts them. A replayed envelope that adds nothing answers success without notifying listeners.

Bounds

BoundValueScope
Signing window72 hoursPer request
Expiry grace60 secondsSubtracted from the transaction's own maxTime
Minimum time left10 minutesAt publish
Open requests400 mainnet, 100 testnetWhole relay process
Open requests per account3Per network and account
XDR length24,576 charactersPublish and sign
Request body32,768 bytesBoth POST routes
Signatures per envelope20The XDR limit
Listeners50 per request, 3 per client addressEvents
Expired-request sweepEvery 60 secondsWhole relay process

A request is served until the earlier of 72 hours after publish and the transaction's maxTime minus 60 seconds. The app builds a signing request's transaction with a 72-hour validity window (SIGNING_WINDOW_SECONDS in src/lib/multisig/signing-request.ts).

State is in process memory

The relay keeps its requests in memory: a restart loses them, and instances behind a load balancer do not share them. A co-signer routed to another instance does not find the request. Run one instance; see Self-hosting.

Cross-network replay

Stellar binds the network passphrase into the transaction hash, so a signature collected on one network cannot complete the same close on another.

On this page