Skip to content

PreviewPayments here are simulated. Don't send real crypto to any address on this site.

Sample accounts
Sealed

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

Diagram: 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.

Diagram: Web ↔ agent

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 (EVM 0x00000000…, Bitcoin tb1…, Intents ids starting with 16 zeros) and their QR codes cannot be scanned. The public site runs this until launch; so do pnpm dev and 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, sol or intents. One key per family per handle; all EVM chains share the evm key.

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 with secret + epsilon recovers 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:e2e repeats 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).

Diagram: J8 Check your @ (and every profile page)

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).

Diagram: J1 Private send

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.

Diagram: J2 Public send and the sweep
  • 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 /ops and 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

Diagram: 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.

Diagram: J4 Withdraw, J5 swap, J6 ZEC
  • 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

Diagram: J7 Take full ownership

Only an active wallet key can remove the system key; passkeys are bound to this website's domain.

Diagram: J7 Take full ownership

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

Diagram: Agents and governance
  • Agents. In TEE mode an agent registers with a dstack attestation verified on-chain by the vendored shade-attestation crate. 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_forever removes 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.