Security model
What the deployment can and cannot do, the trust boundaries, the adversaries the design assumes, what each party can see, and the code that defends each boundary.
Closing an account is irreversible. The app keeps your key in your browser or wallet, checks every transaction it asks you to sign against what that step needs, and puts the trust it does ask for behind boundaries you can verify in the code.
What the deployment runs
| Route | What it does |
|---|---|
/api/mediator/sign, /api/mediator/reissue | Mints an ephemeral mediator for one exchange close, renews its token, and co-signs one strictly validated two-operation forward |
/api/plan and its subroutes | Collects signatures for a shared-account close and merges them into one envelope, in memory |
/api/metrics | Records page visits, funnel steps and verified closes; serves the aggregate behind a bearer token |
The deployment does not:
- receive your secret key, from a wallet or from the pasted-key path;
- build a transaction for you to sign, or submit one on your behalf. Your browser builds every transaction, your wallet signs it, and your browser submits it. The mediator forward is built and submitted by your browser too; the server only adds the mediator's co-signature.
Position discovery, planning, simulation, the allow-list and authorization checks, and the safety gates all run in the browser. There is no server-side positions proxy.
Server secrets
| Variable | Power |
|---|---|
MEDIATOR_SECRET | An HMAC master that derives every mediator key and signs flow tokens. It never signs a transaction itself and is never an account |
METRICS_ADMIN_TOKEN | Reads the aggregate metrics |
The mediator and the operator
An honest server co-signs only a forward that pays the destination bound into the flow token, with the memo bound into it, on the network bound into it, with a capped fee and a short time bound. That binding limits what a token that leaks on its own, from browser storage or a log, can be used for. It does not limit:
- Whoever holds
MEDIATOR_SECRET: the operator, or anyone who compromises the server. The holder derives every mediator key, so it can move any balance that sits in a mediator, whatever any token says. - 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 can mint its own flow for a destination and memo of its choosing, so the binding does not constrain it.
An exchange close therefore 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, but it still runs the code the deployment serves, so every close trusts the operator to serve honest code.
Full detail in Mediator forward protocol.
Trust boundaries
| Boundary | Trust | What bounds it |
|---|---|---|
| Browser | Highest | Your key lives here or in your wallet and nowhere else |
| Wallet | Separate signer | Shows its own confirmation. The app submits only an envelope whose hash equals the transaction it built and checked, carrying a valid signature from the connected key |
| Server | Trusted for every close | Serves the app code every close runs, so a compromised server can change what the page builds, the destination included, before you sign. That holds for a direct close as much as for an exchange close. Holds MEDIATOR_SECRET, the HMAC master, which only an exchange close relies on: whoever holds it, the operator or anyone who compromises the server, derives every mediator key and can move any balance sitting in a mediator between the merge and the forward. A direct close never uses the mediator, but it still runs the code the deployment serves. The server never receives your key |
| Horizon and Soroban RPC | Untrusted for integrity | They supply the state and simulations a plan is built from. Each classic batch is rebuilt from a fresh audit, each Soroban step is re-simulated, and before every signature the target, the authorization tree and the fee are checked, so a lying endpoint cannot widen what you sign |
| Lumeus | Untrusted | A hosted Stellar history index that only adds candidates to the allowance, held-token and Aquarius scans. Every allowance is read back with allowance(), every token with balance(), and every pool through the Aquarius router before the plan uses it. The RPC scans still run, and a Lumeus failure leaves their result and is reported |
| On-chain contracts | Untrusted | Only pinned entry points, plus a router-resolved Aquarius pool for a gauge claim, can be the root of a signed DeFi call, each held to the exact authorization tree it needs. Allowance revokes and token transfers target token contracts found by scanning your account, which anyone can create and send you, so each is held to one exact call shape. Every call is held to a fee cap |
Assets
In rough order of severity:
- Your account and its funds. A close is irreversible, so the destination, the memo and the disposal of every balance are the primary asset.
- A balance in transit through a mediator. Exposed to whoever holds
MEDIATOR_SECRETfor the time between the merge and the forward. - The mediator HMAC master. Whoever holds it can derive every mediator key.
- Signing keys. Your key, which never reaches the server, and each flow's mediator key, derived on demand and never stored.
- Allow-list integrity. If the set of signable contracts could widen at runtime, a malicious position could smuggle an arbitrary call into a signature.
- Plan correctness. What you sign matches what you reviewed, against current state, and a merge never runs while a position is open.
Adversaries and what stops them
| Adversary | Mitigation |
|---|---|
| A hostile token contract emitting fabricated events, or adding calls to a simulated authorization tree | A revoke is held to exactly approve(user, spender, 0, ledger) and a token transfer to exactly transfer(user, destination, amount), each with source-account credentials and no sub-invocations. Every DeFi step is held to the exact tree its call needs. Untrusted-token steps carry a fee and write cap. A token reporting more than 20 spenders is cut and flagged as hostile |
| A compromised or hostile RPC or Horizon returning wrong balances, stale state or a manipulated simulation | Fresh audit and rebuild before each classic batch, re-simulation of each Soroban step, the sign-time target, tree and fee checks, and a merge guard that refuses while any DeFi position is open or a protocol could not be read. A failed fee read fails the step instead of bidding the minimum |
| A hostile wallet or extension returning a different envelope | verifySignedEnvelope refuses any envelope whose hash differs from the transaction the app built, or that carries no valid signature from the connected key |
| A network attacker in the middle | HTTPS from the TLS-terminating proxy, HSTS in production, and a CSP that lists every host the browser is allowed to contact |
| A malicious co-signer trying to get a different transaction signed, or to bloat the request | Signatures merge only when the transaction hash matches the stored one and the signature verifies against one of the account's signers. Deduplication keys on the recovered signer, the envelope stops at the threshold, and the app checks the bundle against the live account before submitting |
| Someone holding a flow token leaked from storage or a log | The co-signer signs only a forward to the committed destination, with the committed memo, on the committed network, with a fee of at most 2,000,000 stroops and no preconditions beyond a short time bound |
| A manipulated market | A conversion whose full-size price is more than 2% worse than a reference trade of 1/1000 of the balance is never sold without your per-token decision. Minimum outputs carry the configured slippage (1% by default), and a fresh quote worse than the reviewed one stops the close |
| A supply-chain attacker | A committed lockfile installed with --frozen-lockfile, a dependency audit in CI, a digest-pinned base image, and a CSP that loads no third-party script origin |
| Abuse of the API routes | Every route is rate-limited per client address, request bodies are capped (and capped again at the proxy), and the relay accepts only a close already signed by one of the account's signers |
Safety gates before signing
- Typed confirmation. You type the last four characters of the destination, compared without regard to case, and a four-second timer has to run out before the confirm button enables. The same screen shows the memo. This catches a wrong paste and a swapped clipboard.
- High-value acknowledgement. Required when the close moves more than 1,000 XLM, counting native XLM, quoted conversions of credit balances, and XLM claimed from claimable balances, or when it moves any holding the app cannot price.
- Scam-token checks. Four checks run over the account's assets and standalone tokens:
- Symbol collision: a well-known symbol (XLM, USDC, EURC, AQUA, BLND) from an issuer or contract other than its canonical one, where the network has a canonical issuer for that symbol, and any credit asset coded XLM. On testnet, AQUA and BLND have no canonical issuer, so the check does not flag them there.
- Look-alike symbol: within an edit distance of 2 of one of those symbols.
- Suspicious characters: a symbol with characters outside A to Z, a to z and 0 to 9, or a token name with hidden or text-reordering characters.
- Unrecognized contract: a token contract that is not on this network's curated list and is not the asset contract of one of the well-known assets.
- Memo enforcement. A destination in the exchange registry, or one that publishes the SEP-29 memo flag, needs a well-formed memo of an accepted type before the close can start. Exchange internal wallets are refused outright.
- Mainnet notice. Whenever the app is on mainnet (the build default, a restored selection or a
switch), the browser must have acknowledged that the code has not had an independent audit. The
acknowledgement is stored in local storage. The notice follows the network the app has selected,
not the one a signing request names: a co-signer on
/signsees it whenever their browser is on mainnet without an acknowledgement.
Contract allow-list
Every DeFi-protocol Soroban transaction is checked against a compiled-in, network-specific list of entry points before it is signed, together with its authorization tree and its fee. Contract creation and wasm upload are always refused. See Contract allow-list.
What the app and its providers can see
The browser talks directly to these hosts, and to nothing else outside the deployment:
| Host | When | What it sees |
|---|---|---|
Horizon (horizon.stellar.org, horizon-testnet.stellar.org) | Every audit, read and classic submission | Your address, the accounts you read, every transaction you submit |
| Soroban RPC (the primary for the network and its failovers) | Discovery, simulation and Soroban submission | Your address and every Soroban transaction |
friendbot.stellar.org | Creating a testnet demo account | The new testnet address |
amm-api.aqua.network, amm-api-testnet.aqua.network | Aquarius pool discovery | Your address |
Lumeus (api.lumeus.dev, assets.lumeus.dev, api-testnet.lumeus.dev, assets-testnet.lumeus.dev) | Discovery and the allowance viewer, only when the build has a Lumeus key for the network | Your address. The browser sends it with a publishable key that is read-only and answers only from the deployment's origin |
| WalletConnect and Reown hosts (relay, RPC, API and verify) | Only when you pick WalletConnect, sign with a restored WalletConnect session, or disconnect or close a restored session | The relay carries the encrypted session. AppKit analytics and sign-client telemetry are off. AppKit's mandatory initialization and connection events, which carry the page URL, go to pulse.walletconnect.org, which connect-src leaves out, so the browser blocks them |
| Your wallet's own services | Whatever the wallet you pick does | Outside the app's control |
Usage metrics
The app sends first-party usage events to its own /api/metrics, never to a third party:
| Event | Sent when | Carries |
|---|---|---|
visit | The landing, /demolish, /allowances or /sign page loads | The page, and the network where it applies |
funnel | You connect a wallet, discovery completes, a plan is built, or a close starts | The step and the network |
close | A close completes | The network and the merge transaction's hash |
No address, wallet identifier or amount is sent, but the merge hash identifies the closed account
on the public ledger. The server stores visits as daily counts per page and network, funnel steps as
counts per network and step, and a close only after verifyCloseTx (src/server/close-verify.ts) confirms on Horizon that the hash is a
successful merge, shaped like a close this app builds, that carries the on-chain close marker: a
manageData set and delete of account-demolisher.close right before the merge. The marker is
public, so anyone can copy it into their own merge; the count means merges that carried it, and
per-network daily caps (1,000 on mainnet, 200 on testnet) bound what copying adds. The store keeps
the verified closes in closes.jsonl and the counts in counters.json under METRICS_DB_DIR.
GET /api/metrics answers 503 until METRICS_ADMIN_TOKEN is set to at least 16 characters, and 401
without the matching Authorization: Bearer token.
Content Security Policy
Built in src/lib/config/csp.ts and set on every document response by src/proxy.ts (Next 16's
name for middleware). /api is outside the proxy's matcher, so route bodies are not buffered before
their caps apply. next.config.ts sets the static headers on every response: X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, a Permissions-Policy
that denies camera, microphone and geolocation, and HSTS in production.
| Directive | Production value |
|---|---|
default-src | 'self' |
connect-src | 'self', Horizon, Soroban RPC and its failovers and friendbot (derived from NETWORKS), the Aquarius API and the Lumeus Activity and Assets hosts on both networks, and the WalletConnect and Reown hosts other than pulse.walletconnect.* |
script-src | 'self' 'unsafe-inline' 'wasm-unsafe-eval' |
style-src | 'self' 'unsafe-inline' |
img-src | 'self' data: and the wallet-icon hosts |
font-src | 'self' and fonts.reown.com |
frame-src | 'self' and the WalletConnect verify hosts |
object-src | 'none' |
frame-ancestors | 'none' |
base-uri, form-action | 'self' |
connect-src is derived from NETWORKS in src/lib/config/networks.ts, so adding a network
endpoint there adds it to the policy.
script-src allows 'unsafe-inline' because Next's inline hydration scripts are not nonce-stamped
in this deployment, and a nonce-only policy blocks hydration. This is a real tradeoff: the policy
loads no third-party script origin, but an injected inline script would run, and could reach every
origin connect-src allows, including Horizon, which accepts any signed transaction, and the
WalletConnect relay. The CSP limits where scripts load from, not where data can go.
Development adds ws: and wss: to connect-src, 'unsafe-eval' to script-src, blob: to
img-src, and data: to font-src.
Origin verification
public/stellar.toml is a SEP-1 declaration served at /.well-known/stellar.toml, so embedders can
verify the origin. It declares no signing key, because the mediator uses per-flow keys. See
SEP support.
SEP support
Which Stellar Ecosystem Proposals the app implements, what each one is used for, and the full server surface.
Contract allow-list
The compiled-in set of contracts a signed DeFi call can target, the authorization-tree and fee checks that run with it, and the two token paths held to their own exact shapes.