Docs
Architecture
How Sealed is put together, for engineers and auditors: every component, and every user journey
(J1–J8) as a diagram. Design choices are recorded, with the alternatives considered, in the
repository's DECISIONS.md. The plain-English version for users is docs/TRUST_MODEL.md; attacks
and mitigations are in docs/THREAT_MODEL.md.
Components
| Part | Role |
|---|---|
apps/web |
Pages and API routes: handle lookups, send quotes, the owner dashboard. Holds no keys and no private balances. Strict nonce CSP, CSRF checks, per-IP rate limits. |
apps/agent |
The only component that handles sessions and signing requests. Runs Sign in with X (OAuth 2.0 PKCE), issues 15-minute sessions, talks to the vault, 1Click and the chains. |
services/sweeper |
Watches handle deposit addresses and asks the agent to sweep confirmed deposits into the handle's private account (pg-boss). Audits registered sweep addresses hourly. |
contracts/vault |
The only account allowed to ask the MPC network to sign for handle paths. Builds every payload itself and enforces agent registration, nonces, caps, delays, the circuit breaker and a 14-day timelock. |
packages/* |
config (brand, chains, defaults, env), core (domain types, ports, API and RPC contracts), derivation, chains, intents, near, x, db, logger, mocks, ui. |
Web ↔ agent
Every call from the web app or the sweeper to the agent goes through one allow-listed RPC table
(packages/core/src/agent-rpc.ts). Each request is HMAC-signed over method, path, timestamp,
nonce and body hash, with a 60-second window and a replay cache. Methods not in the table
(for example the relayer) cannot be reached at all.
Modes
ADAPTER_MODE picks implementations:
- preview (
ADAPTER_MODE=mock): one simulated world stands in for X, NEAR Intents and the chains, and every page shows a preview notice. Sample addresses are recognizable fakes (EVM0x00000000…, Bitcointb1…, Intents ids starting with 16 zeros) and their QR codes cannot be scanned. The public site runs this until launch; so dopnpm devand CI's Playwright journeys. - testnet: each real adapter that is configured replaces its simulated stand-in: X lookups and Sign in with
X, handle addresses (needs
VAULT_ACCOUNT_ID), sweeps and the relayer (Base Sepolia, Bitcoin testnet4, Solana devnet, …) and the money services. 1Click and NEAR Intents have no testnet, so money flows run in fixture mode: the real services against a simulator of both, with the real testnet vault and MPC network checking and signing everything. - mainnet: every service is real: the live 1Click API with the partner key, NEAR
Intents balances from
intents.near, PoA bridge deposit addresses for sweeps, and an executor account that submits owner-key intents. Each app refuses to start, naming what is missing, if anything would be simulated.
Address derivation (Chain Signatures)
Path
x/{xUserId}/v1/{family}
xUserId: the numeric X user ID in decimal, 1–20 digits, no leading zero (R1: bound to the ID, never the @handle, so renames keep the wallet and a recycled @handle gets new addresses).family:evm,btc,solorintents. One key per family per handle; all EVM chains share theevmkey.
Built by pathFor in packages/derivation/src/path.ts and by get_path in
contracts/vault/src/path.rs. Both are tested against
packages/derivation/test/fixtures/path-vectors.json (128 valid paths, 18 rejected IDs).
Keys
NEAR Chain Signatures derives a public key per (predecessor, path) from the MPC network's root key, with additive tweaks (VERIFICATION.md, Phase 5 pass):
epsilon = sha3_256("near-mpc-recovery v0.1.0 epsilon derivation:" + predecessor + "," + path)
secp256k1 = root_secp + int_be(epsilon)·G domain_id 0 families evm, btc
ed25519 = root_ed + (int_le(epsilon) mod L)·B domain_id 1 families sol, intents
The predecessor is the vault contract account. Only the vault can make the MPC network sign
for these keys, which is why the vault account is never guessed: it comes from VAULT_ACCOUNT_ID.
| Network | Signer | Root keys |
|---|---|---|
| testnet | v1.signer-prod.testnet |
packages/derivation/src/mpc.ts (MPC_SIGNERS) |
| mainnet | v1.signer |
same file; checked against the live contract views |
Addresses
| Family | Encoding | Used on |
|---|---|---|
evm |
last 20 bytes of keccak256(uncompressed key), EIP-55 checksum | Ethereum, Base, Arbitrum, Robinhood Chain, BNB Chain |
btc |
P2WPKH over the compressed key: bc1q… (mainnet), tb1q… (testnet) |
Bitcoin |
sol |
base58 of the Ed25519 key | Solana |
intents |
64-hex NEAR implicit account id of the Ed25519 key | the handle's private NEAR Intents account |
Why each chain made the v1 list is recorded in DECISIONS.md.
How it is tested
test/vectors.test.ts: 80 derived keys recorded from both live signer contracts (pnpm --filter @sealed/derivation record-vectors) plus the addresses for them computed with viem, bitcoinjs-lib and @solana/web3.js.test/mpc.test.ts: property tests. For random root secrets, a signature made withsecret + epsilonrecovers to (secp256k1) or verifies against (Ed25519) the offline-derived key.test/path.test.ts: the shared path vectors, round trips and rejection of non-canonical IDs.test/testnet-sign.test.ts(opt-in,test:testnet): the testnet MPC network signs for each family's path and the result matches the offline key;testnet:e2erepeats it through the deployed vault.
Journeys
J8 Check your @ (and every profile page)
Handle lookups run in the web app with an app-only X token, cache-first: handle → profile for 24 hours, negative results for 10 minutes, and per-IP limits in front (every uncached lookup costs X credits). Addresses are pure math in the agent, so every X account has them without signing up (R2).
J1 Private send
The sender never touches our contracts: the quote itself names the recipient's own Intents account as a confidential recipient, and 1Click settles into it. Nothing about the send is stored; the browser keeps an encoded reference and polls status with it (R4).
If a quote expires or the deposit is below the minimum, 1Click refunds the sender before anything is credited (R3 allows this; nothing credited is ever returned).
J2 Public send and the sweep
Someone pays a handle's public address from any wallet; the funds end up in the handle's own NEAR Intents account, and nothing on the way can change that destination.
- Routes are governance-approved per
{chain}:{symbol}. Registered routes use the PoA bridge's deposit address for the handle's Intents account; the agent registers it on first use and the vault only uses it after a public 24-hour delay. The sweeper checks every registered address against the bridge hourly. - Records live in the agent's memory and are mirrored to Postgres by the sweeper, which also confirms, from public chain data, any sweep the agent no longer tracks after a restart.
- The relayer is a hot wallet whose EVM and Solana keys are derived inside the agent.
It takes an X ID, derives the address itself, and has per-funding and daily budgets; it is not
reachable over the agent RPC. Balances and low-balance warnings show on
/opsand in the logs. - Solana tokens: the vault derives both SPL token accounts itself and the handle's own address pays the fee (and the destination account's rent the first time). Bitcoin, SOL and SPL tokens use registered routes only.
- Dust: when fees would exceed 20% of the value, the deposit stays on the address until more arrives.
J3 Sign in with X and opening the wallet
The X token is used once and revoked; only the numeric ID and public profile are kept (R5).
init_handle records the handle's Intents account from the MPC contract once, so no later
request can point the vault at a different account.
J4 Withdraw, J5 swap, J6 ZEC
Every spend from a handle's balance is a 1Click quote funded from its NEAR Intents account. 1Click generates the transfer intent; the vault parses it, applies the per-token daily cap and signs it with the handle's key; the agent submits it.
- Swaps (J5) use the same path with the handle's own account as the destination; they pay no fee by default and are refused above the delay threshold.
- ZEC (J6) is a withdrawal to the owner's unified Zcash address (
u1…), whose shielded receiver hides what happens next; 1Click refuses Sapling (zs…) recipients, so the agent does too. Whether 1Click pays a unified address's shielded receiver is checked in the mainnet smoke test. - Balances: public Intents balances (sweeps) from
intents.near, plus the confidential balance read with a login message the vault signs; the 1Click session stays in memory. - Privacy cost: because limits are enforced on-chain, the token and amount of each outgoing move of a system-key handle are public on NEAR. Balances and incoming payments are not.
J7 Take full ownership
Only an active wallet key can remove the system key; passkeys are bound to this website's domain.
The vault contract
contracts/vault is the only account the MPC network signs handle paths for, so it is where the
rules live. It never signs a hash handed to it: it builds or parses every payload itself.
What it signs
| Request | Payload | Limits |
|---|---|---|
request_external_intent / request_intents_action → transfer |
NEP-413 transfer intent for the handle's Intents account (withdrawals, swaps, fees) |
Per-token rolling 24 h cap; above the threshold it waits in the public delay queue |
request_intents_action → add key |
NEP-413 add_public_key |
Always waits in the delay queue; the owner or the agent can cancel |
request_intents_action → remove key |
NEP-413 remove_public_key of the system key |
Needs an owner key added through the queue; afterwards the vault no longer acts on the account |
request_sweep |
EVM (EIP-1559), Bitcoin (P2WPKH, BIP-143), Solana or SPL transaction | Destination from the route only (Omni to near:intents + handle, or the registered bridge address after 24 h); fees bounded by route and 20% |
Every request carries the handle's nonce and expires within 10 minutes; intent deadlines are at most 30 minutes out. Signatures come back through the vault's callback as ready-to-submit intents or raw transactions; if the MPC network fails, the callback gives the day's allowance back.
Agents and governance
- Agents. In TEE mode an agent registers with a dstack attestation verified on-chain by the
vendored
shade-attestationcrate. Registrations expire (7 days); an agent whose build or CPU is no longer approved is removed on its next call. Local mode (testnet only) uses a governance whitelist. - Governance can only propose changes, which wait in the timelock: limits, approved builds and
CPUs, sweep routes, contract upgrades.
lock_foreverremoves upgrades permanently (R6). There is no function that moves user funds. - Circuit breaker: pauses agent-initiated spends and sweeps; it expires by itself after 72 hours and never moves anything.
How it is tested
125 unit tests (every rejection path, callbacks with real signatures) and proptest properties for
the cap window, transaction builders byte-identical to viem, bitcoinjs-lib and @solana/web3.js,
sandbox tests against a signing MPC stand-in (contracts/mock-mpc), and
pnpm --filter @sealed/near testnet:e2e against the deployed testnet vault and the real testnet
MPC network. Line coverage: 92% (pnpm contract:coverage).
The agent in a TEE
The agent ships as one esbuild bundle in a digest-pinned image; a clean rebuild reproduces the
image digest. Inside the enclave its NEAR key comes from shade-agent-js (enclave
randomness) and its master key from dstack's KMS, so only attested instances of the approved build
can sign vault requests or mint sessions. The relayer keys and the session key are derived from the
master key. scripts/tee/measure.mjs turns an image digest into the exact measurements and code
hash that governance approves; /health reports the vault registration. HANDOFF.md §3a has the
deployment steps.
Fees and buybacks
Fees are taken once, when value leaves: 0.75% on withdrawals (0.5% send + 0.25% withdraw),
0 on swaps by default, rounded up to the next base unit, and paid as a separate vault-signed
transfer to TREASURY_ACCOUNT_ID. The math is in packages/core/src/fees.ts, shared by the agent,
the mocks and the pages. The holder discount stays off until a token exists; buybacks are a dry run
that prices the swap with 1Click dry quotes and never executes.
Data kept
Postgres holds only caches and public metadata: handle → ID lookups, public profile snapshots, X
API usage counters, deposit watches, sweep records and the aggregate counters on /stats. It never holds private balances, OAuth
tokens or who sent what to whom (a hard rule of the design). Logs are redacted by key and by value
pattern, and redaction is covered by tests in packages/logger.
Front-end performance
Browser code imports values from the zod-free @sealed/core/client; the API schemas load only on
pages that call the API. Budgets for gzipped JavaScript are enforced by
apps/web/e2e/perf.spec.ts, and accessibility by axe checks in apps/web/e2e/a11y.spec.ts.