OutLayer Documentation
MPC Vaults
First, how OutLayer key custody works
Every wallet key, every secret-encryption key, every payment-check ephemeral on OutLayer is not stored anywhere. It is derived on demand inside the keystore worker’s TEE from a single 32-byte master. The master itself is also not stored on disk — on every keystore start it is requested from NEAR’s MPC network via a primitive called CKD (Conditional Key Derivation): threshold-key holders in the MPC network jointly hand back the master for a given on-chain identifier, deterministically, without any single MPC node ever assembling the secret.
See Agent Custody and Secrets for how derived keys are used per-feature; the rest of this page focuses on whose master it is.
By default the master is bound to OutLayer’s keystore-DAO contract. The keystore TEE asks NEAR MPC for that master, derives every customer’s keys from it inside the enclave, and never persists it. Nobody manages or holds the master directly: it is reproduced from MPC on every keystore restart, lives only in TEE memory, and the DAO contract enforces hardware-attestation verification before MPC releases the bytes — so even OutLayer operators cannot request it outside an attested keystore.
An MPC vault swaps that DAO-bound root for a master bound to a contract you deploy on a sub-account of your NEAR account. Same CKD primitive, same keystore, same TEE flow — but the binding moves to your vault, so only code that controls the vault account can ever ask MPC for that master.
The vault contract’s only access key is a TEE function-call key scoped to a single proxy method that calls NEAR MPC’s request_app_private_key. As long as that key is in place, OutLayer’s TEE is the only party that can ask MPC for your master.
Read top-down: end-user triggers a request → the runtime (TEE) derives the requested key from a per-customer master → on first call only, the master comes from NEAR MPC bound to a CKD-issuer contract. Tabs change two things: which contract owns the binding and who operates the runtime — watch the chips on the runtime and issuer cards flip.
| Aspect | Default (OutLayer master) | MPC vault (yours) |
|---|---|---|
| Master bound to | OutLayer’s keystore-DAO contract | Your vault contract |
| Runtime that holds master | OutLayer keystore TEE only | Swappable: OutLayer TEE today, your own attested runtime after recovery |
| Takeover path | None — you depend on OutLayer continuing to serve | Cessation recovery (DAO declares is_ceased) or unilateral exit (parent-only, configurable window) |
| After takeover | — | Your runtime calls NEAR MPC from the vault account ⇒ same 32 bytes ⇒ same derived keys |
| One-time cost | $0 | ~0.1 NEAR (storage stake + MPC gas reserve) |
Two modes — one-way switch
Once your vault is deployed you choose how to operate it. You can change modes later, but it’s a one-way move:
OutLayer’s TEE holds the FC key, derives the master via MPC CKD, and runs your agents on its infrastructure. You keep full sovereignty (parent account + recovery path), but the keystore TEE is what does the actual key derivation and decryption.
You initiate recovery (cessation or unilateral exit) and call finalize_recovery(new_parent_pubkey) with a key you generated locally. The contract atomically deletes every OutLayer TEE key and adds your key as FullAccess, then flips unlocked = true; OutLayer’s keystore refuses to serve any further call against this vault. You then re-run the same MPC CKD derivation yourself — from your own TEE, your own attested runtime, or by hand — and reproduce every per-vault wallet / secret key.
One-way: once a vault is unlocked, OutLayer’s keystore won’t serve it again. You can’t come back to mode A — the FC-key + MPC-CKD binding only holds while the contract’s unlocked = false. To use OutLayer with a fresh managed vault you would deploy a new vault on a new sub-account.
What is “CKD”?
Conditional Key Derivation is a primitive of NEAR’s MPC service. Threshold-key holders in the MPC network jointly derive a private key for a given on-chain identifier, deterministically, without ever assembling the secret on any single node. The resulting key is unique to the contract that requested it; another contract asking for the same path gets a completely different key.
For an MPC vault, the requesting contract is the vault itself. The TEE keystore calls request_app_private_key through the vault’s proxy method; NEAR MPC returns 32 bytes that become your per-customer master inside the enclave. Same inputs ⇒ same master, deterministically — so you can reproduce it later by querying the same MPC network from the vault account, even if OutLayer is gone.
When to use a vault#
Trade-offs
- Default master: shared keystore-DAO root, zero customer setup, no on-chain footprint. Best for prototyping and low-value automation. You rely on OutLayer’s keystore as the only path to your derived keys.
- Per-customer MPC vault: on-chain CKD-issuer contract bound to your account. ~0.1 NEAR one-time (storage stake + gas reserve for outbound MPC calls) thanks to
UseGlobalContract; one atomic tx at setup. You can later take the vault over yourself via NEAR MPC and keep deriving every key independently of OutLayer.
Use a vault if your application’s value-at-risk justifies the extra setup, or if your governance / audit requirements mandate independent control over derived keys.
Creating a vault#
A single atomic NEAR transaction creates the sub-account, deploys the vault WASM, calls new(), and adds the TEE function-call key. If any of the five actions fails, the entire sub-account state rolls back — there is no half-deployed state.
Dashboard
- Open Vaults from My Workspace.
- Pick a sub-account name (default:
vault) and an exit window (24h / 7d / 30d). - Click Create vault; sign the atomic-deploy tx in your wallet.
- The vault is now deployed and DAO-verified. No API key is issued at this step. To mint a wallet API key bound to this vault, see the “Using a vault for custody wallets” section below (
POST /register{vault_id}, returnswk_...).
CLI
# defaults: --name vault --exit-window 24h
outlayer vault init
# custom
outlayer vault init --name treasury --exit-window 7dWhat happens technically
- CLI/dashboard probes
is_vault_code_approved(hash)on keystore-DAO so you don’t pay gas to deploy a deprecated WASM. - Coordinator returns the deterministic TEE function-call public key for your vault id (HMAC-derived inside the TEE).
- One transaction, five actions:
CreateAccount+Transfer 0.1 NEAR+UseGlobalContract(code_hash)+new(parent, keystore_dao, mpc_contract, initial_tee_pubkey, initial_exit_window)+AddKey(tee_pubkey, FCAK on vault.request_master). - CLI/dashboard calls
POST /customer/registeron the coordinator. The coordinator forwards to the keystore-worker, which independently re-runs the five RPC verification checks and signsmark_vault_verifiedon the DAO so the vault lands inkeystore-dao.verified_vaults. No API key is minted at this step — vault init is pure on-chain provisioning + DAO trust signal.
If init fails between step 3 and step 4 (e.g. transient network failure between the atomic deploy and the coordinator’s sign-verification call), use outlayer vault resume <account> or the Resume button on the dashboard. Step 4 is idempotent.
Using a vault for secrets#
Secrets encrypted with your vault’s pubkey are decryptable only by the keystore-worker holding your per-customer master. The encryption pubkey for your vault is fetched by the dashboard and CLI by including the X-Customer-Vault: <vault_id> header on the pubkey request; the keystore derives a per-vault sub-key for encryption and returns the matching public half.
Existing secrets encrypted under the default master continue to work without migration — the toggle is per-secret, not per-account.
Using a vault for custody wallets#
Two distinct steps
outlayer vault init does NOT mint a wallet API key. It performs the on-chain atomic deploy and runs POST /customer/register, which triggers keystore re-verification (mark_vault_verifiedon the DAO) so the vault is in the DAO’s verified set. That’s all. No wk_... is returned.
To get a wallet API key bound to your vault, run POST /register with {"vault_id": "<vault>"} separately:
curl -sS -X POST https://api.outlayer.fastnear.com/register \
-H 'Content-Type: application/json' \
-d '{"vault_id": "vault.alice.near"}'
# returns: { api_key: "wk_...", wallet_id: "<uuid>",
# near_account_id: "<hex>", handoff_url, trial }Each call mints a fresh wallet under the same vault — the coordinator stores wallet_accounts(wallet_id, vault_id) and N wallets per vault are allowed. The wallet’s NEAR implicit address comes from HMAC-SHA256(per_vault_master, "wallet:{wallet_id}:near"), so distinct wallet_ids give cryptographically isolated keys even under the same vault.
Auth on /wallet/v1/*
Every wallet API call sends Authorization: Bearer wk_.... The coordinator looks up the API key in its DB, finds the bound wallet_id + vault_id, and forwards X-Customer-Vault: <vault_id> to the keystore on every signing call. The vault scope is auth-driven, never request-driven: a client-supplied X-Customer-Vault header on a /wallet/v1 call is ignored — the binding lives on the API key’s DB row. (Test: tests/vault_multi_customer_isolation.sh.)
Multi-user patterns (bots, agents)
For a Telegram-style bot serving thousands of users from one vault: call POST /register {vault_id} once per user, store the returned (wallet_id, api_key)mapping under your user’s ID in your own DB. Each user ends up with a distinct NEAR address derived from the same per-vault master. The bot signs as user U by using user U’s api_key.
The deterministic /register path (5-tuple: account_id + seed + pubkey + message + signature, where the user proves possession of a NEAR key) does not support vault_id. Passing both is rejected with HTTP 400. Vault-scoped deterministic derivation isn’t a feature today; if you want per-user determinism, derive the user’s seed yourself and use it as the wallet_id-equivalent (i.e. include it in your DB so re-runs land on the same coordinator row).
Default master vs vault
You can run multiple wallets — some on the default master (empty POST /register, no vault_id), some on different vaults. The agent code does not change; the API key fully determines which master derives that wallet’s keys. Legacy customers (pre-vault) keep working on the default master indefinitely.
Recovery procedures#
Cessation recovery (catastrophic)
If the OutLayer DAO declares cessation (is_ceased() == true on the keystore-DAO), anyone can call initiate_recovery on the vault. The vault contract re-checks the DAO flag inside its callback, starts a fixed seven-day timer, then a seven-day window for finalize_recovery. Only the parent account can finalize (closes a front-running window where a third party could substitute their own pubkey at the atomic swap). Generate the new parent key locally first — finalize_recovery takes it as an argument, atomically deletes every OutLayer TEE key, and installs your pubkey as the vault’s only FullAccess key in one tx.
# Generate the new parent key locally before finalize:
./scripts/customer-recovery/target/release/customer-recovery generate-key \
> ~/.outlayer-recovery/vault.alice.near.json
NEW_PUBKEY=$(jq -r .public_key ~/.outlayer-recovery/vault.alice.near.json)
# Run the recovery (parent signs):
outlayer vault initiate-recovery vault.alice.near
# (wait 7 days; DAO can revoke cessation, which auto-cancels)
outlayer vault finalize-recovery vault.alice.near "$NEW_PUBKEY"
# Vault is now unlocked; your key is the only FullAccess key.Unilateral recovery (voluntary)
The parent account can exit at any time without DAO involvement. The delay is the unilateral_exit_window_secs chosen at deploy (default 24h, configurable 1d–30d on mainnet). Changing the window via set_exit_window only affects future recoveries — an in-flight recovery’s finalize timestamps are frozen at initiate time. Like cessation, finalize is parent-only and atomically swaps OutLayer’s TEE keys for new_parent_pubkey.
outlayer vault set-exit-window vault.alice.near 24h # optional, only affects future recoveries
outlayer vault initiate-unilateral-recovery vault.alice.near
# (wait the configured window)
NEW_PUBKEY=$(jq -r .public_key ~/.outlayer-recovery/vault.alice.near.json)
outlayer vault finalize-recovery vault.alice.near "$NEW_PUBKEY"Optional follow-up: outlayer vault unlocked-add-key vault.alice.near ed25519:... adds additional keys (e.g. function-call keys for day-to-day ops) on top of the new parent key that finalize_recovery already installed.
Local master recovery (after finalize)
On-chain finalize is only half of the sovereign exit. The per-vault master OutLayer’s keystore used to derive your wallet keys and decrypt your secrets is still recoverable deterministically — anyone holding a FullAccess key on the unlocked vault can submit a fresh request_app_private_key to the MPC contract and arrive at the same 32-byte master. The standalone customer-recovery binary does that, plus two helpers:
generate-key— emit a fresh ed25519 keypair locally (used in the walkthrough to produce the new vault-owning key beforefinalize_recovery)derive-wallet-key --master <hex> --wallet-id <uuid>— re-derive a custody wallet’s ed25519 private key.wallet_idis the UUID the coordinator returned at/registertime; keep a backup. Re-deriving offline produces the same NEAR implicit address the keystore was serving.decrypt-secret --master <hex> --seed <s> --ciphertext-base64 <b64>— locally decrypt an on-chain secret. Auto-detects ECIES v1 (current CLI / dashboard format) vs legacy ChaCha20-Poly1305 (pre-v0.2 CLI). The ciphertext comes fromget_secrets(accessor, profile, owner)on theoutlayer.nearcontract.
The full procedure (deploy → recovery → master recovery → wallet re-derivation → secret decryption) is documented as a single runbook in docs/LEAVING_OUTLAYER.md. The wrapper script scripts/customer-recovery/walkthrough.sh runs the live steps with idempotency and exit-window introspection.
# Standalone master recovery (after the on-chain finalize landed):
VAULT_PRIVATE_KEY=$(jq -r .private_key ~/.outlayer-recovery/vault.alice.near.json) \
MPC_PUBLIC_KEY='bls12381g2:...' \
./scripts/customer-recovery/target/release/customer-recovery \
--vault-id vault.alice.near \
--from-chain \
--rpc-url https://rpc.mainnet.fastnear.com \
--mpc-contract v1.signer \
--nearblocks-url https://api.nearblocks.io
# stdout: master_hex=<64 hex>/call/<owner>/<project> request that touches a secret bound to it with HTTP 423 Locked (body {reason: "vault_unlocked", vault_id, error}) in under a second, instead of letting the WASI worker time out at the Cloudflare gateway. The same gate emits HTTP 403 with reason: "vault_not_verified" when the DAO has not approved the vault (or revoked it). This is the fence that distinguishes “OutLayer doesn’t serve you anymore” from a transient infra failure — one is permanent (you’ve exited), the other is a 5xx.What end-users should know#
Two questions any end-user transacting with a customer’s vault-bound app should ask before depositing funds:
1. Could the customer have rigged the vault during deploy?
No. The vault becomes immutable immediately after the atomic deploy. Its only access key is the TEE function-call key restricted to mpc_contract.request_app_private_key; that key cannot add keys, deploy code, or call any vault method. The approved vault contract has no method that allows self-upgrading or installing additional keys outside the recovery flow. Any tampering during the atomic deploy either (a) rolls back atomically, or (b) produces a final state that vault-checker observably rejects (extra access keys, unapproved code hash, malformed state). End-users can confirm this themselves with outlayer vault verify <vault_id>.
2. Could the customer drain the vault later?
Yes — after the configured unilateral exit window. This is the explicit sovereignty feature the vault provides. After waiting unilateral_exit_window_secs(1–30 days on mainnet, chosen at deploy and visible on chain), the customer’s parent account calls finalize_recovery(new_parent_pubkey): the contract atomically deletes every OutLayer TEE key and installs the customer’s pubkey as the vault’s only FullAccess key, flips unlocked = true, and the customer can re-derive the per-vault master via MPC CKD themselves.
This is not a vulnerability in OutLayer’s TEE infrastructure — it’s the customer exercising the escape hatch they built the vault to have. From the protocol’s perspective the customer was always able to recover their own vault.
- Read
unilateral_exit_window_secswithoutlayer vault verifyBEFORE depositing. Treat it as “this is how much warning I get before the customer can drain.” - The vault’s
recoverystate is observable in real time. A tool that watchesvault.get_recovery_state()alerts you the moment a recovery starts, giving you the configured window to react. - For high-value deployments, prefer customers whose
parentaccount is a multisig contract — that shifts the trust assumption from “customer is honest” to “customer’s multisig signers are honest.”
End-user verification#
Anyone can verify a vault’s state without deploying or signing anything. The CLI runs five defense-in-depth checks against on-chain state:
outlayer vault verify vault.alice.nearkeystore-dao.is_vault_verified— primary trust signal.is_vault_code_approved— vault WASM hash is in the DAO whitelist.vault.get_state()matches the network’s expected keystore_dao + mpc_contract ids and is not unlocked.- The on-chain access keys are bounded and TEE-only — no full-access key, no out-of-scope FCAK.
registered_tee_keysis a subset of the account’s access keys.
Red flags: vault is banned (is_vault_verified returns false even after cleanup), vault is unlocked (parent has post-recovery key authority — funds are NOT under TEE control anymore), or arecovery is in progress.
Operational considerations#
- One-time cost: ~0.1 NEAR transferred to the new vault account. With
UseGlobalContractthe WASM lives in the global registry, so storage stake is just the contract state (~0.004 NEAR). The remainder is the gas reserve for outbound MPC calls (vault.request_master → mpc.request_app_private_key, ~0.001 NEAR/call; the master is cached in the keystore TEE after the first call). - Top-ups when the gas reserve runs low: gas for
vault.request_masteris paid from the vault account itself (it owns the TEE function-call key that signs the call). If the balance falls below the reserve threshold, the keystore eventually fails to refresh your per-customer master in enclave memory and derived-key requests stall until top-up. Top-up is a plain on-chain NEAR transfer to the vault account from any wallet — no contract method, no signature on a special endpoint. The dashboard surfaces a banner with a suggested transfer amount when the balance is below the threshold. Operationally we recommend customers monitor the vault balance the same way they monitor a hot wallet. - Race-attack protection: a malicious customer who tries to also-derive the per-vault master with a backup key sneaked into the deploy is detected by the OutLayer monitor (Phase 8) and banned automatically. The DAO ban applies retroactively — view-call
is_vault_verifiedreturns false even though the vault was once verified. - TEE worker rotation: the vault contract caps registered TEE keys at 32. Each keystore-worker upgrade registers a new key via
propose_tee_key(permissionless, but the contract’s callback cross-checkskeystore-dao.is_keystore_approvedbefore committing — non-approved adds are rolled back). Parents can retire stale keys withclear_unused_tee_keys(vec![pubkey, ...])(parent-only; typo-guarded — panics on unknown keys).finalize_recoverydeletes the entire registered set atomically as part of the atomic key-swap.