# NEAR OutLayer — Full Documentation > Verifiable compute and custody for AI agents, on NEAR. An agent gets a TEE-held wallet that signs for NEAR, EVM and Solana without a key ever leaving the enclave, spends it under a policy its owner sets, and calls priced connectors — named operations that run inside that same enclave. The compute layer is also callable directly: you publish a WASI program to GitHub, OutLayer compiles it, runs it inside an Intel TDX enclave, and returns the result signed by the enclave — from a NEAR smart contract or over HTTPS. Agents also get encrypted secrets and persistent storage. Two integration modes. **On-chain:** a NEAR contract calls `request_execution` and receives the result in a callback. **Web2:** a backend calls `POST https://api.outlayer.ai/call/{project_owner}/{project_name}` over HTTPS. **API base URL is `https://api.outlayer.ai`** — the `api.` subdomain. `https://app.outlayer.ai` is the dashboard and docs site and serves no API. Testnet API base is `https://testnet-api.outlayer.ai`. HTTPS authentication is either a payment key (`X-Payment-Key: {owner}:{nonce}:{secret}`) or a trial worker key (`Authorization: Bearer wk_...`). There is no `X-API-Key` header. Mainnet contract: `outlayer.near`. Testnet contract: `outlayer.testnet`. NEAR RPC: `https://rpc.mainnet.fastnear.com` / `https://rpc.testnet.fastnear.com`. Full text of every document below is available in one fetch at https://app.outlayer.ai/llms-full.txt. This file inlines every developer-facing document in the OutLayer repository. For a link index instead, fetch https://app.outlayer.ai/llms.txt. ## Contents - Overview — `README.md` - Quick Start — `QUICK_START.md` - HTTPS API Reference — `API.md` - Authentication and Payment Keys — `AUTHENTICATION.md` - Command-Line Interface — `docs/CLI.md` - WASI Examples Overview — `wasi-examples/README.md` - WASI Development Tutorial — `wasi-examples/WASI_TUTORIAL.md` - WASM Environment Variables — `wasi-examples/WASM_ENV_VARS.md` - Best Practices: OutLayer + NEAR — `wasi-examples/BEST_PRACTICES_OUTLAYER_NEAR.md` - Proxy Contracts Tutorial — `wasi-examples/PROXY_CONTRACTS_TUTORIAL.md` - Smart Contract API — `contract/README.md` - Rust SDK — `sdk/outlayer/README.md` - Agent Custody Reference — `CUSTODY.md` - Multi-Chain Custody Wallets — `docs/MULTI_CHAIN.md` - Deterministic Wallets — `docs/DETERMINISTIC_WALLETS.md` - Payment Checks — `docs/PAYMENT_CHECKS.md` - Custody Design Rationale — `docs/outlayer-custody-advantages.md` - Sovereign Vaults — `VAULTS.md` - Leaving OutLayer (Sovereign Exit) — `docs/LEAVING_OUTLAYER.md` - Verifiable Random Function — `VRF.md` - Worker Attestation — `WORKER_ATTESTATION.md` --- > Source: https://github.com/out-layer/outlayer/blob/main/README.md # NEAR OutLayer **Verifiable off-chain computation for NEAR smart contracts using Intel TDX** OutLayer lets smart contracts execute arbitrary code off-chain and receive verified results on-chain. Computation runs inside Intel TDX Trusted Execution Environments (TEEs) on Phala Cloud, ensuring that neither the operator nor any third party can tamper with execution or access secrets. ## Quick Links - **Dashboard & Docs**: [outlayer.fastnear.com](https://outlayer.fastnear.com/dashboard) - **HTTPS API (mainnet)**: `https://api.outlayer.ai` → `/call/{owner}/{project}` - **HTTPS API (testnet)**: `https://testnet-api.outlayer.ai` → `/call/{owner}/{project}` - **Contract**: `outlayer.near` (mainnet) / `outlayer.testnet` (testnet) - **API reference**: [API.md](https://github.com/out-layer/outlayer/blob/main/API.md) — endpoints, base URLs, and source-availability notes - **For AI coding assistants**: [llms.txt](https://outlayer.fastnear.com/llms.txt) — link index, or [llms-full.txt](https://outlayer.fastnear.com/llms-full.txt) for every doc in a single fetch - **Production App**: [near.email](https://near.email) — blockchain-native email built on OutLayer ## How It Works 1. A NEAR smart contract (or HTTP client) submits a computation request 2. A TEE worker picks up the task, compiles/executes the WASI binary with resource limits 3. The verified result is returned on-chain (via yield/resume) or via HTTP response ## Project Structure ``` near-outlayer/ ├── contract/ # Main NEAR contract (outlayer.near) ├── register-contract/ # TEE worker registration contract (5-measurement TDX verification) ├── keystore-dao-contract/ # DAO governance for keystore worker registration ├── coordinator/ # Task queue & API server (Rust + Axum, PostgreSQL + Redis) ├── worker/ # Execution workers (Rust + Tokio, wasmi runtime) ├── keystore-worker/ # Secrets decryption service (Rust, runs in TEE) ├── dashboard/ # Web UI + documentation (Next.js + React) ├── sdk/ # OutLayer SDK for WASI apps (Rust, wasm32-wasip2) ├── wasi-examples/ # Example WASI projects ├── scripts/ # Deployment & utility scripts ├── docker/ # Docker configurations (Phala Cloud deployment) ├── tee-auth/ # TEE authentication utilities └── tests/ # Integration tests ``` ## Two Integration Modes ### Blockchain (NEAR Smart Contracts) Your contract calls `request_execution()` on `outlayer.near`. The result comes back via NEAR's yield/resume mechanism. Best for on-chain workflows that need verified computation. ```rust // In your NEAR contract #[ext_contract(ext_outlayer)] trait OutLayer { fn request_execution( &mut self, execution_source: ExecutionSource, request_params: RequestParams, ) -> Promise; } ``` ### HTTPS API (Web2 Apps) Call the API directly with a payment key. Best for web apps, bots, and services that need off-chain computation without a smart contract. ```bash curl -X POST https://api.outlayer.ai/call/alice.near/my-project \ -H "X-Payment-Key: alice.near:1:your_secret_key" \ -H "Content-Type: application/json" \ -d '{"prompt": "Hello"}' ``` ## Security Model - **Intel TDX**: Hardware-level memory encryption and isolation — the host operator cannot read TEE memory - **5-Measurement Verification**: Workers are verified using all 5 TDX measurements (MRTD + RTMR0-3), preventing dev/debug images from passing attestation - **Sigstore Certification**: Release binaries are cryptographically linked to source code via [Sigstore](https://www.sigstore.dev/) - **Phala Trust Center**: Independently verify the exact image hash running in each TEE worker - **No Compilation in TEE**: TEE workers with access to secrets only execute pre-compiled WASM, preventing supply chain attacks via malicious build scripts ## Development ### Prerequisites - Rust 1.85+ (see `rust-toolchain.toml` in each component) - Docker & Docker Compose - Node.js 18+ (for dashboard) - NEAR CLI - `cargo-near` (for contract builds) - `sqlx-cli` (for coordinator migrations) ### Build & Run ```bash # Contract cd contract && ./build.sh # Coordinator (requires PostgreSQL + Redis) cd coordinator && cargo run # Worker cd worker && cargo run # Keystore Worker cd keystore-worker && cargo run # Dashboard cd dashboard && npm install && npm run dev ``` See [QUICK_START.md](https://github.com/out-layer/outlayer/blob/main/QUICK_START.md) for full setup instructions including database initialization and Docker services. ## Documentation | Document | Description | |----------|-------------| | [PROJECT.md](https://github.com/out-layer/outlayer/blob/main/PROJECT.md) | Complete technical specification | | [QUICK_START.md](https://github.com/out-layer/outlayer/blob/main/QUICK_START.md) | Setup and deployment guide | | [WORKER_ATTESTATION.md](https://github.com/out-layer/outlayer/blob/main/WORKER_ATTESTATION.md) | TEE attestation deep dive | | [AUTHENTICATION.md](https://github.com/out-layer/outlayer/blob/main/AUTHENTICATION.md) | Authentication configuration | | [Onepager.md](https://github.com/out-layer/outlayer/blob/main/Onepager.md) | Project overview one-pager | | [contract/README.md](https://github.com/out-layer/outlayer/blob/main/contract/README.md) | Contract API reference | | [worker/README.md](https://github.com/out-layer/outlayer/blob/main/worker/README.md) | Worker configuration | | [wasi-examples/WASI_TUTORIAL.md](https://github.com/out-layer/outlayer/blob/main/wasi-examples/WASI_TUTORIAL.md) | WASI development tutorial | | [wasi-examples/BEST_PRACTICES_OUTLAYER_NEAR.md](https://github.com/out-layer/outlayer/blob/main/wasi-examples/BEST_PRACTICES_OUTLAYER_NEAR.md) | Best practices guide | | [dashboard/DOCS_INDEX.md](https://github.com/out-layer/outlayer/blob/main/dashboard/DOCS_INDEX.md) | Dashboard documentation index | ### Using OutLayer with an AI coding assistant Point the assistant at one of these instead of pasting files by hand: | File | Contents | |------|----------| | [`/llms.txt`](https://outlayer.fastnear.com/llms.txt) | Index of every documentation page, with a one-line summary each, in the [llms.txt](https://llmstxt.org) format | | [`/llms-full.txt`](https://outlayer.fastnear.com/llms-full.txt) | Full text of all developer docs inlined, ~310 KB, one fetch | | [OpenAPI spec](https://api.outlayer.ai/openapi.json) | Machine-readable HTTPS API schema | | [Agent Custody skill](https://skills.outlayer.ai/agent-custody/SKILL.md) | Drop-in skill file for agent frameworks | Both `llms` files are generated from [dashboard/scripts/llms-manifest.mjs](https://github.com/out-layer/outlayer/blob/main/dashboard/scripts/llms-manifest.mjs) by `npm run llms` in `dashboard/`, and regenerate automatically on `npm run build`. ## Default Ports | Service | Port | |---------|------| | Dashboard | 3000 | | Coordinator API | 8080 | | Keystore Worker | 8081 | | PostgreSQL | 5432 | | Redis | 6379 | ## License MIT --- > Source: https://github.com/out-layer/outlayer/blob/main/QUICK_START.md # NEAR OutLayer - Quick Start ## Replace old instance? Remove old PostgreSQL docker volume rm near-offshore_postgres_data Item Testnet Mainnet PostgreSQL 5432 5433 Redis 6379 6380 Coordinator 8080 8180 Keystore 8081 8181 Dashboard 3000 3000 ## Create new instance ```bash # 1. Copy .env files cp coordinator/.env.example coordinator/.env cp worker/.env.example worker/.env cp keystore-worker/.env.example keystore-worker/.env cp dashboard/.env.example dashboard/.env.local # 2. Edit configuration (optional) # - coordinator/.env - internal port (PORT=8080) # - .env (root) - external Docker ports (COORDINATOR_EXTERNAL_PORT=8080) # - dashboard/.env.local - dashboard port (PORT=3000) # 3. Initialize database (first time only) cd coordinator docker exec offchainvm-postgres psql -U postgres -c "DROP DATABASE IF EXISTS offchainvm;" docker exec offchainvm-postgres psql -U postgres -c "CREATE DATABASE offchainvm;" sqlx migrate run --database-url postgres://postgres:postgres_password@localhost:5432/offchainvm DATABASE_URL=postgres://postgres:postgres_password@localhost:5432/offchainvm cargo sqlx prepare docker-compose -f docker-compose.testnet.yml up -d cd coordinator && env SQLX_OFFLINE=true cargo build --release --bin offchainvm-coordinator docker compose build coordinator # TESTNET ./scripts/run_coordinator.sh testnet --no-cache ./scripts/run_keystore.sh testnet ./scripts/run_worker.sh .env.testnet.worker1 ./scripts/run_worker.sh .env.testnet.worker2 # clear redis queue ./scripts/clear_redis_queue.sh testnet # MAINNET ./scripts/run_coordinator.sh mainnet ./scripts/run_keystore.sh mainnet ./scripts/run_worker.sh mainnet # Dashboard cd dashboard npm run build & npm run start # 4. Start Docker services docker-compose up -d # 5. Start worker (separate terminal) cd coordinator cargo run --release cd .. ./scripts/run_worker.sh .env.worker1 ./scripts/run_worker.sh .env.worker2 # 6. Start dashboard (separate terminal) cd dashboard npm install npm run dev ``` Dashboard available at http://localhost:3000 ## Database Setup (Development Only) **First time setup or after schema changes:** ```bash cd coordinator # Recreate database docker exec offchainvm-postgres psql -U postgres -c "DROP DATABASE IF EXISTS offchainvm;" docker exec offchainvm-postgres psql -U postgres -c "CREATE DATABASE offchainvm;" # Apply migrations sqlx migrate run --database-url postgres://postgres:postgres@localhost/offchainvm # Generate sqlx offline cache (required for Docker build) DATABASE_URL=postgres://postgres:postgres@localhost/offchainvm cargo sqlx prepare # Rebuild coordinator docker-compose build coordinator docker-compose restart coordinator ``` **⚠️ Production:** Use proper migrations workflow - never drop database! ## Default Ports | Service | Port | Configuration | |---------|------|---------------| | Dashboard | 3000 | `dashboard/.env.local` → `PORT=3000` | | Coordinator API | 8080 | `coordinator/.env` → `PORT=8080` | | Keystore Worker | 8081 | `keystore-worker/.env` → `SERVER_PORT=8081` | | PostgreSQL | 5432 | docker-compose.yml | | Redis | 6379 | docker-compose.yml | ## Change Docker External Ports Edit **root `.env`**: ```env COORDINATOR_EXTERNAL_PORT=9090 POSTGRES_EXTERNAL_PORT=15432 REDIS_EXTERNAL_PORT=16379 ``` Restart services: ```bash docker-compose up -d ``` ## Change Dashboard Port ```bash # Option 1: Specify in command cd dashboard PORT=4000 npm run dev # Option 2: Export variable export PORT=4000 cd dashboard npm run dev # Option 3: Create .env file (read by cross-env) cd dashboard echo "PORT=4000" > .env npm run dev ``` ## Network Configuration (Testnet/Mainnet) Dashboard supports switching between testnet and mainnet. Configure contracts in `dashboard/.env.local`: ```env # Testnet NEXT_PUBLIC_TESTNET_CONTRACT_ID=outlayer.testnet NEXT_PUBLIC_TESTNET_RPC_URL=https://rpc.testnet.fastnear.com?apiKey=YOUR_API_KEY # Mainnet NEXT_PUBLIC_MAINNET_CONTRACT_ID=outlayer.near NEXT_PUBLIC_MAINNET_RPC_URL=https://rpc.mainnet.near.org # Default network NEXT_PUBLIC_DEFAULT_NETWORK=testnet ``` Network switcher available on **Playground** page. ## Documentation - [AUTHENTICATION.md](https://github.com/out-layer/outlayer/blob/main/AUTHENTICATION.md) - Authentication setup (production mode) - [CLAUDE.md](https://github.com/out-layer/outlayer/blob/main/CLAUDE.md) - Complete project documentation - [contract/README.md](https://github.com/out-layer/outlayer/blob/main/contract/README.md) - Contract API - [worker/README.md](https://github.com/out-layer/outlayer/blob/main/worker/README.md) - Worker configuration --- > Source: https://github.com/out-layer/outlayer/blob/main/API.md # OutLayer API The OutLayer HTTP API is served by the **coordinator** — the task queue and gateway that fronts the TEE workers, contracts, and keystore. It exposes verifiable off-chain computation (execute WASI modules) and the Agent Custody wallet over plain HTTPS. ## Base URLs (Networks) | Network | Base URL | Contract | |---------|----------|----------| | Mainnet | `https://api.outlayer.ai` | `outlayer.near` | | Testnet | `https://testnet-api.outlayer.ai` | `outlayer.testnet` | The paths below are identical on both networks — only the host differs. Pick the base URL that matches the network your project / wallet is deployed on. > **NEAR Intents is mainnet-only.** Testnet does not run the Intents solver > network, so the intents-dependent Agent Custody endpoints are **not available > on testnet**: every `/wallet/v1/intents/*` route, cross-chain gasless > withdrawals, and all `/wallet/v1/confidential/*` routes. Test those against > the **mainnet** API only. The rest of the wallet API (address, balance, > transfer, `call`, `sign-message`, policy, approval) works on both networks. - **Interactive reference (Scalar UI)**: `https://api.outlayer.ai/docs` - **OpenAPI 3.1 spec**: `https://api.outlayer.ai/openapi.json` — source of truth at [out-layer/api-spec](https://github.com/out-layer/api-spec) - **TypeScript SDK**: [`@outlayer/sdk`](https://www.npmjs.com/package/@outlayer/sdk) ([source](https://github.com/out-layer/sdk-js)) ## Authentication | Header | Used by | Meaning | |--------|---------|---------| | `X-Payment-Key: owner:nonce:secret` | Paid execution calls | Prepaid USD (stablecoin) balance | | `Authorization: Bearer wk_...` | Trial calls + all wallet endpoints | Wallet API key (free trial quota for `/call`) | | _(none)_ | `/register`, public read endpoints | No auth | > Only `X-Payment-Key` (paid) or `Authorization: Bearer wk_...` (trial / wallet) > are accepted for authenticated calls. There is no `X-API-Key` header. ## Execution API | Method | Endpoint | Auth | Description | |--------|----------|------|-------------| | POST | `/call/{owner}/{project}` | `X-Payment-Key` or `Bearer wk_...` | Execute a WASI module (sync response) | | GET | `/calls/{call_id}` | — | Poll an async execution by id | | GET | `/trial/status` | `Bearer wk_...` | Check remaining free trial quota | Optional execution headers: `X-Compute-Limit` (max compute budget in USD micro-units), `X-Attached-Deposit` (payment forwarded to the project author, read by the WASM via the `USD_PAYMENT` env var). ## Agent Custody Wallet API Deterministic per-agent wallets derived inside the TEE via NEAR MPC. All endpoints require `Authorization: Bearer wk_...`. | Method | Endpoint | Description | |--------|----------|-------------| | POST | `/register` | Register a wallet, get API key + trial quota (no auth) | | GET | `/wallet/v1/balance` | Balance (NEAR or FT), per chain | | GET | `/wallet/v1/address` | Address for any supported chain | | GET | `/wallet/v1/tokens` | List supported tokens | | POST | `/wallet/v1/transfer` | Transfer NEAR / FT | | POST | `/wallet/v1/call` | Call a NEAR smart contract | | POST | `/wallet/v1/sign-message` | NEP-413 message signing (`format:"raw"` removed — use `/auth-sign`) | | POST | `/wallet/v1/auth-sign` | OutLayer NEAR-key auth signature (`{purpose, seed, vault_id?}`) | | POST | `/wallet/v1/intents/deposit` | Deposit FT into Intents balance · **mainnet only** | | POST | `/wallet/v1/intents/withdraw` | Withdrawal — same-chain (native NEAR / NEP-141) or cross-chain (gasless); `/dry-run` available · **mainnet only** | | POST | `/wallet/v1/intents/swap` | Swap tokens via Intents; `/swap/quote` for a quote · **mainnet only** | | POST | `/wallet/v1/intents/deposit/cross-chain` | Cross-chain deposit via 1Click (legacy alias `/wallet/v1/deposit-intent`); `/cross-chain/status` + `/cross-chain/list` available · **mainnet only** | | POST | `/wallet/v1/create-payment-key` | Upgrade trial → paid (USDC or NEAR deposit) | | POST | `/wallet/v1/policy` · `/sign-policy` · `/encrypt-policy` | Policy engine (spend limits, allowlists) | | GET/POST | `/wallet/v1/approval/*` · `/approve/*` · `/reject/*` · `/pending_approvals*` | Multisig approval flow | | GET | `/wallet/v1/requests/{id}` | Request status | | GET | `/wallet/v1/audit` | Audit log | | POST | `/wallet/v1/delete` | Delete wallet | ### Confidential Intents (private balances) **Mainnet only** — built on NEAR Intents, so unavailable on the testnet API. | Method | Endpoint | Description | |--------|----------|-------------| | POST | `/wallet/v1/confidential/shield` | Shield funds into the private shard (legacy alias `/wallet/v1/confidential/deposit`, still works) | | GET | `/wallet/v1/confidential/balance` | Private balance | | POST | `/wallet/v1/confidential/transfer` | Private transfer | | POST | `/wallet/v1/confidential/swap` | Private swap; `/swap/quote` for a quote | | POST | `/wallet/v1/confidential/withdraw` | Withdraw (incl. native NEAR); `/dry-run` available | | POST | `/wallet/v1/confidential/unshield` | Move back to public balance | | POST | `/wallet/v1/confidential/deposit/cross-chain` | Cross-chain deposit into the confidential shard, quote-only (legacy alias `/wallet/v1/confidential/deposit-intent`) · **mainnet only** | ### Payment Checks (gasless agent-to-agent payments) `/wallet/v1/payment-check/{create,batch-create,claim,reclaim,peek,status,list}` — see [docs/PAYMENT_CHECKS.md](https://github.com/out-layer/outlayer/blob/main/docs/PAYMENT_CHECKS.md). ## Public (read-only, no auth) | Method | Endpoint | Description | |--------|----------|-------------| | GET | `/health` | Health check | | GET | `/public/pricing` | Current pricing | | GET | `/public/stats` · `/public/workers` | Network stats / live workers | | GET | `/public/storage/get` · `/public/storage/batch` | Read public (unencrypted) project storage | | GET | `/public/payment-keys/{owner}/{nonce}/balance` · `/usage` | Payment key balance / usage | | GET | `/public/project-earnings/{owner}` · `/users/{account}/earnings` | Earnings (read) | | GET | `/vrf/pubkey` | VRF public key (for on-chain verification) | | GET | `/tdx/collateral` | TDX attestation collateral | ## Source availability **The coordinator (this API) is currently closed source.** It is purely the **coordination layer** — task queue, WASM cache, payment accounting, and the HTTPS gateway that routes requests to the verifiable components. It holds no authority that you have to trust: it cannot read TEE memory, cannot forge attestations, and cannot tamper with execution results. **Every component that requires verification is open source:** - **Workers** — execute WASI modules inside Intel TDX TEEs ([worker/](https://github.com/out-layer/outlayer/tree/main/worker/)) - **Contracts** — `outlayer.near` and friends ([contract/](https://github.com/out-layer/outlayer/tree/main/contract/), [register-contract/](https://github.com/out-layer/outlayer/tree/main/register-contract/), [keystore-dao-contract/](https://github.com/out-layer/outlayer/tree/main/keystore-dao-contract/), [vault-contract/](https://github.com/out-layer/outlayer/tree/main/vault-contract/)) - **Keystore worker** — secrets decryption inside the TEE ([keystore-worker/](https://github.com/out-layer/outlayer/tree/main/keystore-worker/)) - **Libraries / SDKs** — [sdk/](https://github.com/out-layer/outlayer/tree/main/sdk/) (Rust, on crates.io as [`outlayer`](https://crates.io/crates/outlayer)), [`@outlayer/sdk`](https://github.com/out-layer/sdk-js) (TypeScript), [shared-tee-helpers](https://github.com/out-layer/shared-tee-helpers) (TEE challenge-response auth) Trust in OutLayer comes from TEE attestation of the open-source workers and from the on-chain contracts — not from trusting the coordinator. The coordinator being closed source does not widen the trust boundary: a malicious coordinator can withhold or misroute a request, but it cannot produce a result that passes attestation without the genuine open-source worker having computed it. See [README.md](https://github.com/out-layer/outlayer/blob/main/README.md#security-model) and [WORKER_ATTESTATION.md](https://github.com/out-layer/outlayer/blob/main/WORKER_ATTESTATION.md). --- > Source: https://github.com/out-layer/outlayer/blob/main/AUTHENTICATION.md # Authentication Configuration ## Overview The Coordinator API supports two authentication modes: 1. **Development Mode** (`REQUIRE_AUTH=false`) - No authentication required 2. **Production Mode** (`REQUIRE_AUTH=true`) - Bearer token authentication required for protected endpoints **Important:** Public endpoints (`/public/*` and `/health`) are **always accessible without authentication**, even when `REQUIRE_AUTH=true`. --- ## Authentication Modes ### Development Mode (Default) In development mode, all endpoints are accessible without authentication: ```env # coordinator/.env REQUIRE_AUTH=false ``` **Use case:** Local development, testing **Security:** ⚠️ Not suitable for production ### Production Mode In production mode, protected endpoints require valid Bearer tokens: ```env # coordinator/.env REQUIRE_AUTH=true ``` **Use case:** Production deployment, public-facing servers **Security:** ✅ Recommended for production --- ## Endpoint Types ### Public Endpoints (No Auth Required) These endpoints are **always accessible** without authentication: - `GET /health` - Health check - `GET /public/workers` - List workers - `GET /public/executions` - List execution history - `GET /public/stats` - System statistics - `GET /public/wasm/info` - Check WASM cache - `GET /public/users/:account/earnings` - User statistics **Dashboard uses only public endpoints**, so it works regardless of auth mode. ### Protected Endpoints (Auth Required in Production) These endpoints require Bearer token authentication when `REQUIRE_AUTH=true`: - `GET /tasks/poll` - Poll for tasks (workers) - `POST /tasks/complete` - Complete task (workers) - `POST /tasks/fail` - Fail task (workers) - `POST /tasks/create` - Create task (event monitor) - `POST /wasm/upload` - Upload WASM (workers) - `GET /wasm/:checksum` - Download WASM (workers) - `POST /locks/acquire` - Acquire distributed lock (workers) - `DELETE /locks/release/:key` - Release lock (workers) - `POST /workers/heartbeat` - Worker heartbeat --- ## Enabling Authentication (Production Setup) ### Step 1: Generate Token Hash Generate a SHA256 hash of your secret token: ```bash # Choose a strong random token TOKEN="my-secret-worker-token-$(openssl rand -hex 16)" echo "Your token: $TOKEN" # Generate SHA256 hash HASH=$(echo -n "$TOKEN" | shasum -a 256 | awk '{print $1}') echo "Token hash: $HASH" ``` **Example output:** ``` Your token: my-secret-worker-token-abc123def456 Token hash: cbd8f6f0e3e8ec29d3d1f58a2c8c6d6e8d7f5a4b3c2d1e0f1a2b3c4d5e6f7a8b ``` **⚠️ Important:** Save the **token** (not the hash) securely - you'll need it for worker configuration. ### Step 2: Add Token Hash to Database Connect to PostgreSQL and insert the token hash: ```bash # Connect to database docker exec -it offchainvm-postgres psql -U postgres -d offchainvm ``` ```sql -- Insert worker token INSERT INTO worker_auth_tokens (token_hash, worker_name, is_active) VALUES ( 'cbd8f6f0e3e8ec29d3d1f58a2c8c6d6e8d7f5a4b3c2d1e0f1a2b3c4d5e6f7a8b', -- Token hash 'production-worker-1', -- Worker identifier true -- Active ); -- Verify SELECT token_hash, worker_name, is_active, created_at FROM worker_auth_tokens; ``` **Output:** ``` token_hash | worker_name | is_active | created_at ----------------------------------------------------------------------+---------------------+-----------+---------------------------- cbd8f6f0e3e8ec29d3d1f58a2c8c6d6e8d7f5a4b3c2d1e0f1a2b3c4d5e6f7a8b | production-worker-1 | t | 2025-10-15 19:30:45.123456 ``` ### Step 3: Enable Auth in Coordinator Edit `coordinator/.env`: ```env # Enable authentication REQUIRE_AUTH=true ``` Restart coordinator: ```bash docker-compose restart coordinator ``` ### Step 4: Configure Workers Edit `worker/.env` and add the **original token** (not the hash): ```env # Coordinator API Configuration API_BASE_URL=http://localhost:8080 API_AUTH_TOKEN=my-secret-worker-token-abc123def456 # ← Original token ``` Restart worker: ```bash cd worker cargo run ``` ### Step 5: Configure Keystore Worker (Optional) If using encrypted secrets, configure keystore worker: Edit `keystore-worker/.env`: ```env # Generate separate token for keystore # Use the same process as Step 1 KEYSTORE_AUTH_TOKEN=my-secret-keystore-token-xyz789 ``` Add keystore token hash to database: ```sql INSERT INTO worker_auth_tokens (token_hash, worker_name, is_active) VALUES ( 'sha256_hash_of_keystore_token', 'keystore-worker', true ); ``` --- ## Testing Authentication ### Test Public Endpoint (Should Work) ```bash # No authentication required curl http://localhost:8080/public/workers ``` **Expected:** Returns worker list (JSON) ### Test Protected Endpoint Without Token (Should Fail) ```bash curl http://localhost:8080/tasks/poll ``` **Expected (when REQUIRE_AUTH=true):** ``` Unauthorized ``` ### Test Protected Endpoint With Token (Should Work) ```bash curl -H "Authorization: Bearer my-secret-worker-token-abc123def456" \ http://localhost:8080/tasks/poll?timeout=5 ``` **Expected:** Returns task or empty response after timeout --- ## Managing Tokens ### List All Tokens ```sql SELECT id, LEFT(token_hash, 12) || '...' as token_hash_preview, worker_name, is_active, last_used_at, created_at FROM worker_auth_tokens ORDER BY created_at DESC; ``` ### Disable Token ```sql -- Disable token without deleting UPDATE worker_auth_tokens SET is_active = false WHERE worker_name = 'production-worker-1'; ``` ### Re-enable Token ```sql UPDATE worker_auth_tokens SET is_active = true WHERE worker_name = 'production-worker-1'; ``` ### Delete Token ```sql -- Permanently delete token DELETE FROM worker_auth_tokens WHERE worker_name = 'production-worker-1'; ``` ### Rotate Token ```bash # 1. Generate new token and hash NEW_TOKEN="my-new-secret-token-$(openssl rand -hex 16)" NEW_HASH=$(echo -n "$NEW_TOKEN" | shasum -a 256 | awk '{print $1}') # 2. Update database docker exec -it offchainvm-postgres psql -U postgres -d offchainvm -c " UPDATE worker_auth_tokens SET token_hash = '$NEW_HASH' WHERE worker_name = 'production-worker-1'; " # 3. Update worker/.env with NEW_TOKEN # 4. Restart worker ``` --- ## Security Best Practices ### 1. Use Strong Random Tokens ```bash # Generate cryptographically secure random token openssl rand -base64 32 ``` ### 2. Never Commit Tokens to Git Add to `.gitignore`: ```gitignore .env .env.local *.secret ``` ### 3. Use Different Tokens Per Worker Each worker should have a unique token for auditing and revocation: ```sql -- Worker 1 INSERT INTO worker_auth_tokens (token_hash, worker_name, is_active) VALUES ('hash1', 'worker-1', true); -- Worker 2 INSERT INTO worker_auth_tokens (token_hash, worker_name, is_active) VALUES ('hash2', 'worker-2', true); ``` ### 4. Rotate Tokens Regularly Rotate tokens every 30-90 days in production. ### 5. Monitor Token Usage Check `last_used_at` to detect unused or compromised tokens: ```sql SELECT worker_name, last_used_at, AGE(NOW(), last_used_at) as inactive_duration FROM worker_auth_tokens WHERE is_active = true ORDER BY last_used_at DESC NULLS LAST; ``` ### 6. Use HTTPS in Production Always use HTTPS when `REQUIRE_AUTH=true`: ```env # Worker configuration API_BASE_URL=https://coordinator.example.com # ← HTTPS ``` ### 7. Restrict Network Access Use firewall rules to restrict coordinator access: ```bash # Example: Allow only specific IPs iptables -A INPUT -p tcp --dport 8080 -s 10.0.1.0/24 -j ACCEPT iptables -A INPUT -p tcp --dport 8080 -j DROP ``` --- ## Troubleshooting ### Worker: "Unauthorized" Error **Cause:** Token mismatch or auth enabled without token **Solution:** 1. Verify token in `worker/.env` matches hash in database 2. Check token is active: `SELECT is_active FROM worker_auth_tokens WHERE worker_name = 'your-worker';` 3. Verify coordinator has `REQUIRE_AUTH=true` ### Worker: "Failed to authenticate" **Cause:** Token hash not found in database **Solution:** ```sql -- Generate and insert hash INSERT INTO worker_auth_tokens (token_hash, worker_name, is_active) VALUES ('your_token_hash', 'worker-name', true); ``` ### Dashboard: Can't Load Data **Cause:** This should NOT happen - public endpoints don't require auth **Solution:** 1. Check CORS is enabled (see coordinator logs) 2. Verify dashboard `.env.local` has correct `NEXT_PUBLIC_COORDINATOR_API_URL` 3. Check browser console for errors ### Logs Show "Auth disabled (dev mode)" **Cause:** `REQUIRE_AUTH=false` in `coordinator/.env` **Solution:** ```bash # Edit coordinator/.env REQUIRE_AUTH=true # Restart coordinator docker-compose restart coordinator ``` --- ## Migration from Dev to Production ### Quick Checklist - [ ] Generate strong random tokens (≥32 characters) - [ ] Insert token hashes into database - [ ] Set `REQUIRE_AUTH=true` in `coordinator/.env` - [ ] Update all worker `.env` files with tokens - [ ] Test protected endpoints with tokens - [ ] Enable HTTPS (recommended) - [ ] Set up firewall rules (recommended) - [ ] Enable monitoring and alerting - [ ] Document token rotation procedure --- ## Additional Resources - [QUICK_START.md](https://github.com/out-layer/outlayer/blob/main/QUICK_START.md) - Quick start guide - [coordinator/README.md](coordinator/README.md) - Coordinator API documentation - [worker/README.md](https://github.com/out-layer/outlayer/blob/main/worker/README.md) - Worker configuration guide --- > Source: https://github.com/out-layer/outlayer/blob/main/docs/CLI.md # OutLayer CLI Command-line tool for deploying, running, and managing OutLayer agents. ```bash outlayer login # Import NEAR full access key outlayer create my-agent # Create project from template outlayer deploy my-agent # Deploy agent to OutLayer outlayer run alice.near/my-agent '{"command": "hello"}' # Execute agent ``` ## Install ```bash # From GitHub (requires Rust) cargo install --git https://github.com/out-layer/outlayer-cli # From local checkout cd outlayer-cli && cargo install --path . ``` ## Quick Start ```bash # 1. Login (prompts for Account ID and Private Key) outlayer login # mainnet outlayer login testnet # testnet # 2. Create a new agent outlayer create my-agent # basic template (stdin/stdout) outlayer create my-agent --template contract # with OutLayer SDK (VRF, storage, RPC) cd my-agent # 3. Edit src/main.rs, push to GitHub, then deploy git init && git remote add origin git push outlayer deploy my-agent # 4. Create a payment key for HTTPS calls outlayer keys create # 5. Run your agent outlayer run alice.near/my-agent '{"command": "hello"}' ``` ## Authentication ### Login ```bash outlayer login # mainnet (default) outlayer login testnet # testnet ``` Prompts for Account ID and ed25519 private key. Saves credentials to `~/.outlayer/{network}/credentials.json` and OS keychain (macOS Keychain, Linux Secret Service). ```bash outlayer whoami # Show current account, network, public key outlayer logout # Delete stored credentials ``` The active network is saved to `~/.outlayer/default-network`. If not set, the CLI auto-detects based on which network has credentials. ## Commands ### Project Workflow | Command | Description | |---------|-------------| | `outlayer create ` | Create project from template (basic) in `.//` | | `outlayer create --template contract` | Create with OutLayer SDK (VRF, storage, RPC) | | `outlayer create --dir /path` | Create in a custom directory | | `outlayer deploy ` | Deploy from current git repo (origin + HEAD) | | `outlayer deploy ` | Deploy from WASM URL (FastFS, etc.) | | `outlayer deploy --no-activate` | Deploy without activating | | `outlayer run [input]` | Execute agent (HTTPS or on-chain fallback) | | `outlayer projects [account]` | List projects for a user | | `outlayer status [call_id]` | Project info or poll async call | ### Run ```bash # Basic execution (uses payment key if available, else on-chain NEAR) outlayer run alice.near/my-agent '{"command": "hello"}' outlayer run alice.near/my-agent --input request.json # input from file outlayer run alice.near/my-agent '{"command": "heavy"}' --async # async (HTTPS only) outlayer run alice.near/my-agent '{"cmd": "premium"}' --deposit 0.01 # attached deposit (USD) outlayer run alice.near/my-agent '{}' --compute-limit 1000000000 # custom compute limit outlayer run alice.near/my-agent '{}' --version abc123 # specific version # Attach secrets to execution outlayer run alice.near/my-agent '{}' --secrets-profile default --secrets-account alice.near # Run from GitHub repo (on-chain) outlayer run --github github.com/user/repo '{"command": "hello"}' outlayer run --github github.com/user/repo --commit abc123 '{"input": 1}' # Run from WASM URL (on-chain) outlayer run --wasm https://alice.near.fastfs.io/outlayer.near/abc.wasm '{"cmd": "hi"}' outlayer run --wasm https://example.com/file.wasm --hash abc123... '{}' ``` **HTTPS mode** (when payment key is available): ``` POST https://api.outlayer.ai/call/{owner}/{project} X-Payment-Key: owner:nonce:secret Content-Type: application/json {"input": ..., "async": false} ``` **On-chain mode** (fallback): calls `request_execution` on `outlayer.near` contract. ### Secrets Encrypted client-side, decrypted only inside TEE. | Command | Description | |---------|-------------| | `outlayer secrets set '{"KEY":"val"}'` | Encrypt and store secrets (JSON, overwrites) | | `outlayer secrets update '{"KEY":"val"}'` | Merge with existing (preserves PROTECTED_*) | | `outlayer secrets set --generate PROTECTED_X:hex32` | Generate protected secret in TEE | | `outlayer secrets list` | List stored secrets (metadata only) | | `outlayer secrets delete` | Delete secrets for a profile | ```bash # Set secrets (JSON object, overwrites existing) outlayer secrets set '{"API_KEY":"sk-...","DB_URL":"postgres://..."}' outlayer secrets set '{"API_KEY":"sk-..."}' --project alice.near/my-agent outlayer secrets set '{"API_KEY":"sk-..."}' --repo github.com/user/repo --branch main outlayer secrets set '{"API_KEY":"sk-..."}' --wasm-hash abc123... # Named profile outlayer secrets set '{"KEY":"val"}' --profile production # Generate protected secrets in TEE (values never visible) outlayer secrets set --generate PROTECTED_MASTER_KEY:hex32 outlayer secrets set '{"API_KEY":"sk-..."}' --generate PROTECTED_DB:hex64 # mixed # Access control outlayer secrets set '{"KEY":"val"}' --access allow-all # default outlayer secrets set '{"KEY":"val"}' --access whitelist:alice.near,bob.near # Update (merge with existing, preserves all PROTECTED_* variables) outlayer secrets update '{"NEW_KEY":"val"}' --project alice.near/my-agent outlayer secrets update --generate PROTECTED_NEW:ed25519 # Generation types: hex16, hex32, hex64, ed25519, ed25519_seed, password, password:N # List / delete outlayer secrets list outlayer secrets delete --project alice.near/my-agent outlayer secrets delete --profile production ``` Default accessor: `--project` auto-resolved from `outlayer.toml` if present. ### Payment Keys Payment keys are required for HTTPS API calls. | Command | Description | |---------|-------------| | `outlayer keys create` | Create a new payment key | | `outlayer keys list` | List keys with balances | | `outlayer keys balance ` | Check key balance | | `outlayer keys topup ` | Top up with NEAR (mainnet, auto-swaps to USDC) | | `outlayer keys delete ` | Delete key (refunds storage deposit) | ```bash outlayer keys create # → Payment key created (nonce: 1) # Key: alice.near:1:a1b2c3d4e5f6... # Save this key — it cannot be recovered. outlayer keys list outlayer keys balance 1 outlayer keys topup 1 0.5 # top up key nonce 1 with 0.5 NEAR outlayer keys delete 2 ``` ### Upload (FastFS) Upload files to on-chain storage via NEAR transactions. | Command | Description | |---------|-------------| | `outlayer upload ` | Upload file to FastFS | | `outlayer upload --receiver ` | Custom receiver (default: outlayer.near) | | `outlayer upload --mime-type ` | Override MIME type | ```bash outlayer upload ./target/wasm32-wasip2/release/my-agent.wasm # → FastFS URL: https://alice.near.fastfs.io/outlayer.near/abcdef.wasm ``` Files >1MB are automatically chunked. > **Note:** FastFS upload transactions are **expected to fail** with "Exceeded the prepaid gas" — this is by design. Gas is intentionally set to 1 to minimize costs. No contract execution is needed — the NEAR indexer picks up the file data from the transaction arguments regardless of execution status. The file will be available at its FastFS URL after indexing, which takes 1–2 minutes. Chunked uploads (files >1MB) may take longer since all chunks must be indexed before the file is assembled. ### Versions | Command | Description | |---------|-------------| | `outlayer versions` | List project versions (requires outlayer.toml) | | `outlayer versions activate ` | Switch active version | | `outlayer versions remove ` | Remove a version | ### Earnings | Command | Description | |---------|-------------| | `outlayer earnings` | View blockchain + HTTPS earnings | | `outlayer earnings withdraw` | Withdraw blockchain earnings | | `outlayer earnings history` | View earnings history | | `outlayer earnings history --source blockchain` | Filter by source | | `outlayer earnings history --limit 50` | Custom limit | ### Logs | Command | Description | |---------|-------------| | `outlayer logs` | View execution history for default payment key | | `outlayer logs --nonce 2` | History for specific key | | `outlayer logs --limit 50` | Custom number of entries (default: 20) | ## Configuration ### Credentials Stored at `~/.outlayer/{network}/credentials.json`. Private key stored in OS keychain when available, fallback to credentials file. ### Project Config `outlayer.toml` in your project root (created by `outlayer create`): ```toml [project] name = "my-agent" owner = "alice.near" [build] target = "wasm32-wasip2" source = "github" [run] payment_key_nonce = 1 ``` ### Environment Variables | Variable | Description | |----------|-------------| | `OUTLAYER_HOME` | Config directory (default: `~/.outlayer`) | | `OUTLAYER_NETWORK` | Network: `mainnet` or `testnet` | | `PAYMENT_KEY` | Payment key for `outlayer run` (format: `owner:nonce:secret`) | ### Global Flags ```bash outlayer --verbose ... # Verbose output (available on all commands) ``` ## Templates ### basic (default) Rust + wasm32-wasip2 project with stdin/stdout I/O. ### contract Rust + wasm32-wasip2 with OutLayer SDK integration (VRF, storage, NEAR RPC bindings). Both include: Cargo.toml, src/main.rs, build.sh, .gitignore --- > Source: https://github.com/out-layer/outlayer/blob/main/wasi-examples/README.md # WASI Examples for NEAR OutLayer Collection of examples and tools for developing WASM modules for NEAR OutLayer platform. ## 📚 Documentation ### [WASI Tutorial](https://github.com/out-layer/outlayer/blob/main/wasi-examples/WASI_TUTORIAL.md) - **START HERE** Complete guide for developing WASI modules: - WASI Preview 1 vs Preview 2 - Quick start templates - Input/output patterns - Requirements and best practices - Common pitfalls and solutions ### [Test Runner](https://github.com/out-layer/outlayer/tree/main/wasi-examples/wasi-test-runner/) - **VALIDATE YOUR MODULE** Universal tool to test WASM modules for compatibility: ```bash cd wasi-test-runner cargo build --release ./target/release/wasi-test --wasm your-app.wasm --input '{"test":"data"}' ``` ## 📦 Examples ### [random-example](https://github.com/out-layer/random-example) - WASI P1 Simple random number generator demonstrating: - ✅ WASI Preview 1 (wasm32-wasip1) - ✅ Binary format with `main()` - ✅ JSON input/output via stdin/stdout - ✅ Random number generation - ✅ ~111KB binary size **Use case**: Basic computations, random numbers, simple I/O ### [ai-example](https://github.com/out-layer/ai-example) - WASI P2 HTTP client for AI APIs demonstrating: - ✅ WASI Preview 2 (wasm32-wasip2) - ✅ Component model - ✅ HTTP/HTTPS requests - ✅ OpenAI-compatible API integration - ✅ Fuel metering **Use case**: HTTP requests, API calls, external data fetching ### [oracle-example](https://github.com/out-layer/oracle-example) - WASI P2 On-demand price oracle demonstrating: - ✅ WASI Preview 2 (wasm32-wasip2) - ✅ Multiple HTTP sources (CoinGecko, CoinMarketCap, TwelveData) - ✅ Price aggregation (average, median, weighted) - ✅ Encrypted API keys via env vars - ✅ Batch requests (up to 10 tokens) **Use case**: Decentralized oracles, price feeds, multi-source data aggregation ## 🚀 Quick Start ### 1. Choose Your WASI Version **WASI P1** - For simple computations: ```bash rustup target add wasm32-wasip1 cargo build --target wasm32-wasip1 --release ``` **WASI P2** - For HTTP and advanced I/O: ```bash rustup target add wasm32-wasip2 cargo build --target wasm32-wasip2 --release ``` ### 2. Follow the Pattern ```rust use serde::{Deserialize, Serialize}; use std::io::{self, Read, Write}; #[derive(Deserialize)] struct Input { /* your fields */ } #[derive(Serialize)] struct Output { /* your fields */ } fn main() -> Result<(), Box> { // Read from stdin let mut input = String::new(); io::stdin().read_to_string(&mut input)?; // Parse JSON let data: Input = serde_json::from_str(&input)?; // Process... let result = process(data)?; // Write to stdout let output = Output { result }; print!("{}", serde_json::to_string(&output)?); io::stdout().flush()?; Ok(()) } ``` ### 3. Test Locally ```bash # With wasmtime echo '{"test":"data"}' | wasmtime your-app.wasm # With test runner (recommended) cd wasi-test-runner ./target/release/wasi-test --wasm ../your-app.wasm --input '{"test":"data"}' ``` ### 4. Deploy to NEAR OutLayer ```bash near call outlayer.testnet request_execution '{ "code_source": { "repo": "https://github.com/user/repo", "commit": "main", "build_target": "wasm32-wasip1" }, "resource_limits": { "max_instructions": 10000000, "max_memory_mb": 128, "max_execution_seconds": 60 }, "input_data": "{\"test\":\"data\"}" }' --accountId your.testnet --deposit 0.1 ``` ## ✅ Requirements Checklist Before deploying to NEAR OutLayer, ensure: - ✅ Using `[[bin]]` format (not `[lib]`) - ✅ Have `fn main()` as entry point - ✅ Reading from stdin (not args) - ✅ Writing to stdout (not stderr) - ✅ Flushing stdout after write - ✅ JSON input/output format - ✅ Output ≤ 900 bytes - ✅ Built with wasm32-wasip1 or wasm32-wasip2 - ✅ Tested with [test runner](https://github.com/out-layer/outlayer/tree/main/wasi-examples/wasi-test-runner/) ## 🛠️ Cargo.toml Template ```toml [package] name = "your-app" version = "0.1.0" edition = "2021" [[bin]] name = "your-app" path = "src/main.rs" [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" # For WASI P2 only: # wasi-http-client = "0.2" [profile.release] opt-level = "z" # Optimize for size lto = true # Link-time optimization strip = true # Strip debug symbols ``` ## 🔧 Useful Commands ```bash # Check binary type file your-app.wasm # Check size ls -lh your-app.wasm # Test with input file echo '{"test":"data"}' > input.json wasmtime your-app.wasm < input.json # Inspect with wasm-tools wasm-tools print your-app.wasm | head -50 # For P2 components wasm-tools component wit your-app.wasm ``` ## 📖 Additional Resources - [NEAR OutLayer Project](https://github.com/out-layer/outlayer/tree/main/./) - Main project documentation - [wasmtime Book](https://docs.wasmtime.dev/) - Runtime documentation - [WASI Specification](https://github.com/WebAssembly/WASI) - Official WASI docs - [Component Model](https://github.com/WebAssembly/component-model) - WASI P2 spec ## 🐛 Troubleshooting ### Common Errors | Error | Solution | |-------|----------| | "entry symbol not defined: _initialize" | Use `[[bin]]` instead of `[lib]` | | "Failed to find _start function" | Add `fn main()` entry point | | Empty output | Add `io::stdout().flush()?` | | "Not a valid WASI P1/P2" | Check build target | | Output truncated | Reduce output to ≤900 bytes | See [WASI_TUTORIAL.md](https://github.com/out-layer/outlayer/blob/main/wasi-examples/WASI_TUTORIAL.md#common-pitfalls) for detailed solutions. ## 🎯 Example Use Cases ### WASI P1 Examples - Random number generation - Hash computation (SHA256, etc.) - JSON transformation - Data validation - Simple calculations - Text processing ### WASI P2 Examples - API requests (REST, GraphQL) - AI/ML inference calls - Price oracles - Data aggregation - Content fetching - Multi-step workflows ## 🤝 Contributing To add your own example: 1. Create new directory in `wasi-examples/` 2. Follow the patterns in existing examples 3. Test with [test runner](https://github.com/out-layer/outlayer/tree/main/wasi-examples/wasi-test-runner/) 4. Add README with usage instructions 5. Update this file with link to your example ## 📝 License The examples in this directory are dual-licensed under **MIT OR Apache-2.0**, at your option — see `LICENSE-MIT` and `LICENSE-APACHE` here. This is deliberately more permissive than the rest of the repository (Apache-2.0), because examples are meant to be copied into your own product without an attribution burden you might overlook. Note that most subdirectories here are **git submodules** with their own repositories, and are governed by the `LICENSE` in those repositories rather than by these files: `random-example`, `ai-example`, `echo-example`, `oracle-example`, `captcha-example`, `weather-example`, `private-dao-example`, `botfather-example`, `env-test-example`, `test-secrets-example`, `vrf-example`, `intents-example`, `eth-proof-example`, `near-email`. `near-email` is Apache-2.0 rather than dual-licensed: it is a finished product built on OutLayer, not an example to copy from. See [LICENSING.md](https://github.com/out-layer/outlayer/blob/main/LICENSING.md) for the full component map. --- **Last updated**: 2025-10-15 **Compatible with**: wasmtime 28+, NEAR OutLayer MVP --- > Source: https://github.com/out-layer/outlayer/blob/main/wasi-examples/WASI_TUTORIAL.md # WASI Development Tutorial for NEAR OutLayer This guide explains how to create WASM modules that work with NEAR OutLayer platform. > **🚨 IMPORTANT:** If you just need to know which build command to use, read [BUILD_TARGETS.md](BUILD_TARGETS.md) first! ## ⚠️ CRITICAL: WASI vs NEAR Smart Contracts **DO NOT CONFUSE THESE TWO!** | Type | Target | Build Command | rust-toolchain.toml? | Purpose | |------|--------|---------------|---------------------|---------| | **WASI Module** | `wasm32-wasip1` or `wasm32-wasip2` | `cargo build --target wasm32-wasip1 --release` | ❌ No | Off-chain computation | | **NEAR Contract** | `wasm32-unknown-unknown` | `cargo near build` | ✅ **YES** (1.85.0) | On-chain smart contract | **NEVER use `cargo build --target wasm32-unknown-unknown` for WASI modules!** **NEVER use `cargo build --target wasm32-wasip1` for NEAR contracts!** ### For NEAR Smart Contracts (on-chain): ```bash # ✅ CORRECT - Use cargo-near cargo near build # ❌ WRONG - DO NOT use raw cargo build cargo build --target wasm32-unknown-unknown --release ``` **CRITICAL: NEAR contracts MUST have rust-toolchain.toml:** ```toml # dao-contract/rust-toolchain.toml [toolchain] channel = "1.85.0" ``` **Why rust-toolchain.toml is required:** - `cargo near build` requires **specific Rust version** (currently 1.85.0) - Without this file, build may fail with ABI incompatibility errors - WASI modules **don't need** this file (work with any recent Rust) - Always copy rust-toolchain.toml from working NEAR contract examples ### For WASI Modules (off-chain OutLayer): ```bash # ✅ CORRECT - Use wasip1 or wasip2 cargo build --target wasm32-wasip1 --release # ❌ WRONG - DO NOT use wasm32-unknown-unknown cargo build --target wasm32-unknown-unknown --release ``` ## Table of Contents 1. [Overview](#overview) 2. [WASI Preview 1 vs Preview 2](#wasi-preview-1-vs-preview-2) 3. [Quick Start: WASI P1](#quick-start-wasi-p1) 4. [Quick Start: WASI P2](#quick-start-wasi-p2) 5. [Input/Output Format](#inputoutput-format) 6. [OutLayer SDK](#outlayer-sdk) 7. [Important Requirements](#important-requirements) 8. [Testing Your Module](#testing-your-module) 9. [Common Pitfalls](#common-pitfalls) 10. [Examples](#examples) ## Overview NEAR OutLayer executes WASM modules off-chain using wasmtime runtime. Your code runs in a sandboxed environment with: - **Stdin** for input data (JSON) - **Stdout** for output data (JSON) - **WASI** for system interfaces (random, time, etc.) - **Fuel metering** for instruction counting - **Resource limits** (memory, time, instructions) ## WASI Preview 1 vs Preview 2 ### WASI Preview 1 (P1) - **Target**: `wasm32-wasip1` or `wasm32-wasi` - **Format**: Binary with `main()` function - **Use case**: Simple computations, random numbers, basic I/O - **Features**: Core WASI functions (random, stdio, environment) - **Size**: Smaller binaries (~100-200KB) - **Example**: [random-example](https://github.com/out-layer/random-example) ### WASI Preview 2 (P2) - **Target**: `wasm32-wasip2` - **Format**: Component model with typed interfaces - **Use case**: HTTP requests, complex I/O, modern features - **Features**: HTTP client, advanced filesystem, sockets - **Size**: Larger binaries (~500KB-1MB) - **Example**: [ai-example](https://github.com/out-layer/ai-example) ### Which to Choose? | Feature | WASI P1 | WASI P2 | |---------|---------|---------| | HTTP requests | ❌ | ✅ | | JSON processing | ✅ | ✅ | | Random numbers | ✅ | ✅ | | File I/O | ⚠️ Limited | ✅ Full | | Binary size | 🟢 Small | 🟡 Larger | | Compilation speed | 🟢 Fast | 🟡 Slower | | Stability | 🟢 Stable | 🟡 Newer | **Rule of thumb**: Use P1 unless you need HTTP or advanced I/O. ## Quick Start: WASI P1 ### 1. Create Binary Project ```bash cargo new my-wasi-app cd my-wasi-app ``` ### 2. Configure Cargo.toml ```toml [package] name = "my-wasi-app" version = "0.1.0" edition = "2021" # IMPORTANT: Must be a binary, not a library [[bin]] name = "my-wasi-app" path = "src/main.rs" [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" [profile.release] opt-level = "z" # Optimize for size lto = true # Link-time optimization strip = true # Strip debug symbols ``` ### 3. Write Code (src/main.rs) ```rust use serde::{Deserialize, Serialize}; use std::io::{self, Read, Write}; #[derive(Deserialize)] struct Input { name: String, } #[derive(Serialize)] struct Output { greeting: String, } fn main() -> Result<(), Box> { // Read input from stdin let mut input_string = String::new(); io::stdin().read_to_string(&mut input_string)?; // Parse JSON input let input: Input = serde_json::from_str(&input_string)?; // Process let output = Output { greeting: format!("Hello, {}!", input.name), }; // Write JSON output to stdout let json = serde_json::to_string(&output)?; print!("{}", json); io::stdout().flush()?; Ok(()) } ``` ### 4. Build ```bash # Add target rustup target add wasm32-wasip1 # Build cargo build --target wasm32-wasip1 --release # Output: target/wasm32-wasip1/release/my-wasi-app.wasm ``` ### 5. Test Locally ```bash # Test with wasmtime echo '{"name":"World"}' | wasmtime target/wasm32-wasip1/release/my-wasi-app.wasm # Expected: {"greeting":"Hello, World!"} ``` ## Quick Start: WASI P2 ### 1. Create Component Project ```bash cargo new my-http-app cd my-http-app ``` ### 2. Configure Cargo.toml ```toml [package] name = "my-http-app" version = "0.1.0" edition = "2021" [[bin]] name = "my-http-app" path = "src/main.rs" [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" wasi-http-client = "0.2" # For HTTP requests [profile.release] opt-level = "z" lto = true strip = true ``` ### 3. Write Code (src/main.rs) ```rust use serde::{Deserialize, Serialize}; use std::io::{self, Read, Write}; use wasi_http_client::{Client, Request, Method}; #[derive(Deserialize)] struct Input { url: String, } #[derive(Serialize)] struct Output { status: u16, body: String, } fn main() -> Result<(), Box> { // Read input let mut input_string = String::new(); io::stdin().read_to_string(&mut input_string)?; let input: Input = serde_json::from_str(&input_string)?; // Make HTTP request let client = Client::new(); let request = Request::new(Method::Get, &input.url); let response = client.send(request)?; // Process response let output = Output { status: response.status(), body: String::from_utf8_lossy(response.body()).to_string(), }; // Write output let json = serde_json::to_string(&output)?; print!("{}", json); io::stdout().flush()?; Ok(()) } ``` ### 4. Build ```bash # Add target rustup target add wasm32-wasip2 # Build cargo build --target wasm32-wasip2 --release # Output: target/wasm32-wasip2/release/my-http-app.wasm ``` ### 5. Test Locally ```bash # Test with wasmtime echo '{"url":"https://api.example.com/data"}' | wasmtime target/wasm32-wasip2/release/my-http-app.wasm ``` ## Input/Output Format ### CRITICAL REQUIREMENTS 1. **Input**: Always read from `stdin` (not command-line arguments) 2. **Output**: Always write to `stdout` (not stderr) 3. **Format**: JSON only (UTF-8 encoded) 4. **Size limit**: Depends on how OutLayer is called: - **Blockchain mode** (via `near call`): ≤900 bytes (NEAR Protocol limit) - **HTTPS API mode** (via Payment Key): Up to 25MB 5. **No buffering**: Call `stdout().flush()` after writing ### Example Pattern ```rust use std::io::{self, Read, Write}; fn main() -> Result<(), Box> { // ✅ CORRECT: Read from stdin let mut input = String::new(); io::stdin().read_to_string(&mut input)?; // Process... let output = process(&input)?; // ✅ CORRECT: Write to stdout and flush print!("{}", output); io::stdout().flush()?; Ok(()) } // ❌ WRONG: Reading from args fn main() { let args: Vec = std::env::args().collect(); // Won't work! } // ❌ WRONG: Writing to stderr fn main() { eprintln!("result"); // Won't be captured! } ``` ## OutLayer SDK The `outlayer` crate provides access to OutLayer-specific features for WASI P2 modules. ### Installation ```toml [dependencies] outlayer = "0.1" ``` ### Getting the Caller's NEAR Account When your WASI module is called via blockchain transaction, you can identify who called it: ```rust use outlayer::env; fn main() -> Result<(), Box> { // Returns the NEAR account that signed the transaction let signer = env::signer_account_id() .ok_or("No signer - must be called via NEAR transaction or Payment Key")?; println!("Called by: {}", signer); // e.g., "alice.near" // Use signer for: // - Access control // - Per-user data isolation // - Audit logging Ok(()) } ``` **Note**: `signer_account_id()` returns: - `Some(account_id)` when called via blockchain transaction - `Some(payment_key_owner)` when called via HTTPS API with Payment Key - `None` when called via HTTPS API without authentication ### Persistent Storage OutLayer provides worker-encrypted storage that persists across executions: ```rust use outlayer::storage; // Write data (encrypted at rest, only this worker can read) storage::set_worker("my_key", b"my_value")?; // Read data if let Some(data) = storage::get_worker("my_key")? { let value = String::from_utf8(data)?; println!("Stored value: {}", value); } ``` **Use cases:** - Store API keys/secrets (migrated from env vars) - Cache expensive computations - Maintain state between executions ### Environment Variables OutLayer injects useful environment variables: ```rust // Detect network (mainnet/testnet) let network = std::env::var("NEAR_NETWORK_ID").unwrap_or("mainnet".to_string()); let suffix = if network == "testnet" { ".testnet" } else { ".near" }; // Example: construct email address let email = format!("{}@near.email", account_id.strip_suffix(suffix).unwrap_or(&account_id)); ``` ### Complete Example ```rust use outlayer::{env, storage}; use serde::{Deserialize, Serialize}; use std::io::{self, Read, Write}; #[derive(Deserialize)] struct Input { action: String, data: Option, } #[derive(Serialize)] struct Output { success: bool, account: String, result: String, } fn main() -> Result<(), Box> { // 1. Get caller let account = env::signer_account_id() .ok_or("Authentication required")?; // 2. Read input let mut input_str = String::new(); io::stdin().read_to_string(&mut input_str)?; let input: Input = serde_json::from_str(&input_str)?; // 3. Process based on action let result = match input.action.as_str() { "save" => { let key = format!("user:{}", account); storage::set_worker(&key, input.data.unwrap_or_default().as_bytes())?; "Data saved".to_string() } "load" => { let key = format!("user:{}", account); storage::get_worker(&key)? .map(|d| String::from_utf8_lossy(&d).to_string()) .unwrap_or_else(|| "No data".to_string()) } _ => "Unknown action".to_string(), }; // 4. Output let output = Output { success: true, account, result }; print!("{}", serde_json::to_string(&output)?); io::stdout().flush()?; Ok(()) } ``` ## Important Requirements ### ⚠️ 0. CRITICAL: Use Exact Versions from Examples **DO NOT blindly use latest versions** of dependencies! The WASI ecosystem is extremely version-sensitive. Using wrong versions will cause cryptic errors like "import not found" or "failed to instantiate". #### For WASI Applications **✅ CORRECT**: Copy `Cargo.toml` from existing working examples: - [random-example/Cargo.toml](./random-example/Cargo.toml) - WASI P1 template - [ai-example/Cargo.toml](./ai-example/Cargo.toml) - WASI P2 template - [oracle-example/Cargo.toml](./oracle-example/Cargo.toml) - WASI P2 with HTTP **Tested and working versions:** ```toml [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" rand = "0.8" getrandom = { version = "0.2", features = ["custom"] } # For WASI P2 (HTTP support): wasi-http-client = "0.2" # OutLayer SDK (persistent storage, env access) - WASI P2 only: outlayer = "0.1" # For WASI P1 with NEAR contracts embedded (advanced): borsh = { version = "1.5", features = ["derive"] } base64 = "0.21" ed25519-dalek = "2.1" ``` **❌ WRONG**: Using `cargo add` or updating to latest: ```bash # These commands may install incompatible versions: cargo add serde serde_json cargo update # Can break working builds! ``` #### For Embedded NEAR Contracts (Advanced) If your WASI app needs to **build and deploy NEAR contracts inside WASM** (like [intents-example](https://github.com/out-layer/intents-example/)), this is **EVEN MORE CRITICAL**: **✅ CORRECT**: Use exact versions from [intents-example/intents-contract/Cargo.toml](https://github.com/out-layer/intents-example/blob/main/intents-contract/Cargo.toml): ```toml [package] edition = "2018" # ← Must be 2018, not 2021! [lib] crate-type = ["cdylib"] [dependencies] near-sdk = { version = "5.9.0", features = ["legacy", "unit-testing"] } serde_json = { version = "1.0.133", features = ["preserve_order"] } [profile.release] codegen-units = 1 opt-level = "s" # ← "s" for contracts, not "z" lto = true panic = "abort" overflow-checks = true ``` **Build command for embedded contracts:** ```bash cd your-contract-dir cargo near build non-reproducible-wasm ``` **Why `non-reproducible-wasm`?** - Reproducible builds require Docker and specific environment - Inside WASI, we can't use Docker - Non-reproducible is fine for testing and development **Example structure:** ``` your-wasi-app/ ├── Cargo.toml # WASI app (edition = "2021", [[bin]]) ├── src/ │ └── main.rs # WASI entry point └── embedded-contract/ ├── Cargo.toml # NEAR contract (edition = "2018", [lib]) ├── build.sh # ← cargo near build non-reproducible-wasm └── src/ └── lib.rs # Contract code ``` #### Why This Matters 1. **wasmtime runtime** expects specific WASI interface versions 2. **Newer crates** may use unstable WASI preview features not yet supported 3. **Older crates** may have missing imports or incompatible ABIs 4. **NEAR SDK** is tightly coupled to specific rustc versions 5. **cargo-near** expects exact near-sdk versions for builds **Common errors from wrong versions:** ``` ❌ error: import 'wasi:http/types@0.3' has not been defined ❌ failed to instantiate WASM module ❌ entry symbol not defined: _initialize ❌ cannot find trait Serialize in module `borsh` ``` **Bottom line**: Always start from a working example, don't experiment with versions until your app works. ### 1. Wrong Build Target (CRITICAL!) **THIS IS THE #1 MISTAKE!** ```bash # ❌ CRITICAL ERROR: Using wrong target for WASI modules cargo build --target wasm32-unknown-unknown --release # This produces NEAR contract WASM, NOT WASI module! # It will NOT work with OutLayer worker! # ✅ CORRECT: Use wasip1 for WASI modules cargo build --target wasm32-wasip1 --release # ✅ CORRECT: Use wasip2 for WASI modules with HTTP cargo build --target wasm32-wasip2 --release ``` **Why this matters:** - `wasm32-unknown-unknown` = NEAR contracts (on-chain, no WASI) - `wasm32-wasip1/wasip2` = OutLayer modules (off-chain, with WASI) - Using wrong target produces incompatible WASM that will fail at runtime **For NEAR contracts (different from WASI modules!):** ```bash # ✅ CORRECT: Use cargo-near for NEAR contracts cargo near build # ❌ WRONG: DO NOT use raw cargo build cargo build --target wasm32-unknown-unknown --release ``` **See the difference?** | What you're building | Target | Build command | |---------------------|--------|---------------| | **WASI Module** (OutLayer) | `wasm32-wasip1` | `cargo build --target wasm32-wasip1 --release` | | **NEAR Contract** (on-chain) | `wasm32-unknown-unknown` | `cargo near build` | **NEAR Contract Setup Checklist:** ```bash # 1. Create rust-toolchain.toml (REQUIRED!) cat > rust-toolchain.toml < Result<(), Box> { // Your code Ok(()) } // ❌ WRONG: Custom exports #[no_mangle] pub extern "C" fn execute() { } // Old pattern, don't use ``` ### 3. Error Handling ```rust // ✅ CORRECT: Return errors from main fn main() -> Result<(), Box> { let data = serde_json::from_str(&input)?; // Propagates error Ok(()) } // ❌ WRONG: Panics crash the worker fn main() { let data = serde_json::from_str(&input).unwrap(); // Don't use unwrap()! } ``` ### 4. Output Size ```rust // ✅ CORRECT: Truncate large outputs let mut output = generate_large_output(); if output.len() > 800 { output.truncate(800); output.push_str("..."); } print!("{}", output); ``` ### 5. Dependencies ```rust // ✅ Safe dependencies serde, serde_json // JSON processing rand // Random numbers (P1 & P2) wasi-http-client // HTTP requests (P2 only) // ⚠️ Avoid these tokio // Async runtime (not needed in WASM) reqwest // Use wasi-http-client instead std::thread // Threading not supported ``` ## Testing Your Module ### Option 1: Quick Test with wasmtime ```bash # Install wasmtime curl https://wasmtime.dev/install.sh -sSf | bash # Test your WASM echo '{"test":"data"}' | wasmtime your-app.wasm ``` ### Option 2: Use Universal Test Runner See [WASI_TEST_RUNNER.md](./WASI_TEST_RUNNER.md) for a comprehensive test tool that validates: - ✅ Binary format correctness - ✅ Fuel metering - ✅ Input/output handling - ✅ Resource limits - ✅ Compatibility with NEAR OutLayer ## Common Pitfalls ### 1. "entry symbol not defined: _initialize" **Problem**: Using `[lib]` with `crate-type = ["cdylib"]` **Solution**: Use `[[bin]]` format (see Quick Start) ### 2. Empty output **Problem**: Forgot to flush stdout **Solution**: ```rust print!("{}", output); io::stdout().flush()?; // ← Add this! ``` ### 3. "Failed to instantiate WASM module" **Problem**: Wrong target or missing `main()` **Solution**: - Use `wasm32-wasip1` or `wasm32-wasip2` target - Ensure you have `fn main()` function ### 4. Output truncated in NEAR explorer **Problem**: Output > 900 bytes **Solution**: Truncate before returning: ```rust if output.len() > 800 { output.truncate(800); } ``` ### 5. "use of unstable library feature" when building **Problem**: Test dependencies in WASM build **Solution**: Use optional dependencies with features (see ai-example example) ### 6. HTTP requests fail **Problem**: Using WASI P1 instead of P2 **Solution**: Use `wasm32-wasip2` target and `wasi-http-client` crate ### 7. Popup window blocked in browser **Problem**: Multiple consecutive wallet calls trigger browser popup blocker **Error**: `Popup window blocked. Please allow popups for this site.` **Solution**: Only make ONE wallet call per user action. Never chain multiple `signMessage` or `signAndSendTransaction` calls: ```typescript // ❌ WRONG - Multiple calls in sequence async function badPattern() { const sig1 = await wallet.signMessage({...}); // First popup OK const sig2 = await wallet.signMessage({...}); // BLOCKED! } // ✅ CORRECT - One call per user click async function handleUserClick() { const sig = await wallet.signMessage({...}); // User initiated // Cache signature for reuse } ``` **Best practice**: Cache signatures on frontend (50-60 min) to avoid repeated popups. See [BEST_PRACTICES_OUTLAYER_NEAR.md](https://github.com/out-layer/outlayer/blob/main/wasi-examples/BEST_PRACTICES_OUTLAYER_NEAR.md) for signature caching pattern. ### 8. Frontend transaction errors with wallet-selector **Problem**: Using manual action format like `{ type: 'FunctionCall', params: {...} }` **Error**: `Enum key (type) not found in enum schema` **Solution**: Use `actionCreators` from `@near-js/transactions`: ```typescript import { actionCreators } from '@near-js/transactions'; // ❌ WRONG - This will fail with enum schema error await wallet.signAndSendTransaction({ receiverId: contractId, actions: [{ type: 'FunctionCall', params: { methodName: 'my_method', args: { foo: 'bar' }, gas: '100000000000000', deposit: '1000000000000000000000', }, }], }); // ✅ CORRECT - Use actionCreators const action = actionCreators.functionCall( 'my_method', // method name { foo: 'bar' }, // args object BigInt('100000000000000'), // gas (BigInt) BigInt('1000000000000000000000') // deposit in yoctoNEAR (BigInt) ); await wallet.signAndSendTransaction({ receiverId: contractId, actions: [action], }); ``` **Why**: @near-wallet-selector expects properly formatted actions from `@near-js/transactions`, not raw objects. **See working example**: `wasi-examples/captcha-example/launchpad-app/src/App.tsx` ### 9. OutLayer callback deserialization errors **Problem**: Callback receives OutLayer response but fails to deserialize **Error**: `Failed to deserialize callback using JSON. Error: 'missing field pubkey'` or `invalid type: map, expected a string` **Root cause**: OutLayer returns wrapped response format: ```json { "success": true, "result": {"your_data": "here"}, "error": null } ``` **Solution**: Use `OutLayerResponse` wrapper type in callback: ```rust // types.rs - Define wrapper type #[derive(Serialize, Deserialize, JsonSchema, Debug)] #[serde(crate = "near_sdk::serde")] pub struct OutLayerResponse { pub success: bool, pub result: serde_json::Value, pub error: Option, } #[derive(Serialize, Deserialize, JsonSchema, Debug)] #[serde(crate = "near_sdk::serde")] pub struct YourActualResponse { pub some_field: String, } // lib.rs - Callback implementation #[private] pub fn on_outlayer_callback( &mut self, user: AccountId, #[callback_result] result: Result, PromiseError>, ) { match result { Ok(Some(outlayer_response)) => { // Check success flag if !outlayer_response.success { let error_msg = outlayer_response.error.unwrap_or_else(|| "Unknown error".to_string()); env::panic_str(&format!("OutLayer error: {}", error_msg)); } // Parse result field to get your actual data let your_data: YourActualResponse = match serde_json::from_value(outlayer_response.result) { Ok(r) => r, Err(e) => { env::panic_str(&format!("Invalid result format: {}", e)); } }; // Use your_data.some_field here log!("Received: {}", your_data.some_field); } Ok(None) => { env::panic_str("OutLayer execution returned None"); } Err(e) => { env::panic_str(&format!("Promise error: {:?}", e)); } } } ``` **Why this works**: - NEAR SDK automatically deserializes the JSON response into `OutLayerResponse` - You then manually parse the `result` field using `serde_json::from_value` - This two-step approach handles the wrapper format correctly **See working example**: `wasi-examples/private-dao-example/dao-contract/src/lib.rs` (on_key_derived callback) ## Examples ### Complete Working Examples 1. **[random-example](https://github.com/out-layer/random-example)** - WASI P1 - Random number generation - JSON input/output - ~111KB binary 2. **[ai-example](https://github.com/out-layer/ai-example)** - WASI P2 - HTTP POST requests - OpenAI API integration - Component model 3. **[near-email](https://github.com/out-layer/near-email)** - WASI P2 (Complex) - Full email service with encryption - OutLayer SDK: `signer_account_id()`, `storage` - External HTTP API (db-api) integration - Next.js frontend with wallet-selector - NEP-413 signature verification - See [BEST_PRACTICES_OUTLAYER_NEAR.md](https://github.com/out-layer/outlayer/blob/main/wasi-examples/BEST_PRACTICES_OUTLAYER_NEAR.md) for patterns ### Minimal Example (Copy-Paste Ready) ```rust use serde::{Deserialize, Serialize}; use std::io::{self, Read, Write}; #[derive(Deserialize)] struct Input { value: i32, } #[derive(Serialize)] struct Output { result: i32, } fn main() -> Result<(), Box> { let mut input = String::new(); io::stdin().read_to_string(&mut input)?; let data: Input = serde_json::from_str(&input)?; let output = Output { result: data.value * 2, }; print!("{}", serde_json::to_string(&output)?); io::stdout().flush()?; Ok(()) } ``` **Cargo.toml**: ```toml [package] name = "example" version = "0.1.0" edition = "2021" [[bin]] name = "example" path = "src/main.rs" [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" [profile.release] opt-level = "z" lto = true strip = true ``` **Build & Test**: ```bash cargo build --target wasm32-wasip1 --release echo '{"value":21}' | wasmtime target/wasm32-wasip1/release/example.wasm # Output: {"result":42} ``` ## Deployment to NEAR OutLayer ### Option 1: Blockchain Mode (via NEAR transaction) 1. **Push code to GitHub** 2. **Call contract**: ```bash near call outlayer.testnet request_execution '{ "code_source": { "repo": "https://github.com/username/repo", "commit": "main", "build_target": "wasm32-wasip1" }, "resource_limits": { "max_instructions": 10000000, "max_memory_mb": 128, "max_execution_seconds": 60 }, "input_data": "{\"value\":21}" }' --accountId your.testnet --deposit 0.1 ``` 3. **Check result** in NEAR Explorer **Pros:** Fully on-chain, trustless **Cons:** Requires transaction approval, output ≤900 bytes ### Option 2: HTTPS API Mode (via Payment Key) For better UX (no popups, larger payloads), use Payment Keys: 1. **Create Payment Key** at OutLayer Dashboard 2. **Call via HTTPS**: ```bash curl -X POST https://api.outlayer.ai/call/your-account.near/your-project \ -H "Content-Type: application/json" \ -H "X-Payment-Key: alice.near:1:your-secret-key" \ -d '{ "input": {"action": "process", "data": "hello"}, "resource_limits": { "max_instructions": 2000000000, "max_memory_mb": 512, "max_execution_seconds": 120 } }' ``` **Pros:** No popups, faster, output up to 25MB **Cons:** Requires pre-paid balance ### Frontend Integration See [BEST_PRACTICES_OUTLAYER_NEAR.md](https://github.com/out-layer/outlayer/blob/main/wasi-examples/BEST_PRACTICES_OUTLAYER_NEAR.md) for: - Wallet-selector setup (avoid popup blocking) - Payment Key integration - NEP-413 signature authentication ## Working with Embedded NEAR Contracts Some WASI applications need to **build, deploy, or interact with NEAR smart contracts** at runtime. Examples: [intents-example](https://github.com/out-layer/intents-example/), [random-example](https://github.com/out-layer/random-example). ### When to Use Embedded Contracts - **Dynamic contract deployment** - Deploy contracts from WASI at runtime - **Contract factories** - Create multiple contract instances - **Intent-based systems** - Deploy contracts per user/session - **Testing infrastructure** - Automated contract testing ### Project Structure ``` your-wasi-app/ ├── Cargo.toml # Workspace root ├── src/ │ └── main.rs # WASI entry point (reads stdin/stdout) │ └── lib.rs # (optional) shared logic ├── build.sh # Build WASI module └── your-contract/ # ← Embedded NEAR contract ├── Cargo.toml # Contract dependencies ├── rust-toolchain.toml # Pin Rust version ├── build.sh # Build contract WASM ├── res/local/ # Built contract output └── src/ └── lib.rs # Contract code ``` ### Critical Configuration #### 1. Workspace Cargo.toml (Root) ```toml [workspace] members = [".", "your-contract"] # Include contract as member resolver = "2" [package] name = "your-wasi-app" version = "0.1.0" edition = "2021" # ← 2021 for WASI app [[bin]] name = "your-wasi-app" path = "src/main.rs" [dependencies] # WASI dependencies serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" wasi-http-client = "0.2" # For WASI P2 # NEAR interaction (if needed) borsh = { version = "1.5", features = ["derive"] } base64 = "0.21" ed25519-dalek = "2.1" [profile.release] opt-level = "z" lto = true strip = true ``` #### 2. Contract Cargo.toml **⚠️ CRITICAL: Copy from existing examples!** ```toml [package] name = "your-contract" version = "0.1.0" edition = "2018" # ← Must be 2018 for near-sdk 5.9.0! [lib] crate-type = ["cdylib"] # ← For WASM contract [dependencies] near-sdk = { version = "5.9.0", features = ["legacy", "unit-testing"] } serde_json = { version = "1.0.133", features = ["preserve_order"] } [profile.release] codegen-units = 1 opt-level = "s" # ← "s" for contracts (not "z") lto = true debug = false panic = "abort" overflow-checks = true ``` #### 3. rust-toolchain.toml (In Contract Directory) ```toml [toolchain] channel = "1.85.0" # ← Pin exact version! components = ["rustfmt"] targets = ["wasm32-unknown-unknown"] ``` **Why pin version?** - near-sdk 5.9.0 requires specific Rust version - Newer Rust may have breaking changes - Older Rust may miss required features #### 4. Contract build.sh ```bash #!/bin/bash set -e cd $(dirname $0) mkdir -p res/local echo "Building contract..." # Build the contract (requires cargo-near installed) cargo near build non-reproducible-wasm # Copy output to res/local/ cp ../target/near/your_contract/your_contract.wasm res/local/ echo "✅ Contract built: res/local/your_contract.wasm" ls -lh res/local/your_contract.wasm ``` **Important notes:** - Use `non-reproducible-wasm` (reproducible needs Docker) - `cargo-near` outputs to workspace `target/near/` directory - Copy final WASM to `res/local/` for easy access from WASI code ### Building Process ```bash # 1. Install cargo-near (one time) cargo install cargo-near # 2. Build the contract first cd your-contract ./build.sh cd .. # 3. Build the WASI module cargo build --target wasm32-wasip2 --release # 4. Your WASI code can now embed the contract WASM # Read from: your-contract/res/local/your_contract.wasm ``` ### Loading Contract WASM in Rust Code ```rust // In your src/main.rs or src/lib.rs // Option 1: Embed at compile time (increases WASI binary size) const CONTRACT_WASM: &[u8] = include_bytes!( "../your-contract/res/local/your_contract.wasm" ); // Option 2: Read from filesystem (if available in WASI env) fn load_contract() -> Result, std::io::Error> { std::fs::read("./your-contract/res/local/your_contract.wasm") } // Use the contract WASM fn deploy_contract(contract_wasm: &[u8]) { // Your deployment logic here // - Encode as base64 // - Send via NEAR RPC // - Handle transaction } ``` ### Examples to Study 1. **[random-example/random-contract](./random-example/random-contract/)** - Simple contract - Single contract in subdirectory - Basic workspace setup - Clean build script 2. **[intents-example/intents-contract](https://github.com/out-layer/intents-example/tree/main/intents-contract/)** - Advanced contract - Workspace with complex dependencies - Contract deployment at runtime - Full transaction handling ### Common Issues #### "near-sdk version mismatch" ```bash # ❌ Wrong: Using different near-sdk versions your-contract/Cargo.toml: near-sdk = "5.5.0" # Old your-contract/Cargo.toml: near-sdk = "6.0.0" # Too new # ✅ Correct: Use 5.9.0 near-sdk = { version = "5.9.0", features = ["legacy", "unit-testing"] } ``` #### "edition 2021 not supported" ```bash # ❌ Wrong: Using edition 2021 for contract [package] edition = "2021" # ✅ Correct: Use edition 2018 [package] edition = "2018" ``` #### "cargo near: command not found" ```bash # Install cargo-near cargo install cargo-near # Verify installation cargo near --version ``` #### "contract WASM not found" ```bash # ❌ Wrong path - contract outputs to workspace target/ ./your-contract/target/wasm32-unknown-unknown/release/contract.wasm # ✅ Correct path - cargo-near uses target/near/ ./target/near/your_contract/your_contract.wasm # Or copy to res/local/ in build.sh ./your-contract/res/local/your_contract.wasm ``` ### Best Practices 1. **Always use `non-reproducible-wasm` for WASI-embedded contracts** - Reproducible builds need Docker environment - WASI can't run Docker - Non-reproducible is fine for development and production 2. **Pin Rust version with rust-toolchain.toml** - Ensures consistent builds - Prevents breaking changes - Required for near-sdk compatibility 3. **Use workspace structure** - Keep contract and WASI app separate - Share dependencies via workspace - Easier to maintain 4. **Copy examples, don't start from scratch** - Version compatibility is complex - Examples are tested and working - Saves hours of debugging 5. **Test contract separately before embedding** ```bash # Test contract standalone first cd your-contract cargo near build non-reproducible-wasm near deploy test.testnet ./res/local/your_contract.wasm # Then integrate into WASI ``` ## Need Help? - Check [examples](https://github.com/out-layer/random-example) for working code - Use [test runner](./WASI_TEST_RUNNER.md) to validate your module - Review [common pitfalls](#common-pitfalls) section - Read [BEST_PRACTICES_OUTLAYER_NEAR.md](https://github.com/out-layer/outlayer/blob/main/wasi-examples/BEST_PRACTICES_OUTLAYER_NEAR.md) for frontend patterns - Test locally with wasmtime before deploying --- **Last updated**: 2025-01 **Compatible with**: wasmtime 28+, NEAR OutLayer --- > Source: https://github.com/out-layer/outlayer/blob/main/wasi-examples/WASM_ENV_VARS.md # WASM Environment Variables Environment variables available to your WASM code during execution. > **Note**: For WASI P2 modules, consider using the [OutLayer SDK](https://github.com/out-layer/outlayer/blob/main/wasi-examples/WASI_TUTORIAL.md#outlayer-sdk) instead of raw env vars. The SDK provides type-safe access via `outlayer::env::signer_account_id()` and persistent storage via `outlayer::storage`. ## Safe Access Pattern **IMPORTANT**: Not all variables are always set. Use safe access patterns: ```rust // CORRECT - won't panic let value = std::env::var("VAR_NAME").ok(); // Option let value = std::env::var("VAR_NAME").unwrap_or_default(); // String, "" if not set let value = std::env::var("VAR_NAME").unwrap_or("fallback".to_string()); // WRONG - may panic let value = std::env::var("VAR_NAME").unwrap(); // panics if not set! ``` ## Execution Type | Variable | Values | Always Set | |----------|--------|------------| | `OUTLAYER_EXECUTION_TYPE` | `"NEAR"` or `"HTTPS"` | Yes | | `NEAR_NETWORK_ID` | `"testnet"` or `"mainnet"` | Yes | ```rust let is_https = std::env::var("OUTLAYER_EXECUTION_TYPE") .map(|v| v == "HTTPS") .unwrap_or(false); let is_mainnet = std::env::var("NEAR_NETWORK_ID") .map(|v| v == "mainnet") .unwrap_or(false); // Common pattern: determine account suffix for user-facing strings let account_suffix = match std::env::var("NEAR_NETWORK_ID").as_deref() { Ok("testnet") => ".testnet", _ => ".near", }; // Example: strip suffix for display let display_name = sender_id.strip_suffix(account_suffix).unwrap_or(&sender_id); ``` ## User Identity | Variable | NEAR Mode | HTTPS Mode | |----------|-----------|------------| | `NEAR_SENDER_ID` | Transaction signer account | Payment Key owner | | `NEAR_USER_ACCOUNT_ID` | Same as sender | Same as sender | Always set in both modes. **Recommended**: For WASI P2, use the OutLayer SDK instead: ```rust use outlayer::env; // Type-safe, returns Option let signer = env::signer_account_id(); ``` See [OutLayer SDK](https://github.com/out-layer/outlayer/blob/main/wasi-examples/WASI_TUTORIAL.md#outlayer-sdk) for details. ## Project Variables | Variable | Description | When Set | |----------|-------------|----------| | `OUTLAYER_PROJECT_ID` | Full project ID: `owner/name` | Only via Project | | `OUTLAYER_PROJECT_OWNER` | Owner account: `alice.near` | Only via Project | | `OUTLAYER_PROJECT_NAME` | Project name: `my-app` | Only via Project | | `OUTLAYER_PROJECT_UUID` | Internal UUID for storage | Only via Project | **NOT SET** when running directly with GitHub URL or WASM hash (without project). ```rust // Safe pattern for project vars fn get_project_info() -> Option<(String, String)> { let owner = std::env::var("OUTLAYER_PROJECT_OWNER").ok()?; let name = std::env::var("OUTLAYER_PROJECT_NAME").ok()?; Some((owner, name)) } // Check if running via project let is_project_execution = std::env::var("OUTLAYER_PROJECT_ID").is_ok(); ``` **Note**: Project name may contain `/`. For `zavodil.near/my/nested/app`: - `OUTLAYER_PROJECT_OWNER` = `zavodil.near` - `OUTLAYER_PROJECT_NAME` = `my/nested/app` (split by first `/` only) ## Payment Variables | Variable | NEAR Mode | HTTPS Mode | |----------|-----------|------------| | `NEAR_PAYMENT_YOCTO` | Attached NEAR (yoctoNEAR) | `"0"` | | `ATTACHED_USD` | USD from contract (micro-USD) | `"0"` | | `USD_PAYMENT` | `"0"` | X-Attached-Deposit (micro-USD) | ```rust // Parse payment (1_000_000 = $1.00) let usd_payment: u64 = std::env::var("USD_PAYMENT") .unwrap_or_default() .parse() .unwrap_or(0); let usd_amount = usd_payment as f64 / 1_000_000.0; ``` ## Blockchain Context (NEAR Mode Only) | Variable | Description | HTTPS Mode Value | |----------|-------------|------------------| | `NEAR_CONTRACT_ID` | OutLayer contract | `""` | | `NEAR_BLOCK_HEIGHT` | Block number | `""` | | `NEAR_BLOCK_TIMESTAMP` | Block timestamp (nanoseconds) | `""` | | `NEAR_RECEIPT_ID` | Receipt ID | `""` | | `NEAR_PREDECESSOR_ID` | Predecessor account | `""` | | `NEAR_SIGNER_PUBLIC_KEY` | Signer's public key | `""` | | `NEAR_GAS_BURNT` | Gas used | `""` | | `NEAR_TRANSACTION_HASH` | Transaction hash | `""` | | `NEAR_REQUEST_ID` | Internal request ID | `""` | In HTTPS mode these are set to empty strings `""`, not missing. ```rust // Safe pattern for blockchain vars let block_height: Option = std::env::var("NEAR_BLOCK_HEIGHT") .ok() .filter(|s| !s.is_empty()) .and_then(|s| s.parse().ok()); ``` ## HTTPS-Specific Variables | Variable | NEAR Mode | HTTPS Mode | |----------|-----------|------------| | `OUTLAYER_CALL_ID` | `""` | Call UUID | ```rust let call_id = std::env::var("OUTLAYER_CALL_ID") .ok() .filter(|s| !s.is_empty()); ``` ## Resource Limits | Variable | Description | Always Set | |----------|-------------|------------| | `NEAR_MAX_INSTRUCTIONS` | Max WASM instructions | Yes | | `NEAR_MAX_MEMORY_MB` | Max memory in MB | Yes | | `NEAR_MAX_EXECUTION_SECONDS` | Max execution time | Yes | Always set in both modes. ## User Secrets Your encrypted secrets are also available as env vars by their names. ```rust let api_key = std::env::var("MY_API_KEY").ok(); let private_key = std::env::var("NEAR_SENDER_PRIVATE_KEY").ok(); ``` ## Complete Example ```rust use std::env; fn main() { // Detect execution mode let exec_type = env::var("OUTLAYER_EXECUTION_TYPE").unwrap_or_default(); let is_https = exec_type == "HTTPS"; // Get user (always available) let sender = env::var("NEAR_SENDER_ID").unwrap_or_default(); // Get project info (optional) let project_owner = env::var("OUTLAYER_PROJECT_OWNER").ok(); let project_name = env::var("OUTLAYER_PROJECT_NAME").ok(); // Get payment based on mode let payment = if is_https { env::var("USD_PAYMENT").unwrap_or_default() } else { env::var("NEAR_PAYMENT_YOCTO").unwrap_or_default() }; // Get blockchain context (NEAR mode only) let tx_hash = env::var("NEAR_TRANSACTION_HASH") .ok() .filter(|s| !s.is_empty()); println!("Mode: {}", exec_type); println!("Sender: {}", sender); if let (Some(owner), Some(name)) = (&project_owner, &project_name) { println!("Project: {}/{}", owner, name); } println!("Payment: {}", payment); if let Some(hash) = tx_hash { println!("TX: {}", hash); } } ``` ## Summary Table | Variable | NEAR | HTTPS | Project-only | |----------|------|-------|--------------| | `OUTLAYER_EXECUTION_TYPE` | `"NEAR"` | `"HTTPS"` | No | | `NEAR_NETWORK_ID` | `"testnet"` or `"mainnet"` | `"testnet"` or `"mainnet"` | No | | `NEAR_SENDER_ID` | Yes | Yes | No | | `NEAR_USER_ACCOUNT_ID` | Yes | Yes | No | | `OUTLAYER_PROJECT_ID` | If project | If project | **Yes** | | `OUTLAYER_PROJECT_OWNER` | If project | If project | **Yes** | | `OUTLAYER_PROJECT_NAME` | If project | If project | **Yes** | | `OUTLAYER_PROJECT_UUID` | If project | If project | **Yes** | | `NEAR_PAYMENT_YOCTO` | Value | `"0"` | No | | `ATTACHED_USD` | Value | `"0"` | No | | `USD_PAYMENT` | `"0"` | Value | No | | `OUTLAYER_CALL_ID` | `""` | UUID | No | | `NEAR_BLOCK_HEIGHT` | Value | `""` | No | | `NEAR_TRANSACTION_HASH` | Value | `""` | No | | `NEAR_MAX_INSTRUCTIONS` | Yes | Yes | No | | `NEAR_MAX_MEMORY_MB` | Yes | Yes | No | | `NEAR_MAX_EXECUTION_SECONDS` | Yes | Yes | No | ## See Also - [WASI Tutorial](https://github.com/out-layer/outlayer/blob/main/wasi-examples/WASI_TUTORIAL.md) - Complete guide to WASI development - [OutLayer SDK](https://github.com/out-layer/outlayer/blob/main/wasi-examples/WASI_TUTORIAL.md#outlayer-sdk) - Type-safe access to env vars and storage - [Best Practices](https://github.com/out-layer/outlayer/blob/main/wasi-examples/BEST_PRACTICES_OUTLAYER_NEAR.md) - Frontend integration patterns --- > Source: https://github.com/out-layer/outlayer/blob/main/wasi-examples/BEST_PRACTICES_OUTLAYER_NEAR.md # Best Practices: OutLayer + NEAR Integration This guide covers patterns for building applications on NEAR OutLayer, based on the near.email production implementation. ## Table of Contents 1. [WASI: Identifying the Caller Account](#1-wasi-identifying-the-caller-account) 2. [Frontend: Wallet Selector Integration](#2-frontend-wallet-selector-integration) 3. [Payment Keys for Better UX](#3-payment-keys-for-better-ux) 4. [NEP-413 Sign Message for Authentication](#4-nep-413-sign-message-for-authentication) --- ## 1. WASI: Identifying the Caller Account OutLayer provides the signer's NEAR account ID via the `outlayer` SDK. When a user calls your WASI module through a blockchain transaction, `env::signer_account_id()` returns their account. ### Rust WASI Code ```rust use outlayer::env; fn main() -> Result<(), Box> { // Get the account that signed the transaction let signer = env::signer_account_id() .ok_or("No signer - must be called via NEAR transaction")?; println!("Called by: {}", signer); // Use signer for access control, data isolation, etc. let user_data = load_user_data(&signer)?; Ok(()) } ``` ### Key Points - `env::signer_account_id()` returns `Option` - Returns `None` when called via HTTPS API without payment key - Returns the NEAR account ID (e.g., `alice.near`) when called via blockchain transaction - For HTTPS API calls with Payment Key, the key owner's account is used ### Dependencies (Cargo.toml) ```toml [dependencies] outlayer = "0.1" # OutLayer SDK for WASI P2 serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" ``` --- ## 2. Frontend: Wallet Selector Integration ### Critical: Avoid Popup Blocking **Problem**: Multiple consecutive wallet calls (e.g., sign + send) trigger browser popup blockers with error: ``` Popup window blocked. Please allow popups for this site. ``` **Solution**: Only make ONE wallet call per user action. Let the user initiate each action. ### Setup Wallet Selector ```typescript import { setupWalletSelector } from '@near-wallet-selector/core'; import { setupModal } from '@near-wallet-selector/modal-ui'; import { setupMyNearWallet } from '@near-wallet-selector/my-near-wallet'; import { setupHereWallet } from '@near-wallet-selector/here-wallet'; import { setupMeteorWallet } from '@near-wallet-selector/meteor-wallet'; import type { WalletSelector } from '@near-wallet-selector/core'; import { actionCreators } from '@near-js/transactions'; const NETWORK_ID = process.env.NEXT_PUBLIC_NETWORK_ID || 'mainnet'; const OUTLAYER_CONTRACT = NETWORK_ID === 'testnet' ? 'outlayer.testnet' : 'outlayer.near'; let selector: WalletSelector | null = null; let modal: ReturnType | null = null; export async function initWalletSelector(): Promise { if (selector) return selector; selector = await setupWalletSelector({ network: NETWORK_ID as 'mainnet' | 'testnet', modules: [ setupMyNearWallet(), setupHereWallet(), setupMeteorWallet(), ], }); // IMPORTANT: Omit contractId to prevent wallets from creating // a function call access key during sign-in (saves gas) modal = setupModal(selector, {}); return selector; } export function showModal() { modal?.show(); } ``` ### Call OutLayer via Transaction ```typescript export async function callOutLayer( action: string, params: Record ): Promise { if (!selector) throw new Error('Wallet not initialized'); const wallet = await selector.wallet(); const accounts = selector.store.getState().accounts; if (accounts.length === 0) throw new Error('Not connected'); // Build input data for WASI module const inputData = JSON.stringify({ action, ...params }); // IMPORTANT: Use actionCreators, not raw objects! const functionCallAction = actionCreators.functionCall( 'request_execution', { source: { Project: { project_id: 'your-account.near/your-project', version_key: null, }, }, input_data: inputData, resource_limits: { max_instructions: 2000000000, max_memory_mb: 512, max_execution_seconds: 120, }, response_format: 'Json', }, BigInt('300000000000000'), // 300 TGas BigInt('100000000000000000000000') // 0.1 NEAR deposit ); // Single wallet call - user approves once const result = await wallet.signAndSendTransaction({ receiverId: OUTLAYER_CONTRACT, actions: [functionCallAction], }); // Parse result from transaction return parseTransactionResult(result); } function parseTransactionResult(result: any): any { let successValue: string | null = null; if (result?.receipts_outcome) { for (const receipt of result.receipts_outcome) { if (receipt?.outcome?.status?.SuccessValue) { successValue = receipt.outcome.status.SuccessValue; break; } } } if (!successValue) { throw new Error('No result from OutLayer execution'); } const decoded = atob(successValue); const response = JSON.parse(decoded); if (!response.success) { throw new Error(response.error || 'Unknown error'); } return response; } ``` ### UI Pattern: One Action Per Click ```tsx // GOOD: Each button triggers exactly one wallet interaction function EmailApp() { const [emails, setEmails] = useState([]); async function handleCheckMail() { // Single wallet call - user clicks button, approves transaction const result = await callOutLayer('get_emails', {}); setEmails(result.inbox); } async function handleSendEmail(to: string, body: string) { // Single wallet call - user clicks send, approves transaction await callOutLayer('send_email', { to, body }); } return (
); } // BAD: Multiple wallet calls in sequence - WILL BE BLOCKED async function badPattern() { const sig1 = await wallet.signMessage({...}); // First popup const sig2 = await wallet.signMessage({...}); // BLOCKED! } ``` ### Dependencies (package.json) ```json { "dependencies": { "@near-wallet-selector/core": "^8.9.0", "@near-wallet-selector/modal-ui": "^8.9.0", "@near-wallet-selector/my-near-wallet": "^8.9.0", "@near-wallet-selector/here-wallet": "^8.9.0", "@near-wallet-selector/meteor-wallet": "^8.9.0", "@near-js/transactions": "^1.2.0" } } ``` --- ## 3. Payment Keys for Better UX Payment Keys allow users to interact with OutLayer via HTTPS API instead of blockchain transactions. Benefits: - No transaction approval popups - Faster response times - Larger payload support (10MB vs ~1.5MB) - Pre-paid execution costs ### Create Payment Keys Users create Payment Keys at the OutLayer Dashboard. Format: `owner:nonce:secret` Example: `alice.near:0:a1b2c3d4...` ### Frontend: Payment Key Mode ```typescript // Payment Key configuration let paymentKeyConfig: { enabled: boolean; key: string | null; owner: string | null; } = { enabled: false, key: null, owner: null }; // Parse payment key format: owner:nonce:secret function parsePaymentKey(key: string): { owner: string; nonce: string; secret: string } | null { const parts = key.split(':'); if (parts.length < 3) return null; return { owner: parts[0], nonce: parts[1], secret: parts.slice(2).join(':') }; } // Set payment key (call when user enters key) export function setPaymentKey(key: string | null): boolean { if (key === null) { paymentKeyConfig = { enabled: false, key: null, owner: null }; localStorage.removeItem('payment-key'); return true; } const parsed = parsePaymentKey(key); if (!parsed) return false; paymentKeyConfig = { enabled: true, key, owner: parsed.owner }; localStorage.setItem('payment-key', key); return true; } // Call OutLayer via HTTPS (Payment Key mode) async function callOutLayerHttps(action: string, params: Record): Promise { if (!paymentKeyConfig.key) throw new Error('Payment key not configured'); const url = `https://api.outlayer.ai/call/your-account.near/your-project`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Payment-Key': paymentKeyConfig.key, // Auth via header }, body: JSON.stringify({ input: { action, ...params }, resource_limits: { max_instructions: 2000000000, max_memory_mb: 512, max_execution_seconds: 120, }, }), }); if (!response.ok) { const error = await response.text(); throw new Error(error); } const result = await response.json(); if (result.status === 'failed') { throw new Error(result.error || 'Execution failed'); } return JSON.parse(result.output); } // Unified call function - routes to HTTPS or blockchain export async function callOutLayer(action: string, params: Record): Promise { if (paymentKeyConfig.enabled) { return callOutLayerHttps(action, params); } return callOutLayerTransaction(action, params); // blockchain version } ``` ### UI: Payment Key Toggle ```tsx function AccountMenu() { const [paymentKeyEnabled, setEnabled] = useState(false); const [paymentKeyInput, setInput] = useState(''); function handleSaveKey() { if (setPaymentKey(paymentKeyInput)) { setEnabled(true); } } return (
setInput(e.target.value)} />
); } ``` --- ## 4. NEP-413 Sign Message for Authentication For operations that need cryptographic proof of account ownership (without blockchain transactions), use NEP-413 message signing. **Use cases:** - Invite system authentication - Off-chain access control - Proving account ownership to external APIs ### Frontend: Sign Message with Caching ```typescript // Signature data structure interface SignedData { signature: string; // base64 encoded public_key: string; // ed25519:xxx format timestamp_ms: number; nonce: string; // base64 encoded 32-byte nonce } // Cache signatures to avoid repeated popups (50 min cache, signatures expire at 60 min) const signatureCache: Map = new Map(); const CACHE_DURATION_MS = 50 * 60 * 1000; function getCachedSignature(accountId: string): SignedData | null { const cached = signatureCache.get(accountId); if (!cached) return null; const age = Date.now() - cached.timestamp_ms; if (age > CACHE_DURATION_MS) { signatureCache.delete(accountId); return null; } return cached; } // Sign message with NEP-413 (wallet popup) async function signMessage(accountId: string): Promise { // Check cache first - avoid popup if we have valid signature const cached = getCachedSignature(accountId); if (cached) { console.log('Using cached signature'); return cached; } const timestamp_ms = Date.now(); // Generic message - one signature works for multiple operations const message = `your-app:${accountId}:${timestamp_ms}`; if (!selector) return null; const wallet = await selector.wallet(); if (!wallet.signMessage) { console.error('Wallet does not support signMessage'); return null; } // Generate 32-byte nonce (required by NEP-413) const nonceBytes = new Uint8Array(32); crypto.getRandomValues(nonceBytes); const nonce = Buffer.from(nonceBytes); try { const result = await wallet.signMessage({ message, recipient: 'your-app', // Your app identifier nonce, }); if (!result) return null; // Handle signature format (can be string, Uint8Array, or array-like) let signatureBase64: string; const sig = result.signature as unknown; if (typeof sig === 'string') { signatureBase64 = sig; } else if (sig instanceof Uint8Array) { signatureBase64 = btoa(String.fromCharCode(...sig)); } else if (Array.isArray(sig)) { signatureBase64 = btoa(String.fromCharCode(...new Uint8Array(sig))); } else { return null; } const nonceBase64 = btoa(String.fromCharCode(...nonceBytes)); const signedData: SignedData = { signature: signatureBase64, public_key: result.publicKey, timestamp_ms, nonce: nonceBase64, }; // Cache the signature for future calls signatureCache.set(accountId, signedData); return signedData; } catch (e) { console.error('Signing failed:', e); return null; } } // Example: Authenticated API call async function authenticatedApiCall(accountId: string, endpoint: string, body: any) { const signed = await signMessage(accountId); if (!signed) throw new Error('Failed to sign request'); return fetch(`https://your-api.com/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ account_id: accountId, signature: signed.signature, public_key: signed.public_key, timestamp_ms: signed.timestamp_ms, nonce: signed.nonce, ...body, }), }); } ``` ### Backend: Verify NEP-413 Signature (Rust) ```rust use ed25519_dalek::{Signature, VerifyingKey, Verifier}; use sha2::{Sha256, Digest}; use borsh::BorshSerialize; /// NEP-413 payload structure #[derive(BorshSerialize)] struct Nep413Payload { message: String, nonce: [u8; 32], recipient: String, callback_url: Option, } /// NEP-413 tag: 2^31 + 413 const NEP413_TAG: u32 = 2147484061; /// Signed request from frontend struct SignedRequest { account_id: String, signature: String, // base64 public_key: String, // ed25519:base58... timestamp_ms: u64, nonce: String, // base64 } /// Verify NEP-413 signature /// Returns Ok(()) if valid, Err(reason) otherwise fn verify_signature(signed: &SignedRequest) -> Result<(), String> { // 1. Check timestamp (allow 1 hour window) let now_ms = chrono::Utc::now().timestamp_millis() as u64; let one_hour_ms = 60 * 60 * 1000; if signed.timestamp_ms > now_ms + one_hour_ms { return Err("Timestamp is in the future".to_string()); } if now_ms > signed.timestamp_ms + one_hour_ms { return Err("Signature expired".to_string()); } // 2. Parse public key (format: "ed25519:base58...") let pubkey_parts: Vec<&str> = signed.public_key.split(':').collect(); if pubkey_parts.len() != 2 || pubkey_parts[0] != "ed25519" { return Err("Invalid public key format".to_string()); } let pubkey_bytes = bs58::decode(pubkey_parts[1]) .into_vec() .map_err(|e| format!("Failed to decode public key: {}", e))?; // 3. Decode signature and nonce (base64) let sig_bytes = base64::decode(&signed.signature) .map_err(|e| format!("Failed to decode signature: {}", e))?; let nonce_bytes = base64::decode(&signed.nonce) .map_err(|e| format!("Failed to decode nonce: {}", e))?; let nonce_array: [u8; 32] = nonce_bytes.try_into() .map_err(|_| "Invalid nonce length")?; // 4. Reconstruct the message (must match frontend) let message = format!( "your-app:{}:{}", signed.account_id, signed.timestamp_ms ); // 5. Build NEP-413 payload let payload = Nep413Payload { message, nonce: nonce_array, recipient: "your-app".to_string(), callback_url: None, }; // 6. Serialize with Borsh let payload_bytes = borsh::to_vec(&payload) .map_err(|e| format!("Failed to serialize: {}", e))?; // 7. Build final hash: SHA256(NEP413_TAG || Borsh(payload)) let mut to_hash = Vec::with_capacity(4 + payload_bytes.len()); to_hash.extend_from_slice(&NEP413_TAG.to_le_bytes()); to_hash.extend_from_slice(&payload_bytes); let hash = Sha256::digest(&to_hash); // 8. Verify signature let verifying_key = VerifyingKey::from_bytes( &pubkey_bytes.try_into().map_err(|_| "Invalid key length")? ).map_err(|e| format!("Invalid public key: {}", e))?; let signature = Signature::from_bytes( &sig_bytes.try_into().map_err(|_| "Invalid signature length")? ); verifying_key .verify(&hash, &signature) .map_err(|_| "Signature verification failed")?; Ok(()) } ``` ### Backend: Verify Public Key Ownership After verifying the signature, verify the public key belongs to the claimed account: ```rust /// Verify public key belongs to account via FastNEAR API async fn verify_key_ownership( public_key: &str, // ed25519:base58... account_id: &str, ) -> Result<(), String> { // FastNEAR API expects key without prefix let key = public_key.strip_prefix("ed25519:").unwrap_or(public_key); // Mainnet: https://api.fastnear.com // Testnet: https://test.api.fastnear.com let fastnear_url = if account_id.ends_with(".testnet") { "https://test.api.fastnear.com" } else { "https://api.fastnear.com" }; let url = format!("{}/v1/public_key/{}", fastnear_url, key); let response = reqwest::get(&url).await .map_err(|e| format!("FastNEAR request failed: {}", e))?; if response.status() == 404 { return Err("Public key not found on chain".to_string()); } #[derive(Deserialize)] struct Response { account_ids: Vec } let data: Response = response.json().await .map_err(|e| format!("Failed to parse response: {}", e))?; if data.account_ids.contains(&account_id.to_string()) { Ok(()) } else { Err(format!("Key does not belong to {}", account_id)) } } /// Full verification: signature + ownership async fn verify_request(signed: &SignedRequest) -> Result<(), String> { verify_signature(signed)?; verify_key_ownership(&signed.public_key, &signed.account_id).await?; Ok(()) } ``` ### Dependencies (Cargo.toml for backend) ```toml [dependencies] ed25519-dalek = { version = "2.1", features = ["rand_core"] } bs58 = "0.5" borsh = { version = "1.5", features = ["derive"] } sha2 = "0.10" base64 = "0.21" reqwest = { version = "0.11", features = ["json"] } chrono = "0.4" serde = { version = "1.0", features = ["derive"] } ``` ### Best Practices for Signature Caching 1. **Cache on frontend** - Avoid repeated wallet popups 2. **Use timestamp in message** - For replay protection 3. **Set reasonable expiry** - 50-60 minutes is good balance 4. **Generic message format** - One signature for multiple operations 5. **Clear cache on logout** - Security hygiene ```typescript // Clear signature cache when user signs out export async function signOut(): Promise { signatureCache.clear(); await wallet.signOut(); } ``` --- ## Summary | Pattern | When to Use | User Experience | |---------|-------------|-----------------| | Blockchain Transaction | Default, most secure | Popup per action | | Payment Key (HTTPS) | Frequent operations | No popups, fast | | NEP-413 Sign Message | Off-chain auth, APIs | One popup, cached | **Key rules:** 1. One wallet call per user click (avoid popup blocking) 2. Cache signatures when possible 3. Offer Payment Keys for power users 4. Always verify public key ownership on backend --- *Based on near.email production implementation. Last updated: 2025-01* --- > Source: https://github.com/out-layer/outlayer/blob/main/wasi-examples/PROXY_CONTRACTS_TUTORIAL.md # Proxy Contracts for OutLayer How to build NEAR smart contracts that call OutLayer for off-chain computation. ## Overview A proxy contract is a NEAR smart contract that: 1. Accepts user calls with attached NEAR 2. Forwards computation requests to OutLayer 3. Receives results via callback 4. Returns results to users or updates state ``` User → Your Contract → OutLayer Contract → Worker → Callback → Your Contract ``` ## Basic Setup ### Dependencies ```toml # Cargo.toml [dependencies] near-sdk = "5.9.0" serde = { version = "1", features = ["derive"] } serde_json = "1" [lib] crate-type = ["cdylib"] [profile.release] codegen-units = 1 opt-level = "z" lto = true ``` ### Toolchain ```toml # rust-toolchain.toml [toolchain] channel = "1.85.0" ``` ## OutLayer Contract Interface Define the external contract interface: ```rust use near_sdk::{ext_contract, AccountId, Gas, NearToken}; const OUTLAYER_CONTRACT_ID: &str = "outlayer.near"; // or "outlayer.testnet" #[ext_contract(ext_outlayer)] trait OutLayer { fn request_execution( &mut self, code_source: serde_json::Value, resource_limits: serde_json::Value, input_data: String, secrets_ref: Option, response_format: String, payer_account_id: Option, ); } ``` ### Parameters | Parameter | Type | Description | |-----------|------|-------------| | `code_source` | JSON | GitHub URL or WASM hash | | `resource_limits` | JSON | Memory, instructions, time limits | | `input_data` | String | JSON input for your WASM | | `secrets_ref` | Option | Reference to encrypted secrets | | `response_format` | String | `"String"` or `"Json"` | | `payer_account_id` | Option | Who gets refund on failure | ## Simple Example: Coin Flip A minimal proxy that calls OutLayer for random number generation. ```rust use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; use near_sdk::{env, near_bindgen, AccountId, Gas, NearToken, Promise, PromiseError}; const OUTLAYER_CONTRACT_ID: &str = "outlayer.near"; const MIN_DEPOSIT: NearToken = NearToken::from_millinear(10); // 0.01 NEAR const CALLBACK_GAS: Gas = Gas::from_tgas(5); #[ext_contract(ext_outlayer)] trait OutLayer { fn request_execution( &mut self, code_source: serde_json::Value, resource_limits: serde_json::Value, input_data: String, secrets_ref: Option, response_format: String, payer_account_id: Option, ); } #[ext_contract(ext_self)] trait SelfCallback { fn on_flip_result(&mut self, player: AccountId) -> String; } #[near_bindgen] #[derive(BorshDeserialize, BorshSerialize, Default)] pub struct CoinFlip { wins: u64, losses: u64, } #[near_bindgen] impl CoinFlip { #[payable] pub fn flip(&mut self) -> Promise { let deposit = env::attached_deposit(); assert!(deposit >= MIN_DEPOSIT, "Minimum deposit is 0.01 NEAR"); let player = env::predecessor_account_id(); // Code source - GitHub URL let code_source = serde_json::json!({ "GitHub": { "url": "https://github.com/example/random-wasm", "hash": null } }); // Resource limits let resource_limits = serde_json::json!({ "max_memory_mb": 64, "max_instructions": 1_000_000_000u64, "max_execution_seconds": 30 }); // Input data let input_data = serde_json::json!({ "action": "flip" }).to_string(); // Calculate gas for OutLayer call let remaining_gas = env::prepaid_gas().saturating_sub(CALLBACK_GAS); ext_outlayer::ext(OUTLAYER_CONTRACT_ID.parse().unwrap()) .with_attached_deposit(deposit) .with_static_gas(remaining_gas) .with_unused_gas_weight(1) .request_execution( code_source, resource_limits, input_data, None, // no secrets "String".into(), Some(player.clone()), // refund to player on failure ) .then( ext_self::ext(env::current_account_id()) .with_static_gas(CALLBACK_GAS) .on_flip_result(player) ) } #[private] pub fn on_flip_result( &mut self, player: AccountId, #[callback_result] result: Result, PromiseError>, ) -> String { match result { Ok(Some(output)) => { if output.contains("heads") { self.wins += 1; format!("{} won! Result: {}", player, output) } else { self.losses += 1; format!("{} lost! Result: {}", player, output) } } Ok(None) => { self.losses += 1; "No result returned".to_string() } Err(_) => { // Deposit refunded to payer_account_id "Execution failed".to_string() } } } pub fn stats(&self) -> (u64, u64) { (self.wins, self.losses) } } ``` ## Code Source Formats ### GitHub URL ```rust let code_source = serde_json::json!({ "GitHub": { "url": "https://github.com/owner/repo", "hash": null // or specific commit hash } }); ``` ### Project ID ```rust let code_source = serde_json::json!({ "Project": { "project_id": "alice.near/my-app" } }); ``` ### WASM Hash (pre-uploaded) ```rust let code_source = serde_json::json!({ "WasmHash": { "hash": "abc123..." } }); ``` ## Advanced: OutLayerResponse Wrapper For JSON responses, OutLayer wraps results: ```rust #[derive(Deserialize)] struct OutLayerResponse { success: bool, result: Option, error: Option, } ``` ### Parsing JSON Results ```rust use serde::Deserialize; #[derive(Deserialize)] struct VoteResult { yes_votes: u64, no_votes: u64, passed: bool, } #[private] pub fn on_tally_result( &mut self, proposal_id: u64, #[callback_result] result: Result, PromiseError>, ) { let Ok(Some(output)) = result else { env::log_str("Execution failed"); return; }; // Parse OutLayerResponse wrapper let response: OutLayerResponse = match serde_json::from_str(&output) { Ok(r) => r, Err(e) => { env::log_str(&format!("Parse error: {}", e)); return; } }; if !response.success { env::log_str(&format!("WASM error: {:?}", response.error)); return; } if let Some(vote_result) = response.result { // Update proposal state if let Some(proposal) = self.proposals.get_mut(&proposal_id) { proposal.yes_votes = vote_result.yes_votes; proposal.no_votes = vote_result.no_votes; proposal.passed = Some(vote_result.passed); } } } ``` ## Using Secrets For WASM that needs encrypted secrets (API keys, private keys): ```rust #[payable] pub fn execute_with_secrets(&mut self) -> Promise { let player = env::predecessor_account_id(); // Reference secrets owned by current contract let secrets_ref = serde_json::json!({ "owner_id": env::current_account_id(), "names": ["API_KEY", "PRIVATE_KEY"] }); ext_outlayer::ext(OUTLAYER_CONTRACT_ID.parse().unwrap()) .with_attached_deposit(env::attached_deposit()) .with_static_gas(env::prepaid_gas().saturating_sub(CALLBACK_GAS)) .with_unused_gas_weight(1) .request_execution( code_source, resource_limits, input_data, Some(secrets_ref), // <- secrets reference "Json".into(), Some(player), ) .then(/* callback */) } ``` The WASM code accesses secrets via env vars: ```rust // In your WASM let api_key = std::env::var("API_KEY").ok(); let private_key = std::env::var("PRIVATE_KEY").ok(); ``` ## Gas Management ### Constants ```rust const CALLBACK_GAS: Gas = Gas::from_tgas(5); // 5 TGas for callback const OUTLAYER_BASE_GAS: Gas = Gas::from_tgas(10); // Minimum for OutLayer ``` ### Gas Allocation Pattern ```rust // Reserve gas for callback, give rest to OutLayer let remaining_gas = env::prepaid_gas() .saturating_sub(CALLBACK_GAS) .saturating_sub(Gas::from_tgas(5)); // buffer for current call ext_outlayer::ext(...) .with_static_gas(remaining_gas) .with_unused_gas_weight(1) // OutLayer gets unused gas .request_execution(...) ``` ### `with_unused_gas_weight(1)` This is important - it tells NEAR to give any unused gas from the current call to the OutLayer call. OutLayer needs gas to: 1. Parse and validate request 2. Store job in queue 3. Return result via callback ## Deposit Requirements OutLayer charges based on computation used. Minimum: **0.01 NEAR**. ```rust const MIN_DEPOSIT: NearToken = NearToken::from_millinear(10); #[payable] pub fn my_method(&mut self) -> Promise { let deposit = env::attached_deposit(); assert!(deposit >= MIN_DEPOSIT, "Minimum deposit is 0.01 NEAR"); // ... } ``` Unused deposit is refunded to `payer_account_id`. ## Multiple OutLayer Calls For complex workflows with multiple OutLayer calls: ```rust #[payable] pub fn complex_workflow(&mut self) -> Promise { let deposit = env::attached_deposit(); let half_deposit = NearToken::from_yoctonear(deposit.as_yoctonear() / 2); // First call let first_call = ext_outlayer::ext(OUTLAYER_CONTRACT_ID.parse().unwrap()) .with_attached_deposit(half_deposit) .with_static_gas(Gas::from_tgas(50)) .request_execution(/* step 1 params */); // Chain with callback that triggers second call first_call.then( ext_self::ext(env::current_account_id()) .with_static_gas(Gas::from_tgas(100)) .on_first_result() ) } #[private] pub fn on_first_result( &mut self, #[callback_result] result: Result, PromiseError>, ) -> Promise { // Process first result, then call OutLayer again let second_call = ext_outlayer::ext(OUTLAYER_CONTRACT_ID.parse().unwrap()) .with_attached_deposit(NearToken::from_millinear(10)) .with_static_gas(Gas::from_tgas(50)) .request_execution(/* step 2 params */); second_call.then( ext_self::ext(env::current_account_id()) .with_static_gas(Gas::from_tgas(5)) .on_final_result() ) } ``` ## Error Handling ### Callback Result Types ```rust #[callback_result] result: Result, PromiseError> ``` | Result | Meaning | |--------|---------| | `Ok(Some(output))` | Success, WASM returned output | | `Ok(None)` | Success but no output (unusual) | | `Err(PromiseError)` | OutLayer call failed | ### Refunds on Failure When `payer_account_id` is set, OutLayer refunds unused deposit on failure: ```rust ext_outlayer::ext(...) .request_execution( // ... Some(env::predecessor_account_id()), // refund to caller ) ``` ## Build & Deploy ```bash # Build cargo near build # Deploy near deploy your-contract.testnet ./target/near/your_contract.wasm # Initialize (if needed) near call your-contract.testnet new '{}' --accountId your-contract.testnet ``` ## Testing Locally ```bash # Call your proxy contract near call your-contract.testnet flip '{}' \ --accountId alice.testnet \ --deposit 0.1 \ --gas 100000000000000 ``` ## Complete Example Structure ``` my-proxy/ ├── Cargo.toml ├── rust-toolchain.toml ├── src/ │ └── lib.rs └── build.sh ``` ### build.sh ```bash #!/bin/bash set -e cargo near build ``` ## See Also - [WASI Tutorial](https://github.com/out-layer/outlayer/blob/main/wasi-examples/WASI_TUTORIAL.md) - Building WASM modules for OutLayer - [WASM Environment Variables](https://github.com/out-layer/outlayer/blob/main/wasi-examples/WASM_ENV_VARS.md) - Env vars available in WASM - [random-example](https://github.com/out-layer/random-example) - Simple coin flip example - [private-dao-example](https://github.com/out-layer/private-dao-example) - Complex DAO with voting ## Examples in This Repository | Example | Description | Complexity | |---------|-------------|------------| | [random-example](https://github.com/out-layer/random-example) | Coin flip with random number | Simple | | [private-dao-example](https://github.com/out-layer/private-dao-example) | DAO with encrypted voting | Advanced | --- > Source: https://github.com/out-layer/outlayer/blob/main/contract/README.md # OutLayer Smart Contract NEAR smart contract for off-chain WASM execution using yield/resume mechanism. - Testnet: `outlayer.testnet` - Mainnet: `outlayer.near` ## Features - **Yield/Resume Mechanism**: Uses `promise_yield_create` to pause execution - **Off-chain Computation**: Execute arbitrary WASM code off-chain - **Resource Limits**: Configurable limits for instructions, memory, and time - **Dynamic Pricing**: Cost calculated based on actual resource usage - **Stale Request Cancellation**: Users can cancel requests after timeout - **Admin Controls**: Owner can manage operators, pricing, and pause contract - **Secret Management**: Encrypted secrets support via keystore worker integration ## Contract API ### User Functions #### `request_execution` Request off-chain execution of WASM code. **Basic execution (no secrets):** ```bash near call outlayer.testnet request_execution '{ "code_source": { "repo": "https://github.com/user/project", "commit": "abc123", "build_target": "wasm32-wasi" }, "resource_limits": { "max_instructions": 1000000000, "max_memory_mb": 128, "max_execution_seconds": 60 }, "input_data": "{\"key\": \"value\"}" }' --accountId user.testnet --deposit 0.01 ``` **With encrypted secrets (e.g., API keys):** ```bash # 1. Get keystore public key near contract call-function as-read-only outlayer.testnet get_keystore_pubkey json-args {} network-config testnet now # 2. Encrypt your secrets with the public key (use keystore encryption library) # encrypted_data = encrypt_for_keystore(pubkey, "OPENAI_API_KEY=sk-...") # 3. Call with encrypted secrets near call outlayer.testnet request_execution '{ "code_source": {...}, "resource_limits": {...}, "input_data": "{...}", "secrets_ref": { "profile": "default", "account_id": "dev.testnet" } }' --accountId user.testnet --deposit 0.1 ``` #### `cancel_stale_execution` Cancel execution request after timeout (10 minutes). ```bash near call outlayer.testnet cancel_stale_execution '{ "request_id": 123 }' --accountId user.testnet ``` ### Operator Functions #### `resolve_execution` Resolve execution with results (called by worker). **Small output (<1024 bytes):** ```bash near call outlayer.testnet resolve_execution '{ "request_id": 0, "response": { "success": true, "output": {"Text": "Hello, NEAR!"}, "error": null, "resources_used": { "instructions": 1000000, "time_ms": 100 } } }' --accountId operator.testnet ``` #### `submit_execution_output_and_resolve` Optimized single-transaction method for large outputs (>1024 bytes). Used automatically by worker. ```bash near call outlayer.testnet submit_execution_output_and_resolve '{ "request_id": 0, "output": {"Text": "Very long output..."}, "success": true, "error": null, "resources_used": { "instructions": 1000000, "time_ms": 100, "compile_time_ms": null }, "compilation_note": null }' --accountId operator.testnet ``` **Note**: Worker automatically chooses between `resolve_execution` (small output) and `submit_execution_output_and_resolve` (large output) based on payload size. ### Admin Functions #### `set_operator` Change operator account. ```bash near call outlayer.testnet set_operator '{ "new_operator_id": "new-operator.testnet" }' --accountId owner.testnet ``` #### `set_pricing` Update pricing parameters. ```bash near call outlayer.testnet set_pricing '{ "base_fee": "10000000000000000000000", "per_instruction_fee": "1000000000000000", "per_mb_fee": "100000000000000000000", "per_second_fee": "1000000000000000000000" }' --accountId owner.testnet ``` #### `set_paused` Pause/unpause contract. ```bash near call outlayer.testnet set_paused '{ "paused": true }' --accountId owner.testnet ``` ### View Functions #### `get_request` Get execution request by ID. ```bash near view outlayer.testnet get_request '{ "request_id": 123 }' ``` #### `get_stats` Get contract statistics. ```bash near contract call-function as-read-only outlayer.testnet get_stats json-args {} network-config testnet now ``` #### `get_pricing` Get current pricing. ```bash near view outlayer.testnet get_pricing '{}' ``` #### `get_config` Get contract configuration. ```bash near view outlayer.testnet get_config '{}' ``` ### Secrets Management Functions #### `store_secrets` Store encrypted secrets for a repository. **Important:** Always estimate storage cost first using `estimate_storage_cost` to attach the correct deposit. ```bash # 1. Estimate storage cost near view outlayer.testnet estimate_storage_cost '{ "repo": "github.com/alice/project", "branch": "main", "profile": "default", "owner": "alice.testnet", "encrypted_secrets_base64": "YWJjZGVm...", "access": "AllowAll" }' # Output: "1500000000000000000000" (0.0015 NEAR) # 2. Store secrets with exact deposit near call outlayer.testnet store_secrets '{ "repo": "github.com/alice/project", "branch": "main", "profile": "default", "encrypted_secrets_base64": "YWJjZGVm...", "access": "AllowAll" }' --accountId alice.testnet --deposit 0.0015 ``` #### `estimate_storage_cost` Estimate the storage cost before storing secrets. Returns exact cost in yoctoNEAR. ```bash near view outlayer.testnet estimate_storage_cost '{ "repo": "github.com/alice/project", "branch": null, "profile": "production", "owner": "alice.testnet", "encrypted_secrets_base64": "YWJjZGVm...", "access": {"Whitelist": {"accounts": ["alice.testnet", "bob.testnet"]}} }' ``` **Pricing factors:** - Base overhead: 40 bytes (LookupMap entry) - Key size: repo + branch + profile + owner (with Borsh length prefixes) - Value size: encrypted_secrets + access condition + timestamps - Index overhead: 64 bytes (for new secrets) - Storage price: 0.00001 NEAR per byte **Note:** Complex access conditions (e.g., Whitelist with many accounts) cost more than simple ones (e.g., AllowAll). #### `get_secrets` Retrieve secrets for a repository (called by keystore worker). ```bash near view outlayer.testnet get_secrets '{ "repo": "github.com/alice/project", "branch": "main", "profile": "default", "owner": "alice.testnet" }' ``` #### `delete_secrets` Delete secrets and get storage deposit refund. ```bash near call outlayer.testnet delete_secrets '{ "repo": "github.com/alice/project", "branch": "main", "profile": "default" }' --accountId alice.testnet ``` #### `list_user_secrets` List all secrets stored by an account. ```bash near view outlayer.testnet list_user_secrets '{ "account_id": "alice.testnet" }' ``` ## Events ### `execution_requested` Emitted when user requests execution. ```json { "standard": "near-outlayer", "version": "1.0.0", "event": "execution_requested", "data": [{ "request_data": "{...}", "data_id": [0,1,2,...], "timestamp": 1234567890 }] } ``` ### `execution_completed` Emitted when execution is completed. ```json { "standard": "near-outlayer", "version": "1.0.0", "event": "execution_completed", "data": [{ "sender_id": "user.testnet", "code_source": {...}, "resources_used": {...}, "success": true, "timestamp": 1234567890 }] } ``` ## Build & Deploy ### Build ```bash ./build.sh ``` ### Deploy with init ```bash near contract deploy outlayer.testnet use-file res/local/outlayer_contract.wasm with-init-call new json-args '{"owner_id":"owner.outlayer.testnet","operator_id":"worker.outlayer.testnet"}' prepaid-gas '100.0 Tgas' attached-deposit '0 NEAR' network-config testnet sign-with-keychain send ``` ### Set event standard ``` near contract call-function as-transaction dev.outlayer.testnet set_event_metadata json-args '{"standard":"near-outlayer-dev"}' prepaid-gas '100.0 Tgas' attached-deposit '0 NEAR' sign-as owner.outlayer.testnet network-config testnet sign-with-keychain send ``` ### Set operator account ``` near contract call-function as-transaction dev.outlayer.testnet set_operator json-args '{"new_operator_id":"dev.outlayer.testnet"}' prepaid-gas '100.0 Tgas' attached-deposit '0 NEAR' sign-as owner.outlayer.testnet network-config testnet sign-with-keychain send ``` ### Set testnet USDC near contract call-function as-transaction dev.outlayer.testnet set_payment_token_contract json-args '{"token_contract":"usdc.fakes.testnet"}' prepaid-gas '100.0 Tgas' attached-deposit '0 NEAR' sign-as owner.outlayer.testnet network-config testnet sign-with-keychain send # register storage near contract call-function as-transaction usdc.fakes.testnet storage_deposit json-args '{"account_id": "dev.outlayer.testnet"}' prepaid-gas '100.0 Tgas' attached-deposit '0.1 NEAR' sign-as dev.outlayer.testnet network-config testnet sign-with-keychain send ### Deploy without init ```bash near contract deploy dev.outlayer.testnet use-file res/local/outlayer_contract.wasm without-init-call network-config testnet sign-with-keychain send ``` ```bash near contract deploy outlayer.testnet use-file res/local/outlayer_contract.wasm without-init-call network-config testnet sign-with-keychain send near contract call-function as-transaction outlayer.testnet migrate json-args {} prepaid-gas '100.0 Tgas' attached-deposit '0 NEAR' sign-as outlayer.testnet network-config testnet sign-with-keychain send ``` # Mainnet ``` near contract deploy outlayer.near use-file res/local/outlayer_contract.wasm without-init-call network-config mainnet sign-with-keychain send near contract call-function as-transaction outlayer.near migrate json-args {} prepaid-gas '100.0 Tgas' attached-deposit '0 NEAR' sign-as outlayer.near network-config mainnet sign-with-keychain send near contract call-function as-transaction outlayer.near new json-args '{"owner_id":"owner.outlayer.near","operator_id":"worker.outlayer.near"}' prepaid-gas '100.0 Tgas' attached-deposit '0 NEAR' sign-as outlayer.near network-config mainnet sign-with-keychain send near contract call-function as-transaction outlayer.near set_payment_token_contract json-args '{"token_contract":"17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1"}' prepaid-gas '100.0 Tgas' attached-deposit '0 NEAR' sign-as owner.outlayer.near network-config mainnet sign-with-keychain send near contract call-function as-transaction 17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1 storage_deposit json-args '{"account_id": "outlayer.near"}' prepaid-gas '100.0 Tgas' attached-deposit '0.1 NEAR' sign-as outlayer.near network-config mainnet sign-with-keychain send ``` ### Test ```bash cargo test ``` ## License MIT --- > Source: https://github.com/out-layer/outlayer/blob/main/sdk/outlayer/README.md # OutLayer SDK Rust SDK for building WASM applications on [OutLayer](https://outlayer.fastnear.com) - verifiable off-chain computation for NEAR. [![Crates.io](https://img.shields.io/crates/v/outlayer.svg)](https://crates.io/crates/outlayer) [![Documentation](https://docs.rs/outlayer/badge.svg)](https://docs.rs/outlayer) ## Installation ```toml [dependencies] outlayer = "0.1" ``` **Requirements:** WASI Preview 2 (`wasm32-wasip2` target) ```bash rustup target add wasm32-wasip2 cargo build --target wasm32-wasip2 --release ``` ## Quick Start ```rust use outlayer::{env, storage}; fn main() { // Get caller info let signer = env::signer_account_id().unwrap_or_default(); // Read input let input = env::input_string().unwrap_or_default(); // Use persistent storage let count = storage::increment("visits", 1).unwrap(); // Output result env::output_json(&serde_json::json!({ "signer": signer, "visits": count, "input": input })).unwrap(); } ``` ## Features ### Environment (`outlayer::env`) Access execution context and I/O: ```rust use outlayer::env; // Get NEAR account info let signer = env::signer_account_id(); // User who signed tx (alice.near) let predecessor = env::predecessor_account_id(); // Contract that called OutLayer let tx_hash = env::transaction_hash(); // Input/Output let input: MyRequest = env::input_json()?.unwrap(); env::output_json(&response)?; // Environment variables (including secrets) let api_key = env::var("OPENAI_API_KEY"); ``` **Available environment variables:** - `NEAR_SENDER_ID` - Account that signed the transaction - `NEAR_PREDECESSOR_ID` - Contract that called OutLayer - `NEAR_TRANSACTION_HASH` - Transaction hash - `USD_PAYMENT` - Attached USD payment (micro-units) - Custom secrets stored via dashboard ### Storage (`outlayer::storage`) Encrypted persistent key-value storage: ```rust use outlayer::storage; // Basic operations storage::set("key", b"value")?; let data = storage::get("key")?; let exists = storage::has("key"); storage::delete("key"); let keys = storage::list_keys("prefix:")?; // Convenience methods storage::set_string("name", "Alice")?; storage::set_json("config", &my_struct)?; let config: Config = storage::get_json("config")?.unwrap(); // Atomic operations (concurrent-safe) storage::increment("counter", 1)?; storage::decrement("stock", 1)?; storage::set_if_absent("init", b"done")?; storage::set_if_equals("balance", &old, &new)?; // Worker-private storage (shared across all users) storage::set_worker("global_state", b"data")?; let state = storage::get_worker("global_state")?; // Public storage (readable by other projects) storage::set_worker_with_options("oracle:ETH", &price, Some(false))?; let price = storage::get_worker_from_project("oracle:ETH", Some("p0000000000000001"))?; ``` **Storage isolation:** - User storage: Isolated per caller (`alice.near` can't read `bob.near`'s data) - Worker storage: Shared across all users, only accessible from WASM - Public storage: Cross-project readable (for oracles, shared configs) ### Version Migration ```rust // Read data from previous WASM version let old_data = storage::get_by_version("key", "abc123...")?; // Clean up old version's data after migration storage::clear_version("abc123...")?; ``` ## Example Project ```toml # Cargo.toml [package] name = "my-outlayer-app" version = "0.1.0" edition = "2021" [[bin]] name = "my-outlayer-app" path = "src/main.rs" [dependencies] outlayer = "0.1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" [profile.release] opt-level = "s" lto = true strip = true ``` ```rust // src/main.rs use outlayer::{env, storage}; use serde::{Deserialize, Serialize}; #[derive(Deserialize)] struct Request { action: String, } #[derive(Serialize)] struct Response { success: bool, message: String, } fn main() { let result = run(); let response = match result { Ok(msg) => Response { success: true, message: msg }, Err(e) => Response { success: false, message: e.to_string() }, }; env::output_json(&response).unwrap(); } fn run() -> Result> { let signer = env::signer_account_id() .ok_or("No signer")?; let request: Request = env::input_json()? .ok_or("No input")?; match request.action.as_str() { "increment" => { let count = storage::increment(&format!("count:{}", signer), 1)?; Ok(format!("Count: {}", count)) } "get" => { let count = storage::get_json::(&format!("count:{}", signer))? .unwrap_or(0); Ok(format!("Count: {}", count)) } _ => Err("Unknown action".into()) } } ``` Build and test: ```bash cargo build --target wasm32-wasip2 --release echo '{"action":"increment"}' | wasmtime target/wasm32-wasip2/release/my-outlayer-app.wasm ``` ## Publishing to crates.io ```bash cd sdk/outlayer # 1. Bump version in Cargo.toml # 0.1.1 -> 0.1.2 (patch: bug fixes) # 0.1.1 -> 0.2.0 (minor: new features like VRF) # 2. Verify it compiles for the target cargo check --target wasm32-wasip2 # 3. Dry-run publish (checks packaging without uploading) cargo publish --dry-run # 4. Publish cargo publish ``` **Before publishing:** - Ensure `version` in `Cargo.toml` is bumped - Ensure `wit/` directory is included (check that `.gitignore` doesn't exclude it) - WIT files (`wit/world.wit`, `wit/deps/*.wit`) must be in the published crate — `wit-bindgen` reads them at compile time **If WIT files are missing from the published crate**, add to `Cargo.toml`: ```toml [package] include = ["src/**/*", "wit/**/*", "Cargo.toml", "README.md", "LICENSE*"] ``` ## Documentation - [OutLayer Docs](https://outlayer.fastnear.com/docs) - Full documentation - [Storage Guide](https://outlayer.fastnear.com/docs/storage) - Persistent storage - [WASI Tutorial](https://github.com/fastnear/near-outlayer/blob/main/wasi-examples/WASI_TUTORIAL.md) - Building WASM apps - [Examples](https://github.com/fastnear/near-outlayer/tree/main/wasi-examples) - Working examples ## License MIT OR Apache-2.0 --- > Source: https://github.com/out-layer/outlayer/blob/main/CUSTODY.md # Agent Custody — Developer Reference Institutional-grade custody wallets for AI agents. An agent gets an API key to operate a NEAR-native wallet whose cross-chain value is custodied on `intents.near`. Private keys live exclusively inside a TEE (Intel TDX). The wallet owner sets policy (spending limits, whitelists, multisig, freeze) — all enforced inside the TEE. Cross-chain deposits/withdrawals via NEAR Intents + the 1Click solver (gasless), like a CEX: deposit, operate, withdraw to an external address. The wallet now signs EVM payloads itself — EIP-712 typed data, EIP-191 `personal_sign`, and raw EVM transactions (the client builds/serializes the unsigned tx and broadcasts; the keystore only keccak256-hashes and signs). Solana signing follows the same model — off-chain messages and serialized transaction messages, ed25519, base58 signature; the client assembles and broadcasts. > **⚠️ Only send whitelisted Intents assets — anything else is lost permanently.** > Deposits/withdrawals only work for assets in the NEAR Intents / 1Click token > catalog (`GET /wallet/v1/tokens`), on the exact chain a deposit address was > issued for. Sending an unsupported token, the wrong token, a token on the > wrong chain, an NFT, or an unlisted native gas coin to a deposit address is > **unrecoverable**. Deposit addresses from > `/wallet/v1/intents/deposit/cross-chain` (legacy alias `/wallet/v1/deposit-intent`) > are per-request and expire (30 min) — never reuse one or send after expiry. --- ## Integrating For most use cases, use the **TypeScript SDK** instead of calling the HTTP API directly: ```bash npm install @outlayer/sdk ``` ```ts import { OutlayerClient } from '@outlayer/sdk'; // 1. Register a wallet (anonymous, returns API key once) const { apiKey, walletId, handoffUrl } = await OutlayerClient.register(); // 2. Use it const client = new OutlayerClient({ apiKey }); const result = await client.withdraw({ chain: 'ethereum', to: '0x742d35Cc6634C0532925a3b844Bc9e7595f8b4f5', amount: '1000000', token: 'nep141:usdt.tether-token.near', }); ``` - **SDK source**: [out-layer/sdk-js](https://github.com/out-layer/sdk-js) (MIT) - **SDK on npm**: [`@outlayer/sdk`](https://www.npmjs.com/package/@outlayer/sdk) - **OpenAPI spec**: [out-layer/api-spec](https://github.com/out-layer/api-spec) - **Interactive API docs**: https://api.outlayer.ai/docs (Scalar UI) The SDK auto-generates types from the OpenAPI spec, adds typed error classes (`PolicyDeniedError`, `WalletFrozenError`, etc.), automatic idempotency keys, and retry with backoff on 5xx + network errors. SDK feature parity with the raw HTTP API; the rest of this document is the reference for both. For other languages, generate a client from the OpenAPI spec: ```bash # Python openapi-python-client generate --url https://api.outlayer.ai/openapi.json # Go oapi-codegen -generate types https://api.outlayer.ai/openapi.json > types.go ``` --- ## Architecture ``` ┌──────────────┐ ┌───────────────┐ │ AI Agent │ │ Wallet Owner │ │ (API key) │ │ (NEAR wallet) │ └──────┬───────┘ └───────┬───────┘ │ withdraw, call, address │ set policy, freeze ▼ ▼ ┌─────────────────────────────────────────────────┐ │ Coordinator (stateless proxy) │ │ auth API key → forward to keystore → track DB │ └──────────────────────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────┐ │ TEE (Intel TDX) │ │ │ │ ┌────────────┐ ┌───────────┐ ┌─────────────┐ │ │ │ Key Derivat│ │ Tx Signing│ │ Policy Eval │ │ │ │ HMAC-SHA256│ │ Ed25519 / │ │ Decrypt from│ │ │ │ from MPC │ │ secp256k1 │ │ chain, check│ │ │ │ master key │ │ │ │ all rules │ │ │ └────────────┘ └───────────┘ └─────────────┘ │ └──────────────────────┬──────────────────────────┘ │ submit signed tx / read policy ▼ ┌──────────────────┐ ┌─────────────────────────┐ │ NEAR Blockchain │ │ NEAR Intents │ │ policy storage │ │ gasless cross-chain │ │ freeze/unfreeze │ │ NEAR, ETH, BTC, SOL │ └──────────────────┘ └─────────────────────────┘ ``` **Coordinator is a stateless proxy.** It authenticates API keys, forwards requests to the keystore TEE, and tracks operational data in PostgreSQL. All security-critical work (key derivation, signing, policy evaluation) happens inside the TEE. --- ## Components & Key Files ### Coordinator — `coordinator/src/wallet/` HTTP API server. Handles auth, routing, usage tracking, webhooks. | File | Lines | Description | |------|-------|-------------| | [mod.rs](coordinator/src/wallet/mod.rs) | 124 | Router setup, `WalletState` struct, negative policy cache | | [handlers.rs](coordinator/src/wallet/handlers.rs) | 3,425 | All HTTP endpoint handlers | | [auth.rs](coordinator/src/wallet/auth.rs) | 819 | API key authentication (SHA-256 hash lookup) | | [types.rs](coordinator/src/wallet/types.rs) | 659 | Request/response structs, error types | | [policy.rs](coordinator/src/wallet/policy.rs) | 460 | Policy caching, NEAR RPC `has_wallet_policy()` calls | | [backend/mod.rs](coordinator/src/wallet/backend/mod.rs) | — | `WalletBackend` trait + 1Click API types | | [backend/intents.rs](coordinator/src/wallet/backend/intents.rs) | — | 1Click REST API (swap quotes, status polling), token list | | [audit.rs](coordinator/src/wallet/audit.rs) | 76 | Audit log recording | | [webhooks.rs](coordinator/src/wallet/webhooks.rs) | 278 | Webhook delivery with retry + HMAC-SHA256 | | [idempotency.rs](coordinator/src/wallet/idempotency.rs) | 38 | Idempotency key check/store | | [nonce.rs](coordinator/src/wallet/nonce.rs) | 74 | Per-wallet nonce mutex for concurrent withdrawals | #### Key handler functions (handlers.rs) | Function | Line | Description | |----------|------|-------------| | `register()` | 36 | Generate UUID wallet_id + API key → call keystore TEE to derive NEAR address | | `withdraw()` | — | Build `Op::Withdraw` → check-policy → keystore `/wallet/sign` (gasless `native_withdraw`/`ft_withdraw` intent) → publish to solver relay → record usage after success | | `withdraw_dry_run()` | 755 | Simulate withdraw: policy + balance check without execution | | `call()` | 2186 | Native NEAR function call: policy check → keystore sign → broadcast | | `transfer()` | 2621 | Chain-agnostic transfer (chain param, currently near only): policy → keystore sign → broadcast | | `get_balance()` | 2983 | Chain-agnostic balance query (chain param, currently near only) via RPC | | `intents_deposit()` | — | Deposit FT into intents.near via `ft_transfer_call` (intents.near auto-registers callers via its own `ft_on_transfer` hook — no NEP-145 `storage_deposit` issued) | | `swap()` | — | Swap via 1Click: quote → ft_transfer_call to intents.near → mt_transfer → poll | | `deposit()` | 857 | Cross-chain deposit via Intents quote | | `get_address()` | 345 | Derive wallet address. Serves **`near` + all EVM chains + `solana`** (EVM chains share **one secp256k1 `0x` address**; `solana`/`sol` returns the base58 ed25519 pubkey). `bitcoin` stays gated (no signing path yet; that cross-chain value uses Intents). | | `encrypt_policy()` | 1155 | Send policy JSON to keystore for encryption | | `sign_policy()` | 1199 | Keystore signs encrypted policy SHA256 for on-chain verification | | `approve()` | 1447 | Submit multisig approval (NEP-413 signature verification) | | `reject()` | 1714 | Reject pending approval | | `get_policy()` | 1284 | Fetch decrypted policy from keystore | | `record_usage()` | 263 | Write spending to `wallet_usage` (daily/hourly/monthly periods) | | `get_current_usage()` | 298 | Read current usage for velocity limit checks | | `internal_wallet_check()` | 2623 | Worker-only: check policy for WASI execution | | `internal_activate_policy()` | 2873 | Worker-only: activate policy after on-chain signing | | `internal_wallet_frozen_change()` | 3106 | Sync freeze status from contract events | ### Keystore TEE — `keystore-worker/src/` Runs inside Intel TDX. Holds master secret from NEAR MPC. All crypto happens here. | File | Key area | Description | |------|----------|-------------| | [api.rs](https://github.com/out-layer/outlayer/blob/main/keystore-worker/src/api.rs) | Wallet routes | Router for `/wallet/*` (coordinator-token-only) endpoints | | [api.rs](https://github.com/out-layer/outlayer/blob/main/keystore-worker/src/api.rs) | `wallet_derive_address_handler` | Derive pubkey from seed `"wallet:{wallet_id}:{chain}"` | | [api.rs](https://github.com/out-layer/outlayer/blob/main/keystore-worker/src/api.rs) | `wallet_sign_handler` | **Single** signing entry point. Takes a canonical `op` (+ optional `approval_info`, `artifact` carrying `bytes_base64`/`message`/`nonce_base64`/`recipient`, and `usage`); derives `request_hash = sha256(canonical_json(op))`, evaluates the on-chain policy, verifies approver signatures when required, then produces the artifact per the op's bind mode (Built / Hash-pinned / Trusted). Replaces the old per-flow sign endpoints (transaction / nep413 / near-call / near-transfer). | | [api.rs](https://github.com/out-layer/outlayer/blob/main/keystore-worker/src/api.rs) | `wallet_sign_policy_handler` | Sign an encrypted policy blob: decrypt-validates the ciphertext, then signs `sha256(encrypted_data)` (rejects a caller-supplied raw hash — not a signing oracle) | | [api.rs](https://github.com/out-layer/outlayer/blob/main/keystore-worker/src/api.rs) | `wallet_check_policy_handler` | Pre-flight: decrypt policy from chain → `evaluate(policy, op, usage, now)` → return `{allowed, frozen, requires_approval, required_approvals, reason, request_hash}` (the decrypted policy never leaves the TEE) | | [crypto.rs](https://github.com/out-layer/outlayer/blob/main/keystore-worker/src/crypto.rs) | `derive_keypair()` | `HMAC-SHA256(master_secret, seed)` → Ed25519 keypair | The keystore exposes exactly one signing endpoint, `POST /wallet/sign`. The OLD five separate sign endpoints (`/wallet/sign-transaction`, `/wallet/sign-nep413`, `/wallet/sign-near-call`, `/wallet/sign-near-transfer`, and the policy-hash signer used as a raw oracle) are **removed**. OutLayer's own Bearer/register/api-key authentication (previously `sign-message` with `format:"raw"`) is now a dedicated coordinator endpoint, `POST /wallet/v1/auth-sign`, which maps to an `Op::Auth` the keystore constructs and signs raw ed25519. #### Canonical `op` model Every signable operation is a canonical `op` with `request_hash = sha256(canonical_json(op))` (a recursive-key-sorted, compact JSON; amounts are decimal strings, never JSON numbers — so the hash reproduces even for `call.args`). The kind fixes the **bind mode** — how the keystore is allowed to produce the artifact it signs: | Bind mode | Kinds | Keystore behavior | |-----------|-------|-------------------| | **Built** | `transfer`, `call`, `delete`, `withdraw` (+ `auth`) | Constructs the NEAR tx / NEP-413 intent / auth string FROM the op fields → artifact == approved op | | **Hash-pinned** | `raw`, `sign_message` | Op carries `payload_hash`/`message_hash`; signs the supplied bytes iff `sha256(bytes) == hash` | | **Trusted** | `swap`, `confidential`, `cross_chain_withdraw`, `payment_check` | Artifact (e.g. the 1Click quote / deposit address) can't exist at approval time; the keystore checks capability + policy + multisig on the op fields and pins the recipient, then signs the supplied artifact, **trusting the coordinator to have built it from the approved op**. The keystore does NOT itself re-verify the artifact's token/amount against the op — those are bound only by that coordinator-trust, the same trust as the coordinator-supplied off-chain deposit address (documented tradeoff) | The deposit family (`intents/deposit`, `storage-deposit`, cross-chain deposit) is all `Op::Call` — there is no finer deposit policy type. `auth` is non-fund (a domain-separated `auth:`/`register:`/`api-key:` string, never a 32-byte tx hash), so it is always allowed on a non-frozen wallet — no capability, no multisig. #### Key derivation ``` master_secret (from NEAR MPC network, never leaves TEE) │ ├── seed: "wallet:{wallet_id}:near" → Ed25519 → NEAR implicit account ├── seed: "wallet:{wallet_id}:evm" → secp256k1 → ETH address (shared by all EVM chains) ├── seed: "wallet:{wallet_id}:solana" → Ed25519 → Solana address └── seed: "wallet:{wallet_id}:bitcoin" → secp256k1 → BTC address ``` Same wallet_id always produces same addresses across chains. Deterministic, stateless. > The keystore *can* derive and sign for all of the above (secp256k1 for EVM, > Ed25519 for NEAR/Solana). The public `GET /wallet/v1/address` endpoint now > serves **NEAR + all EVM chains + Solana** — every EVM chain returns the same > shared secp256k1 `0x` address; Solana returns the base58 ed25519 public key. > The keystore signs EVM payloads (EIP-712 / EIP-191 / raw tx) and Solana > payloads (off-chain messages / serialized tx messages, ed25519); the > **client** assembles and broadcasts the transaction (the coordinator/keystore > never build, fund, or broadcast one). Cross-chain value movement also still > works through NEAR Intents + the 1Click solver. See > [coordinator `docs/MULTI_CHAIN.md`](https://github.com/out-layer/coordinator/blob/main/docs/MULTI_CHAIN.md). #### Policy evaluation flow (inside TEE) One engine (`shared_tee_helpers::wallet_policy::evaluate`) is shared by check-policy and `/wallet/sign`. **The keystore is the sole evaluator** — only it can decrypt the on-chain policy, so the plaintext policy never leaves the TEE. The coordinator only *supplies* `usage` (it owns the stateful spend counters). 1. Coordinator sends `POST /wallet/check-policy { wallet_id, op, usage? }` (the same call shape `/wallet/sign` takes; `usage` is the coordinator's `current_usage` JSON, optional) 2. Keystore calls `get_wallet_policy(wallet_pubkey)` view method on NEAR (O(1) lookup) 3. Keystore decrypts `encrypted_data` with the derived key 4. `evaluate(policy, op, usage, now)` checks, in order: frozen → `transaction_types` → `allowed_tokens` → whitelist/blacklist → per-tx limit → **velocity limits (only when `usage` is supplied)** → time restrictions → capabilities → generic multisig trigger 5. Returns `Decision`: `Allow`, `Deny { reason }`, `RequiresApproval { threshold }`, `Frozen` **Stateless vs stateful.** The stateless clauses (frozen, transaction_types, allowed_tokens, whitelist, per-transaction, time, capabilities, the multisig trigger) are enforced *exactly* on every signature, with or without `usage`. The cumulative clauses (`daily`/`hourly`/`monthly` spend and the hourly tx-count / `rate_limit`) are stateful: they run only when the coordinator supplies `usage`, and a token's spend is recorded (+1) only after a successful operation. Under concurrency they are therefore **best-effort** — simultaneous requests can read the same pre-spend counter and all pass, so a burst can overshoot a cumulative cap. For hard stops, rely on the per-transaction limit, multisig, or freeze. ### Contract — `contract/src/wallet.rs` On-chain storage for encrypted policies and freeze flags. | Function | Line | Description | |----------|------|-------------| | `store_wallet_policy()` | 164 | Store encrypted policy + verify wallet signature on-chain | | `freeze_wallet()` | 280 | Controller-only emergency freeze (no wallet sig needed) | | `unfreeze_wallet()` | 313 | Controller-only unfreeze | | `delete_wallet_policy()` | 344 | Delete policy, refund storage deposit | | `has_wallet_policy()` | 387 | View: check existence (for negative cache) | | `get_wallet_policy()` | 394 | View: return `{ owner, encrypted_data, frozen, updated_at }` | ```rust pub struct WalletPolicyEntry { pub owner: AccountId, // Controller NEAR account pub encrypted_data: String, // Encrypted by keystore TEE pub frozen: bool, // Emergency freeze (separate from encrypted_data) pub updated_at: u64, // Block timestamp pub storage_deposit: Balance, // Refundable } ``` **Ownership**: First `store_wallet_policy()` call sets `owner = caller`. Subsequent updates only from same owner. Wallet signature required (anti-spam + proof of key ownership). **On-chain signature verification**: Ed25519 → `env::ed25519_verify()` (~26 Tgas), secp256k1 → `env::ecrecover()` (~35 Tgas). ### Worker WASI host functions — `worker/src/outlayer_wallet/` WASI containers can call wallet functions via WIT interface. | File | Description | |------|-------------| | [host_functions.rs](https://github.com/out-layer/outlayer/blob/main/worker/src/outlayer_wallet/host_functions.rs) | WIT interface implementation (9,316 lines) | | [mod.rs](https://github.com/out-layer/outlayer/blob/main/worker/src/outlayer_wallet/mod.rs) | Module setup & linker bindings | | [wallet.wit](https://github.com/out-layer/outlayer/blob/main/worker/wit/deps/wallet.wit) | WIT interface definition | **WIT interface** (`outlayer:wallet/api@0.1.0`): ```wit get-id() → (string, string) get-address(chain) → (string, string) # currently: near only withdraw(chain, to, amount, token) → (string, string) # cross-chain via Intents (whitelisted assets only) withdraw-dry-run(chain, to, amount, token) → (string, string) get-request-status(request-id) → (string, string) list-tokens() → (string, string) transfer(chain, to, amount) → (string, string) # chain-specific (currently: near) get-balance(chain, token) → (string, string) # chain-specific (currently: near) intents-deposit(token, amount) → (string, string) # deposit FT to intents.near swap(token-in, token-out, amount-in, min-amount-out) → (string, string) # swap via Intents ``` Available only when `WALLET_ID` env var is set (coordinator passes it when `X-Wallet-Id` header is valid). Rate limited to 50 calls per execution. ### Dashboard — `dashboard/app/wallet/` | Page | File | Description | |------|------|-------------| | Handoff/setup | [page.tsx](https://github.com/out-layer/outlayer/blob/main/dashboard/app/wallet/page.tsx) | Receive API key, connect NEAR wallet, set initial policy | | Policy management | [manage/page.tsx](https://github.com/out-layer/outlayer/blob/main/dashboard/app/wallet/manage/page.tsx) | Edit policy, manage approvers, freeze/unfreeze | | Approvals list | [approvals/page.tsx](https://github.com/out-layer/outlayer/blob/main/dashboard/app/wallet/approvals/page.tsx) | List pending multisig approvals | | Approval detail | [approvals/[id]/page.tsx](https://github.com/out-layer/outlayer/blob/main/dashboard/app/wallet/approvals/[id]/page.tsx) | View & sign specific approval | | Audit log | [audit/page.tsx](https://github.com/out-layer/outlayer/blob/main/dashboard/app/wallet/audit/page.tsx) | Full transaction and event history | | Fund request | [fund/page.tsx](https://github.com/out-layer/outlayer/blob/main/dashboard/app/wallet/fund/page.tsx) | User funds agent via link (?to, ?amount, ?token) | ### Documentation page | File | Description | |------|-------------| | [docs/agent-custody/page.tsx](https://github.com/out-layer/outlayer/blob/main/dashboard/app/docs/agent-custody/page.tsx) | User-facing docs page | --- ## Database Schema Migrations: `coordinator/migrations/20260220000001_wallet.sql`, `20260220000002_wallet_policy_columns.sql` | Table | Purpose | |-------|---------| | `wallet_accounts` | wallet_id, near_pubkey, policy_json (synced), frozen flag | | `wallet_api_keys` | SHA-256 hash of API key → wallet_id mapping | | `wallet_requests` | Async operation tracking (withdraw, deposit, call) | | `wallet_pending_approvals` | Multisig approval state machine | | `wallet_approval_signatures` | Individual approver signatures | | `wallet_usage` | Per-token per-period spending (hourly/daily/monthly) | | `wallet_audit_log` | Complete event history | | `wallet_webhook_deliveries` | Webhook retry queue | **Note**: `wallet_usage` is in the coordinator DB, not on-chain. If DB is compromised, velocity limits could be reset. Mitigation: per-tx limits and whitelists are checked in keystore TEE (not bypassable), and audit log records all operations. --- ## Flows ### Registration ``` Agent → POST /register → Coordinator Coordinator: 1. Generate UUID wallet_id 2. Generate random API key (wk_...) 3. Store SHA-256(api_key) → wallet_id in DB 4. Call keystore POST /wallet/derive-address { wallet_id, chain: "near" } Keystore TEE: 5. HMAC-SHA256(master_secret, "wallet:{wallet_id}:near") → Ed25519 keypair 6. Return { address, public_key } Coordinator: 7. Return { api_key, near_account_id, handoff_url } ``` No blockchain transaction. Instant. API key shown once. ### Withdraw (with policy) Withdraws tokens from the wallet's intents.near balance to a receiver as a **gasless** NEP-413 `withdraw` intent published to the solver relay (`native_withdraw` for native NEAR, `ft_withdraw` for NEP-141). The wallet pays no NEAR gas. (The old direct on-chain `/intents/ft-withdraw` endpoint — which gated as a `call` and lost the amount/token limits — is **removed**; all same-chain FT withdrawals go through this path.) ``` Agent → POST /wallet/v1/intents/withdraw { to, amount, chain, token } with Authorization: Bearer wk_... Coordinator: 1. Lookup wallet_id from SHA-256(api_key) 2. Check idempotency key 3. Build canonical op: Op::Withdraw { to, amount, token } 4. Get current_usage from wallet_usage table 5. Call keystore POST /wallet/check-policy { wallet_id, op, usage } Keystore TEE: 6. get_wallet_policy(wallet_pubkey) via NEAR RPC 7. Decrypt policy → evaluate(policy, op, usage, now) 8. Return decision: Allow / Deny / RequiresApproval / Frozen Coordinator (if Allow): 9. Call keystore POST /wallet/sign { wallet_id, op, usage } Keystore TEE: 10. Re-derive request_hash, re-run policy, then BUILD the NEP-413 native_withdraw / ft_withdraw intent from the op and sign it Coordinator: 11. publish_intent to the solver relay (gasless) 12. record_usage() → wallet_usage table (only AFTER a successful sign+submit) 13. Create wallet_requests entry → return { request_id, status } (with result_data: { intent_hash, delivered }) 14. Record audit log 15. Enqueue webhook if configured ``` **Usage is recorded only after a successful operation** (`record_usage()` runs post-settle, never on create-pending or failure). This keeps the velocity counters honest while still bounding each op by the exact (stateless) per-transaction limit, whitelist, and capability checks inside the TEE. **Token options for `chain=near`** — the `token` field selects what the recipient receives: | `token` | Recipient receives | Notes | |---------|--------------------|-------| | omitted / `"near"` / `"native"` | **native NEAR** (default) | intents.near unwraps the wallet's wNEAR and sends native NEAR via the `native_withdraw` intent. Gasless; recipient needs **no** `wrap.near` storage. The recipient account must already exist (or be a 64-char implicit account) — a `native_withdraw` to a non-existent named account burns the wNEAR and is rejected up front. | | `"nep141:wrap.near"` (or `"wrap.near"`) | **wNEAR** (NEP-141) | Explicit opt-in. Recipient must be storage-registered on `wrap.near` (`POST /wallet/v1/storage-deposit`). | | other `nep141:` | that NEP-141 | Recipient must be storage-registered on that token. | This solves the "wallet holds only wNEAR, 0 native NEAR" case: it can withdraw native NEAR for gas/staking without first unwrapping. For cross-chain (`chain=ethereum`, etc.) the `token` is the source Intents asset and 1Click delivers the destination chain's native asset. ### Policy Setup ``` Dashboard → POST /wallet/v1/encrypt-policy { rules, approval, ... } Coordinator → Keystore: encrypt policy JSON Keystore → Return encrypted_base64 Dashboard → POST /wallet/v1/sign-policy { encrypted_data } Coordinator → Keystore: sign SHA256(encrypted_data) with wallet key Keystore → Return { signature, wallet_pubkey } Dashboard → NEAR tx: store_wallet_policy(wallet_pubkey, encrypted_base64, signature) Contract: verify signature on-chain → store WalletPolicyEntry Dashboard → POST /wallet/v1/invalidate-cache { wallet_id } Coordinator: clear negative policy cache ``` ### Freeze (Emergency) ``` Wallet Owner → NEAR tx: freeze_wallet(wallet_pubkey) Contract: check caller == entry.owner → set frozen = true Any subsequent wallet operation: Keystore reads fresh policy → sees frozen == true → rejects ``` No API gateway involvement needed. Owner can freeze directly on-chain. Latency: 2-5 seconds (blockchain confirmation). ### Multisig Approval ``` Agent → POST /wallet/v1/intents/withdraw { amount > threshold } Policy check → RequiresApproval(2 of 3) Create wallet_pending_approvals entry Return { status: "pending_approval", approval_id, required: 2 } Approver 1 → POST /wallet/v1/approve/{approval_id} with NEP-413 wallet signature Store in wallet_approval_signatures → approved: 1/2 Approver 2 → POST /wallet/v1/approve/{approval_id} Store signature → threshold met Auto-execute: sign tx → submit via intents → update request status Enqueue webhook: request_completed ``` Approvers sign `approve:{approval_id}:{wallet_pubkey}:{request_hash}` (NEP-413, recipient == the wallet contract, wallet-bound); a `reject:` vote from a real approver vetoes. The keystore re-derives `request_hash` from the stored canonical op and verifies the signatures itself — the coordinator transports them but cannot forge or rebind them. **Multisig covers Trusted ops too.** On a wallet with an approval threshold, the Trusted kinds — `swap`, `confidential`, `cross_chain_withdraw` — also create a pending approval and execute only after the approvers confirm. What the approval actually binds is narrow: it binds *whether* the op runs (the keystore verifies the approver signatures over the canonical op, and pins the recipient). It does **not** bind the token/amount through the keystore — at execution the coordinator fetches the 1Click artifact (quote → deposit address) and the keystore signs it **without re-verifying** the artifact's `token_in`/`amount_in` against the approved op. The artifact matching the op is enforced only by trusting the coordinator to have built it from that op — the **same coordinator-trust** as the off-chain deposit address (generated at execution, coordinator-supplied, and not independently verifiable by the keystore — the 1Click quote signature does not cover it). So: a compromised coordinator could substitute the artifact's token/amount/routing post-approval; the on-chain guarantees are the recipient pin + the approver signatures, not the value terms — a documented tradeoff. `payment_check` is the **exception**: it is NOT wired into the generic multisig trigger — its creation is gated by the default-DENY `payment_check` capability + the per-transaction amount cap (cap-gated, not approval-gated, even on a multisig wallet). --- ## Policy Format Stored encrypted on NEAR blockchain. Only keystore TEE can decrypt. ```json { "version": 1, "frozen": false, "rules": { "transaction_types": ["transfer", "call", "withdraw", "swap", "delete"], "allowed_tokens": ["*"], "addresses": { "mode": "whitelist", "list": ["bob.near", "dex.near"] }, "limits": { "per_transaction": { "native": "10000000000000000000000000", "nep141:usdt.tether-token.near": "1000000000" }, "daily": { "*": "100000000000000000000000000" }, "hourly": { "*": "50000000000000000000000000" }, "monthly": { "*": "500000000000000000000000000" } }, "time_restrictions": { "timezone": "UTC", "allowed_hours": [9, 17], "allowed_days": [1, 2, 3, 4, 5] }, "rate_limit": { "max_per_hour": 60 } }, "approval": { "threshold": { "required": 2 }, "approvers": [ { "id": "alice.near", "role": "admin", "pubkey": "ed25519:" }, { "id": "bob.near", "role": "signer", "pubkey": "ed25519:" }, { "id": "carol.near", "role": "signer", "pubkey": "ed25519:" } ], "excluded_types": [] }, "capabilities": { "raw_sign": { "allowed": false, "chains": ["ethereum", "solana"], "requires_approval": true }, "evm_sign": { "allowed": true, "raw_tx": false }, "solana_sign": { "allowed": false, "raw_tx": false }, "confidential": { "allowed": false, "requires_approval": false }, "sign_message": { "allowed": true, "requires_approval": false, "allowed_recipients": [] }, "swap": { "allowed": false, "requires_approval": false }, "cross_chain_withdraw": { "allowed": false, "requires_approval": false }, "payment_check": { "allowed": false, "requires_approval": false } }, "webhook_url": "https://myapp.com/webhook/wallet" } ``` ### `transaction_types` — the keystore op kinds `transfer`, `call`, `delete`, `withdraw` (same-chain intents withdrawal), `swap`, `cross_chain_withdraw`, `raw`, `sign_message`. The deposit family (`intents/deposit`, `storage-deposit`, cross-chain deposit) all gate as **`call`** — there is no separate deposit type. Legacy deposit names (`intents_deposit`/`storage_deposit`/`cross_chain_deposit`) in a deployed policy are normalized to `call` so old policies keep matching. Note `cross_chain_withdraw` is its **own** type (NOT folded into `withdraw`) — a policy must list it explicitly to permit bridging out. ### Roles | Role | Approve transactions | Modify policy | Freeze wallet | |------|---------------------|---------------|---------------| | admin | Yes | Yes (quorum) | Yes | | signer | Yes | No | No | ### Limits — `"*"` = wildcard for all tokens - `per_transaction` — max amount per single tx (STATELESS, enforced in the TEE on every signature) - `hourly` / `daily` / `monthly` — velocity limits (STATEFUL — checked against the coordinator-supplied `usage`; best-effort under concurrency) - `rate_limit.max_per_hour` — max number of transactions per hour (STATEFUL) ### Capabilities — default-DENY opt-ins for the non-Built primitives All capabilities default to **DENY** under a policy except `sign_message` (default-allow). Under a policy a wallet must explicitly enable each of the rest (a wallet with **no policy** is unrestricted): - `raw_sign` — sign arbitrary raw bytes. `chains` is an optional allowlist (absent = all chains **including `near`**, which can sign a NEAR tx/intent outside the structured policy — by design; warn before enabling). With no on-chain policy at all, raw is permitted (permissionless start). - `evm_sign` — sign EVM payloads (EIP-712 typed data, EIP-191 `personal_sign`, raw EVM tx). **DEFAULT-DENY** under a policy, like the other fund-moving capabilities — set `evm_sign.allowed: true` to permit (the dashboard writes this when its EVM-signing box is checked). Carries a `raw_tx` sub-flag that is **DEFAULT-OFF**: with `allowed:true`, typed-data and message signing work, but signing a raw EVM transaction additionally requires `raw_tx: true`. `requires_approval` is **NOT supported** for `evm_sign`. CAVEAT (why it's opt-in): an EIP-712 signature is itself fund-moving (EIP-3009 ≈ transfer, EIP-2612 ≈ approve), so `evm_sign` grants full authority over the EVM address's float — bounded to what is bridged onto that address; the NEAR-intents balance is never exposed through it. - `solana_sign` — sign Solana payloads (off-chain messages, serialized transaction messages). Same model as `evm_sign`: **DEFAULT-DENY** under a policy, `raw_tx` sub-flag **DEFAULT-OFF** gating transaction signing, `requires_approval` NOT supported. The message endpoint signs raw bytes (nacl/SIWS-verifiable) but **rejects** bytes that parse as a valid Solana transaction message — Solana has no EIP-191-style prefix, so without this guard a "message" could be a broadcastable transaction bypassing `raw_tx` (same protection Phantom/Solflare apply). A signed transaction message is fund-moving: `solana_sign` + `raw_tx` grants full authority over the Solana address's float; the NEAR-intents balance is never exposed through it. - `confidential` — the confidential-intents flows (Trusted). - `sign_message` — generic non-fund NEP-413 (e.g. dApp login). `allowed_recipients` is a default-DENY allowlist of verifier recipients (NOT a blocklist); `intents.near`/`intents.far` are always excluded. This is NOT OutLayer auth (that is `/wallet/v1/auth-sign`). - `swap` — 1Click swap (Trusted). Default-DENY even when `transaction_types` is absent. - `cross_chain_withdraw` — 1Click swap+bridge exit (Trusted, irreversible). Default-DENY; pairs with the `cross_chain_withdraw` type + the `to` whitelist + amount limit. - `payment_check` — claimable-link escrow (Trusted, whitelist-BYPASS: funds reach an arbitrary holder via the link). Default-DENY; gated by this capability + the per-transaction amount cap. Each capability also honors `requires_approval` (opt-in multisig for that primitive specifically). `approval.threshold` is either a bare number or `{ "required": N }`; `approval.approvers[].id` is a NEAR account id (with the on-chain `pubkey` pinned); `approval.excluded_types` lists op types exempt from the generic approval trigger. --- ## API Endpoints Base: `https://api.outlayer.ai` (mainnet) · `https://testnet-api.outlayer.ai` (testnet) > **NEAR Intents are mainnet-only.** There are no testnet Intents solvers, so on testnet the > coordinator returns **HTTP 503** for every intents-dependent endpoint — the whole > `/wallet/v1/intents/*` family (deposit, withdraw, swap, cross-chain deposit, payment-check) **and** > all `/wallet/v1/confidential/*` routes. The non-intents surface (address, balance, `transfer`, > `call`, `sign-message`, `auth-sign`, the `/wallet/v1/evm/*` signers — pure crypto, no intents — > policy, approvals, delete) works on both networks. ### Public | Method | Path | Description | |--------|------|-------------| | POST | `/register` | Create wallet, returns API key (one-time) | ### Authenticated (Bearer API key) | Method | Path | Description | |--------|------|-------------| | GET | `/wallet/v1/address?chain={chain}` | Derive address — `near` + all EVM chains (one shared secp256k1 `0x` address) + `solana` (base58 ed25519); `bitcoin` gated | | POST | `/wallet/v1/intents/withdraw` | Withdraw / cross-chain transfer | | POST | `/wallet/v1/intents/withdraw/dry-run` | Simulate withdrawal (policy + balance check) | | POST | `/wallet/v1/call` | Native NEAR contract call | | POST | `/wallet/v1/transfer` | Chain-agnostic transfer (`chain` param, currently near) | | GET | `/wallet/v1/balance?chain={chain}&token={token}` | Chain-agnostic balance (defaults to near) | | POST | `/wallet/v1/intents/deposit` | Deposit FT into intents.near (for manual intents operations) | | POST | `/wallet/v1/intents/swap` | Swap via 1Click: quote → deposit to intents.near → mt_transfer → poll | | POST | `/wallet/v1/intents/deposit/cross-chain` | Cross-chain deposit (via 1Click / NEAR Intents; `source_asset` or `chain`+`token` shape). Legacy alias `/wallet/v1/deposit-intent`, still works | | GET | `/wallet/v1/intents/deposit/cross-chain/status?id={intent_id}` | Poll a cross-chain deposit's status. Legacy alias `/wallet/v1/deposit-status`, still works | | GET | `/wallet/v1/intents/deposit/cross-chain/list` | List this wallet's cross-chain deposits. Legacy alias `/wallet/v1/deposits`, still works | | POST | `/wallet/v1/confidential/shield` | SHIELD: public intents → confidential shard (503 if not enabled). Legacy alias `/wallet/v1/confidential/deposit`, still works | | POST | `/wallet/v1/confidential/unshield` | Confidential → public intents | | POST | `/wallet/v1/confidential/withdraw` | Confidential → external chain (or `chain="near"` for **native NEAR** delivery via `intents.near native_withdraw`) | | POST | `/wallet/v1/confidential/withdraw/dry-run` | Quote a confidential withdraw | | POST | `/wallet/v1/confidential/transfer` | Private confidential → confidential transfer | | POST | `/wallet/v1/confidential/swap` | Confidential swap (distinct assets) | | POST | `/wallet/v1/confidential/swap/quote` | Quote a confidential swap | | POST | `/wallet/v1/confidential/deposit/cross-chain` | Cross-chain deposit into confidential (via 1Click / NEAR Intents). Legacy alias `/wallet/v1/confidential/deposit-intent`, still works | | GET | `/wallet/v1/confidential/balance` | Read confidential balances (private shard `intents.far`, no public RPC) | | GET | `/wallet/v1/requests/{id}` | Poll async operation status | | GET | `/wallet/v1/requests` | List operations (filter: type, status, limit) | | GET | `/wallet/v1/tokens` | List available tokens (Intents proxy) | | POST | `/wallet/v1/sign-message` | Generic NEP-413 message signing (recipient default-DENY allowlist; `intents.*` excluded). `format:"raw"` is **gone** — use `/auth-sign` | | POST | `/wallet/v1/evm/sign-typed-data` | Sign EIP-712 typed data (v4). 65-byte `0x r‖s‖v` sig, `v∈{27,28}`, low-s. Gated by `evm_sign` capability | | POST | `/wallet/v1/evm/sign-message` | Sign EIP-191 `personal_sign` (this is **different** from the NEP-413 `/wallet/v1/sign-message` above). Gated by `evm_sign` | | POST | `/wallet/v1/evm/sign-transaction` | Sign a raw EVM tx — **client serializes the unsigned tx**, keystore keccak256-hashes + signs (no assembly/nonce/gas/broadcast; `yParity = v − 27` for EIP-1559). Gated by `evm_sign` + the `raw_tx` sub-flag | | POST | `/wallet/v1/solana/sign-message` | Sign raw Solana message bytes (ed25519, base58 sig; `encoding: utf8\|hex\|base64`). Rejects bytes that parse as a valid tx message. Gated by `solana_sign` capability | | POST | `/wallet/v1/solana/sign-transaction` | Sign a Solana tx **message** — **client serializes** (web3.js `tx.serializeMessage()`, base64, ≤1232 bytes), keystore ed25519-signs the bytes as-is (no assembly/blockhash/broadcast). Gated by `solana_sign` + the `raw_tx` sub-flag | | POST | `/wallet/v1/auth-sign` | OutLayer NEAR-key auth signature (`{purpose: bearer\|register\|api-key, seed, vault_id?}` → `{auth_message, auth_timestamp, signature, public_key}`). Replaces the old `sign-message format:"raw"` | | GET | `/wallet/v1/policy` | View current policy (decrypted via keystore) | | POST | `/wallet/v1/encrypt-policy` | Encrypt policy for on-chain storage | | POST | `/wallet/v1/sign-policy` | Keystore signs encrypted policy SHA256 | | POST | `/wallet/v1/invalidate-cache` | Clear negative policy cache | | GET | `/wallet/v1/pending_approvals` | List pending multisig approvals | | POST | `/wallet/v1/approve/{id}` | Submit multisig approval signature | | POST | `/wallet/v1/reject/{id}` | Reject pending approval | | GET | `/wallet/v1/audit` | Full event history | ### Internal (worker network only) | Method | Path | Description | |--------|------|-------------| | POST | `/internal/wallet-check` | Policy check for WASI execution | | POST | `/internal/wallet-audit` | Record audit event from WASI | --- ## Confidential Intents > **Building an agent?** See the integration guide > [`CONFIDENTIAL_INTENTS.md`](https://github.com/out-layer/coordinator/blob/main/docs/CONFIDENTIAL_INTENTS.md) > in the coordinator repo — it covers the mental model (private on-chain shard, > same-wallet identity, what privacy you actually get) + all methods, written > for agent developers. This section is the operator/architecture summary. The `/wallet/v1/confidential/*` routes mirror `/wallet/v1/intents/*` but operate on the Defuse **confidential** shard — a separate PRIVATE shard (the `intents.far` contract), distinct from public `intents.near`. Disabled by default — gated by `ENABLE_CONFIDENTIAL_INTENTS` plus a **separate** Defuse partner agreement (`ONECLICK_CONFIDENTIAL_BASE_URL` + `ONECLICK_CONFIDENTIAL_JWT`, which **must differ** from the public `ONECLICK_JWT`). When unconfigured, every confidential route returns **HTTP 503** `service_unavailable`. Pipeline per op: NEP-413 challenge → per-account JWT (cached in Redis `wallet:{id}:cfjwt`, 14 min) → 1Click quote → generate-intent → sign via keystore → submit-intent. Ops are async; status is refreshed on read of `GET /wallet/v1/requests/{id}` until terminal. **Privacy** (must be disclosed to users): - Confidential balances are **real on-chain state** on the private `intents.far` shard — not off-chain, not a solver database. The privacy is that this shard has **no public RPC**: you cannot read it (verified — `intents.far` resolves to `UNKNOWN_ACCOUNT` on public mainnet RPC). It is an auditable smart contract: the operator/Defuse, auditors, or law enforcement with a warrant CAN read it. - Internal moves (confidential transfer/swap) leave **no public-chain trace** — they settle on the private shard. Only the edges touch the public chain. - **SHIELD/UNSHIELD link the wallet on-chain** (entry/exit reveal); cross-chain DEPOSIT/WITHDRAW only expose the external-chain sender/receiver (public on that chain), not the confidential shard's internal moves. - **Not hidden, ever**: the Defuse/1Click solver layer (sees plaintext intents), the `partner_id` mapping, and the source-chain identity. - **Cross-chain DEPOSIT/WITHDRAW are still correlatable by timing and amount**: the source-chain deposit (at T) and destination-chain delivery (at T+N, e.g. 0.5 in / 0.44 out after the 1Click solver fee) are both visible on their public chains and join trivially. True unlinkability needs jitter delays + amount splitting. Each wallet has a single confidential identity (the custody wallet itself); there is no separate or unlinkable confidential identity. --- ## Negative Policy Cache Coordinator caches `wallet_id → NoPolicy` in-memory (HashMap, TTL 5 min). If no policy exists, subsequent requests skip the keystore call entirely (no limits to check). Cache cleared on: - `POST /wallet/v1/invalidate-cache` (dashboard calls after on-chain tx) - TTL expiry (5 min) If policy exists → keystore always reads fresh from chain (never cached). --- ## Error Codes | Error | Meaning | |-------|---------| | `missing_auth` | No Authorization header | | `invalid_api_key` | Key not found or revoked | | `policy_denied` | Operation blocked by policy rules | | `wallet_frozen` | Wallet frozen by controller | | `insufficient_balance` | Not enough funds | | `pending_approval` | Needs multisig (not an error — returns approval_id) | | `rate_limited` | Too many requests | | `invalid_address` | Bad destination address | | `unsupported_token` | Token not supported | --- ## Per-customer Vaults (sovereignty option) By default, every wallet's keys are derived from the **OutLayer default master** (HMAC-SHA256 chain rooted in the keystore-worker's TEE secret). Convenient and recovery-free, but if OutLayer ceases, the derived keys are gone with it. A **per-customer vault** replaces that shared master with a per-customer master derived via NEAR's MPC network from a sub-account the customer controls. The wallet's API key is bound to the vault at registration time; subsequent wallet operations forward `X-Customer-Vault: ` to the keystore, which routes derivations through the per-vault master. ### Wallet creation flow with vault scope ``` Customer → outlayer vault init (or dashboard /vault page) Atomic NEAR tx (5 actions, all-or-nothing): CreateAccount(vault.) Transfer(0.1 NEAR) // storage stake + MPC-call gas reserve UseGlobalContract(approved_code_hash) FunctionCall("new", {parent, keystore_dao, mpc_contract, exit_window}) AddKey(tee_pubkey, FCAK on vault.request_master) POST /customer/sign-verification → keystore re-verifies + signs mark_vault_verified on chain POST /customer/register {vault_id, webhook_url?} Coordinator: 1. View-call keystore_dao.is_vault_verified(vault_id) — must be true 2. INSERT wallet_accounts (wallet_id, vault_id, vault_webhook_url) 3. INSERT wallet_api_keys (key_hash, customer_account_id=vault_id) 4. POST /wallet/derive-address (with X-Customer-Vault header) Keystore TEE: 5. Lazy-load: ensure_customer_loaded(vault_id) drives MPC CKD with derivation_path = HMAC(default_master, "vault-master:{vault_id}") 6. Cache per-vault master in masters: HashMap 7. HMAC(per_vault_master, "wallet:{wallet_id}:near") → keypair 8. Return { address, public_key } Coordinator: 9. Save derived public_key on the wallet row 10. Commit transaction; return API key + fire vault_registered webhook ``` The customer's API key is now permanently bound to the vault. Every wallet operation uses the per-vault master; on cessation or unilateral exit, the customer recovers control of the vault account and the per-vault master remains derivable by any post-recovery DAO-approved TEE worker (deterministic — same `(default_master, vault_id)` → same `secret_path` → same MPC-derived master). ### Recovery flow (cessation path) ``` DAO members → keystore_dao.declare_cessation() [is_ceased() = true] Anyone → vault.initiate_recovery() → cross-contract is_ceased() check → recovery = {trigger: Cessation, finalize_after: now+7d, finalize_before: now+14d} (7-day delay) Anyone → vault.finalize_recovery() → cross-contract is_ceased() check (still true?) → unlocked = true → recovery = None Parent → vault.unlocked_add_key(parent_pubkey, full_access: true) [parent now controls the sub-account; can withdraw funds and migrate to a new custody provider] ``` ### Recovery flow (unilateral path) ``` Parent → vault.set_exit_window(86400) [optional, 24h-30d range] Parent → vault.unilateral_initiate_recovery() → recovery = {trigger: Unilateral, finalize_after: now + window_secs} (configured delay — default 24h) Anyone → vault.finalize_recovery() [no DAO check] → unlocked = true Parent → vault.unlocked_add_key(...) ``` For the architectural reference (two-layer key derivation, race-attack mitigation, governance fixes), see [VAULTS.md](https://github.com/out-layer/outlayer/blob/main/VAULTS.md). For the customer-facing how-to, see `dashboard/app/docs/vaults/page.tsx`. --- ## Security Model 1. **MPC master secret** — obtained from NEAR Protocol MPC network via DAO-governed process. Lives only inside TEE. Individual wallet keys derived deterministically via HMAC-SHA256. 2. **TEE isolation** — Intel TDX enclaves. Key derivation, signing, policy evaluation all inside TEE. Even infrastructure operator cannot extract keys or bypass policy. 3. **Policy on-chain** — encrypted, stored in NEAR contract `LookupMap`. Only TEE can decrypt. Controller can freeze wallet directly on-chain without going through API. 4. **API key security** — only SHA-256 hash stored in DB. Plaintext shown once at registration. Key prefix `wk_` for identification. 5. **Velocity limits** — tracked in coordinator DB (`wallet_usage` table). Usage recorded BEFORE execution (prevents bypass via intentional failures). Per-tx limits checked in TEE (not bypassable even if DB is compromised). 6. **Agent compromise recovery** — freeze wallet (instant, on-chain) → revoke API key → create new key. Private key never exposed — nothing to rotate. --- > Source: https://github.com/out-layer/outlayer/blob/main/docs/MULTI_CHAIN.md # Multi-Chain Support for Agent Custody Wallets Agent Custody allows AI agents to hold and manage funds via TEE-secured wallets with configurable spending policies. **NEAR, all EVM chains, and Solana are supported.** The keystore generates keys for EVM chains (secp256k1) and Solana (ed25519). This document describes the shipped EVM and Solana signing models. ## Current Status | Component | NEAR | EVM (eth/polygon/base/arbitrum/optimism/bsc/avalanche) | Solana | |-----------|------|---------------------|--------| | Key generation (keystore) | ed25519 | secp256k1 (one shared address across all EVM chains) | ed25519 (base58 address) | | Transaction signing (keystore) | ed25519 | ECDSA secp256k1 (keccak256 + sign; off-chain EIP-712 / EIP-191 / raw-tx hash) | ed25519 over the raw serialized message (no digest step) | | Derivation seed | `wallet:{id}:near` | `wallet:{id}:evm` (shared by every EVM chain) | `wallet:{id}:solana` (the `sol` alias canonicalizes to it) | | Coordinator handlers | withdraw, call, transfer, swap, deposit | `evm/sign-typed-data`, `evm/sign-message`, `evm/sign-transaction` (signing only — no build/broadcast) | `solana/sign-message`, `solana/sign-transaction` (signing only — no build/broadcast) | | Dashboard UI | full support | address display | not implemented | | Policy evaluation (keystore) | all rules | `evm_sign` capability (default-DENY under a policy; set `allowed:true`) + `raw_tx` sub-flag (default-OFF); shared policy | `solana_sign` capability — same model as `evm_sign` (`allowed` + `raw_tx`); shared policy | ## Architecture: Shared Policy Policy is **one per `wallet_id`**, shared across all chains. Spending limits, rate limits, time restrictions, and approval thresholds apply to all operations regardless of chain. Address restrictions (`addresses.list`) can contain addresses of any format. Policy is stored on-chain keyed by `wallet_pubkey` (the NEAR ed25519 key). This key serves as the wallet's "anchor". Other chain keys are linked through the same `wallet_id` in the coordinator database. ## EVM Signing (shipped) EVM signing is **live**. The model is deliberately narrow: **the client builds and broadcasts; the keystore only hashes (keccak256) and signs.** The keystore and coordinator never assemble an EVM transaction, never pick a nonce or gas, and never broadcast. Cross-chain value movement still rides `/wallet/v1/deposit-intent` + `/wallet/v1/intents/withdraw` (1Click + NEAR signatures, no native EVM tx). ### Supported chains `ethereum`, `polygon`, `base`, `arbitrum`, `optimism`, `bsc`, `avalanche` — plus the 1Click-style aliases `eth`, `pol`, `matic`, `arb`, `op`, `avax`. **All EVM chains share ONE derived secp256k1 address** (a single EOA, seed `wallet:{id}:evm`). `GET /wallet/v1/address` serves any of these and returns that one `0x` address. Account delete stays NEAR-only. ### Endpoints | Endpoint | Standard | What the keystore does | |----------|----------|------------------------| | `POST /wallet/v1/evm/sign-typed-data` | EIP-712 v4 | Computes the digest from the full `eth_signTypedData_v4` object server-side, then signs | | `POST /wallet/v1/evm/sign-message` | EIP-191 `personal_sign` | Computes `keccak256("\x19Ethereum Signed Message:\n" + len + msg)`, then signs | | `POST /wallet/v1/evm/sign-transaction` | raw tx | The **client** serializes the unsigned tx; the keystore keccak256-hashes and signs it. No assembly, nonce/gas selection, or broadcast. For an EIP-1559 (type-2) tx the `yParity` needed to assemble the final tx is `v - 27`. | All three return a 65-byte `0x` signature `r‖s‖v`, with `v ∈ {27, 28}` and low-s (EIP-2) normalization. The EIP-712 encoder is **hand-rolled** (no `alloy`/`ethers`) so it adds no dependency to the enclave's attestation surface; it is pinned against viem-generated reference vectors (`keystore-worker/src/eip712_vectors.json`). ### Policy capability: `evm_sign` `evm_sign` is **default-DENY under a policy** — like every other fund-moving capability, a policy must explicitly set `capabilities.evm_sign.allowed = true` to permit EIP-712 / EIP-191 signing (a wallet with **no policy** is unrestricted). `sign_message` is the only default-allow capability. A `raw_tx` sub-flag is **default-OFF** and separately gates the raw-transaction endpoint. `requires_approval` is **not supported** for `evm_sign` (a policy that sets it fails closed rather than silently ignoring the owner's intent). **Caveat — `evm_sign` is fund-moving authority, not a read-only grant.** An EIP-712 signature can itself move funds: EIP-3009 `transferWithAuthorization` ≈ a transfer, EIP-2612 `Permit` ≈ an approve. So `evm_sign` grants full authority over whatever float sits on the wallet's EVM address — bounded to what has been bridged there. The `raw_tx` flag is a kill-switch for arbitrary raw transactions, **not** a containment boundary for typed-data drains. The NEAR-intents balance is never exposed by any EVM signing path. ### Remaining EVM work - **Dashboard chain selector for signing flows** — address display exists; per-chain signing UX is not built. - **Persisting derived EVM addresses** (`wallet_chain_addresses`) is optional — the address is deterministic from the seed and the shared EOA is identical across chains, so the table is only a convenience cache. ## Solana Signing (shipped) Solana signing follows the EVM model exactly: **the client builds and broadcasts; the keystore only signs.** There is no digest step on Solana — the ed25519 signature covers the raw serialized message bytes — so the keystore signs the supplied bytes as-is. It never assembles a transaction, never picks a blockhash, and never broadcasts. The chain identifier is `solana` (alias `sol`); both spellings canonicalize to the ONE derived key on seed `wallet:{id}:solana`. `GET /wallet/v1/address?chain=solana` returns the base58 ed25519 public key (which IS the Solana address). ### Endpoints | Endpoint | What the keystore does | |----------|------------------------| | `POST /wallet/v1/solana/sign-message` | Signs the raw decoded bytes (`encoding`: `utf8` default / `hex` / `base64`, no content sniffing) — verifiable with `nacl.sign.detached.verify`; Sign-in-with-Solana flows work unchanged. **Rejects bytes that parse as a valid transaction message** (see guard below). Max 64 KiB. | | `POST /wallet/v1/solana/sign-transaction` | The **client** serializes the unsigned transaction **message** (base64 — what the signature covers: web3.js `tx.serializeMessage()` / `versionedTx.message.serialize()`); the keystore signs the bytes as-is. Max 1232 bytes (the Solana packet limit). The client assembles the signed tx (`compact-u16 sig count ‖ signatures ‖ message`) and broadcasts. | Both return a 64-byte ed25519 signature, **base58** (Solana convention). ### The message/transaction guard Unlike EVM, Solana has no EIP-191-style prefix cryptographically separating messages from transactions: a "message" whose bytes are a valid serialized transaction message would, once signed, be broadcastable — silently bypassing the `raw_tx` sub-flag. Wallets (Phantom, Solflare) close this by refusing to `signMessage` bytes that parse as a transaction message; `keystore-worker/src/solana.rs::parses_as_transaction_message` implements the same **reject-only** check (legacy + versioned v0 wire format, strict full-byte consumption, `sanitize()`-level header/index checks). It never interprets the payload beyond "could a node accept this as a transaction" — blind signing stays blind. The parser is hand-rolled (no `solana-sdk` in the enclave, same rationale as the EIP-712 encoder) and pinned against `@solana/web3.js`-generated reference vectors (`keystore-worker/src/solana_vectors.json`, generator: `keystore-worker/scripts/gen_solana_vectors.mjs`), including a **byte-exact cross-signing test**: the keystore's signature over the vector transactions is asserted identical to web3.js/nacl output, and splicing it into the wire format reproduces the web3.js-signed transaction exactly. ### Policy capability: `solana_sign` Same model as `evm_sign`, evaluated by the shared `solana_sign_decision` in `shared-tee-helpers` (one implementation with the EVM gate — the chains cannot drift): **default-DENY under a policy** (`capabilities.solana_sign.allowed = true` to opt in; no policy → unrestricted), `frozen` + `time_restrictions` global gates apply, `requires_approval` fails closed, and the `raw_tx` sub-flag (**default-OFF**) separately gates `sign-transaction`. **Caveat:** a signed Solana transaction message is itself fund-moving, so `solana_sign` + `raw_tx` grants full authority over the wallet's Solana float — bounded to what has been sent there. The NEAR-intents balance is never exposed by any Solana signing path. **Overlap with `raw_sign`:** the unified `/wallet/sign` endpoint's `Op::Raw { chain: "solana" }` also blind-signs bytes with the same key, gated by the separate default-DENY `raw_sign` capability (optionally restricted per-chain via `raw_sign.chains`). `raw_sign` is an independent, blunter blind-sign gate with no message/transaction distinction — a policy author locking down Solana must leave BOTH `solana_sign` and `raw_sign` disabled (both are default-DENY under a policy, so the default is safe). ⚠️ Alias caveat: `Op::Raw` treats `chain` as a **literal seed namespace** (`wallet:{id}:{chain}`, no alias canonicalization — a pre-existing property of that endpoint), so `Op::Raw { chain: "sol" }` derives a *different* key than the `/wallet/v1/solana/*` endpoints (which canonicalize `sol` → `solana`). Always use `"solana"` in `Op::Raw`. ### Remaining Solana work - **Dashboard**: Solana address display + signing UX (matches EVM, where signing UX is also TODO). - **SDK**: thin `solanaSignMessage` / `solanaSignTransaction` methods after an api-spec sync. ## Checklist: Adding another chain 1. Add a chain predicate in `shared-tee-helpers/src/lib.rs` (single source of truth for keystore + coordinator) and a capability + decision fn in `wallet_policy.rs` (reuse `chain_sign_decision`). 2. Add keystore signing handler(s) in `keystore-worker/src/api.rs` mirroring `evm_sign_digest` / `solana_sign_bytes`; canonicalize aliases in `wallet_seed()`. 3. **Every chain uses a distinct derivation seed** (`wallet:{id}:near` vs `wallet:{id}:evm` vs `wallet:{id}:solana`) — this is the cross-curve/cross-chain domain-separation invariant: a blind signature on one chain's key can never forge another chain's transaction or a NEAR auth message. 4. Follow the blind-signing model: the **client** builds and broadcasts; the keystore only signs. Do not build/broadcast in the coordinator. 5. If the chain signs raw bytes (no digest), decide how messages are separated from transactions (prefix or reject-guard) BEFORE shipping the message endpoint. 6. Un-gate the chain in the coordinator's `validate_chain()`, add `/wallet/v1//*` routes, update the api-spec + reference vectors. ## Key Files | File | What it does | |------|----------------| | `coordinator/src/wallet/handlers.rs` | `validate_chain()` admits near + EVM + Solana; `evm_sign_*` / `solana_sign_*` handlers forward to the keystore via `keystore_chain_sign` (no build/broadcast) | | `keystore-worker/src/crypto.rs` | `derive_keypair()` (ed25519), `derive_secp256k1_keypair()`, `derive_eth_address()`, `sign()` (ed25519 over raw bytes — the Solana primitive), `sign_secp256k1_prehash()` | | `keystore-worker/src/eip712.rs` | Hand-rolled EIP-712 v4 + EIP-191 digest computation; pinned to `eip712_vectors.json` | | `keystore-worker/src/solana.rs` | Hand-rolled reject-only Solana tx-message guard + size caps; pinned to `solana_vectors.json` (generator: `scripts/gen_solana_vectors.mjs`) | | `keystore-worker/src/api.rs` | `/wallet/evm/*` + `/wallet/solana/*` handlers; `wallet_seed()` canonicalization; capability gating via `shared_tee_helpers::wallet_policy::{evm_sign_decision, solana_sign_decision}` | | `dashboard/app/wallet/manage/page.tsx` | Multi-chain address display (per-chain signing UX still TODO) | | `contract/src/wallet.rs` | No changes needed — policy is keyed by `wallet_pubkey` (NEAR key) | --- > Source: https://github.com/out-layer/outlayer/blob/main/docs/DETERMINISTIC_WALLETS.md # Deterministic Wallets via NEAR Signature Auth ## Why this matters OutLayer wallets run inside TEE (Intel TDX) — keys never leave the enclave. Combined with NEAR MPC network for cross-chain signing, this gives integrators **verifiable custody**: any wallet operation can be audited on-chain, and the TEE attestation proves the key was never extracted. Deterministic wallets make this accessible to any developer: one NEAR account → unlimited wallets for your users, with TEE-backed security and cross-chain capabilities out of the box. No key management, no per-user storage, no infrastructure to maintain. ## Problem POST /register creates wallets with random API keys. Clients (bots, servers) must store per-user api_key in their DB. DB leak = all wallets compromised. ## Solution Deterministic wallets authenticated by NEAR signature on every request. Coordinator stores zero auth secrets. Client derives wallet access from its NEAR key + seed — nothing to store per-user. Key revocation = remove key from NEAR account. Effect within 60 seconds (cache TTL). ## Use cases **Web app with OAuth login.** Server has one NEAR key. User logs in via Google/GitHub/etc. Server derives `seed = SHA256(provider + ":" + user_id)`, calls /register, user gets a wallet instantly. Zero per-user key storage. DB stores only user profiles, not wallet credentials. **Telegram/Discord bot.** Bot has one NEAR key in env. For each user: `seed = SHA256(telegram_user_id)`. Bot re-derives wallet on every request from BOT_SECRET. No per-user DB. **AI agent spawning sub-agents.** Parent agent derives a `wk_` key from (near_key, seed, index), registers its hash via `PUT /wallet/v1/api-key`, hands the key string to sub-agent. Sub-agent uses simple `Bearer wk_...` — zero crypto, works with any HTTP client or LLM framework. Parent can re-derive the key anytime without storage. All three share the same pattern: **one NEAR account, many wallets, zero stored secrets. Access control via NEAR keys.** ## What integrators get out of the box Every deterministic wallet has the same capabilities as a regular wallet — all existing features work via the same API: - **Multi-chain addresses** — NEAR, Ethereum, Base, Arbitrum, Solana, Bitcoin derived from one wallet via NEAR MPC network - **Gasless swaps** — `/intents/swap` via solver relay, no gas needed on wallet - **Cross-chain deposits** — any source chain (Solana, Ethereum, Bitcoin, EVM L2s) → NEAR via the 1Click bridge (`/deposit-intent`) - **On-chain calls** — arbitrary NEAR contract calls (`/call`) - **Token transfers** — NEAR native + any NEP-141 token - **Policy engine** — spending limits, allowed actions, freeze thresholds, multisig approval - **Webhooks** — notifications on wallet events - **TEE attestation** — all wallet keys derived inside Intel TDX, verifiable on-chain - **Trial tier** — 100 free WASI executions per wallet, no payment setup needed ## Architecture ``` Client (bot) Coordinator Keystore (TEE) | | | |-- Bearer near: -->| | | |-- verify ed25519 sig | | |-- RPC: pubkey in account? | | |-- derive wallet_id | | |-- keystore_derive_address->| | |<-- near implicit account --| |<-- { wallet_id, account_id } --| | ``` - **Auth** = NEAR signature verification (ed25519, in-process) + access key check (RPC, cached 60s) - **Wallet keys** = keystore derives & signs (existing path, unchanged) - **Coordinator DB** = wallet_accounts row only, no auth secrets ## Auth: Bearer token format Single `Authorization` header for both wallet types. Middleware detects format by prefix: ``` # Random wallets (existing) Authorization: Bearer wk_a1b2c3d4... # Deterministic wallets (new) Authorization: Bearer near: ``` The base64url payload is a JSON object: ```json { "account_id": "my-tg-bot.near", "seed": "a1b2c3...", "pubkey": "ed25519:", "timestamp": 1712000000, "signature": "" } ``` | Field | Description | |-------|-------------| | `account_id` | NEAR account_id of the client | | `seed` | Arbitrary string — determines which wallet | | `pubkey` | NEAR public key (ed25519:base58) | | `timestamp` | Unix timestamp (seconds) | | `signature` | ed25519 signature of `"auth::"` | ## Auth middleware flow ``` 1. Try X-Internal-Wallet-Auth (trusted worker, no DB query) 2. Try Bearer near:... → NEAR signature auth: a. base64url-decode → parse JSON b. Verify timestamp ±30 sec (MAX_TIMESTAMP_SKEW = 30) c. Parse pubkey "ed25519:" → raw 32 bytes d. Verify raw ed25519 signature of "auth::" against pubkey e. Access key cache check: "account_id\0pubkey" → valid? - Cache hit → use cached result - Cache miss → NEAR RPC view_access_key (single call, no retry) - Cache result for 60s (both positive and negative) f. Derive wallet_id = deterministic(account_id, seed) g. Verify wallet_id exists in wallet_accounts h. Return WalletAuth { wallet_id, ... } 3. Try Bearer wk_... → existing api_key auth (unchanged) 4. No auth → Err(MissingAuth) ``` **Two timestamp windows:** - `MAX_TIMESTAMP_SKEW = 30` sec — Bearer near: auth (every request, tight) - `MAX_REGISTRATION_SKEW = 300` sec (5 min) — POST /register, PUT /api-key with NEAR sig in body (setup steps) All wallet/v1/* endpoints receive `WalletAuth` — they don't know or care how it was obtained. ### Access key cache ```rust struct AccessKeyCache { // "account_id\0pubkey" → (valid, cached_at) // NUL-separated composite key avoids tuple allocation on lookup entries: Arc>>, } ``` - TTL: 60 seconds - Max entries: 10K (lazy cleanup on write, same pattern as ApiKeyCache) - Negative caching: invalid keys cached for 60s to prevent RPC spam - Security tradeoff: 60s window after key deletion before access revoked ## POST /register — deterministic path ### Request ```json { "account_id": "my-tg-bot.near", "seed": "a1b2c3...", "pubkey": "ed25519:", "message": "register::", "signature": "" } ``` All 5 fields present = deterministic path. All absent = current behavior (random key). ### Validation 1. All 5 fields must be present together, else 400 2. `seed` must not be empty 3. Parse `message` via `rsplit_once(':')` — extract seed and timestamp, verify seed matches `seed` field. Seeds may contain `:`. 4. Timestamp ±5 min (`MAX_REGISTRATION_SKEW = 300` — longer window for setup operations) 5. Parse `pubkey` "ed25519:\" → raw 32 bytes 6. Verify **raw ed25519** signature (NOT NEP-413) of `message` against `pubkey` 7. NEAR RPC: `check_access_key_exists(rpc_url, account_id, pubkey)` — **only for new wallets** (cached 60s) ### Wallet creation ``` wallet_id = UUID(SHA256("outlayer:deterministic-wallet-id:" + account_id + ":" + seed)) ``` Deterministic. Same (account_id, seed) = same wallet_id = same NEAR implicit account. ```sql INSERT INTO wallet_accounts (wallet_id) VALUES ($1) ON CONFLICT DO NOTHING -- rows_affected > 0 → new wallet, insert trial_quotas, derive address via keystore -- rows_affected = 0 → existing wallet, skip inserts, derive address (idempotent) ``` RPC access key check: only when creating (rows_affected > 0). Idempotent return skips RPC. ### Response ```json { "wallet_id": "uuid-string", "near_account_id": "hex64-implicit-account", "trial": { "calls_remaining": 100, "expires_at": "...", "limits": {...} } } ``` No `api_key`. No `handoff_url`. Client doesn't need them. ## Key rotation **No endpoint needed.** ```bash # 1. Bot adds new NEAR key near add-key my-tg-bot.near ed25519:NEW_KEY # 2. Bot starts signing with new key — works immediately (RPC sees new key) # 3. Bot removes old key near delete-key my-tg-bot.near ed25519:OLD_KEY # 4. Within 60s, old key expires from cache → 401 for anyone using it ``` Wallet identity = (account_id, seed). Keys rotate freely. No coordinator action needed. ## Key revocation (compromised key) ```bash # Remove compromised key from NEAR account near delete-key my-tg-bot.near ed25519:COMPROMISED # Within 60 seconds: cache expires, attacker gets 401 # No coordinator action. No DB update. Automatic. ``` ## What uses keystore (unchanged) Keystore (TEE) still: - Derives wallet's ed25519 keypair from `"wallet:{wallet_id}:near"` - Signs NEAR transactions on behalf of the wallet - Wallet's implicit NEAR account is custodial (keystore holds private key) What's NOT stored anywhere: auth credentials. Coordinator DB has zero secrets for deterministic wallets. ## What to reuse from existing code | Component | Location | Usage | |-----------|----------|-------| | `verify_ed25519()` | `wallet/auth.rs:293` | Signature verification | | `check_access_key_exists()` | `near_client.rs:276` | RPC access key check | | `MAX_TIMESTAMP_SKEW = 30` | `wallet/auth.rs:25` | Timestamp validation | | `state.near_rpc_url` | `wallet/mod.rs:46` | RPC URL | | `ed25519-dalek`, `bs58` | `Cargo.toml` | Already in deps | | `keystore_derive_address()` | `wallet/handlers.rs` | Wallet address derivation | | `build_trial_info()` | `wallet/handlers.rs` | Trial quota response | | `INSERT ON CONFLICT` pattern | current diff | Race condition safe | | `Bytes` body parsing | current diff | Backward compat | | `ApiKeyCache` pattern | `wallet/auth.rs` | Same DashMap + TTL pattern for AccessKeyCache | ## PUT /wallet/v1/api-key — delegate key for sub-agents Registers a client-derived `wk_` key hash. Creates sub-wallet if not exists. Idempotent. **Two auth modes:** ### Mode 1: Bearer header (custody wallets) Custody wallet (`Bearer wk_...`) or deterministic wallet (`Bearer near:...`) creates a sub-wallet. No NEAR signatures needed — parent is already authenticated. Sends `Authorization: Bearer wk_...` header + minimal body: ```json { "seed": "sub-task-42", "key_hash": "sha256hex64chars..." } ``` Sub-wallet_id = `deterministic(parent_wallet_id, seed)`. No RPC check. **Ambiguity guard:** If both Bearer header AND signature fields (account_id, pubkey, message, signature) are present in body → 400 error. Prevents silent fallthrough bugs. ### Mode 2: NEAR signature in body (external NEAR accounts) No Bearer header. All 6 fields required: ```json { "account_id": "parent-agent.near", "seed": "sub-task-42", "key_hash": "sha256hex64chars...", "pubkey": "ed25519:", "message": "api-key:sub-task-42:", "signature": "" } ``` Timestamp window: ±5 min. Raw ed25519 signature. RPC check for new wallets. ### Response (both modes) ```json { "wallet_id": "uuid-string", "near_account_id": "hex64-implicit-account" } ``` ### Flow (Mode 1 — Bearer) 1. Authenticate via Bearer header (existing `authenticate()`) 2. Reject if signature fields also present (ambiguous auth) 3. Derive wallet_id = `deterministic(parent_wallet_id, seed)` 4. Create wallet if not exists (no RPC check — parent is authenticated) 5. Store key_hash in wallet_api_keys (INSERT ON CONFLICT DO NOTHING — idempotent) 6. Return wallet info ### Flow (Mode 2 — NEAR sig) 1. Validate signature + timestamp (±5 min) 2. NEAR RPC: check pubkey is on account (cached 60s, skip if wallet already exists) 3. Derive wallet_id = `deterministic(account_id, seed)` 4. Create wallet if not exists (INSERT ON CONFLICT DO NOTHING + trial_quotas) 5. Store key_hash in wallet_api_keys (INSERT ON CONFLICT DO NOTHING — idempotent) 6. Return wallet info ## DELETE /wallet/v1/api-key/:key_hash — revoke delegate key Revokes a `wk_` key for a deterministic wallet. **Auth: `Bearer near:...` header** (unlike PUT, wallet must already exist here, so normal auth middleware works). Sets `revoked_at = NOW()` in wallet_api_keys. The `wk_` key stops working after auth cache expires (60s). Coordinator verifies the caller owns the wallet (derives wallet_id from Bearer's account_id + seed, checks key_hash belongs to that wallet_id). **Cannot revoke the last key.** If wallet has only one active `wk_` key, DELETE returns 409 Conflict. This prevents locking out wallets that are only accessible via `wk_` keys (Flow 1 random wallets). Deterministic wallet owners can always access via `Bearer near:...` regardless, but the guard applies uniformly. ### Sub-agent usage ```python # Parent: derive key (no storage needed, re-derive anytime) api_key = f"wk_{hmac_sha256(near_private_key, f'{seed}:0').hex()}" # Parent: register key hash (idempotent, call once or many times) requests.put(".../wallet/v1/api-key", json={ "account_id": ACCOUNT_ID, "seed": "sub-task-42", "key_hash": sha256(api_key.encode()).hexdigest(), "pubkey": NEAR_PUBKEY, "message": message, "signature": sig, }) # Parent hands string to sub-agent sub_agent.set_bearer_token(api_key) # Sub-agent: zero crypto, zero dependencies requests.get(".../wallet/v1/balance", headers={"Authorization": f"Bearer {api_key}"}) # Later, on another machine, without any DB: api_key = f"wk_{hmac_sha256(near_private_key, f'{seed}:0').hex()}" # Same key, re-derived from (near_key, seed, index) ``` ## Summary of endpoints | Endpoint | Auth | Creates wallet? | Returns api_key? | Use case | |----------|------|----------------|-----------------|----------| | `POST /register` (no body) | None | Yes (random) | Yes (`wk_`) | Quick start, testing | | `POST /register` (with signature) | NEAR sig in body | Yes (deterministic) | No | Server/bot, uses `Bearer near:...` | | `PUT /wallet/v1/api-key` | Bearer header OR NEAR sig in body | Yes if needed | No (caller knows it) | Register delegate key for sub-agent | | `DELETE /wallet/v1/api-key/:key_hash` | `Bearer near:...` or `Bearer wk_...` | No | — | Revoke delegate key (last key protected) | ## OutLayer auth signatures from the wallet key — POST /wallet/v1/auth-sign To produce OutLayer's own Bearer / register / api-key auth token **from the wallet's TEE key** (not from a locally-held NEAR key), use the dedicated `POST /wallet/v1/auth-sign` endpoint. > The old `POST /wallet/v1/sign-message` `"format": "raw"` parameter is **removed** — calling > `/sign-message` with `format:"raw"` now returns an error pointing here. `/sign-message` only > produces NEP-413 signatures (and enforces a default-DENY recipient allowlist); it is no longer a > raw byte signer. Request body: `{ "purpose": "bearer" | "register" | "api-key", "seed": "...", "vault_id": "..." }` (`vault_id` is only valid for `bearer`). The keystore **constructs** the exact domain-separated auth string with a fresh server timestamp and signs it raw ed25519 (an `Op::Auth` — non-fund, always allowed on a non-frozen wallet, never a 32-byte tx hash). Response: ```json { "auth_message": "auth::", // send verbatim "auth_timestamp": 1712000000, "signature": "", "public_key": "ed25519:" } ``` For an arbitrary plain ed25519 signature over your own bytes (custom auth protocols, off-chain proofs on a chain the structured policy doesn't cover), use the `raw` op via `/wallet/sign` — gated by the default-DENY `raw_sign` capability (with an optional per-chain allowlist). ## What to write new 1. **`parse_near_pubkey(s: &str) -> Result<[u8; 32]>`** — parse `"ed25519:"` → raw bytes 2. **`AccessKeyCache`** — in-memory cache for NEAR RPC results, 60s TTL 3. **`extract_near_bearer_auth()`** — parse `Bearer near:`, verify sig, check access key, derive wallet_id 4. **`register_deterministic()`** — handler for deterministic path in register 5. **`register_api_key()`** — handler for PUT /wallet/v1/api-key (register client-derived key hash) 6. **`revoke_api_key()`** — handler for DELETE /wallet/v1/api-key/:key_hash (revoke delegate key, NEAR sig auth) 7. **`RegisterRequest`** — extended with 5 new optional fields 8. **Update `authenticate()`** — check Bearer prefix: `wk_` → existing path, `near:` → new path ## What does NOT change - POST /register without body — current behavior, random key - Bearer wk_... auth — unchanged, works for random wallets - Worker — unchanged (stateless proxy) - Keystore-worker — unchanged - All wallet/v1/* endpoint handlers — unchanged (only auth layer adds new path) - Trial quota logic — same rules for both wallet types - DB migrations — no new tables (wallet_accounts, trial_quotas, wallet_api_keys reused) - wallet_api_keys used for random wallets (Flow 1) and delegate `wk_` keys (Flow 4), not for `Bearer near:...` auth ## Flow examples All Python examples share these helpers (defined in Flow 2): `_sign_message()`, `_make_bearer()`, `NEAR_SECRET`, `NEAR_PUBKEY`, `ACCOUNT_ID`, `API_BASE`. Flows 4, 6, 7 also use: ```python def wallet_request_by_seed(seed: str, method: str, path: str, **kwargs): """Wallet API call using Bearer near:... for a specific seed.""" return requests.request(method, f"{API_BASE}{path}", headers={"Authorization": f"Bearer {_make_bearer(seed)}"}, **kwargs, ) ``` ### Flow 1: Quick start (existing behavior, no changes) A developer wants to try the API. No NEAR account needed. ```python # 1. Register — one call, no auth resp = requests.post("https://api.outlayer.ai/register") api_key = resp.json()["api_key"] # "wk_a1b2c3..." account = resp.json()["near_account_id"] # "hex64..." # 2. Use wallet — simple Bearer token requests.get("https://api.outlayer.ai/wallet/v1/balance", headers={"Authorization": f"Bearer {api_key}"}) # Same with curl: # curl -H "Authorization: Bearer wk_a1b2c3..." .../wallet/v1/balance ``` Developer stores `api_key`. If lost — wallet is lost (no recovery). --- ### Flow 2: Telegram bot (deterministic, NEAR signature auth) Bot has one NEAR key in env. Creates wallets for thousands of users. Stores nothing per-user. ```python import hashlib, hmac, json, time, base64, requests from nacl.signing import SigningKey import base58 # ── Setup (once, in env) ────────────────────────────────────────────── NEAR_SECRET = load_near_secret_bytes() # 32 bytes from env NEAR_KEY = SigningKey(NEAR_SECRET) NEAR_PUBKEY = f"ed25519:{base58.b58encode(NEAR_KEY.verify_key.encode()).decode()}" ACCOUNT_ID = "my-tg-bot.near" API_BASE = "https://api.outlayer.ai" # ── Helpers ─────────────────────────────────────────────────────────── def _sign_message(message: str) -> str: """Sign a message with the bot's NEAR key, return base58 signature.""" return base58.b58encode(NEAR_KEY.sign(message.encode()).signature).decode() def _seed_for_user(telegram_user_id: int) -> str: """Deterministic seed from user ID. Same user → same seed → same wallet.""" return hashlib.sha256(str(telegram_user_id).encode()).hexdigest() def _make_bearer(seed: str) -> str: """Build Bearer near:... token for wallet API calls.""" timestamp = int(time.time()) message = f"auth:{seed}:{timestamp}" payload = json.dumps({ "account_id": ACCOUNT_ID, "seed": seed, "pubkey": NEAR_PUBKEY, "timestamp": timestamp, "signature": _sign_message(message), }, separators=(",", ":")) token = base64.urlsafe_b64encode(payload.encode()).decode().rstrip("=") return f"near:{token}" # ── Registration (idempotent — safe to call on every bot /start) ────── def ensure_wallet(telegram_user_id: int) -> dict: seed = _seed_for_user(telegram_user_id) timestamp = int(time.time()) message = f"register:{seed}:{timestamp}" resp = requests.post(f"{API_BASE}/register", json={ "account_id": ACCOUNT_ID, "seed": seed, "pubkey": NEAR_PUBKEY, "message": message, "signature": _sign_message(message), }) return resp.json() # First call: creates wallet, returns { wallet_id, near_account_id, trial } # Repeat call: returns same wallet, skips creation # ── Wallet operations (Bearer near:... on every request) ───────────── def wallet_request(telegram_user_id: int, method: str, path: str, **kwargs): seed = _seed_for_user(telegram_user_id) return requests.request(method, f"{API_BASE}{path}", headers={"Authorization": f"Bearer {_make_bearer(seed)}"}, **kwargs, ) # ── Bot handlers ────────────────────────────────────────────────────── def on_start(user_id: int): wallet = ensure_wallet(user_id) send_message(user_id, f"Your wallet: {wallet['near_account_id']}") def on_balance(user_id: int): resp = wallet_request(user_id, "GET", "/wallet/v1/balance") send_message(user_id, f"Balance: {resp.json()}") def on_swap(user_id: int, token_in: str, token_out: str, amount: str): resp = wallet_request(user_id, "POST", "/wallet/v1/intents/swap", json={ "token_in": token_in, "token_out": token_out, "amount": amount, }) send_message(user_id, f"Swap: {resp.json()}") def on_send(user_id: int, receiver: str, amount: str): resp = wallet_request(user_id, "POST", "/wallet/v1/transfer", json={ "to": receiver, "amount": amount, }) send_message(user_id, f"Sent: {resp.json()}") ``` Bot stores zero per-user keys. Restart bot, redeploy, move to another server — everything works. NEAR key in env is the only secret. --- ### Flow 3: Web app with Google login (deterministic, NEAR signature auth) Same as Telegram bot, but seed derived from OAuth provider + user ID. ```python # User logs in via Google OAuth → server gets google_user_id def on_google_login(google_user_id: str) -> dict: seed = hashlib.sha256(f"google:{google_user_id}".encode()).hexdigest() # ... same ensure_wallet() / wallet_request() as above return ensure_wallet_with_seed(seed) # User logs in via GitHub def on_github_login(github_user_id: str) -> dict: seed = hashlib.sha256(f"github:{github_user_id}".encode()).hexdigest() return ensure_wallet_with_seed(seed) # Different provider + same person = different wallets (by design) # Same provider + same person = always the same wallet ``` --- ### Flow 4a: Custody wallet creating sub-agents (Bearer auth, no crypto) Parent agent has a `wk_` API key (custody wallet). No NEAR key needed. ```python import hashlib, requests API = "https://api.outlayer.ai" PARENT_KEY = "wk_..." HEADERS = {"Authorization": f"Bearer {PARENT_KEY}", "Content-Type": "application/json"} def create_sub_agent_wallet(task_id: str) -> tuple[str, str]: seed = f"sub-agent:{task_id}" sub_key = f"wk_{hashlib.sha256(f'{seed}:0:{PARENT_KEY}'.encode()).hexdigest()}" key_hash = hashlib.sha256(sub_key.encode()).hexdigest() resp = requests.put(f"{API}/wallet/v1/api-key", headers=HEADERS, json={"seed": seed, "key_hash": key_hash}, ).json() return resp["near_account_id"], sub_key # Create and hand key to sub-agent account, key = create_sub_agent_wallet("task-42") # Sub-agent uses: Authorization: Bearer wk_... ``` --- ### Flow 4b: External NEAR account creating sub-agents (NEAR signature) Parent agent has a NEAR key. Creates wallets for sub-agents. Sub-agents use simple Bearer tokens — no crypto libraries needed. ```python # ── Parent agent ────────────────────────────────────────────────────── def create_sub_agent_wallet(task_id: str, key_index: int = 0) -> tuple[str, str]: """Create wallet + derive a wk_ key for a sub-agent. Returns (near_account_id, api_key). Can be called again to re-derive.""" seed = f"sub-agent:{task_id}" # 1. Derive wk_ key deterministically (parent can re-derive anytime) key_material = hmac.new( NEAR_SECRET, f"{seed}:{key_index}".encode(), hashlib.sha256 ).hexdigest() api_key = f"wk_{key_material}" key_hash = hashlib.sha256(api_key.encode()).hexdigest() # 2. Register key hash in coordinator (idempotent) timestamp = int(time.time()) message = f"api-key:{seed}:{timestamp}" resp = requests.put(f"{API_BASE}/wallet/v1/api-key", json={ "account_id": ACCOUNT_ID, "seed": seed, "key_hash": key_hash, "pubkey": NEAR_PUBKEY, "message": message, "signature": _sign_message(message), }) near_account_id = resp.json()["near_account_id"] return near_account_id, api_key # 3. Parent creates sub-agent, hands it the key account, key = create_sub_agent_wallet("task-42") sub_agent = spawn_agent( task="Buy 10 USDT on NEAR", wallet_key=key, # simple string ) # ── Sub-agent (zero crypto, any language, any LLM framework) ───────── def sub_agent_main(wallet_key: str): headers = {"Authorization": f"Bearer {wallet_key}"} # Check balance balance = requests.get(f"{API_BASE}/wallet/v1/balance", headers=headers).json() print(f"Balance: {balance}") # Execute swap swap = requests.post(f"{API_BASE}/wallet/v1/intents/swap", headers=headers, json={ "token_in": "wrap.near", "token_out": "usdt.tether-token.near", "amount": "10.0", }).json() print(f"Swap result: {swap}") # ── Later: parent re-derives the key without any storage ───────────── # Parent's server restarts, DB is empty, doesn't matter: _, same_key = create_sub_agent_wallet("task-42") # same key as before ``` --- ### Flow 5: NEAR key rotation Bot's NEAR key was exposed. Rotate without affecting wallets. ```bash # 1. Generate new key near generate-key --outputDir ./new-key # 2. Add new key to the NEAR account (on-chain tx, signed by old key) near add-key my-tg-bot.near ed25519:NEW_PUBLIC_KEY # 3. Update bot's env with new key, restart bot # Bot now signs with new key → works immediately (RPC sees new key) # 4. Remove old key near delete-key my-tg-bot.near ed25519:OLD_PUBLIC_KEY # 5. Within 60 seconds: anyone using old key → cache expires → 401 ``` Wallets are NOT affected — wallet_id depends on (account_id, seed), not on which key signs. **Note for Flow 4 (sub-agents):** if parent rotates NEAR key, existing `wk_` keys keep working (they're in wallet_api_keys, independent of NEAR key). But `hmac(near_private_key, ...)` changes, so parent can no longer re-derive old `wk_` keys. For sub-agents created after rotation, parent uses new key. Old sub-agent keys remain valid until revoked. --- ### Flow 6: Key revocation (compromised key) ```bash # Remove compromised key from NEAR account near delete-key my-tg-bot.near ed25519:COMPROMISED_KEY # Result: # - Bearer near:... signed by compromised key → 401 within 60s (cache expires) # - Bearer wk_... keys derived from compromised key → still work (stored by hash) ``` To also revoke `wk_` delegate keys derived from the compromised NEAR key: ```python # Parent re-derives the key_hash of each compromised wk_ key (knows seed + index) for seed, index in compromised_sub_agents: old_key = f"wk_{hmac_sha256(OLD_NEAR_SECRET, f'{seed}:{index}').hex()}" old_hash = hashlib.sha256(old_key.encode()).hexdigest() # Revoke via DELETE (authenticated with new NEAR key) wallet_request_by_seed(seed, "DELETE", f"/wallet/v1/api-key/{old_hash}") ``` --- ### Flow 7: Sub-agent with policy (spending limits) Parent creates a wallet for sub-agent with restrictions: max $5 per swap, no transfers. Uses existing policy system — no new endpoints needed. ```python # ── Parent agent ────────────────────────────────────────────────────── # 1. Create wallet + wk_ key for sub-agent (same as Flow 4) account, sub_key = create_sub_agent_wallet("task-42") # 2. Set policy on the wallet (parent signs with Bearer near:...) seed = "sub-agent:task-42" # 2a. Encrypt policy rules via keystore TEE policy_resp = wallet_request_by_seed(seed, "POST", "/wallet/v1/encrypt-policy", json={ "rules": [ {"action": "swap", "max_amount_usd": "5.00"}, {"action": "balance", "allow": True}, {"action": "transfer", "deny": True}, {"action": "delete", "deny": True}, ], "freeze_threshold_usd": "20.00", # freeze wallet if balance drops below $20 }) encrypted_policy = policy_resp.json() # 2b. Sign encrypted policy with wallet's key (coordinator asks keystore) wallet_request_by_seed(seed, "POST", "/wallet/v1/sign-policy", json=encrypted_policy) # Policy is now stored on-chain → enforced on every operation # 3. Hand wk_ key to sub-agent sub_agent = spawn_agent(task="Buy USDT", wallet_key=sub_key) # ── Sub-agent tries to use the wallet ──────────────────────────────── headers = {"Authorization": f"Bearer {sub_key}"} # ✅ Check balance — allowed requests.get(f"{API_BASE}/wallet/v1/balance", headers=headers) # ✅ Swap $3 of NEAR → USDT — allowed (under $5 limit) requests.post(f"{API_BASE}/wallet/v1/intents/swap", headers=headers, json={ "token_in": "wrap.near", "token_out": "usdt.tether-token.near", "amount_usd": "3.00", }) # ❌ Swap $10 — denied by policy requests.post(f"{API_BASE}/wallet/v1/intents/swap", headers=headers, json={ "token_in": "wrap.near", "token_out": "usdt.tether-token.near", "amount_usd": "10.00", }) # → 403 {"error": "Policy denied: max_amount_usd exceeded"} # ❌ Transfer to external address — denied by policy requests.post(f"{API_BASE}/wallet/v1/transfer", headers=headers, json={ "to": "attacker.near", "amount": "100", }) # → 403 {"error": "Policy denied: transfer not allowed"} ``` Policy is enforced at coordinator level, inside TEE. Sub-agent cannot bypass it — policy is checked before keystore signs any transaction. Parent can update policy anytime via `Bearer near:...`. --- > Source: https://github.com/out-layer/outlayer/blob/main/docs/PAYMENT_CHECKS.md # Payment Checks Gasless agent-to-agent payments via ephemeral intents accounts. Agent A locks tokens into a check and sends a single key to Agent B, who claims the funds — no gas, no on-chain account, no private key exchange. Supports partial claims, expiry, and reclaim. ## Why Payment Checks | Problem | Payment Checks Solution | |---------|------------------------| | Direct transfers require gas (NEAR) from both sides | Fully gasless — uses solver relay with off-chain NEP-413 signatures | | Receiver needs an on-chain account with gas | Receiver only needs a wallet API key | | Direct transfers are irreversible | Sender can reclaim unclaimed funds at any time | | No native partial payment support | Built-in partial claim and partial reclaim | | Escrow requires smart contract development | Check acts as lightweight escrow — one API call | ## How It Works ``` Agent A (Sender) Agent B (Receiver) | | | 1. POST /create | | ────────────────► | | ← check_key | | | | 2. Send check_key | | ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─►| | (any channel) | | | | 3. POST /peek | | (verify) | | | | 4. POST /claim | | ◄─────────────| | funds move | | | | 5. POST /reclaim (optional) | | (get unclaimed funds back) | ``` ### Step-by-step 1. **Create** — Agent A calls `POST /wallet/v1/payment-check/create`. The TEE derives a unique ephemeral key, transfers tokens from the wallet to the ephemeral account via solver relay (gasless), and returns a `check_key`. 2. **Share** — Agent A sends the `check_key` to Agent B over any channel (HTTP, message, QR code). The key is a 64-char hex string. 3. **Peek** (optional) — Agent B calls `POST /wallet/v1/payment-check/peek` with the key to verify the check's balance, memo, and expiry before claiming. 4. **Claim** — Agent B calls `POST /wallet/v1/payment-check/claim` with the key. The coordinator signs a transfer intent from the ephemeral account to Agent B's wallet via solver relay. Supports partial claims. 5. **Reclaim** (optional) — Agent A can reclaim any unclaimed funds at any time via `POST /wallet/v1/payment-check/reclaim`. The TEE re-derives the ephemeral key (no need to store it). ## Transfer Mechanism All operations use the **NEAR Intents solver relay** — a gasless off-chain transfer protocol. Instead of on-chain transactions (which require NEAR for gas), the coordinator signs NEP-413 messages and submits them to the solver relay. | Operation | From → To | Who Signs | Gas | |-----------|-----------|-----------|-----| | Create | Wallet → Ephemeral | Wallet key (TEE keystore) | None | | Claim | Ephemeral → Claimer | Ephemeral key (from check_key) | None | | Reclaim | Ephemeral → Creator | Ephemeral key (TEE re-derivation) | None | ### Ephemeral Accounts Each check gets its own ephemeral account on `intents.near`, derived from the wallet's master secret + a monotonic counter. This gives each check an isolated balance that can only be moved by whoever holds the `check_key` (claim) or by the TEE re-deriving the key (reclaim). ``` Key derivation hierarchy: wallet:{id}:near ← main wallet key wallet:{id}:near:check:{counter} ← ephemeral key per check The check_key IS the raw ed25519 private key of the ephemeral account. The ephemeral account ID = hex(public_key) on intents.near. ``` ## API Reference Base URL: `https://api.outlayer.ai/wallet/v1/payment-check` All endpoints require `Authorization: Bearer wk_...` header. ### POST /create Create a new payment check. **Request:** ```json { "token": "17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1", "amount": "1000000", "memo": "Payment for data analysis", "expires_in": 3600 } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `token` | string | yes | Token contract ID (e.g., USDC contract) | | `amount` | string | yes | Amount in smallest units (e.g., "1000000" = 1 USDC) | | `memo` | string | no | Optional memo (max 256 chars), visible to receiver | | `expires_in` | number | no | Expiry in seconds from now | **Response:** ```json { "check_id": "a1b2c3d4-...", "check_key": "7f3a9b2c...64 hex chars...", "token": "17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1", "amount": "1000000", "memo": "Payment for data analysis", "created_at": "2026-03-13T10:00:00Z", "expires_at": "2026-03-13T11:00:00Z" } ``` ### POST /batch-create Create multiple checks in one call (max 10). **Request:** ```json { "checks": [ {"token": "170...a1", "amount": "1000000", "memo": "Task 1"}, {"token": "170...a1", "amount": "2000000", "memo": "Task 2"} ] } ``` **Response:** ```json { "checks": [ {"check_id": "...", "check_key": "...", "token": "...", "amount": "1000000", ...}, {"check_id": "...", "check_key": "...", "token": "...", "amount": "2000000", ...} ] } ``` ### POST /claim Claim funds from a check. Supports partial claims. **Request:** ```json { "check_key": "7f3a9b2c...64 hex chars...", "amount": "500000" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `check_key` | string | yes | The 64-char hex key from the sender | | `amount` | string | no | Partial claim amount (omit for full claim) | **Response:** ```json { "token": "17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1", "amount_claimed": "500000", "remaining": "500000", "memo": "Payment for data analysis", "claimed_at": "2026-03-13T10:05:00Z", "intent_hash": "Bx7k..." } ``` ### POST /reclaim Reclaim unclaimed funds. Only the original creator can reclaim. **Request:** ```json { "check_id": "a1b2c3d4-...", "amount": "500000" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `check_id` | string | yes | The check ID from create response | | `amount` | string | no | Partial reclaim amount (omit for full reclaim) | **Response:** ```json { "token": "17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1", "amount_reclaimed": "500000", "remaining": "0", "reclaimed_at": "2026-03-13T10:10:00Z", "intent_hash": "Cx9m..." } ``` ### GET /status?check_id=... Get current status of a check. Only the creator can query. **Response:** ```json { "check_id": "a1b2c3d4-...", "token": "17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1", "amount": "1000000", "claimed_amount": "500000", "reclaimed_amount": "500000", "status": "reclaimed", "memo": "Payment for data analysis", "created_at": "2026-03-13T10:00:00Z", "expires_at": "2026-03-13T11:00:00Z", "claimed_at": "2026-03-13T10:05:00Z", "claimed_by": "a2b3b5b5c72c..." } ``` ### GET /list?status=...&limit=50&offset=0 List all checks created by the authenticated wallet. | Param | Required | Description | |-------|----------|-------------| | `status` | no | Filter: unclaimed, claimed, reclaimed, partially_claimed | | `limit` | no | Max results (default 50, max 100) | | `offset` | no | Pagination offset | ### POST /peek Check a payment check's balance using the `check_key`. Use to verify before claiming. **Request:** ```json { "check_key": "7f3a9b2c...64 hex chars..." } ``` **Response:** ```json { "token": "17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1", "balance": "1000000", "memo": "Payment for data analysis", "status": "unclaimed", "expires_at": "2026-03-13T11:00:00Z" } ``` ## Check Lifecycle ``` create | v [unclaimed] / \ claim / \ reclaim v v [partially_claimed] [partially_reclaimed] | \ / | claim | \ / | reclaim v v v v [claimed] (mixed) [reclaimed] ``` **Statuses:** - `unclaimed` — funds locked, waiting to be claimed - `partially_claimed` — some funds claimed, rest available - `partially_reclaimed` — some funds reclaimed by sender - `claimed` — all funds claimed by receiver - `reclaimed` — all funds reclaimed by sender **Expiry:** If `expires_in` is set, the check cannot be claimed after expiry. Funds remain in the ephemeral account — the sender must explicitly reclaim them. Expiry prevents new claims but does not auto-return funds. ## Security | Threat | Mitigation | |--------|------------| | check_key intercepted in transit | Use encrypted channels (HTTPS, E2E messaging). Leaked key + any wallet API key = funds claimed. | | Sender's wallet compromised | Wallet private key never leaves TEE. API key can be revoked. Policy engine limits exposure. | | Replay of claim/reclaim | Each intent has unique nonce + 5-minute deadline. Solver relay rejects duplicates. DB tracks amounts atomically. | | Ephemeral key collision | Monotonic counter per wallet (DB enforced). Same wallet + counter = same key, counter never reuses. | | Funds stuck in ephemeral account | Sender can always reclaim — TEE re-derives ephemeral key from same deterministic path. | **Key insight:** The `check_key` is the only secret. It never touches the blockchain, never enters the TEE for claim (coordinator signs locally with it). The sender doesn't need to store it — the TEE re-derives it for reclaim. Even if the sender loses all local state, funds are recoverable through the TEE. ## Comparison | | Payment Checks | Direct Transfer | Smart Contract Escrow | |--|----------------|-----------------|----------------------| | Gas required | None | Sender pays | Both parties pay | | Receiver needs account | Only wallet API key | On-chain account + gas | On-chain account + gas | | Partial payment | Built-in | N/A | Custom logic | | Reclaim | Built-in, gasless | N/A (irreversible) | Custom logic + gas | | On-chain footprint | Solver relay only | 1 transaction | Contract deployment + calls | | Setup complexity | One API call | One API call | Contract dev + deployment | ## Use Cases - **Agent-to-agent payments** — Agent A creates a check, sends key as part of API request. Agent B verifies (peek), performs work, claims payment. If B doesn't deliver, A reclaims. - **Bounties & task rewards** — Create check with expiry. Share key with task completer. Partial claims allow splitting among contributors. - **Escrow-style payments** — Lock funds in a check. Share key only when conditions are met. Lightweight escrow without a smart contract. - **Batch payouts** — Use `batch-create` to generate up to 10 checks in a single call, each with independent key. --- > Source: https://github.com/out-layer/outlayer/blob/main/docs/outlayer-custody-advantages.md # Agent Custody: Outlayer (TEE) vs. TLA active-signer (on-chain MPC) An objective side-by-side of the two custody models, followed by notes on the theses. The two are built for different jobs — this is meant to make the trade-offs visible, not to declare a winner. ## Comparison | Dimension | Outlayer custody (TEE) | TLA `active-signer` (on-chain MPC) | |---|---|---| | Trust root | Intel TDX enclave, remotely attested (Intel DCAP / PCS)  \* | NEAR MPC network + on-chain `active-signer` contract | | Where the key lives | Sealed in the enclave, never leaves | Held by no one — threshold-shared across MPC nodes | | Signature latency | Sub-second (in-enclave, ~ms) | ~2–6 s — signer call + MPC yield/resume, multi-block | | Overhead per signed action | None beyond the action's own transaction | Signer function-call + MPC round + broadcast, on top of the action | | Gas to produce a signature | None | ~0.003–0.008 NEAR/sig (tens of TGas burned) | | Chains from one custody | NEAR, Solana (ed25519), EVM (secp256k1) | NEAR — `active-signer` builds NEAR transactions | | Account onboarding | Implicit account — no `create_account` / `add_key`, no intents key registration | Named account — MPC-derive + `create_account` + `add_key` + funding (~0.002+ NEAR storage), **plus** `add_public_key` on `intents.near`, all on-chain per agent | | Concurrency | Parallel in-enclave signing | Per-account nonce commit serializes actions | | Action set | Programmable (capabilities, reject-if-tx guards) | `function_call` + `transfer` only, enforced at the type level | | Ownership transfer / resale | Not a native primitive | Native — compare-and-swap of the operating key | | Built-in recovery | Via composition (vaults / external policy) | Native policy engine — timelock, watcher quorum, attestation | | Human-readable identity | Implicit hex by default | Named (TLA sub-account, e.g. `agent.claude`) | | Verifiability of authority | Remote attestation of the measured code | On-chain contract logic + MPC network | \* Both models ultimately root in MPC — the difference is *when* and *how often*. Outlayer's per-key material is deterministically derived from a master secret that the keystore-worker obtains **once, at startup, from the NEAR MPC network**. That derivation runs inside the enclave, is deterministic, and the master never leaves the enclave. From then on the worker signs locally, with no further MPC call per action. The `active-signer` model instead calls MPC (`v1.signer`) on **every** signature. So it is not "TEE vs MPC" — both use MPC — it is one MPC touch at worker startup to seed an in-enclave master versus one MPC round-trip per signed action. Rows 10–13 are where the TLA model is stronger: ownership transfer, a built-in recovery policy engine, and human-readable names are first-class there and are not native to Outlayer custody. Rows 3–9 are where the TEE model is stronger, and they cluster around one thing: signing is local, not an on-chain round-trip. ## Notes on the theses ### Latency and cost per action Outlayer signs inside the enclave: sub-second, no on-chain round-trip, and no gas to produce the signature. The on-chain-signer + MPC path pays, for **every** signed action, a function-call transaction into the signer contract, an MPC signing round (yield / resume across several blocks, seconds of latency), and a broadcast. Both models still pay the underlying transaction's own gas when the action hits the chain; the difference is the signing overhead layered on top. At wallet cadence — a few transactions a day — that overhead is negligible. At agent cadence — many actions a minute — it dominates. **Worked example — an agent doing one action per minute** (1,440 actions/day): - *TLA `active-signer`:* ~0.003–0.008 NEAR of signing overhead per action → roughly **4–12 NEAR/day**, purely in signing overhead, before the underlying transactions' own gas — plus 2–6 s of added latency on every action. It scales linearly with cadence, so an agent acting every few seconds costs proportionally more. - *Outlayer:* **0 NEAR** of signing overhead and sub-second signing, at any cadence. These figures are estimates — `1 TGas ≈ 0.0001 NEAR` at floor gas price, tens of TGas burned along the `active-signer` → `v1.signer` → callback chain (confidence: low–moderate). The point is the order of magnitude and that the cost accrues **per action**, not the exact number. ### One custody, multiple chains The same derived-key root signs NEAR (ed25519), Solana (ed25519) and EVM (secp256k1). There is no per-chain signing contract to deploy or maintain. The `active-signer` model, as designed, builds NEAR transactions specifically. ### Onboarding: implicit accounts Agents onboard as NEAR implicit accounts — the account ID *is* the public key — so there is no `create_account` and no `add_key`. A single off-chain signature is enough to start transacting, which is what keeps NEAR Intents onboarding to one signature with no on-chain key registration. The named-account model requires on-chain account creation, funding for storage, and a key install per agent. There is a second, intents-specific cost. NEAR Intents authorizes a NEP-413 signature against a public key. For an implicit account the account ID *is* that key, so Intents can derive it directly and nothing needs to be registered. For a named account the key cannot be inferred from the name, so the signing key must be registered with `intents.near` via `add_public_key` before Intents will recognize the account — an extra on-chain call per agent that the implicit model skips entirely. ### Programmable policy, co-located with the key Signing policy runs in the same attested enclave that holds the key: a capability model per key, canonical-operation signing with reject-if-transaction guards, and per-customer master-secret isolation. The `active-signer` model takes a different and also-strong approach to safety — the action set is restricted to `function_call` + `transfer` at the type level, so dangerous actions cannot be expressed at all. Ours is programmable; theirs is hard-restricted. Different tools for different risk models. ### Compute and custody in one environment Outlayer's core is verifiable off-chain computation. An agent's logic and its signing run in the same TEE, so it can compute privately and sign the result in one attested environment rather than splitting where it thinks from where it signs. The TLA model is a custody-and-ownership layer; it has no co-located compute story, and does not need one. ### Concurrency There is no per-account on-chain nonce commit serializing an agent's actions; the enclave signs in parallel. In the `active-signer` model the nonce is committed per account per action, which serializes concurrent actions from one account. ### Honest trust assumptions To be precise rather than to overclaim: - Outlayer trusts the Intel TDX TEE and the DCAP / Intel PCS attestation chain, plus on-chain approval of the enclave's measurements. It is a single-vendor hardware trust root. - The TLA model trusts the NEAR MPC network (threshold, no single holder) and the correctness of the `active-signer` contract. These are genuinely different roots — threshold-distributed versus attested hardware — not one strictly stronger than the other. The latency, cost, multi-chain and co-located-compute properties above are consequences of the TEE root; the ownership, recovery and naming primitives are consequences of the on-chain root. ### Where the two compose Naming and recovery can sit **on top of** Outlayer custody without changing the root. A native-mode recovery that targets ordinary NEAR accounts already reaches Outlayer's implicit accounts, so recovery does not require moving custody or adopting named accounts. The sensible split is one custody root (TEE, for agent-cadence signing) with a policy layer — naming, ownership, recovery — composing above it. --- > Source: https://github.com/out-layer/outlayer/blob/main/VAULTS.md # Sovereign Vaults — Architecture & Recovery Procedure OutLayer's per-customer master keys with on-chain recoverability. This document describes the **architecture**, the **trust model**, and the two **recovery procedures** in detail. For the customer-facing how-to, see `dashboard/app/docs/vaults/page.tsx` (rendered at `/docs/vaults` on the dashboard) or `outlayer vault --help`. ## Why vaults exist By default, every wallet key and encrypted secret on OutLayer is derived from a shared **OutLayer master**, held inside the keystore-worker's TEE. Convenient: zero customer setup, shared infrastructure cost, automatic key rotation across keystore-worker upgrades. The trust model is "OutLayer is honest" — if OutLayer shuts down or its keystore-DAO loses quorum, customers' derived keys are gone. A **vault** replaces that shared master with a per-customer master derived via NEAR's MPC network, recoverable by the customer through two independent escape hatches: 1. **Cessation recovery** — OutLayer's DAO declares `is_ceased() == true`; anyone can drive the recovery; fixed 7-day delay before finalization. 2. **Unilateral recovery** — the customer's parent NEAR account exits at any time without DAO involvement; configurable 24h-30d delay. After either recovery completes, the vault is `unlocked` and the parent account can install full-access keys and migrate funds. ## Components | Layer | Responsibility | Path | |---|---|---| | **Vault contract** | Per-customer NEAR sub-account holding the TEE function-call key + recovery state machine | `vault-contract/` | | **Keystore-DAO** | Whitelists vault WASM hashes (multisig-gated, see [F4 audit fix](#governance-fixes)), tracks `verified_vaults`, `banned_vaults`, `ceased_operations` | `keystore-dao-contract/src/lib.rs` | | **Vault-checker WASI** | Public open-source agent that re-verifies vault state in TEE and forwards to keystore-worker | `wasi-examples/vault-checker/` | | **Keystore-worker** | Multi-customer master cache with lazy MPC CKD load; `/sign-vault-verification` + `/admin/ban-vault` + `/admin/evict-customer` | `keystore-worker/src/api.rs` | | **Coordinator** | `/customer/derive-tee-key` + `/customer/sign-verification` + `/customer/register` + `/internal/vault-event` proxies | `outlayer-coordinator/src/wallet/handlers.rs` (separate repo) | | **CLI** | `outlayer vault {init,resume,status,verify,initiate-recovery,...}` | `outlayer-cli/src/commands/vault.rs` (separate repo) | | **Dashboard** | `/vault` management page + `` on secrets/wallet pages | `dashboard/app/vault/page.tsx`, `dashboard/lib/vault.ts` | | **Race-attack monitor** | `near-lake-framework` consumer that detects duplicate `request_app_private_key` MPC calls and triggers `/admin/ban-vault` | `outlayer-monitor/` | ## Atomic deploy flow The customer signs a single transaction with five actions that either all succeed or all roll back: ``` receiver = vault. actions = [ CreateAccount, Transfer(0.1 NEAR), // storage stake (~0.004) + MPC-call gas reserve UseGlobalContract(approved_code_hash), // NEP-591 — WASM lives in global registry, not on-account FunctionCall("new", {parent, keystore_dao, mpc_contract, initial_exit_window}), AddKey(tee_function_call_key, FCAK on vault.request_master, allowance: Unlimited), ] ``` Why all-or-nothing matters: a half-deployed vault (account exists, contract not initialised, or TEE key absent) would either be unrecoverable (no key to drive MPC) or insecure (parent backup keys still installed). Atomic deploy collapses every failure mode into "sub-account never existed; retry safely". After tx finality, three coordinator endpoints finish the flow: 1. `/customer/derive-tee-key {vault_id}` — fetched BEFORE deploy so the AddKey action installs the right TEE pubkey (deterministic HMAC of vault_id, computed inside the TEE). 2. `/customer/sign-verification {vault_id}` — keystore re-runs the five RPC checks defense-in-depth and submits `mark_vault_verified` on chain. 3. `/customer/register {vault_id, webhook_url?}` — coordinator confirms `is_vault_verified == true` and mints the API key. ## Two-layer key derivation ``` Layer 1 (TEE function-call key, HMAC-derived from OutLayer master): tee_keypair = HMAC(outlayer_master, "outlayer.near:{vault_id}") install pubkey on vault as FCAK on (mpc_contract, ["request_app_private_key"]) Layer 2 (per-vault master, MPC CKD called FROM the vault): secret_path = HMAC(outlayer_master, "vault-master:{vault_id}") master = MPC.request_app_private_key( signer=vault_id (Layer 1 keypair), app_public_key=ephemeral, derivation_path=secret_path, domain_id=2) ``` Determinism: same vault_id + same OutLayer master → same secret_path → same MPC-derived master, regardless of which approved TEE worker runs the derivation. After cessation, an approved-by-new-DAO TEE worker can re-derive both layers and recover funds for the customer. The `secret_path` is HMAC-derived so a malicious customer who copies their own vault's MPC tx from the mempool cannot replay it from a fake vault — the path itself is unguessable without the OutLayer master. ## Trust model & race-attack mitigation The end-user (the customer's customer) interacts with the customer's app directly. The customer is the trusted party for their end-users; OutLayer is the customer's TEE infrastructure provider, not a counterparty to end-users. This shapes the recovery design: **unilateral recovery** is a *customer* escape hatch, not an end-user protection mechanism. ### Why initialization-time attacks don't compromise the master A natural concern for end-users: "what if the customer is malicious and rigs the vault during deploy to give themselves a backdoor?" **Outcome: not possible by construction.** The vault becomes immutable immediately after the atomic deploy: - The atomic deploy is a single all-or-nothing NEAR transaction. Any half-baked state (malicious code, extra keys, tampered contract state) either rolls back entirely or produces a final state that vault-checker observably rejects. - After the atomic deploy completes, the vault account holds **only the TEE function-call key**, restricted to `mpc_contract.request_app_private_key`. That key cannot `AddKey`, `DeployContract`, or call any method on the vault contract itself. The customer's parent account holds **no key on the vault account**. - The approved vault contract has no method that calls `Promise::new(self).deploy_contract(...)` or `Promise::new(self).add_full_access_key(...)` — verified by the contract's audit checklist (`vault-contract/src/lib.rs:25-44`). - Any Promises emitted during the atomic deploy run synchronously in the next 1-3 blocks. They cannot be timed to evade vault-checker's view-call (which runs at finality, after all emitted promises have completed). - The per-vault `secret_path` is `HMAC(outlayer_master, "vault-master:{vault_id}")` — unguessable without TEE compromise. Even if a hostile customer pre-emits an MPC call with a chosen derivation_path, the resulting master is uncorrelated with the legitimate per-vault master and useless. The only paths from "deployed, verified" back to "parent-controlled" are the two recovery procedures below. Both impose explicit delays (24h-7d minimum), and both are visible on chain through the contract's `recovery` state field, which `outlayer vault verify` surfaces. ### Post-recovery customer-fraud risk (end-user disclosure) A malicious customer who deployed the vault honestly CAN regain full control of the vault account through the unilateral recovery flow: 1. Wait at least `unilateral_exit_window_secs` (24h-30d, set at deploy and visible on chain). 2. Call `unilateral_initiate_recovery`, wait the configured window, call `finalize_recovery`. Vault is now `unlocked`. 3. Call `unlocked_add_key(attacker_full_access)` — install a full-access key on the vault. 4. Sign a tx from the vault that calls `mpc_contract.request_app_private_key` with the same `derivation_path` the legitimate keystore-worker used. 5. Receive the same per-vault master — and with it every wallet key and every secret encrypted under that vault. This is **not a vulnerability in OutLayer's TEE infrastructure** — it's the customer exercising the sovereignty feature the vault was built to provide. From the protocol's perspective, the customer was ALWAYS able to recover their own vault; that's the entire point of the unilateral exit window. **Implication for end-users:** - Treat the customer the same way you would treat them if they ran custody themselves: they CAN drain the vault after the configured exit window. - Read `unilateral_exit_window_secs` via `outlayer vault verify ` BEFORE depositing funds. The minimum is 24h, the maximum is 30 days. A customer with a long exit window has promised they will not exit for that long; a customer with the minimum 24h has reserved the option to exit quickly. - `recovery` state on the vault is observable in real time. Tools that watch `vault.get_recovery_state()` can alert end-users the moment a recovery starts, providing `unilateral_exit_window_secs` of warning before the customer gains full control. - For high-value, low-trust deployments, the customer can use a `parent` account controlled by a multisig or a contract that rate-limits `unilateral_initiate_recovery` calls. This shifts the trust assumption from "customer is honest" to "customer's multisig signers are honest", which may be acceptable for some applications. OutLayer's role ends at the TEE boundary: keys are only ever exfiltrated through (a) DAO cessation or (b) the customer's own unilateral exit. End-users transacting with a customer's app are trusting the customer's good faith for the duration of the configured exit window, not OutLayer's. ### Race attack A malicious customer could try to: 1. Sneak a backup full-access key into the atomic deploy. 2. Observe the keystore-worker's MPC `request_app_private_key` tx in the mempool. 3. Replay it from the vault account using their backup key BEFORE the keystore-worker submits `mark_vault_verified`. 4. Get the same per-vault master themselves; DeleteKey the backup to pass `vault-checker`'s access-key-list check. 5. Onboard end-users on a vault they secretly control. ### Mitigations - **vault-checker** rejects any vault whose access-key-list is not exactly `[tee_pubkey]`. A customer who DeleteKey's their backup before verification can pass this check, so we add: - **outlayer-monitor** subscribes to NEAR-lake receipts filtered by `(receiver=mpc_contract, method="request_app_private_key")`. Two calls from the same `(predecessor, derivation_path)` pair within 600 blocks (~10 minutes) trip `/admin/ban-vault {reason="duplicate_mpc_call_after_init"}`. - **`/admin/ban-vault`** submits `keystore_dao.ban_vault(vault_id)` on chain AND evicts the in-memory cached master. Thereafter `is_vault_verified(vault_id)` returns false and any wallet operation referencing the banned vault is rejected at the lazy-load gate. - **Detection window:** minutes (lake delivery + monitor latency) vs onboarding-fraud-damage window: days/weeks (time to attract end-users). DAO-driven recovery converges before damage scales. ## Recovery procedures in detail ### Cessation recovery Used when OutLayer ceases operations and customers need their funds back. Permissionless to start; gated entirely on DAO state. ``` DAO members → keystore_dao.declare_cessation() [ceased_operations = true] Anyone → vault.initiate_recovery() ↓ cross-contract is_ceased() check ↓ if true: recovery = {trigger: Cessation, finalize_after: now+7d} (7-day delay) Anyone → vault.finalize_recovery() ↓ cross-contract is_ceased() check (re-checked, can be cancelled ↓ if DAO calls revoke_cessation in the window) ↓ if still ceased: unlocked = true, recovery = None Parent → vault.unlocked_add_key(parent_pubkey, full_access: true) [parent now controls the vault account directly; funds, secrets, and per-vault master are all derivable again because secret_path is recomputable by the post-cessation DAO-approved TEE] ``` If the DAO revokes cessation during the 7-day window, the recovery state is cleared on the next `finalize_recovery` call and the vault remains TEE-controlled. The customer can re-initiate after a fresh `declare_cessation`. ### Unilateral recovery Customer-driven voluntary exit. No DAO involvement. ``` Parent → vault.set_exit_window(86400) [optional, 24h-30d range] Parent → vault.unilateral_initiate_recovery() [recovery = {trigger: Unilateral, finalize_after: now + unilateral_exit_window_secs}] (configured delay — default 24h) Anyone → vault.finalize_recovery() [synchronous, no DAO check, no callback; unlocked = true, recovery = None] Parent → vault.unlocked_add_key(...) ``` The exit window is **frozen at initiate time** — calling `set_exit_window` after `unilateral_initiate_recovery` only affects future recoveries. ## Governance fixes The keystore-DAO's vault-version registry uses the **proposal+vote flow** (Phase 7 audit F4 fix). `approve_vault_version` and `revoke_vault_version` require `approval_threshold` (>50% of DAO members) distinct votes for the same `(action, hash, label, audit_url)` tuple before executing; any single member alone records a vote and returns its count. `deprecate_vault_version` is intentionally single-member (soft signal, reversible by re-approving) so the DAO can react quickly to a flagged hash without burning a full vote cycle. Vote ledger is `LookupMap>` keyed on the borsh-encoded full action tuple, so distinct `(label, audit_url)` variants of the same hash are independent proposals. Once a tuple's voter count reaches the threshold the action executes and the entry is cleared; a late vote arriving after execution starts a fresh proposal. ## Operational considerations - **One-time cost:** ~0.1 NEAR transferred to the vault account at deploy time. With NEP-591 `UseGlobalContract` the WASM bytes live in the global registry, so storage stake collapses to the contract state (~0.004 NEAR for 391 bytes — three `AccountId`s, flags, empty `registered_tee_keys`). The remainder is gas reserve for outbound `vault.request_master → mpc.request_app_private_key` (~0.001 NEAR/call; the master is cached in keystore-worker enclave memory after the first call, so most vaults trigger MPC only a handful of times). Top up if you ever exhaust the reserve. - **TEE key cap:** `Vault::MAX_REGISTERED_TEE_KEYS = 32`. Bricking this through griefing requires 32 distinct DAO-approved keystore pubkeys (each call costs the attacker gas and adds only legitimate keys); covers years of operational rotations. See `vault-contract/src/lib.rs::propose_tee_key` doc-comment. - **Webhook subscriptions** (Phase 5 task 5): customers pass `webhook_url` to `/customer/register` to receive `vault_registered`, `vault_verified`, `recovery_*`, `vault_banned`, `vault_unbanned`, `vault_tee_key_added`, `exit_window_set` events. The coordinator emits `vault_registered` and `vault_verified` directly during its own request handling; on-chain transitions are forwarded by the `outlayer-monitor` crate (`LakeSource` reads FastNEAR's neardata feed, RPC-cross-checks each event, then POSTs to `/internal/vault-event`). - **Email alerter:** intentionally deferred. Phase 8 plan listed "Slack/email integration"; the `outlayer-monitor` ships with `Slack`, `Telegram`, and `Stdout` alerters today. Email can be added as a fourth `Alerter` impl post-launch — operators comfortable with Slack/Telegram pipelines won't notice. ## Operator-side launch tasks Items that require operator infra (live keys, DAO members, real alpha-testers) are tracked outside this file. The local memory note `~/.claude/projects/-Users-alice-projects-near-offshore/memory/project_vault_operator_debts.md` is the canonical list — items there are the only deploy-day work remaining. ## Cross-references - `dashboard/app/docs/vaults/page.tsx` — customer-facing how-to - `CUSTODY.md` — wallet custody overview with vault flow diagrams - `tests/vault_e2e.sh` — automated scenarios (happy/isolation/compat) - `/Users/alice/.claude/plans/partitioned-dreaming-patterson.md` — full implementation plan (internal) --- > Source: https://github.com/out-layer/outlayer/blob/main/docs/LEAVING_OUTLAYER.md # Leaving OutLayer (Sovereign Exit) This document is the **operational runbook** for taking a per-customer vault out from under OutLayer's keystore and continuing to use the same wallet addresses and the same on-chain secrets without OutLayer infrastructure. It targets two audiences: 1. **Customers** who have deployed an MPC vault via `outlayer vault init` and want a step-by-step procedure to walk through if OutLayer becomes unavailable, untrustworthy, or simply unwanted. 2. **Operators / auditors** reviewing the sovereignty guarantee — each step has a "why this works" pointer to the contract / keystore code that enforces the property. For the architectural rationale of MPC vaults (why they exist, what guarantees the design provides), see [`dashboard/app/docs/vaults/page.tsx`](https://github.com/out-layer/outlayer/blob/main/dashboard/app/docs/vaults/page.tsx) (rendered at https://outlayer.fastnear.com/docs/vaults). --- ## The promise After you run the procedure in this document: | Capability | Before (OutLayer in the loop) | After (sovereign exit) | |---|---|---| | Sign transactions for your custody wallet(s) | OutLayer keystore signs via `/wallet/v1/*` | You sign locally with a re-derived ed25519 key | | Decrypt secrets bound to the vault | OutLayer worker / keystore decrypts in TEE | You decrypt locally with the recovered master | | Mint new wallets / mint API keys | Coordinator | Not available — you've left OutLayer | | Recover funds locked on chain | N/A | Direct on-chain control via the parent / new-parent key | You do **not** lose any value the vault held. You do **not** need OutLayer's cooperation, web UI, or testnet/mainnet API to be online. --- ## What you need to keep before exit Even on the happy path (OutLayer is online), do these once when you deploy each vault and stash the result offline: 1. **Vault id** — printed by `outlayer vault init`. Example: `vault.alice.near`. 2. **Parent NEAR account credentials** — the account that the `parent:` field on the vault points to. Without this you cannot trigger `finalize_recovery`. Treat it like a cold-storage wallet. 3. **`wallet_id`** (UUID) and **`near_account_id`** for every custody wallet minted under the vault — both are returned by `POST /register {"vault_id": "..."}` once and never re-fetchable. The wallet's private key is **derived** from `HMAC-SHA256(per_vault_master, "wallet::near")`, so without the UUID you cannot reach a specific wallet's key. 4. **(Optional) `MPC_PUBLIC_KEY`** — the `bls12381g2:...` MPC verification key. Same value the operator's keystore uses; ask OutLayer support or pull it from `docker/.env.{testnet,mainnet}-keystore-phala`. Pre-stash it so the recovery script can run without network access to ops. 5. **Profile + project_id of every vault-bound secret you store** — needed to look up the encrypted ciphertext on chain after exit. All of the above is public information except the parent's private key. Storing a list in a password manager next to that key is sufficient. --- ## The procedure in 60 seconds If you are reading this in an emergency and want the headline: ```bash # 1. Trigger the on-chain exit (parent signs). outlayer vault initiate-unilateral-recovery vault.alice.near sleep $(( $(outlayer vault status vault.alice.near | grep -oE 'Exit window:.*[0-9]+s' | grep -oE '[0-9]+') + 10 )) # 2. Generate a fresh key that you will own (offline). ./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) # 3. Hand the vault to that key (atomic on-chain key-swap). outlayer vault finalize-recovery vault.alice.near "$NEW_PUBKEY" # 4. Recover the per-vault master. 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 includes: master_hex=<32 hex bytes> ``` That's the on-chain half. The rest (re-deriving wallets, decrypting secrets) is local and doesn't touch the network. The walkthrough script [`scripts/customer-recovery/walkthrough.sh`](https://github.com/out-layer/outlayer/blob/main/scripts/customer-recovery/walkthrough.sh) runs all of steps 1-4 with idempotency, pre-flight checks, and exit-window introspection — recommended over the inline form above. --- ## Step-by-step ### Phase A — On-chain exit (parent-signed) #### A.1 Confirm the vault is yours and locked ```bash outlayer vault status vault.alice.near ``` Required fields: - `Parent: ` — must equal the NEAR account whose key you hold. The contract checks `env::predecessor_account_id() == self.parent` as the very first action of `finalize_recovery`; if you are not parent, this entire procedure is for a different person. - `Status: locked (TEE-controlled)` — if it already says `UNLOCKED (recovered)`, skip to Phase B. - `Recovery: none in progress` — if a recovery is already running and it was started by someone else, see "What if someone front-ran me?" below. If `Parent` is wrong, **stop**. You will burn the gas of every following call and the contract will reject them. #### A.2 Initiate unilateral recovery ```bash outlayer vault initiate-unilateral-recovery vault.alice.near ``` The contract records `finalize_after = now + exit_window`. The exit window was chosen at deploy (`--exit-window 24h` by default, configurable 60s–30d). Read the current value with `outlayer vault status`. Setting a shorter window for testing is parent-only via `outlayer vault set-exit-window` and only affects FUTURE recoveries (an in-flight one freezes its timestamps at initiate time). #### A.3 Wait the exit window Real time. The contract enforces `block_timestamp >= finalize_after` at finalize time. No way to shortcut it on chain. Trying to finalize early panics with `recovery delay not yet elapsed` (clean panic, no state mutation, you can retry later within `finalize_before`). #### A.4 Generate the key that will own the vault ```bash mkdir -m 700 -p ~/.outlayer-recovery ./scripts/customer-recovery/target/release/customer-recovery generate-key \ > ~/.outlayer-recovery/vault.alice.near.json chmod 600 ~/.outlayer-recovery/vault.alice.near.json ``` The output is a `{public_key, private_key}` JSON in the format `near-cli-rs` produces. This new keypair becomes the **only** FullAccess key on the vault after finalize. Back this file up offline immediately. Losing it after finalize = losing the vault. #### A.5 Finalize — atomic key swap ```bash NEW_PUBKEY=$(jq -r .public_key ~/.outlayer-recovery/vault.alice.near.json) outlayer vault finalize-recovery vault.alice.near "$NEW_PUBKEY" ``` What this does on chain: 1. Parent-only check fires (`predecessor == self.parent`). 2. Exit window check fires (`now >= finalize_after && now <= finalize_before`). 3. The contract dispatches an atomic Promise batch: - `Promise::delete_key(initial_tee_key)` — removes the TEE function-call key OutLayer installed at deploy time. - `Promise::delete_key(k)` for every entry in `registered_tee_keys` (DAO-rotated TEE keys, if any). - `Promise::add_full_access_key(new_parent_pubkey)` — adds your new key. 4. `callback_after_swap` sets `unlocked = true` and clears `self.recovery` if and only if the swap receipt succeeded. If the swap panicked (e.g. the new pubkey collides with an existing key), state is unchanged — you can retry within the same `finalize_before` window with a fresh keypair. After this transaction lands, **OutLayer no longer has any access key on the vault account**. Their keystore can still hold a stale per-vault master in memory until the indexer-driven eviction fires (seconds), but they cannot derive a fresh one because the function call key that authorised `vault.request_master(...)` is gone. Independently, the coordinator now refuses any `/call//` request that touches a secret bound to this vault with **HTTP 423 Locked** — the vault-serving pre-check fast-fails at the API boundary instead of letting the request spend gas trying to decrypt. #### A.6 Confirm the unlock landed ```bash outlayer vault status vault.alice.near # Status: UNLOCKED (recovered) # Recovery: none in progress ``` `vault.get_state().unlocked == true` is the on-chain ground truth that everything downstream (keystore eviction, coordinator fast-fail, your own decrypt) keys off. --- ### Phase B — Local key derivation (offline) You are no longer touching OutLayer. The remaining steps only need the NEAR RPC and the MPC contract — both are operator-independent NEAR infrastructure. #### B.1 Recover the per-vault master via MPC CKD ```bash export VAULT_PRIVATE_KEY=$(jq -r .private_key ~/.outlayer-recovery/vault.alice.near.json) export MPC_PUBLIC_KEY='bls12381g2:...' # ask ops; same value keystore-worker uses ./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 ``` What `--from-chain` does: queries NEARblocks for the most recent successful `request_app_private_key` tx originating from this vault, extracts the `derivation_path` from its args, and re-uses it. The path is `HMAC-SHA256(default_master, "vault-master:")` — an opaque 32-byte value that was unguessable before the keystore's first CKD call but appears in plaintext on chain after that call. The binary submits its own `request_app_private_key(...)` to the MPC contract, decrypts the encrypted G1 element with a fresh ephemeral key, verifies via a pairing check, and HKDF-stretches the 48-byte secret to a 32-byte master. Output ends with: ``` master_hex=<64 hex chars> ``` **Save the master**. This is *the* secret. Anyone who has it can derive every wallet, sign for the vault's secrets, etc. #### B.2 Re-derive each wallet's NEAR keypair For every wallet you minted under the vault (one per `POST /register {"vault_id": "..."}`): ```bash ./scripts/customer-recovery/target/release/customer-recovery derive-wallet-key \ --master "$MASTER_HEX" \ --wallet-id "" ``` Output is JSON: ```json { "wallet_id": "...", "near_address": "", "public_key": "ed25519:...", "private_key": "ed25519:..." } ``` `near_address` MUST equal the `near_account_id` the coordinator returned at `/register` time. If they differ, the derivation seed shape diverged — file a bug. You can now sign **any** NEAR transaction as that wallet directly: ```bash near tokens "" send-near alice.near '0.01 NEAR' \ network-config mainnet \ sign-with-plaintext-private-key 'ed25519:...' send ``` No OutLayer involvement. The wallet's funds and key authority are entirely yours. #### B.3 Decrypt each on-chain secret For every vault-bound secret you stored (via `outlayer secrets set --vault-id ` or the dashboard): ```bash # 1. Fetch the encrypted ciphertext from the contract. ARGS=$(jq -n --arg pid 'alice.near/my-project' --arg owner 'alice.near' --arg profile 'prod' \ '{accessor: {Project: {project_id: $pid}}, profile: $profile, owner: $owner}') ARGS_B64=$(printf '%s' "$ARGS" | base64 | tr -d '\n') CIPHERTEXT=$(curl -s https://rpc.mainnet.fastnear.com -X POST \ -H 'Content-Type: application/json' \ -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"query\",\"params\":{\"request_type\":\"call_function\",\"finality\":\"final\",\"account_id\":\"outlayer.near\",\"method_name\":\"get_secrets\",\"args_base64\":\"$ARGS_B64\"}}" \ | jq -r '.result.result | implode' \ | jq -r '.encrypted_secrets') # 2. Decrypt with the recovered master + project seed. ./scripts/customer-recovery/target/release/customer-recovery decrypt-secret \ --master "$MASTER_HEX" \ --seed 'project:alice.near/my-project:alice.near' \ --ciphertext-base64 "$CIPHERTEXT" ``` Output is the original plaintext JSON object you stored (`{"KEY":"value", ...}`). The seed format is fixed per accessor variant: - **Project** (`outlayer secrets set --project owner/name`): `project:/:` - **Repo** (`--repo github.com/...`): `::` or `:` if branch is wildcard - **WasmHash** (`--wasm-hash `): `wasm_hash::` `customer-recovery decrypt-secret` auto-detects the wire format (ECIES v1 if the first byte is `0x01`, legacy ChaCha20-Poly1305 otherwise) and runs whichever matches. Dashboard-stored secrets and secrets stored via current `outlayer-cli` (post-v0.1) use ECIES; both work. --- ## What survives the exit After the procedure: | Artifact | You have | OutLayer has | |---|---|---| | Vault's NEAR account with funds | full-access key | nothing | | Per-vault master (HKDF-stretched 32 bytes) | offline | evicted from TEE memory, cannot regenerate | | Wallet ed25519 private keys | derivable from master + wallet_id | derivable from master, which they don't have | | On-chain ciphertext of secrets | readable + decryptable | readable but cannot decrypt | | `parent` field on the vault | unchanged (still your account) | irrelevant — no keys | | Trust in OutLayer | not required for anything above | n/a | --- ## What you give up - **OutLayer's keystore signing API** — `/wallet/v1/sign-message`, `/wallet/v1/transfer`, etc. return HTTP 5xx on every request that touches this vault. You sign locally instead. - **`/call//` execution against vault-bound secrets** — the coordinator pre-check refuses at HTTP 423 in under a second. WASI execution that doesn't need vault-bound secrets is unaffected (the legacy default-master path still works). - **The OutLayer dashboard** — vault detail pages will show `UNLOCKED (recovered)`. There is no "re-enrol" flow on chain; unlock is one-way by design. You can deploy a **new** vault under the same parent at any time and start fresh on the OutLayer side, but the unlocked vault stays unlocked forever. --- ## What if someone front-ran me? Short answer: they couldn't. `finalize_recovery` is parent-only as of vault contract `v1.1`. The contract requires `env::predecessor_account_id() == self.parent` as its very first action. Even if a malicious watcher polled `recovery` state and saw your `finalize_after` elapse, calling `finalize_recovery` from a non-parent account panics with `only the parent account can finalize recovery` and leaves state untouched. You can finalize at your leisure within the `finalize_before` window (`finalize_after + FINALIZE_WINDOW`). `initiate_unilateral_recovery` is also parent-only — only you can start the timer in the first place. `initiate_recovery` (the cessation path) is permissionless, but the `is_ceased()` cross-contract check in `callback_initiate` will refuse if the DAO hasn't actually declared cessation. So a malicious actor can't start a cessation flow against an active DAO. --- ## Trade-off the design makes If your **parent account becomes unavailable** (lost key, deceased operator, lost multisig signers), the vault stays locked forever even after DAO cessation. This is the deliberate trade-off — the alternative (anyone-can-finalize-cessation) would open a vault- hijack vector where a third party substitutes their own pubkey at finalize time and captures the vault. For high-value deployments, configure parent-account social recovery / multisig out-of-band so this risk is bounded. --- ## Verification check-list (for auditors) The sovereign-exit guarantee rests on five contract-level properties. Each is testable independently: 1. **Parent-only `finalize_recovery`** — `require!(predecessor == self.parent)` at the top of the method. Sandbox test: `vault-contract/tests/integration.rs::unilateral_finalize_rejects_non_parent_after_window`. 2. **Atomic key-swap** — `dispatch_swap` issues `DeleteKey(*) + AddFullAccessKey(new_parent_pubkey)` in a single Promise; state mutation deferred to `callback_after_swap` and gated on swap success. Sandbox test: `unlocked_add_key_actually_adds_full_access_key_after_recovery`. 3. **Keystore refuses unlocked vaults** — `keystore-worker/src/api.rs` `ensure_customer_loaded` calls `assert_serving_allowed` which view-calls `vault.get_state()` and rejects on `unlocked == true`. Eviction also fires. 4. **Coordinator fast-fails unlocked vaults** — `outlayer-coordinator/src/handlers/call.rs` `assert_secret_vault_serving_allowed` runs BEFORE `create_execution_task`; HTTP 423 within 1 s instead of 100 s. 5. **MPC CKD is deterministic** — same `(predecessor_id, derivation_path)` produces the same secret, so `customer-recovery --from-chain` reaches the identical master OutLayer's keystore used. End-to-end sovereignty proof on real testnet runs via [`tests/sovereignty_e2e.sh`](../tests/sovereignty_e2e.sh) — 14 steps from `vault init` through sovereign send-near using the locally-derived wallet key. --- ## Files referenced from this document | Path | Purpose | |---|---| | [`scripts/customer-recovery/`](https://github.com/out-layer/outlayer/tree/main/scripts/customer-recovery/) | Standalone MPC CKD + key derivation + secret decrypt tool | | [`scripts/customer-recovery/walkthrough.sh`](https://github.com/out-layer/outlayer/blob/main/scripts/customer-recovery/walkthrough.sh) | One-shot interactive runbook (recommended) | | [`scripts/customer-recovery/README.md`](https://github.com/out-layer/outlayer/blob/main/scripts/customer-recovery/README.md) | Detailed CLI reference for the binary | | [`tests/sovereignty_e2e.sh`](../tests/sovereignty_e2e.sh) | End-to-end test of this whole procedure | | [`tests/vault_detach_test.sh`](../tests/vault_detach_test.sh) | Run the detach half against an existing vault+secret | | [`vault-contract/src/lib.rs`](https://github.com/out-layer/outlayer/blob/main/vault-contract/src/lib.rs) | Contract source — search `finalize_recovery`, `dispatch_swap`, `callback_after_swap` | | [`outlayer-coordinator/src/handlers/call.rs`](https://github.com/out-layer/outlayer-coordinator) | Coordinator-side vault-unlock fast-fail (separate repo) | --- > Source: https://github.com/out-layer/outlayer/blob/main/VRF.md # OutLayer VRF — Verifiable Random Function Cryptographically provable randomness for NEAR smart contracts. No oracle trust required — anyone can verify the proof on-chain. ## How it works ``` WASI Module Worker (TEE) Keystore (TEE) | | | | vrf::random("coin-flip") | | |----------------------------->| | | | alpha = "vrf:42:alice.near:coin-flip" | | POST /vrf/generate {alpha} | | |----------------------------->| | | | signature = Ed25519_sign(vrf_sk, alpha) | | | output = SHA256(signature) | | {output_hex, signature_hex} | | |<-----------------------------| | VrfOutput { | | output_hex, | | signature_hex, | | alpha | | } | |<-----------------------------| On-chain verification: env::ed25519_verify(signature, alpha.as_bytes(), vrf_pubkey) // native NEAR, ~26 TGas ``` ### Alpha format ``` vrf:{request_id}:{sender_id}:{user_seed} ``` - **request_id** — from blockchain event or HTTPS call ID. Auto-injected by worker, WASM cannot set it. - **sender_id** — signer account (blockchain) or payment key owner (HTTPS). Auto-injected by worker. - **user_seed** — arbitrary string from developer's WASM module. Must not contain `:`. Example: `vrf:98321:alice.near:coin-flip` ### Cryptographic primitives | Primitive | Usage | |-----------|-------| | HMAC-SHA256 | Key derivation: `HMAC-SHA256(master_secret, "vrf-key")` → 32 bytes → Ed25519 keypair | | Ed25519 (RFC 8032) | Deterministic signature: `sign(vrf_sk, alpha)` → 64-byte signature | | SHA-256 | Output derivation: `SHA256(signature)` → 32-byte random output | ## Security properties ### 1. Deterministic — no re-rolling Ed25519 signatures are deterministic per RFC 8032. Same key + same alpha = same signature = same output. The worker cannot retry to get a different result. ### 2. Unpredictable without the key The VRF private key lives only inside TEE (Intel TDX via Phala Cloud). It is derived from the master secret via `HMAC-SHA256(master_secret, "vrf-key")`. The master secret is distributed through MPC key ceremony — no single party holds it. ### 3. Non-manipulable alpha The WASM module only provides `user_seed`. The worker auto-prepends `request_id` (from the blockchain event) and `sender_id` (the caller's account). The WASM guest cannot change these values. This means: - Same seed by different users → different output (sender_id differs) - Same seed in different requests → different output (request_id differs) - A WASM module cannot forge an alpha to replay a previous result ### 4. Publicly verifiable Anyone can verify the VRF output — no trust in the TEE required: ``` ed25519_verify(vrf_pubkey, alpha, signature) == true SHA256(signature) == output ``` The VRF public key is available at `GET /vrf/pubkey` and can be hardcoded in smart contracts. ### 5. Consistent across keystore instances All keystore instances derive the VRF keypair from the same master secret with the fixed seed `"vrf-key"`. This means: - All instances produce the same VRF public key - Any instance can generate the same output for the same alpha - Keystore restarts don't change the key ### 6. Rate-limited Max 10 VRF calls per WASM execution. Prevents abuse of the signing endpoint. ## Developer guide ### SDK usage (Rust WASI module) Add `outlayer` to your `Cargo.toml`: ```toml [dependencies] outlayer = "0.2" ``` Generate random output: ```rust use outlayer::vrf; // Get verifiable random output let result = vrf::random("my-seed")?; println!("Random: {}", result.output_hex); // SHA256(signature), 32 bytes hex println!("Proof: {}", result.signature_hex); // Ed25519 signature, 64 bytes hex println!("Alpha: {}", result.alpha); // "vrf:{request_id}:{sender_id}:my-seed" // Or get raw bytes let (bytes, signature_hex, alpha) = vrf::random_bytes("my-seed")?; // bytes: [u8; 32] — use for random number generation // Get VRF public key (for including in output) let pubkey = vrf::public_key()?; ``` Map to a range (e.g. 0..=99): ```rust let result = vrf::random("roll")?; let first_4_bytes = u32::from_be_bytes(hex_to_bytes(&result.output_hex[..8])); let roll = (first_4_bytes as u64 * 100 / (u32::MAX as u64 + 1)) as u32; // 0..=99 ``` Multiple independent random values — use unique sub-seeds: ```rust for i in 0..5 { let result = vrf::random(&format!("card:{}", i))?; // Each call gets a unique alpha → unique output } ``` ### Constraints - `user_seed` must not contain `:` (used as alpha delimiter) - Max 10 VRF calls per execution - VRF requires keystore — project must be deployed on OutLayer ### On-chain verification (NEAR smart contract) ```rust use near_sdk::env; fn verify_vrf( vrf_pubkey: &[u8; 32], // from GET /vrf/pubkey or hardcoded alpha: &str, // from VRF output signature: &[u8; 64], // from VRF output (signature_hex decoded) ) -> bool { // NEAR native ed25519_verify: ~1 TGas env::ed25519_verify(signature, alpha.as_bytes(), vrf_pubkey) } ``` Full contract example — see [wasi-examples/vrf-example/vrf-contract/](https://github.com/out-layer/vrf-example/tree/main/vrf-contract/). ### Deploying a contract with VRF **Step 1.** Get the VRF public key: ```bash # Mainnet curl -s https://api.outlayer.ai/vrf/pubkey | jq -r .vrf_public_key_hex # Testnet curl -s https://testnet-api.outlayer.ai/vrf/pubkey | jq -r .vrf_public_key_hex ``` **Step 2.** Initialize contract with the pubkey: ```bash near call my-vrf.near new '{ "outlayer_contract_id": "outlayer.near", "project_id": "alice.near/vrf-ark", "vrf_pubkey_hex": "a1b2c3d4..." }' --accountId my-vrf.near ``` **Step 3.** Verify it was stored: ```bash near view my-vrf.near get_vrf_pubkey # "a1b2c3d4..." ``` If the keystore rotates the VRF key (rare), update via `set_vrf_pubkey` (contract owner only): ```bash near call my-vrf.near set_vrf_pubkey '{"vrf_pubkey_hex": "new_key_hex..."}' --accountId my-vrf.near ``` ### Complete example: coin flip with on-chain verification **WASI module** — generates VRF random number: ```rust use outlayer::vrf; let result = vrf::random("coin-flip")?; // Map to 0 (Heads) or 1 (Tails) let bytes = u32::from_be_bytes(/* first 4 bytes of output_hex */); let side = (bytes as u64 * 2 / (u32::MAX as u64 + 1)) as u32; ``` **NEAR contract** — requests execution and verifies: ```rust // 1. Request execution ext_outlayer::ext(outlayer_contract_id) .with_attached_deposit(NearToken::from_millinear(10)) .request_execution( json!({"Project": {"project_id": "alice.near/vrf-ark"}}), Some(resource_limits), Some(r#"{"seed":"coin-flip","max":1}"#.to_string()), None, Some("Json".to_string()), Some(player.clone()), ) .then(ext_self::ext(current_account_id()).on_vrf_result(player, choice)); // 2. In callback — verify proof let entry = &vrf_response.results[0]; let sig_bytes: [u8; 64] = hex::decode(&entry.signature_hex).try_into().unwrap(); let valid = env::ed25519_verify(&sig_bytes, entry.alpha.as_bytes(), &self.vrf_pubkey); assert!(valid, "VRF proof verification failed"); ``` ## User verification guide ### 1. Get the VRF public key ```bash curl https://api.outlayer.ai/vrf/pubkey # {"vrf_public_key_hex":"a1b2c3d4..."} (64 hex chars = 32 bytes) ``` ### 2. Verify Ed25519 signature Given a VRF result: ```json { "value": 0, "signature_hex": "abcd...1234", "alpha": "vrf:98321:alice.near:coin-flip" } ``` **Python (PyNaCl):** ```python from nacl.signing import VerifyKey import hashlib vrf_pubkey_hex = "..." # from /vrf/pubkey signature_hex = "..." # from result alpha = "vrf:98321:alice.near:coin-flip" vrf_pubkey = bytes.fromhex(vrf_pubkey_hex) signature = bytes.fromhex(signature_hex) # Verify: Ed25519 signature over alpha verify_key = VerifyKey(vrf_pubkey) verify_key.verify(alpha.encode(), signature) # raises if invalid print("Signature VALID") # Verify: output = SHA256(signature) output = hashlib.sha256(signature).hexdigest() print(f"Output: {output}") # If mapped to range: first 4 bytes → u32 → scale first_4 = int(output[:8], 16) mapped = first_4 * (max_value + 1) // (2**32) print(f"Mapped value: {mapped}") ``` **JavaScript (tweetnacl):** ```javascript import nacl from 'tweetnacl'; import { createHash } from 'crypto'; const vrfPubkey = Buffer.from(vrfPubkeyHex, 'hex'); const signature = Buffer.from(signatureHex, 'hex'); const alpha = Buffer.from('vrf:98321:alice.near:coin-flip'); // Verify signature const valid = nacl.sign.detached.verify(alpha, signature, vrfPubkey); console.log('Valid:', valid); // Verify output const output = createHash('sha256').update(signature).digest('hex'); console.log('Output:', output); ``` **NEAR contract (on-chain):** ```rust let valid = env::ed25519_verify(&signature_bytes, alpha.as_bytes(), &vrf_pubkey_bytes); // ~26 TGas, native NEAR support ``` ### 3. Verify alpha integrity The alpha `vrf:{request_id}:{sender_id}:{user_seed}` contains: - **request_id** — visible in the blockchain transaction event (from `request_execution` call) - **sender_id** — the account that initiated the request - **user_seed** — the seed from the WASM input Reconstruct and compare: ```python expected_alpha = f"vrf:{request_id}:{sender_id}:{user_seed}" assert alpha == expected_alpha, "Alpha mismatch — possible tampering" ``` ### Verification checklist 1. `ed25519_verify(vrf_pubkey, alpha, signature)` — signature is valid 2. `SHA256(signature) == output_hex` — output matches signature 3. Alpha contains correct `request_id` from blockchain event 4. Alpha contains correct `sender_id` (the caller) 5. VRF public key matches `GET /vrf/pubkey` If all 5 checks pass, the random output is provably correct and was not manipulated. ## API reference | Endpoint | Method | Auth | Response | |----------|--------|------|----------| | `/vrf/pubkey` | GET | Public | `{"vrf_public_key_hex": "..."}` | SDK functions: | Function | Returns | Description | |----------|---------|-------------| | `vrf::random(seed)` | `Result` | Random output + proof | | `vrf::random_bytes(seed)` | `Result<([u8; 32], String, String)>` | Raw bytes + signature + alpha | | `vrf::public_key()` | `Result` | VRF public key hex | ## Source code - Crypto: [keystore-worker/src/crypto.rs](https://github.com/out-layer/outlayer/blob/main/keystore-worker/src/crypto.rs) — `vrf_generate`, `vrf_public_key_hex` - Host functions: [worker/src/outlayer_vrf/host_functions.rs](https://github.com/out-layer/outlayer/blob/main/worker/src/outlayer_vrf/host_functions.rs) - SDK: [sdk/outlayer/src/vrf.rs](https://github.com/out-layer/outlayer/blob/main/sdk/outlayer/src/vrf.rs) - WIT interface: [sdk/outlayer/wit/deps/vrf.wit](https://github.com/out-layer/outlayer/blob/main/sdk/outlayer/wit/deps/vrf.wit) - Example WASI: [wasi-examples/vrf-example/](https://github.com/out-layer/vrf-example/) - Example contract: [wasi-examples/vrf-example/vrf-contract/](https://github.com/out-layer/vrf-example/tree/main/vrf-contract/) --- > Source: https://github.com/out-layer/outlayer/blob/main/WORKER_ATTESTATION.md # Worker Attestation How NEAR OutLayer cryptographically verifies that workers run inside Intel TDX and restricts sensitive operations to attested code. ## Overview Every worker runs inside an **Intel TDX** (Trust Domain Extension) confidential VM on Phala Cloud. Before a worker can submit execution results or decrypt user secrets, it must prove two things: 1. **Its code is genuine** — the TDX hardware measurements (MRTD + RTMR0-3) match an admin-approved set. 2. **It holds a TEE-generated private key** — the ed25519 keypair was created inside the TEE and the public key is registered on-chain. These proofs happen at two distinct stages: **key registration** (on-chain, at startup) and **session establishment** (off-chain, challenge-response with coordinator and keystore). ## Architecture ``` ┌─────────────────────────────────────────────────────┐ │ Worker (Intel TDX) │ │ │ │ 1. Generate ed25519 keypair │ │ 2. Generate TDX quote (public key in report_data) │ │ 3. Call register-contract → on-chain key │ │ 4. Challenge-response → coordinator session │ │ 5. Challenge-response → keystore session │ │ 6. Work: auth_key + X-TEE-Session on every request │ └────────┬──────────────────┬─────────────────────────┘ │ │ ▼ ▼ ┌────────────────┐ ┌───────────────┐ │ Coordinator │ │ Keystore │ │ (PostgreSQL) │ │ (in-memory) │ │ │ │ │ │ verify sig │ │ verify sig │ │ check NEAR │ │ check NEAR │ ← independent RPC │ issue session │ │ issue session│ └────────────────┘ └───────────────┘ │ ▼ ┌─────────────────────────────┐ │ register-contract (NEAR) │ │ Source of truth for keys │ │ Verifies TDX quotes │ │ Stores keys as access keys │ └─────────────────────────────┘ ``` ## Stage 1: On-Chain Key Registration Happens once per worker startup. Code: `worker/src/registration.rs`. ### Steps 1. **Keypair generation.** The worker generates a fresh ed25519 keypair inside the TEE (`worker/src/registration.rs:80-99`). The private key never leaves the confidential VM. The keypair is persisted to `~/.near-credentials/worker-keypair.json` so that a soft restart reuses the same key. 2. **TDX quote generation.** The worker calls the Phala dstack SDK to produce a TDX quote (`worker/src/tdx_attestation.rs:33-54`). The worker's public key is embedded in the first 32 bytes of `report_data`, cryptographically binding the key to the TEE instance. The quote also contains **5 hardware measurements** (MRTD + RTMR0-3) of the entire TEE image. 3. **On-chain verification.** The worker sends the quote to `register_worker_key()` on the register-contract (`register-contract/src/lib.rs:120-187`). The contract: - Verifies the Intel TDX signature using `dcap-qvl` (same library as NEAR MPC Node). - Extracts all 5 measurements (MRTD, RTMR0-3) and checks them against the admin-approved list. - Extracts the public key from `report_data` and confirms it matches the `public_key` argument. - Adds the public key as an access key on its own account, scoped to `resolve_execution` and related methods on the main outlayer contract. 4. **Result.** The worker now has a NEAR access key that can only call specific methods on the outlayer contract. This key is the basis for all subsequent attestation. ### What the contract proves | Property | How | |----------|-----| | Key was generated in a TEE | Public key extracted from TDX `report_data`, signed by Intel | | TEE runs approved code | All 5 measurements (MRTD + RTMR0-3) checked against admin-maintained allowlist | | Key is scoped | Access key limited to specific contract methods with 10 NEAR gas allowance | ## Stage 2: TEE Session Establishment (Challenge-Response) After on-chain registration, the worker establishes **sessions** with the coordinator and keystore using a challenge-response protocol. This proves to each service that the caller holds the private key registered on-chain — without re-verifying the full TDX quote. Shared cryptographic logic lives in the `tee-auth` crate (`tee-auth/src/lib.rs`), used by both coordinator and keystore. ### Protocol ``` Worker Server (coordinator or keystore) │ │ │ POST /tee-challenge │ │ (bearer auth_key) │ │ ─────────────────────────────► │ │ │ Generate 32 random bytes │ { challenge: "a1b2c3..." } │ Store with timestamp │ ◄───────────────────────────── │ │ │ │ Sign challenge with TEE key │ │ │ │ POST /register-tee │ │ { public_key, challenge, │ │ signature } │ │ ─────────────────────────────► │ │ │ 1. Validate challenge (one-time, <60s) │ │ 2. Verify ed25519 signature │ │ 3. NEAR RPC view_access_key │ │ → key exists on register-contract? │ │ 4. Create session │ { session_id: UUID } │ │ ◄───────────────────────────── │ │ │ │ All subsequent requests: │ │ Authorization: Bearer │ │ X-TEE-Session: │ │ ─────────────────────────────► │ ``` ### Verification steps (server-side) 1. **Challenge lookup.** Find the challenge in storage, verify it belongs to this worker's token and is less than 60 seconds old. Delete it (one-time use). 2. **Signature verification.** `tee_auth::verify_signature()` — parse the ed25519 public key, decode the hex challenge to raw bytes, verify the signature. Uses `ed25519-dalek`. 3. **On-chain key check.** `tee_auth::check_access_key_on_contract()` — NEAR RPC `view_access_key` query against the register-contract account. If the key exists, it was registered via TDX attestation. If it was removed (admin revocation), the check fails. 4. **Session creation.** - Coordinator: stored in PostgreSQL (`worker_tee_sessions` table), survives restarts. - Keystore: stored in-memory (`HashMap`), lost on restart. ### Coordinator specifics - Endpoints: `POST /workers/tee-challenge`, `POST /workers/register-tee` - Auth middleware (`coordinator/src/auth.rs`) extracts `X-TEE-Session` header and validates against DB. - DB query only runs when `REQUIRE_TEE_SESSION=true` (no overhead when feature is off). - HTTPS call handler (`coordinator/src/handlers/call.rs`) rejects results without a valid session when the feature flag is on. ### Keystore specifics - Endpoints: `POST /tee-challenge`, `POST /register-tee` - Keystore verifies the key on register-contract **independently** — a compromised coordinator cannot forge sessions. - Session middleware checks `X-TEE-Session` on all worker endpoints (`/decrypt`, `/encrypt`, `/storage/*`). - In-memory sessions are lost on keystore restart; workers detect 403 and re-register automatically (two HTTP calls, no blockchain transaction). ### Worker specifics - Code: `worker/src/api_client.rs` — `register_tee_session()` and `register_keystore_tee_session()` both use the shared `do_tee_challenge_response()` method. - On startup, the worker registers sessions with both coordinator and keystore (`worker/src/main.rs:311-340`). - `ApiClient` and `KeystoreClient` both attach `X-TEE-Session` to every subsequent request via `add_auth_headers()`. - If session registration fails, the worker logs a warning and continues (graceful degradation when `REQUIRE_TEE_SESSION=false`). ## Layers of Defense | Layer | What it does | When | |-------|-------------|------| | `auth_key` (bearer token) | Anti-spam, identifies worker | Every request | | TDX attestation (on-chain) | Proves code identity + key origin | Worker startup | | Challenge-response (coordinator) | Proves private key possession | Session setup | | Challenge-response (keystore) | Independent proof, not trusting coordinator | Session setup | | `X-TEE-Session` header | Binds requests to verified identity | Every request | | `view_access_key` (NEAR RPC) | Checks key still exists on contract | Session setup | ## Threat Model | Threat | Mitigation | |--------|-----------| | Attacker knows auth_key | Cannot sign challenge — no TEE private key | | Attacker reads public keys from chain | Cannot sign challenge | | Attacker replays a signed challenge | Challenge is one-time use and expires in 60 seconds | | Coordinator is compromised | Keystore verifies independently via its own NEAR RPC call | | Worker restarts | New keypair generated → new on-chain registration → new sessions | | Leaked worker private key | Admin calls `remove_worker_keys()` on contract → `view_access_key` returns false → no new sessions | | Stale keys accumulate on contract | `remove_worker_keys()` deletes keys individually (independent promises) and frees ~0.042 NEAR storage per key | ## Configuration ### Coordinator | Env var | Default | Description | |---------|---------|-------------| | `OPERATOR_ACCOUNT_ID` | (none) | Account where TEE worker keys are registered as access keys (e.g., `worker.outlayer.testnet`). Required for TEE session endpoints. | | `REQUIRE_TEE_SESSION` | `false` | When `true`, HTTPS call completions require a valid `X-TEE-Session`. | ### Keystore | Env var | Default | Description | |---------|---------|-------------| | `OPERATOR_ACCOUNT_ID` | (none) | Account where TEE worker keys are registered. Used for NEAR RPC verification. | ### Worker | Env var | Default | Description | |---------|---------|-------------| | `USE_TEE_REGISTRATION` | `true` | Enable TDX-based key registration flow. | | `OPERATOR_ACCOUNT_ID` | (required) | Operator account where register-contract is deployed and keys are stored. | | `TEE_MODE` | `outlayer_tee` | `outlayer_tee` for production, `none` for dev. | ## Zero-Downtime Rollout Deploy in this order: 1. Coordinator with `REQUIRE_TEE_SESSION=false` + keystore with `TEE_MODE=none` — new endpoints exist but are not enforced. 2. Workers with `TEE_MODE=outlayer_tee` — they register TEE sessions on startup. Existing workers without sessions continue to work. 3. Set `REQUIRE_TEE_SESSION=true` on coordinator and `TEE_MODE=outlayer_tee` on keystore — only attested workers can submit results and decrypt secrets. ## Key Cleanup Each worker restart generates a new keypair, leaving dead access keys on the operator account (~0.042 NEAR storage each). Clean up periodically: ```bash near call worker.outlayer.near remove_worker_keys \ '{"public_keys": ["ed25519:...", "ed25519:..."]}' \ --accountId outlayer.near \ --gas 300000000000000 ``` Removing a key also instantly invalidates any TEE sessions that depend on it — the next `view_access_key` check will fail.