diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d401dba..82e19f1 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ { "name": "0g-private-sandbox", "description": "Create and manage 0G Private Sandboxes from Claude Code", - "version": "1.0.0", + "version": "2.0.0", "author": { "name": "0G Foundation" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 08e5a02..1bd0b84 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "0g-private-sandbox", - "version": "1.1.0", + "version": "2.0.0", "description": "0G Private Sandbox — create and manage private TEE sandboxes. Includes user skill (create, manage, vibe-code) and provider skill (deploy, register, operate).", "author": { "name": "0G Foundation" }, "repository": "https://github.com/0gfoundation/0g-sandbox", diff --git a/.claude/skills/0g-private-sandbox-provider/SKILL.md b/.claude/skills/0g-private-sandbox-provider/SKILL.md index 6f02df7..46b162f 100644 --- a/.claude/skills/0g-private-sandbox-provider/SKILL.md +++ b/.claude/skills/0g-private-sandbox-provider/SKILL.md @@ -1,7 +1,7 @@ --- name: 0g-private-sandbox-provider -description: Use this skill when a provider wants to deploy, register, or operate their 0G Private Sandbox service — covering tapp-cli deployment, on-chain registration, snapshot management, earnings withdrawal, sandbox monitoring, and settlement history. -version: 2.0.0 +description: Use this skill when a provider wants to deploy, register, or operate their 0G Private Sandbox service — covering tapp-cli deployment, on-chain registration, node rotation, snapshot management, resource quotas, earnings withdrawal, sandbox monitoring, and settlement history. +version: 3.0.0 author: 0G Labs tags: [0g, sandbox, provider, registration, snapshot, tapp] repository: https://github.com/0gfoundation/0g-sandbox @@ -9,6 +9,21 @@ repository: https://github.com/0gfoundation/0g-sandbox # 0G Private Sandbox — Provider Skill +## Identity model (v2 — read this first) + +- The **provider address** on-chain is the node's **TEE signer address** — its + key lives inside the enclave, never leaves, and changes when the machine is + rebuilt (service restarts keep it). It is the ledger identity: voucher payee, + user balance bucket, earnings account. +- The **app owner** (the appId's TappRegistry owner wallet) does ALL + management: register/remove services, withdraw earnings, rotate nodes, + dashboard/admin APIs. Export it as `OWNER_KEY` (legacy alias `PROVIDER_KEY` + still accepted). +- Nothing identity-related is configured in `.env`: the billing service derives + its provider address from the TEE key and resolves the owner from + `getAppInfo(BACKEND_APP_NAME).owner` at runtime. The owner is always an + admin; `ADMIN_ADDRESSES` adds extra operator wallets (additive). + --- ## MANDATORY: Session setup @@ -43,16 +58,20 @@ What would you like to do? Deploy the billing service and register it on-chain for the first time. **B — Update service registration** -Update URL, TEE signer, or pricing on an already-deployed service. +Update URL or pricing on an already-deployed service. + +**C — Rotate after a machine rebuild** +The TEE signer changed — migrate the service to the new signer safely. -**C — Day-to-day operations** +**D — Day-to-day operations** - `snapshot` — Register, import, list, or delete sandbox images -- `withdraw` — Withdraw accumulated earnings +- `withdraw` — Withdraw a node's earnings to the owner wallet +- `quota` — Set machine-wide resource caps (oversell control) - `monitor` — View active billing sessions, force-delete sandboxes, archive all - `events` — View voucher settlement history -- `status` — Check current registration and earnings +- `status` — Check a node's registration and earnings -Type A, B, or the operation name → +Type A, B, C, or the operation name → --- @@ -62,115 +81,93 @@ After receiving the answer, proceed to the relevant section below. ## A — First-time Setup -Full sequence: deploy the billing service → get TEE signing address → register on-chain → verify. +Full sequence: deploy → get the node's TEE signer → register on-chain (owner-signed) → verify. ### Step 1 — Deploy the billing service -Use the **0g-tapp-cli skill** to deploy. Invoke it now if the user hasn't deployed yet: +Use the **0g-tapp-cli skill** to deploy. Key points: -> Key points for 0G Sandbox deployment: -> - App ID: `0g-sandbox` (must match `BACKEND_APP_NAME` in `.env`) -> - Docker Compose file: `docker-compose.yml` in the repo root -> - Server: provider's Tapp TEE server (e.g. `:50051`) -> - Fill `.env` from `.env.testnet` or `.env.mainnet` before deploying -> - After deploy: note the task ID, wait for it to complete +> - App ID (e.g. `0g-sandbox-provider`) must equal `BACKEND_APP_NAME` in `.env` — +> it is the same string as the TappRegistry appId. +> - `.env` needs no wallet addresses (see Identity model). Optional: +> `ADMIN_ADDRESSES` for extra operator wallets. +> - ⚠ Every `.env` change alters the app's `volumesHash` → redeploy + +> `update-onchain` bumps `ackVersion` → **all users must re-acknowledge**. +> Batch config changes; don't drip them. ```bash -tapp-cli -s http://:50051 start-app -f docker-compose.yml --app-id 0g-sandbox +tapp-cli -s http://:50051 start-app -f docker-compose.yml --app-id tapp-cli -s http://:50051 get-task-status --task-id ``` -### Step 2 — Get the TEE signing address - -The TEE signing key is only known after the app starts — this is why registration must happen after deploy. +### Step 2 — TappRegistry registration (owner wallet, via tapp-cli) ```bash -tapp-cli -s http://:50051 get-app-key --app-id 0g-sandbox -# → e.g. 0xe29b6f4e65a796d77196faf511e0e0b859503656 -``` - -Save this address — it is the `--tee-signer` value for registration. - -### Step 3 — Register service on-chain - -Two options — **dashboard is recommended**: - -#### Option A: Dashboard (recommended) +# Registers the app + its FIRST node (signer auto-fetched from the server), pays per-node stake +tapp-cli -s http://:50051 -k 0x register-onchain \ + --app-id --rpc-url --contract --stake-wei -Open `http://:8080/dashboard` in a browser. -Connect your provider wallet → Service Registration card → click "Register / Update". - -Fill in: -- **Service URL** — public URL of the billing proxy (e.g. `http://:8080`) -- **TEE Signer Address** — from Step 2 -- **CPU Price / min** — default `1000000000000000` neuron (= 0.001 0G/CPU/min) -- **Mem Price / GB / min** — default `500000000000000` neuron (= 0.0005 0G/GB/min) -- **Create Fee** — fee per sandbox creation; **enter carefully** (dashboard default `5000000` is very small — CLI default `60000000000000000` = 0.06 0G is more typical) -- **Stake** — required only on first registration (typically 100 0G on testnet); check the "Required Stake" value shown in the Contract card +# Authorize the settlement contract to bump ackVersion on price changes (once per contract) +cast send "authorizeInvalidator(string,address)" "" \ + --rpc-url --private-key 0x --legacy --gas-price 3000000000 +``` -#### Option B: CLI +### Step 3 — Get the node's TEE signer (= the provider address) -Ask the user for all values, then run: +Either of: ```bash -PROVIDER_KEY=0x $PROVIDER_CLIregister \ - --rpc \ - --chain-id \ - --contract \ - --url \ - --tee-signer \ - --price-per-cpu \ - --price-per-mem \ - --fee +tapp-cli -s http://:50051 get-app-key --app-id +# or, once the billing service is up: +curl -s http://:8082/api/info | grep provider_address ``` -Network presets: -- Testnet: `--rpc https://evmrpc-testnet.0g.ai --chain-id 16602` -- Mainnet: `--rpc https://evmrpc.0g.ai --chain-id 16661` +### Step 4 — Register the service on-chain (owner-signed) -**First registration:** the required stake is read from the contract and attached automatically as `msg.value`. Provider wallet must have enough 0G. +Dashboard (recommended): open `/dashboard`, connect the **owner wallet** +(App Owner row shows which), Service Registration → "Register / Update" — +the signer field is pre-filled from `/api/info`. -### Step 4 — Verify registration +CLI: ```bash -$PROVIDER_CLIstatus \ - --rpc \ +OWNER_KEY=0x $PROVIDER_CLI register \ + --rpc --chain-id \ --contract \ - --address + --app-id \ + --signer \ + --url \ + --price-per-cpu --price-per-mem --fee ``` -Or check the Service Registration card on the dashboard — it should show **✓ Registered**. +Network presets: testnet `--rpc https://evmrpc-testnet.0g.ai --chain-id 16602`; +mainnet `--rpc https://evmrpc.0g.ai --chain-id 16661`. + +No stake is collected here — stake lives in TappRegistry per node (Step 2). +The contract checks: caller == appId's TappRegistry owner, signer is an active +node, contract is an authorized invalidator. -### Step 5 — Restart billing service to pick up registration +### Step 5 — Verify + restart billing to pick up on-chain pricing ```bash -tapp-cli -s http://:50051 stop-service --app-id 0g-sandbox --service-name billing -tapp-cli -s http://:50051 start-service --app-id 0g-sandbox --service-name billing +$PROVIDER_CLI status --rpc --contract \ + --address --tapp + +tapp-cli -s http://:50051 stop-service --app-id --service-name sandbox +tapp-cli -s http://:50051 start-service --app-id --service-name sandbox ``` +`/api/info` should then show `signer.status: "aligned"` and the on-chain prices. + --- ## B — Update Service Registration -Re-run registration with new values. No stake required for updates. +Re-run `register` (owner-signed) with new values — same `--signer`, same `--app-id`. -**Via dashboard:** open `/dashboard` → Service Registration → "Register / Update" → submit new values. - -**Via CLI:** - -```bash -PROVIDER_KEY=0x $PROVIDER_CLIregister \ - --rpc \ - --chain-id \ - --contract \ - --url \ - --tee-signer \ - --price-per-cpu \ - --price-per-mem \ - --fee -``` - -> **Warning:** changing the TEE signer increments `signerVersion`. All existing users will get `NOT_ACKNOWLEDGED` errors until they re-run acknowledge. +> **Price/createFee changes bump `ackVersion`** — every existing user must +> re-run `acknowledge` before their vouchers settle again. URL-only changes do +> NOT invalidate acks. ### Unit conversion reference @@ -182,18 +179,41 @@ PROVIDER_KEY=0x $PROVIDER_CLIregister \ --- +## C — Rotate After a Machine Rebuild + +A rebuilt machine has a **new TEE signer** → new provider identity. The old +signer's key is gone forever. Run these in order: + +``` +1. Machine back up with the new key. The settler detects its signer is not a + registered node and HOLDS the voucher queue (no gas burned, no lost revenue). +2. tapp-cli add-node-onchain — ADD the new signer (do NOT replace): + old + new nodes coexist so both signers' pending vouchers can settle. +3. Wait for the OLD signer's queue to drain: GET /api/queue/summary → 0. +4. OWNER_KEY=0x $PROVIDER_CLI rotate --contract \ + --old 0x --new 0x + (copies appId/URL/prices to the new signer, removes the old service and + sweeps its pending earnings to the owner; same prices → no ack bump) +5. tapp-cli remove-node-onchain for the old signer (stake unlocks ~1 day → withdraw). +6. Users with balance at the old signer: requestRefund → 2h lock → + withdrawRefund → deposit to the new signer. +``` + +To just remove a node's service (no successor): `$PROVIDER_CLI remove-service --signer 0x`. + +--- + ## Snapshot Management -Snapshots are provider-managed shared base images that users pick when creating sandboxes. +Snapshots are provider-managed shared base images. Specs (CPU/mem/disk) are +fixed per snapshot — users cannot override them at create time. -**Easiest via dashboard:** `/dashboard` → Snapshots section. +**Easiest via dashboard:** `/dashboard` → Snapshots (owner wallet login). ### Option A: Build locally and push -**1. Build image** - ```bash -docker build -t : -f . +docker build -t : -f . # tag must NOT be :latest ``` Base image must be `daytonaio/sandbox:0.5.0-slim` (runs as `USER daytona`): @@ -201,149 +221,151 @@ Base image must be `daytonaio/sandbox:0.5.0-slim` (runs as `USER daytona`): ```dockerfile FROM daytonaio/sandbox:0.5.0-slim RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain stable --profile minimal -ENV PATH="/home/daytona/.cargo/bin:${PATH}" -# NOT /root/.cargo/bin +ENV PATH="/home/daytona/.cargo/bin:${PATH}" # NOT /root/.cargo/bin ``` -Tag must NOT be `:latest`. - -**2. Push to internal registry via runner container** +Push into the internal registry via the runner: ```bash -docker save : | docker exec -i docker load -docker exec docker tag \ - : \ - registry:6000/daytona/: -docker exec docker push \ - registry:6000/daytona/: +$PROVIDER_CLI push-image --image : --runner ``` -Or with the built-in helper (default runner: `0g-sandbox-billing-runner-1`): +### Option B: Import from an external registry + +**Via dashboard:** Snapshots → "↓ Import Image" (source image, optional +credentials, target name + tag). Synchronous — large images take minutes. +(API: `POST /api/registry/pull`, admin-signed.) + +### Register / list / delete snapshots ```bash -$PROVIDER_CLIpush-image \ - --image : \ - --runner +OWNER_KEY=0x $PROVIDER_CLI snapshot \ + --api http://:8082 \ + --image registry:6000/daytona/: \ + --name [--cpu N --memory N --disk N | --tiers] + +OWNER_KEY=0x $PROVIDER_CLI snapshots --api http://:8082 +OWNER_KEY=0x $PROVIDER_CLI delete-snapshot --api http://:8082 --id ``` -### Option B: Import from external registry +`--tiers` sizes: small (1C/1GB/10GB), medium (2C/4GB/30GB), large (4C/8GB/60GB). +State: `pending` → `active` in ~30s. Standard base snapshots to offer users: +**0g-ubuntu22** (1C/1G, general) and **0g-openclaw** (2C/4G, AI coding gateway). + +--- + +## New Provider Domain Checklist -Pull any public or private image directly into the internal registry — no local build needed. +Setting up a domain for a provider is a MANUAL infra ritual (DNS/LB/certs live +outside the deploy tooling). Five items — miss any and a whole feature dies +silently (verified the hard way, 2026-07-20): -**Via dashboard:** Snapshots → "↓ Import Image" — fill in source image, optional credentials, target name and tag. +1. Apex `domain` → `:8082` (billing API + dashboard) +2. **Wildcard `*.domain` → `:4000`** (daytona proxy; preserve Host + header, WebSocket upgrade) — powers port previews (`8000-.domain`) +3. Wildcard TLS certificate covering `*.domain` +4. TCP 2222 forwarding (SSH gateway); then set `SSH_GATEWAY_HOST=` +5. **All records DNS-only (grey cloud)** — Cloudflare proxying (orange) blocks + server-to-server calls (the broker health check gets WAF 403 → "offline") -**Via API:** +Acceptance probe after wiring: ```bash -# POST /api/registry/pull (provider auth required) -# src: source image (e.g. docker.io/library/ubuntu:22.04) -# name: target repo under registry:6000/daytona/ -# tag: must NOT be "latest" -# username/password: optional, for private registries +curl -s https://8000-test./ | head -c 100 +# "dex/auth" redirect = wildcard routing OK; "Welcome to OpenResty!" = rule missing ``` +Domain/env changes alter volumesHash → users must re-acknowledge; batch them. -This is **synchronous** — may take several minutes for large images. +--- -### Register snapshot +## Resource Quotas (oversell control) -After the image is in the registry, register it as a snapshot: +Two independent layers (both verified live): -```bash -# Single snapshot (custom or default spec) -PROVIDER_KEY=0x $PROVIDER_CLIsnapshot \ - --api http://:8080 \ - --image registry:6000/daytona/: \ - --name +1. **Per-sandbox cgroup limits** — `RESOURCE_LIMITS_DISABLED=false` on the + runner service (compose already sets it; the runner image bakes `true`, so + removing the line silently unlimits every sandbox). +2. **Machine-wide cap** — Daytona **org quota**, checked on every create: + total CPU/mem/disk across all sandboxes + per-sandbox maxima. This is the + ONLY total-capacity gate. (`DEFAULT_RUNNER_CPU/MEMORY/DISK` do NOT cap + anything — they only rank runners in multi-runner setups; useless with one + runner. The runner also auto-reports real host resources over them.) -# With size tiers (creates -small, -medium, -large) -PROVIDER_KEY=0x $PROVIDER_CLIsnapshot \ - --api http://:8080 \ - --image registry:6000/daytona/: \ - --name \ - --tiers -``` +Sizing rule of thumb: usable = host − stack overhead (~2C/6G); then CPU ×4 +(compressible), memory ×1.5 (OOM risk — add swap), disk ×2. Per-sandbox max +should stay small relative to the total. -CLI `--tiers` sizes: small (1C/1GB/10GB disk), medium (2C/4GB/30GB), large (4C/8GB/60GB). -Dashboard presets: small (1C/1GB/3GB), medium (2C/4GB/10GB), large (4C/8GB/20GB). +**Fresh install:** set in `.env` (compose passes them through): -State: `pending` → `active` in ~30s (Daytona pulls the image). +```bash +DEFAULT_REGION_ENFORCE_QUOTAS=true +DEFAULT_ORG_QUOTA_TOTAL_CPU_QUOTA= +DEFAULT_ORG_QUOTA_TOTAL_MEMORY_QUOTA= +DEFAULT_ORG_QUOTA_TOTAL_DISK_QUOTA= +DEFAULT_ORG_QUOTA_MAX_CPU_PER_SANDBOX=4 +DEFAULT_ORG_QUOTA_MAX_MEMORY_PER_SANDBOX=8 +DEFAULT_ORG_QUOTA_MAX_DISK_PER_SANDBOX=50 +``` -### List / delete snapshots +**Existing install:** env seeds are ignored (rows already exist) — update the DB +directly, effective immediately, no restart: -```bash -PROVIDER_KEY=0x $PROVIDER_CLIsnapshots --api http://:8080 -PROVIDER_KEY=0x $PROVIDER_CLIdelete-snapshot \ - --api http://:8080 --id +```sql +UPDATE region SET "enforceQuotas"=true WHERE id='us'; +-- INSERT the region_quota row first if the table is empty: +INSERT INTO region_quota ("organizationId","regionId",total_cpu_quota,total_memory_quota, + total_disk_quota,max_cpu_per_sandbox,max_memory_per_sandbox,max_disk_per_sandbox,"sandboxClass") +VALUES ('','us',,,,4,8,50,'container'); ``` +Over-limit creates fail with `Total CPU limit exceeded` (nothing is billed). +Stopped-but-not-deleted sandboxes still occupy quota (they can be started back). + --- ## Withdraw Earnings +Earnings accrue per node (TEE signer) and are withdrawn **by the owner, to the +owner wallet** — the signer key has no payout rights. + ```bash -PROVIDER_KEY=0x $PROVIDER_CLIwithdraw \ - --rpc \ - --chain-id \ - --contract +OWNER_KEY=0x $PROVIDER_CLI withdraw \ + --rpc --chain-id \ + --contract --signer ``` -Or via dashboard: Earnings card → "Withdraw Earnings" button. - -If earnings are 0, the command exits early with a message. +Or via dashboard: Earnings card → "Withdraw Earnings" (connect owner wallet). --- ## Monitor — Billing Sessions & Sandboxes -**Via dashboard:** `/dashboard` → All Sandboxes. - -Shows all active billing sessions: sandbox ID, owner wallet, state, accrued fee. - -### Force delete a sandbox - -Permanently deletes a sandbox regardless of state: - -- Dashboard: Delete button next to each sandbox row -- API: `DELETE /api/sandbox//force` (signed request) - -### Archive all sandboxes - -Stops all running sandboxes and backs up state to object storage. Use before a server redeploy. +**Via dashboard:** `/dashboard` → All Sandboxes (owner or ADMIN_ADDRESSES wallet). -- Dashboard: "Archive All" button -- API: `POST /api/archive-all` (signed request) - -User sandboxes are not lost — they can be restored after redeploy. +- Force delete: Delete button / `DELETE /api/sandbox//force` (signed) +- Archive all before a redeploy: "Archive All" / `POST /api/archive-all` — + user sandboxes are backed up and restorable afterwards --- ## Voucher Settlement History -Browse on-chain `SettleFeesWithTEE` events. - -**Via dashboard:** `/dashboard` → Voucher Settlement History → select time range → Load. +**Via dashboard:** `/dashboard` → Voucher Settlement History → time range → Load. -Time range options: last 1h / 24h / 7d / all history. Results are paginated (50 per page). - -**Via API:** -``` -GET /api/events?since=&page=&page_size=50 -``` - -Each event: `timestamp`, `user`, `total_fee`, `nonce`, `status` (SUCCESS / INSUFFICIENT_BALANCE / …), `tx_hash`. +**Via API:** `GET /api/events?since=&page=&page_size=50` +Each event: `timestamp`, `user`, `total_fee`, `nonce`, `status`, `tx_hash`. --- ## Status Check ```bash -# With address (no key needed) -$PROVIDER_CLIstatus \ - --rpc \ - --contract \ - --address +# Read-only, no key needed. --address is the node's TEE signer. +$PROVIDER_CLI status --rpc --contract \ + --address [--tapp ] ``` -Shows: URL, TEE signer, CPU/mem pricing, create fee, signer version, stake, earnings. +Shows: URL, appId, pricing, create fee, earnings; with `--tapp` also the app +owner and the node's TappRegistry state (active/stake). --- @@ -351,11 +373,16 @@ Shows: URL, TEE signer, CPU/mem pricing, create fee, signer version, stake, earn | Symptom | Cause | Fix | |---------|-------|-----| -| `AddOrUpdateService` fails: insufficient funds | Wallet < required stake | Fund provider wallet (100 0G on testnet) | -| `PROVIDER_KEY` not found | Env var not set | `export PROVIDER_KEY=0x` before running | -| Snapshot state stays `pending` | Daytona can't pull image | Check registry:6000 is reachable from Daytona; tag must not be `:latest` | -| `push-image` fails: runner not found | Wrong runner container name | `docker ps` to find name, pass `--runner ` | -| `delete-snapshot` 404 | Wrong snapshot name | Run `snapshots` command to list exact names | -| Import image very slow | Large image, synchronous pull | Normal — wait or check server logs | -| Users get `NOT_ACKNOWLEDGED` after re-registration | TEE signer changed, signerVersion incremented | Users must re-run `acknowledge` | -| Dashboard create fee default looks too small | Dashboard default = `5000000` neuron (≈ 0 0G) | Always set fee explicitly; CLI default = `60000000000000000` (0.06 0G) | +| `register` reverts: `not app owner` | Key isn't the appId's TappRegistry owner | Use the owner key (`OWNER_KEY`) | +| `register` reverts: `signer not an active node` | Signer not added in TappRegistry | `tapp-cli add-node-onchain` first | +| `register` reverts: invalidator error | Settlement contract not authorized for this appId | Run `authorizeInvalidator` (once per settlement contract — re-do after a contract migration) | +| Users suddenly get `NOT_ACKNOWLEDGED` / 402 | `ackVersion` bumped: price change, node change, or **any `.env` change + update-onchain** (volumesHash) | Users re-run `acknowledge`; batch config changes to avoid churn | +| Vouchers pile up, nothing settles | Settler holds the queue while its signer isn't a registered node (rotation window) | `add-node-onchain` the new signer — settler resumes automatically | +| Dashboard says "admin only" | Connected wallet is neither the app owner nor in `ADMIN_ADDRESSES` | Connect the owner wallet (App Owner row shows it) | +| `/api/info` signer.status `mismatch` | On-chain service appId ≠ `BACKEND_APP_NAME`, or node removed | Check registration; see startup log "appId drift" | +| On-chain `imageHashes` empty (`0x`) | Old tapp-server parses `docker compose images` wrong | Upgrade tapp-server, then `update-onchain` (bumps ackVersion) | +| Snapshot stays `pending` | Daytona can't pull the image | registry:6000 reachable? tag must not be `:latest` | +| Everyone can create unlimited sandboxes | Org quota not enforced | See Resource Quotas — `enforceQuotas` + region_quota row | +| SSH shows IP instead of domain | `SSH_GATEWAY_HOST` set to IP | Set it to the domain (confirm the LB forwards :2222 first) | +| Broker frontend shows provider "offline" | Broker's server-side health check blocked: Cloudflare orange-cloud WAF 403, or container DNS can't resolve a fresh domain | Grey-cloud the DNS record (or WAF-whitelist the broker IP); fresh domains: wait out propagation | +| Port preview URL shows an OpenResty/nginx default page | Wildcard `*.domain` traffic not forwarded to the daytona proxy | Add the wildcard→4000 rule (see New Provider Domain Checklist) | diff --git a/.claude/skills/0g-private-sandbox/SKILL.md b/.claude/skills/0g-private-sandbox/SKILL.md index ac1d891..c3c4477 100644 --- a/.claude/skills/0g-private-sandbox/SKILL.md +++ b/.claude/skills/0g-private-sandbox/SKILL.md @@ -145,14 +145,16 @@ After picking a provider, update `API` to the selected provider URL (from the ex The command scans the chain and prints available providers with their URL, pricing, and TEE signer. Example output: ``` -[1] 0xB831371eb2703305f1d9F8542163633D0675CEd7 - URL: http://:8080 +[1] 0xf982279B872B9a99d64C547a0faC2Dfdfc2AEE5D + URL: https://provider-private-sandbox.0g.ai Create fee: 0.0600 0G - CPU price: 0.001000 0G/CPU/sec - Mem price: 0.000500 0G/GB/sec - TEE signer: 0x61BEb835... (v4) + CPU price: 0.001000 0G/CPU/min + Mem price: 0.000500 0G/GB/min ``` +> The provider address IS the node's TEE signer (v2 identity model) — there is +> no separate signer field. Deposits and billing are bound to this address. + It also outputs ready-to-use export commands — have the user run them: ```bash @@ -219,13 +221,22 @@ Then list available snapshots: $USER_CLIsnapshots --api $API ``` +Standard snapshots (specs are FIXED by the snapshot — `--cpu/--memory/--disk` +cannot be combined with `--snapshot`; the API rejects it): + +| Snapshot | Specs | For | +|----------|-------|-----| +| **0g-ubuntu22** | 1 CPU / 1 GB | General vibe coding, running code, light services | +| **0g-openclaw** | 2 CPU / 4 GB | AI coding assistant (OpenClaw gateway) in a secure sandbox | + Based on goal, recommend: | User goal | Recommendation | |-----------|----------------| -| General vibe coding / running code | Default image (no snapshot) | -| AI coding assistant in secure sandbox | **openclaw** snapshot | +| General vibe coding / running code | **0g-ubuntu22** snapshot (1C/1G) | +| AI coding assistant in secure sandbox | **0g-openclaw** snapshot (2C/4G) | | Specific environment (Rust, Python…) | Match from snapshot list | +| Needs custom CPU/memory | Create WITHOUT `--snapshot` and pass `--cpu/--memory/--disk` (subject to the provider's per-sandbox limits) | **STOP and present recommendation:** > "Based on your goal, I recommend **[snapshot]**: [one-line description]. @@ -242,8 +253,17 @@ $USER_CLIcreate --api $API --name # With snapshot $USER_CLIcreate --api $API --name --snapshot -# Sealed sandbox — TEE attestation injected, SSH and toolbox permanently blocked -$USER_CLIcreate --api $API --name --sealed +# Sealed sandbox — TEE attestation injected, SSH and toolbox permanently blocked. +# MUST include --snapshot (bare --sealed is rejected: sealing resolves the image +# digest, so the image must exist in the provider's internal registry). +$USER_CLIcreate --api $API --name --sealed --snapshot + +# Restrict public ports — only listed ports are reachable without auth; +# all other ports require the owner's preview token. Omit = all ports public. +$USER_CLIcreate --api $API --name --snapshot --ports 8000,3000 +# Rules: max 16 ports; system ports 22222/2280/33333 rejected; immutable after +# create; sealed + --ports must include 8080. The create response returns +# ready-to-use `preview_urls` for each listed port. ``` Copy the returned sandbox ID: @@ -257,13 +277,16 @@ Billing starts immediately. **Do NOT wait for the sandbox to be ready** — star If the provider has `PROXY_DOMAIN` configured, user-defined service ports are reachable at: ``` -http://-./ +://-./ ``` -For example, if a process listens on port 8080 inside the sandbox: +The protocol matches the provider's setup (the hosted testnet uses **https**). +For example, if a process listens on port 8000 inside the sandbox: ``` -http://8080-.sandbox.example.com/ +https://8000-.provider-private-sandbox.0g.ai/ ``` -The provider's `/info` endpoint lists `proxy_domain` when configured. +Prefer the `preview_urls` field from the create response — it is already the +correct protocol + domain. Requires the provider to have wildcard DNS + TLS for +`*.`; if the subdomain doesn't resolve, report it to the provider. > **Note:** Sealed sandboxes (`--sealed`) block SSH and toolbox access — use the proxy URL to reach services running inside them. @@ -369,23 +392,23 @@ done & > SSH token expires ~1h. Re-run `ssh-access` to refresh. -**If `RSYNC_OK=0` → use toolbox upload instead:** +**If `RSYNC_OK=0` → upload via exec + base64 instead:** + +> Daytona v0.189 changed `files/upload` to multipart form (JSON base64 body no +> longer works, and the CLI toolbox command only sends JSON). Write files +> through `exec` instead: ```bash # Upload a single file FILE=main.py -$USER_CLItoolbox \ - --api $API --id $SANDBOX_ID \ - --method POST --action files/upload \ - --body "{\"path\":\"$REMOTE_DIR/$FILE\",\"content\":\"$(base64 -w0 $LOCAL_DIR/$FILE)\"}" +$USER_CLIexec --api $API --id $SANDBOX_ID \ + --cmd "sh -c 'mkdir -p $REMOTE_DIR && echo $(base64 -w0 $LOCAL_DIR/$FILE) | base64 -d > $REMOTE_DIR/$FILE'" # For multiple files — loop over them for FILE in $(find $LOCAL_DIR -type f -not -path '*/.git/*' -not -name '*.pyc'); do REL=${FILE#$LOCAL_DIR/} - $USER_CLItoolbox \ - --api $API --id $SANDBOX_ID \ - --method POST --action files/upload \ - --body "{\"path\":\"$REMOTE_DIR/$REL\",\"content\":\"$(base64 -w0 $FILE)\"}" + $USER_CLIexec --api $API --id $SANDBOX_ID \ + --cmd "sh -c 'mkdir -p $REMOTE_DIR/$(dirname $REL) && echo $(base64 -w0 $FILE) | base64 -d > $REMOTE_DIR/$REL'" done ``` @@ -429,9 +452,13 @@ Present the setup plan and ask how the user wants to proceed: > 3. **SSH Tunnel** — forward local port to sandbox — you run this on your **local machine** > 4. **Open Browser** — visit `http://localhost:13284/#token=` > -> **You'll need an Anthropic API Key** (`ANTHROPIC_API_KEY`) set in your terminal: -> - **Claude Code users**: run `claude setup-token` to get your key -> - **Others**: visit https://console.anthropic.com/settings/keys +> **You'll need Anthropic credentials.** Two DIFFERENT kinds — do not mix them up: +> - **API key** (`sk-ant-api…`, from https://console.anthropic.com/settings/keys) +> → export as `ANTHROPIC_API_KEY` before starting the gateway. +> - **Claude Code OAuth token** (`sk-ant-oat…`, from `claude setup-token`) +> → this is NOT an API key and must NOT go into `ANTHROPIC_API_KEY`. +> Register it inside the sandbox via `openclaw models auth add` (paste-token +> flow) after the gateway is set up. > > **Choose setup method:** > - `A` — **Agent-assisted**: I'll run all steps and give you the SSH tunnel command @@ -441,30 +468,30 @@ Present the setup plan and ask how the user wants to proceed: Proceed with Step 1. -**Step 1 — Set gateway mode + start** +**Step 1 — Configure gateway (mode + auth token), then start** -Before running, verify `ANTHROPIC_API_KEY` is set in the terminal: -```bash -echo $ANTHROPIC_API_KEY -``` +If using an **API key**, verify it is set in the terminal (`echo $ANTHROPIC_API_KEY`); +if empty, stop and ask the user to set it. (OAuth-token users skip this — their +credential is registered in Step 2b instead.) -If the output is empty, **stop and ask the user to set it** before continuing: -> "`ANTHROPIC_API_KEY` is not set. Please set it in your terminal (`export ANTHROPIC_API_KEY=sk-ant-...`) and let me know when done." - -Only proceed once confirmed non-empty. +> Current openclaw (2026.x) **refuses to bind to lan without gateway auth** +> ("Refusing to bind gateway to lan without auth") — the auth token is no longer +> auto-generated on first start. Set it explicitly BEFORE starting. Also: +> `nohup … &` inside `exec` dies with the exec session — use `setsid`. ```bash $USER_CLIexec --api $API --id $SANDBOX_ID \ - --cmd "/bin/bash -c 'openclaw config set gateway.mode local'" + --cmd "/bin/bash -c 'openclaw config set gateway.mode local && openclaw config set gateway.auth.mode token && openclaw config set gateway.auth.token \$(openssl rand -hex 16)'" $USER_CLIexec --api $API --id $SANDBOX_ID \ - --cmd "/bin/bash -c 'export ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY; nohup bash -c \"openclaw gateway run --bind lan --port 3284 > /tmp/openclaw.log 2>&1\" &'" + --cmd "/bin/bash -c 'export ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY; setsid nohup openclaw gateway run --bind lan --port 3284 /tmp/openclaw.log 2>&1 & echo launched'" ``` -Wait 3s, confirm running: +Wait a few seconds, confirm it is actually up (the process must survive the exec +session — if `port3284:000`, it died; check /tmp/openclaw.log): ```bash $USER_CLIexec --api $API --id $SANDBOX_ID \ - --cmd "/bin/bash -c 'sleep 3 && grep \"listening on\" /tmp/openclaw.log'" + --cmd "/bin/bash -c 'sleep 5; curl -s -o /dev/null -w \"port3284:%{http_code}\" http://127.0.0.1:3284/'" ``` **Step 2 — Get gateway auth token** @@ -474,7 +501,15 @@ $USER_CLIexec --api $API --id $SANDBOX_ID \ --cmd "/bin/bash -c 'node -e \"console.log(require(\\\"/root/.openclaw/openclaw.json\\\").gateway.auth.token)\"'" ``` -Show the token clearly, then say: +**Step 2b — (OAuth-token users only) register the Claude Code token** + +```bash +$USER_CLIexec --api $API --id $SANDBOX_ID \ + --cmd "/bin/bash -c 'openclaw models auth add'" # paste the sk-ant-oat… token when prompted +``` +(If the interactive flow doesn't work over exec, do this step via SSH — Option B.) + +Show the gateway token clearly, then say: > Token retrieved: `` > Next, run the following command on your **local machine**, then let me know when it's running. @@ -505,12 +540,17 @@ Present the SSH token and the following exact steps to the user: # 1. SSH into the sandbox (use the token from ssh-access output as password) ssh -p -o StrictHostKeyChecking=no '@' -# 2. Inside the sandbox — set gateway mode and start with API key as env var +# 2. Inside the sandbox — set gateway mode + auth token, then start. +# API-key users: export ANTHROPIC_API_KEY first. +# OAuth-token users (claude setup-token): do NOT export it as ANTHROPIC_API_KEY — +# run `openclaw models auth add` and paste the sk-ant-oat… token instead. openclaw config set gateway.mode local -export ANTHROPIC_API_KEY=sk-ant- -nohup bash -c "openclaw gateway run --bind lan --port 3284 > /tmp/openclaw.log 2>&1" & -# Shell prints "[1] " — press Enter once to return to prompt, then: -sleep 3 && grep "listening on" /tmp/openclaw.log +openclaw config set gateway.auth.mode token +openclaw config set gateway.auth.token $(openssl rand -hex 16) +export ANTHROPIC_API_KEY=sk-ant-api- # API-key users only +setsid nohup openclaw gateway run --bind lan --port 3284 /tmp/openclaw.log 2>&1 & +# Press Enter once to return to prompt, then: +sleep 5 && curl -s -o /dev/null -w "port3284:%{http_code}\n" http://127.0.0.1:3284/ # 3. Get auth token node -e "console.log(require('/root/.openclaw/openclaw.json').gateway.auth.token)" @@ -525,10 +565,15 @@ ssh -N -L 13284:localhost:3284 -p 2222 -o StrictHostKeyChecking=no '@log 2>&1 &` +- `ANTHROPIC_API_KEY` accepts real API keys (sk-ant-api…) ONLY. + `claude setup-token` output (sk-ant-oat…) is an OAuth token → register via + `openclaw models auth add`, never via ANTHROPIC_API_KEY +- Do NOT use `openclaw config set anthropic.*` (invalid key path, will error) +- Gateway token is at `gateway.auth.token` in /root/.openclaw/openclaw.json - Browser URL must use `#token=` hash fragment - SSH token expires 60 min → re-run `ssh-access` to refresh tunnel - Stop gateway: `exec --cmd "/bin/bash -c 'pkill -f openclaw'"` @@ -570,7 +615,7 @@ $USER_CLItoolbox \ | Action | Method | Description | |--------|--------|-------------| | `files` | GET | List files | -| `files/upload` | POST | Upload a file (base64) | +| `files/upload` | POST | ⚠ v0.189: multipart form — NOT usable via `--body` JSON; use exec+base64 instead | | `files/download` | GET | Download a file | | `git/status` | GET | Git status | | `git/clone` | POST | Clone repo | @@ -587,7 +632,7 @@ $USER_CLItoolbox \ | `insufficient balance` on create | Balance < 0.12 0G | `deposit` more | | `deposit: insufficient funds` | Wallet has no 0G | Transfer 0G to wallet first | | `GetBalance` revert | Wrong contract address | Check `SETTLEMENT_CONTRACT` | -| `NOT_ACKNOWLEDGED` after price change | `signerVersion` incremented | Re-run `acknowledge` | +| `NOT_ACKNOWLEDGED` after price/node change | `ackVersion` bumped in TappRegistry | Re-run `acknowledge` | | Sandbox state is `stopped` | Auto-stopped or billing stopped it | Run `start` before `exec` | | SSH token expired | 60-min TTL | Re-run `ssh-access` | | `sudo apt-get` fails in sandbox | User may already be root | Try without sudo | diff --git a/CLAUDE.md b/CLAUDE.md index e2c5af8..4a75900 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ cmd/ deploy/ deploy beacon-proxy stack (3 steps: impl → beacon → proxy) upgrade/ upgrade via beacon.upgradeTo(newImpl) verify/ verify contracts on block explorer - provider/ provider CLI: register (binds appId), status, withdraw, snapshot management + provider/ provider CLI (signed by the appId OWNER key): register (binds signer→appId), remove-service, rotate, status, withdraw, snapshot management user/ user CLI: create/stop/delete sandbox, exec, balance checkbal/ quick balance/nonce/earnings check for a private key internal/ @@ -78,6 +78,22 @@ contracts/ - `1 0G = 10^18 neuron` (neuron is the smallest unit, analogous to ETH/wei) - All on-chain amounts are **neuron** (big.Int) +### Identity Model (v2: provider IS the TEE signer) +- The **provider address** (services key, voucher payee, `(user, provider)` + balance bucket, earnings ledger) is the node's TEE signer address, derived + from the TEE key at runtime. There is no separate provider wallet. +- The **owner** (the appId's TappRegistry owner — resolved on-chain from + `getAppInfo(BACKEND_APP_NAME).owner`, never configured) does all + management: `register --signer`, `remove-service`, `withdraw`, `rotate`. + Owner is always an admin; `ADMIN_ADDRESSES` adds extra operator wallets. +- Settlement requires the voucher to be signed **by its own payee** + (`recovered == v.provider`) and that address to be an active TappRegistry + node — one node can never settle vouchers naming another node. +- **Machine rebuild = signer rotation** (service restarts keep the key): + the settler holds its queue until the new signer is a registered node; + `cmd/provider rotate` migrates the service entry; users move balances off + the old signer via the normal refund flow. + ### Billing Flow 1. User sends EIP-191-signed `POST /api/sandbox` → proxy authenticates, injects `daytona-owner` label, forwards to Daytona @@ -160,6 +176,16 @@ remain protected by Daytona regardless of this flag. **Proxy URL format:** `http://-./` +**`publicPorts` (per-port public preview)** — a create request may include +`"publicPorts": [8080, 3000]`: only listed ports are publicly reachable; all other +ports fall back to Daytona's private-sandbox auth (owner preview tokens still work). +Omit for the default all-ports-public behavior. Requires the 0g-daytona fork images +(compose defaults to them via `REGISTRY_PREFIX`); against stock Daytona images +the billing proxy rejects such creates with 502 instead of silently ignoring the +restriction. Rules: max 16 ports, system ports (22222/2280/33333) rejected, immutable +after create, sealed sandboxes must include 8080. Successful creates return +`preview_urls: {"8080": "http://8080-."}`. + The `PROXY_DOMAIN` env var controls the URL format. Examples: - nip.io (no real domain): `PROXY_DOMAIN=.nip.io:4000` → `http://8080-..nip.io:4000/result` @@ -219,7 +245,6 @@ DAYTONA_API_URL=http://localhost:3000 \ DAYTONA_ADMIN_KEY= \ SETTLEMENT_CONTRACT=0x \ TAPP_REGISTRY=0x \ -PROVIDER_ADDRESS=0x \ BACKEND_APP_NAME= \ RPC_URL=https://evmrpc-testnet.0g.ai \ CHAIN_ID=16602 \ @@ -228,8 +253,10 @@ go run ./cmd/billing/ ``` `TAPP_REGISTRY` and `BACKEND_APP_NAME` are required — at startup the billing -server reads `services[PROVIDER_ADDRESS].appId` from SandboxServing and queries -TappRegistry for signer + ack state on every voucher. +server derives its provider identity from the TEE key (**provider IS the TEE +signer** — there is no provider wallet), resolves the app owner from +`getAppInfo(BACKEND_APP_NAME).owner` (standing admin, surfaced in /api/info), +and queries TappRegistry for node + ack state on every voucher. `PROXY_DOMAIN` controls the URL format for accessing user-defined service ports inside the sandbox. Format: `http://-./`. The Daytona proxy listens @@ -288,10 +315,11 @@ violating the workload-privacy guarantee. The two `/force*` paths predate `withOwnerOrAdmin` and remain as explicit operator-intent endpoints so log/audit grep is unambiguous. -`ADMIN_ADDRESSES` is comma-separated. When unset, defaults to `[PROVIDER_ADDRESS]` for -backward compatibility with single-key deployments. Distinct from `PROVIDER_ADDRESS` -(the on-chain settlement identity), so multiple operators can manage infrastructure -without holding the provider's settlement key. +The appId's TappRegistry owner is **always** an admin — resolved live from +the chain (`getAppInfo(BACKEND_APP_NAME).owner`), never configured; +`ADMIN_ADDRESSES` is an additive list of extra operator wallets. The on-chain +settlement identity (the provider address) is the TEE signer and never +appears in admin config. ### Dashboard @@ -346,9 +374,12 @@ cast send 0x \ --rpc-url https://evmrpc-testnet.0g.ai \ --private-key 0x -# 5. Bind the SandboxServing service to the appId + set prices. -PROVIDER_KEY=0x go run ./cmd/provider/ register \ +# 5. Bind the node's service to the appId + set prices. Signed by the appId +# OWNER key; --signer is the node's TEE address (tapp-cli get-app-key, or +# the billing server's /api/info `provider_address`). +OWNER_KEY=0x go run ./cmd/provider/ register \ --app-id \ + --signer 0x \ --url https:// \ --price-per-cpu \ --price-per-mem \ @@ -383,7 +414,7 @@ tapp-cli -s $TAPP_SERVER docker-login \ ```bash # 1. Prepare env (must be named .env for docker compose to pick it up). -# Required: SETTLEMENT_CONTRACT, TAPP_REGISTRY, PROVIDER_ADDRESS, +# Required: SETTLEMENT_CONTRACT, TAPP_REGISTRY, # BACKEND_APP_NAME (= $APP_ID). cp .env.testnet .env @@ -431,6 +462,6 @@ tapp-cli -s $TAPP_SERVER get-task-status --task-id - `BACKEND_APP_NAME` in `.env` must match the tapp app-id exactly, and that same string is the `appId` registered in both TappRegistry and SandboxServing - `.env` is uploaded because docker-compose.yml mounts `./.env:/app/.env:ro` — this mount's only purpose is to trigger tapp-cli to upload the file; docker compose on the server reads it from the working directory for `${VAR}` substitution - The provider wallet only needs enough 0G to pay gas for `addOrUpdateService` and ongoing `settleFeesWithTEE` batches; SandboxServing no longer holds a provider stake -- `cmd/provider register` uses `PROVIDER_KEY` env var (not `MOCK_APP_PRIVATE_KEY`) +- `cmd/provider register` uses `OWNER_KEY` env var — the appId owner's key (`PROVIDER_KEY` accepted as a legacy alias) - Updating prices via `cmd/provider register` bumps `ackVersion(appId)` in TappRegistry — every existing user must call `cmd/user acknowledge` again before further vouchers settle - **`RESOURCE_LIMITS_DISABLED=false` on the runner service is load-bearing.** The `daytonaio/daytona-runner` image bakes `ENV RESOURCE_LIMITS_DISABLED=true` into its Dockerfile, so omitting the var in compose falls back to `true` and the runner skips setting Docker CFS / memory cgroup limits — every sandbox runs unconstrained on host resources regardless of snapshot tier. To verify: `docker exec 0g-sandbox-runner-1 env | grep RESOURCE_LIMITS` should print `=false`, and any sandbox container's `docker inspect` should show non-zero `HostConfig.CpuQuota` / `Memory`. diff --git a/README.md b/README.md index 2c64564..e5301fd 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ go run ./cmd/billing/ | `SETTLEMENT_CONTRACT` | (required) | BeaconProxy address | | `RPC_URL` | (required) | EVM RPC endpoint | | `CHAIN_ID` | (required) | Chain ID (e.g. 16602) | -| `PROVIDER_ADDRESS` | (required) | Provider's Ethereum address | +| `BACKEND_APP_NAME` | (required) | tapp app-id = TappRegistry appId; the app owner (standing admin) and provider identity (TEE signer) are both derived from it at runtime | | `REDIS_ADDR` | `redis:6379` | Redis address | | `COMPUTE_PRICE_PER_SEC` | `16667` | neuron/sec fallback (used only when per-resource on-chain pricing is not set) | | `CREATE_FEE` | `5000000` | neuron flat fee fallback (on-chain value takes priority after provider registration) | diff --git a/cmd/billing/e2e_test.go b/cmd/billing/e2e_test.go index 78f8e40..e097e07 100644 --- a/cmd/billing/e2e_test.go +++ b/cmd/billing/e2e_test.go @@ -171,8 +171,9 @@ func setupE2E(teeKeyHex string) (*e2eEnv, error) { RPCURL: rpcURL, ContractAddress: contractAddr, TEEPrivateKey: teeKeyHex, - ProviderAddress: providerAddr.Hex(), - ChainID: chainIDVal, + // v2: provider identity is derived from the TEE key inside + // chain.NewClient — providerAddr above matches it by construction. + ChainID: chainIDVal, }, Billing: config.BillingConfig{VoucherIntervalSec: voucherIntervalSec, ComputePricePerSec: computePriceStr, CreateFee: createFeeStr}, Daytona: config.DaytonaConfig{APIURL: daytonaURL, AdminKey: daytonaKey}, diff --git a/cmd/billing/main.go b/cmd/billing/main.go index f83c5aa..7c3aefb 100644 --- a/cmd/billing/main.go +++ b/cmd/billing/main.go @@ -11,6 +11,7 @@ import ( "os" "os/signal" "strings" + "sync" "syscall" "time" @@ -44,15 +45,6 @@ func main() { log.Fatal("config load failed", zap.Error(err)) } - // PROVIDER_ADDRESS identifies which on-chain provider this billing service - // represents — it goes into voucher.provider (EIP-712), the settler queue - // key, and provider-bound chain lookups. Enforced here (not in - // chain.NewClient) so broker can reuse the same chain client without - // declaring a provider identity it doesn't have. - if cfg.Chain.ProviderAddress == "" { - log.Fatal("PROVIDER_ADDRESS is required for the billing service") - } - ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -80,11 +72,68 @@ func main() { log.Fatal("chain client init failed", zap.Error(err)) } + // Provider identity = the TEE signer's own address (v2: provider IS the + // TEE signer). It keys the voucher payee, the settler queue, and every + // provider-bound lookup below. + providerAddr := onchain.ProviderAddress() + providerHex := providerAddr.Hex() + + // The appId owner is resolved from the chain, never configured: + // getAppInfo(BACKEND_APP_NAME).owner. It is the standing admin for + // operator endpoints and is surfaced through /api/info. TTL-cached; on + // lookup failure the last known value is served so a flaky RPC can't + // lock the owner out mid-session. + backendAppName := os.Getenv("BACKEND_APP_NAME") + if backendAppName == "" { + log.Warn("BACKEND_APP_NAME not set — app owner cannot be resolved; only ADMIN_ADDRESSES wallets are admins") + } + var ownerMu sync.Mutex + var ownerCached string + var ownerCachedAt time.Time + appOwnerFn := func(ctx context.Context) (string, error) { + ownerMu.Lock() + defer ownerMu.Unlock() + if ownerCached != "" && time.Since(ownerCachedAt) < time.Minute { + return ownerCached, nil + } + if backendAppName == "" { + return "", fmt.Errorf("BACKEND_APP_NAME not set") + } + owner, err := onchain.GetAppOwner(ctx, backendAppName) + if err != nil { + if ownerCached != "" { + return ownerCached, nil // serve stale over failing closed + } + return "", err + } + if owner == (common.Address{}) { + return "", fmt.Errorf("app %q not registered in TappRegistry", backendAppName) + } + ownerCached = strings.ToLower(owner.Hex()) + ownerCachedAt = time.Now() + return ownerCached, nil + } + if owner, err := appOwnerFn(ctx); err != nil { + log.Warn("app owner not resolvable yet", zap.String("app_id", backendAppName), zap.Error(err)) + } else { + log.Info("provider identity derived from TEE key", + zap.String("provider", providerHex), + zap.String("app_id", backendAppName), + zap.String("owner", owner)) + } + // Drift check: the service this signer is registered under must be bound + // to the same appId we fetch our TEE key for. A mismatch means the key + // and the on-chain registration point at different apps. + if svcAppId, err := onchain.GetServiceAppId(ctx, providerAddr); err == nil && svcAppId != "" && backendAppName != "" && svcAppId != backendAppName { + log.Error("appId drift: on-chain service appId != BACKEND_APP_NAME", + zap.String("on_chain", svcAppId), zap.String("configured", backendAppName)) + } + // ── Pricing: on-chain service registration is the source of truth ──────── // Read per-resource prices and createFee from the contract so users can // verify the actual billing rate on the chain explorer. // Fall back to env vars only when the service is not yet registered. - chainCPUPerSec, chainMemPerSec, createFee, err := onchain.GetServicePricing(ctx, common.HexToAddress(cfg.Chain.ProviderAddress)) + chainCPUPerSec, chainMemPerSec, createFee, err := onchain.GetServicePricing(ctx, providerAddr) if err != nil { log.Warn("could not read on-chain service pricing; falling back to env vars", zap.Error(err)) } @@ -145,7 +194,7 @@ func main() { onchain.PrivateKey(), onchain.ChainID(), onchain.ContractAddress(), - common.HexToAddress(cfg.Chain.ProviderAddress), + providerAddr, rdb, onchain, log, @@ -157,7 +206,7 @@ func main() { // ── Billing event handler ───────────────────────────────────────────────── billingHandler := billing.NewEventHandler( rdb, - cfg.Chain.ProviderAddress, + providerHex, computePricePerSec, createFee, pricePerCPUPerSec, @@ -178,7 +227,7 @@ func main() { // without a webhook URL. With ALERT_WEBHOOK_URL set, also dispatches // to the configured destination (Slack/PagerDuty/etc). dedup := time.Duration(cfg.Alert.DedupWindowSec) * time.Second - alerter := alert.NewWebhook(cfg.Alert.WebhookURL, cfg.Chain.ProviderAddress, rdb, dedup, log) + alerter := alert.NewWebhook(cfg.Alert.WebhookURL, providerHex, rdb, dedup, log) if cfg.Alert.WebhookURL != "" { log.Info("alert webhook configured", zap.Duration("dedup", dedup)) } else { @@ -196,9 +245,9 @@ func main() { // particular is the only safety net against KMS rotation drift, since // INVALID_SIGNATURE on-chain emits no event and accumulates silently. go observability.RunBalanceMonitor(ctx, onchain, alerter, cfg.Alert.SettlerLowBalanceFactor, log) - queueKey := fmt.Sprintf(voucher.VoucherQueueKeyFmt, cfg.Chain.ProviderAddress) + queueKey := fmt.Sprintf(voucher.VoucherQueueKeyFmt, providerHex) go observability.RunQueueDepthMonitor(ctx, rdb, queueKey, alerter, cfg.Alert.QueueBacklogThreshold, log) - go observability.RunSignerMismatchMonitor(ctx, onchain, common.HexToAddress(cfg.Chain.ProviderAddress), alerter, log) + go observability.RunSignerMismatchMonitor(ctx, onchain, providerAddr, alerter, log) // ── HTTP server ─────────────────────────────────────────────────────────── gin.SetMode(gin.ReleaseMode) @@ -247,7 +296,7 @@ func main() { CreateFee string `json:"create_fee"` } // For now: just the configured provider. Extend via KNOWN_PROVIDERS in the future. - addrs := []string{cfg.Chain.ProviderAddress} + addrs := []string{providerHex} var providers []ProviderInfo for _, addr := range addrs { if addr == "" { @@ -276,6 +325,16 @@ func main() { c.JSON(http.StatusOK, providers) }) + // ownerForInfo returns the resolved app owner or "" — /api/info is + // best-effort display, not an auth surface. + ownerForInfo := func(ctx context.Context) string { + owner, err := appOwnerFn(ctx) + if err != nil { + return "" + } + return owner + } + rpcOrigin := cfg.Chain.RPCURL if u, err := url.Parse(cfg.Chain.RPCURL); err == nil { rpcOrigin = u.Scheme + "://" + u.Host @@ -283,7 +342,6 @@ func main() { r.GET("/api/info", func(c *gin.Context) { ctx := c.Request.Context() settlerAddr := onchain.SettlerAddress() - providerAddr := common.HexToAddress(cfg.Chain.ProviderAddress) // Signer health — derived from sandbox.services[provider].appId and // tap.getNode(appId, settler).addedAt. All on-chain readable; no admin @@ -329,7 +387,9 @@ func main() { c.JSON(http.StatusOK, gin.H{ "contract_address": cfg.Chain.ContractAddress, - "provider_address": cfg.Chain.ProviderAddress, + "provider_address": providerHex, + "owner_address": ownerForInfo(c.Request.Context()), + "app_id": backendAppName, "chain_id": cfg.Chain.ChainID, "rpc_url": rpcOrigin, "compute_price_per_sec": computePricePerSec.String(), @@ -432,8 +492,9 @@ func main() { // Anything mounted here should be derivable from public chain RPC. apiPublic := r.Group("/api") api := r.Group("/api", auth.Middleware(rdb)) - proxyHandler := proxy.NewHandler(dtona, billingHandler, onchain, onchain, onchain, createFee, pricePerCPUPerSec, pricePerMemGBPerSec, computePricePerSec, cfg.Chain.ProviderAddress, cfg.Chain.AdminList(), cfg.Server.SSHGatewayHost, rdb, log, cfg.Server.BrokerURL, onchain.PrivateKey(), cfg.Billing.VoucherIntervalSec) + proxyHandler := proxy.NewHandler(dtona, billingHandler, onchain, onchain, onchain, createFee, pricePerCPUPerSec, pricePerMemGBPerSec, computePricePerSec, providerHex, cfg.Chain.AdminList(), cfg.Server.SSHGatewayHost, rdb, log, cfg.Server.BrokerURL, onchain.PrivateKey(), cfg.Billing.VoucherIntervalSec) proxyHandler.SealedOnly = cfg.Server.SealedOnly + proxyHandler.AppOwner = appOwnerFn proxyHandler.RegisterPublic(apiPublic) proxyHandler.Register(api) go runStopHandler(ctx, stopCh, dtona, rdb, log, proxyHandler.BrokerDeregister) @@ -442,7 +503,7 @@ func main() { // The import runs synchronously (crane.Copy) — may take minutes for large images. api.POST("/registry/pull", func(c *gin.Context) { wallet := c.GetString("wallet_address") - if !cfg.Chain.IsAdmin(wallet) { + if !proxyHandler.IsAdmin(wallet) { c.JSON(http.StatusForbidden, gin.H{"error": "admin only"}) return } @@ -477,7 +538,7 @@ func main() { // Pass ?dry_run=true to preview without deleting. api.POST("/registry/gc", func(c *gin.Context) { wallet := c.GetString("wallet_address") - if !cfg.Chain.IsAdmin(wallet) { + if !proxyHandler.IsAdmin(wallet) { c.JSON(http.StatusForbidden, gin.H{"error": "admin only"}) return } @@ -540,7 +601,7 @@ func main() { // already-aggregated singleton rows. api.GET("/queue/summary", func(c *gin.Context) { wallet := c.GetString("wallet_address") - if !cfg.Chain.IsAdmin(wallet) { + if !proxyHandler.IsAdmin(wallet) { c.JSON(http.StatusForbidden, gin.H{"error": "admin only"}) return } @@ -550,7 +611,7 @@ func main() { minCount = 1 } } - queueKey := fmt.Sprintf(voucher.VoucherQueueKeyFmt, cfg.Chain.ProviderAddress) + queueKey := fmt.Sprintf(voucher.VoucherQueueKeyFmt, providerHex) rows, scanned, truncated, err := voucher.SummarizeQueue(c.Request.Context(), rdb, queueKey, minCount) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -570,11 +631,11 @@ func main() { // requeues or discards them. api.GET("/queue/dlq", func(c *gin.Context) { wallet := c.GetString("wallet_address") - if !cfg.Chain.IsAdmin(wallet) { + if !proxyHandler.IsAdmin(wallet) { c.JSON(http.StatusForbidden, gin.H{"error": "admin only"}) return } - entries, err := voucher.ListDLQ(c.Request.Context(), rdb, common.HexToAddress(cfg.Chain.ProviderAddress)) + entries, err := voucher.ListDLQ(c.Request.Context(), rdb, providerAddr) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -591,7 +652,7 @@ func main() { // rationale. api.POST("/queue/dlq/discard", func(c *gin.Context) { wallet := c.GetString("wallet_address") - if !cfg.Chain.IsAdmin(wallet) { + if !proxyHandler.IsAdmin(wallet) { c.JSON(http.StatusForbidden, gin.H{"error": "admin only"}) return } @@ -621,7 +682,7 @@ func main() { // Uses WATCH-based atomic queue rewrite; safe against concurrent settler BLPOP. api.POST("/queue/aggregate", func(c *gin.Context) { wallet := c.GetString("wallet_address") - if !cfg.Chain.IsAdmin(wallet) { + if !proxyHandler.IsAdmin(wallet) { c.JSON(http.StatusForbidden, gin.H{"error": "admin only"}) return } @@ -637,7 +698,7 @@ func main() { c.JSON(http.StatusBadRequest, gin.H{"error": "user and provider are required"}) return } - queueKey := fmt.Sprintf(voucher.VoucherQueueKeyFmt, cfg.Chain.ProviderAddress) + queueKey := fmt.Sprintf(voucher.VoucherQueueKeyFmt, providerHex) result, err := voucher.Aggregate(c.Request.Context(), rdb, queueKey, common.HexToAddress(req.User), common.HexToAddress(req.Provider), @@ -674,13 +735,13 @@ func main() { // on-chain data. api.GET("/observability", func(c *gin.Context) { wallet := c.GetString("wallet_address") - if !cfg.Chain.IsAdmin(wallet) { + if !proxyHandler.IsAdmin(wallet) { c.JSON(http.StatusForbidden, gin.H{"error": "admin only"}) return } ctx := c.Request.Context() - queueKey := fmt.Sprintf(voucher.VoucherQueueKeyFmt, cfg.Chain.ProviderAddress) - dlqKey := fmt.Sprintf(voucher.VoucherDLQKeyFmt, cfg.Chain.ProviderAddress) + queueKey := fmt.Sprintf(voucher.VoucherQueueKeyFmt, providerHex) + dlqKey := fmt.Sprintf(voucher.VoucherDLQKeyFmt, providerHex) depth, _ := rdb.LLen(ctx, queueKey).Result() dlqDepth, _ := rdb.LLen(ctx, dlqKey).Result() queueStatus := "ok" diff --git a/cmd/checkbal/main.go b/cmd/checkbal/main.go index 6ed86e0..ae0b065 100644 --- a/cmd/checkbal/main.go +++ b/cmd/checkbal/main.go @@ -17,7 +17,7 @@ func main() { eth, _ := ethclient.Dial("https://evmrpc-testnet.0g.ai") privKey, _ := crypto.HexToECDSA("859c3bd1baf85767059b81448d0902d2bb649d137f0df460eb576915d15d58eb") addr := crypto.PubkeyToAddress(privKey.PublicKey) - c, _ := chain.NewSandboxServing(common.HexToAddress("0x2024eB0Cc14316fF8Cc425bFB7CC37FD8713E9b3"), eth) + c, _ := chain.NewSandboxServing(common.HexToAddress("0x3D0F2D62A60c8e62095671FfB23D15Cc4C98ca7c"), eth) opts := &bind.CallOpts{Context: context.Background()} bal, _ := c.GetBalance(opts, addr, addr) @@ -46,7 +46,7 @@ func main() { fmt.Println() fmt.Println("=== Recent VoucherSettled events (last 5000 blocks) ===") ctx := context.Background() - chainClient := &chainReader{eth: eth, c: c, addr: common.HexToAddress("0x2024eB0Cc14316fF8Cc425bFB7CC37FD8713E9b3")} + chainClient := &chainReader{eth: eth, c: c, addr: common.HexToAddress("0x3D0F2D62A60c8e62095671FfB23D15Cc4C98ca7c")} _ = chainClient _ = ctx } diff --git a/cmd/checkuserbal/main.go b/cmd/checkuserbal/main.go index 4c5925a..700e93c 100644 --- a/cmd/checkuserbal/main.go +++ b/cmd/checkuserbal/main.go @@ -14,7 +14,7 @@ import ( func main() { rpc := getEnv("RPC_URL", "https://evmrpc-testnet.0g.ai") - contractAddr := getEnv("SETTLEMENT_CONTRACT", "0x2024eB0Cc14316fF8Cc425bFB7CC37FD8713E9b3") + contractAddr := getEnv("SETTLEMENT_CONTRACT", "0x3D0F2D62A60c8e62095671FfB23D15Cc4C98ca7c") user := common.HexToAddress(getEnv("USER_ADDR", "0x2ff0F380d85543e0Ab6D32eba80DA7F3dB332dcB")) provider := common.HexToAddress(getEnv("PROVIDER_ADDR", "0xB831371eb2703305f1d9F8542163633D0675CEd7")) diff --git a/cmd/provider/main.go b/cmd/provider/main.go index 43e1e4f..6daabaf 100644 --- a/cmd/provider/main.go +++ b/cmd/provider/main.go @@ -1,30 +1,38 @@ -// cmd/provider — provider-side management CLI +// cmd/provider — provider-side management CLI (v2: provider IS the TEE signer) +// +// The provider address in SandboxServing is the node's TEE signer address; +// its key lives inside the enclave and dies with the machine. All on-chain +// management is therefore signed by the appId's TappRegistry OWNER key +// (OWNER_KEY env, PROVIDER_KEY accepted as a legacy alias). // // Subcommands: // -// register Bind URL, prices, and createFee to an appId in SandboxServing -// status Show provider registration and earnings -// withdraw Withdraw accumulated earnings -// push-image Load a local Docker image into the internal registry via the runner -// snapshot Register a registry image as a named Daytona snapshot -// snapshots List all snapshots +// register Register/update a node's service: bind --signer to an appId, set URL + prices +// remove-service Remove a node's service (sweeps pending earnings to the owner) +// rotate After a machine rebuild: re-register the new signer with the old terms, remove the old +// status Show a node's registration and earnings +// withdraw Withdraw a node's accumulated earnings to the owner +// push-image Load a local Docker image into the internal registry via the runner +// snapshot Register a registry image as a named Daytona snapshot +// snapshots List all snapshots // delete-snapshot Delete a snapshot by name // // Examples: // -// PROVIDER_KEY=0x go run ./cmd/provider/ register \ +// OWNER_KEY=0x go run ./cmd/provider/ register \ // --contract 0x... \ -// --api http://billing-host:8080 \ +// --signer 0x \ // --app-id 0g-sandbox-provider \ +// --url http://billing-host:8080 \ // --price-per-cpu 1000000000000000 \ // --price-per-mem 500000000000000 \ -// --create-fee 60000000000000000 +// --fee 60000000000000000 // -// PROVIDER_KEY=0x go run ./cmd/provider/ status --contract 0x... -// PROVIDER_KEY=0x go run ./cmd/provider/ withdraw --contract 0x... +// go run ./cmd/provider/ status --contract 0x... --address 0x +// OWNER_KEY=0x go run ./cmd/provider/ withdraw --contract 0x... --signer 0x +// OWNER_KEY=0x go run ./cmd/provider/ rotate --contract 0x... --old 0x --new 0x // // go run ./cmd/provider/ push-image --image rust-sandbox:1.0.0 -// PROVIDER_KEY=0x go run ./cmd/provider/ snapshot --api http://... --image registry:6000/daytona/rust-sandbox:1.0.0 --name rust-sandbox package main import ( @@ -61,15 +69,17 @@ const ( func main() { if len(os.Args) < 2 { fmt.Fprintln(os.Stderr, "usage: provider [flags]") - fmt.Fprintln(os.Stderr, " subcommands: register | deregister | status | withdraw | push-image | snapshot | snapshots | delete-snapshot | gc-images") + fmt.Fprintln(os.Stderr, " subcommands: register | remove-service | rotate | status | withdraw | push-image | snapshot | snapshots | delete-snapshot | gc-images") os.Exit(1) } switch os.Args[1] { case "register", "init-service": runRegister(os.Args[2:]) - case "deregister": - runDeregister(os.Args[2:]) + case "remove-service": + runRemoveService(os.Args[2:]) + case "rotate": + runRotate(os.Args[2:]) case "status": runStatus(os.Args[2:]) case "withdraw": @@ -86,28 +96,52 @@ func main() { runGCImages(os.Args[2:]) default: fmt.Fprintf(os.Stderr, "unknown subcommand: %s\n", os.Args[1]) - fmt.Fprintln(os.Stderr, " subcommands: register | deregister | status | withdraw | push-image | snapshot | snapshots | delete-snapshot | gc-images") + fmt.Fprintln(os.Stderr, " subcommands: register | remove-service | rotate | status | withdraw | push-image | snapshot | snapshots | delete-snapshot | gc-images") os.Exit(1) } } +// resolveOwnerKey resolves the appId owner's private key: --key flag, then +// OWNER_KEY env, then PROVIDER_KEY env (legacy alias from before v2, when +// the provider wallet did its own management). +func resolveOwnerKey(flagVal string) *ecdsa.PrivateKey { + hex := flagVal + if hex == "" { + hex = os.Getenv("OWNER_KEY") + } + if hex == "" { + hex = os.Getenv("PROVIDER_KEY") + } + if hex == "" { + fatalf("app owner private key required: use --key or OWNER_KEY env") + } + privKey, err := crypto.HexToECDSA(strings.TrimPrefix(hex, "0x")) + if err != nil { + fatalf("parse private key: %v", err) + } + return privKey +} + // ── register ────────────────────────────────────────────────────────────────── -// runRegister binds the SandboxServing service to a TappRegistry appId and -// sets prices. Prerequisites (done by the provider on tapp directly, in +// runRegister registers/updates a node's service in SandboxServing: binds the +// node's TEE signer address to a TappRegistry appId and sets URL + prices. +// Signed by the appId OWNER's key. Prerequisites (done on tapp directly, in // separate txs; this CLI doesn't currently chain them): // -// 1. tappRegistry.registerApp(appId, hashes, signer, teeUrl) ← pays stake -// 2. tappRegistry.authorizeInvalidator(appId, sandboxServingAddr) +// 1. tappRegistry.registerApp(appId, ...) ← pays stake +// 2. tappRegistry.addNode(appId, signer, teeUrl) ← one per machine +// 3. tappRegistry.authorizeInvalidator(appId, sandboxServingAddr) // -// Then this command runs sandbox.addOrUpdateService(url, appId, prices). +// Then this command runs sandbox.addOrUpdateService(signer, url, appId, prices). func runRegister(args []string) { fs := flag.NewFlagSet("register", flag.ExitOnError) rpc := fs.String("rpc", defaultRPC, "RPC endpoint") chainID := fs.Int64("chain-id", defaultChainID, "Chain ID") contractHex := fs.String("contract", envOrDefault("SETTLEMENT_CONTRACT", ""), "Settlement contract address (required: --contract or SETTLEMENT_CONTRACT env)") - keyHex := fs.String("key", "", "Provider private key (hex); or set PROVIDER_KEY env") - appId := fs.String("app-id", "", "TappRegistry appId to bind (required; must already be registered in tapp)") + keyHex := fs.String("key", "", "App owner private key (hex); or set OWNER_KEY env") + signerHex := fs.String("signer", "", "Node's TEE signer address = the provider address (required; get it from the node's /api/info or tapp-cli get-app-key)") + appId := fs.String("app-id", "", "TappRegistry appId to bind (required; signer must already be an active node of it)") serviceURL := fs.String("url", "", "Provider service URL (required)") pricePerCPU := fs.String("price-per-cpu", "1000000000000000", "Price per CPU per minute (neuron)") pricePerMemGB := fs.String("price-per-mem", "500000000000000", "Price per GB memory per minute (neuron)") @@ -120,20 +154,25 @@ func runRegister(args []string) { if *appId == "" { fatalf("--app-id is required") } - privKey := resolveKey(*keyHex, "PROVIDER_KEY") - providerAddr := crypto.PubkeyToAddress(privKey.PublicKey) + if *signerHex == "" { + fatalf("--signer is required (the node's TEE address; see its /api/info `provider_address` or tapp-cli get-app-key)") + } + privKey := resolveOwnerKey(*keyHex) + ownerAddr := crypto.PubkeyToAddress(privKey.PublicKey) + signerAddr := common.HexToAddress(*signerHex) pricePerCPUBig := parseBigInt(*pricePerCPU, "--price-per-cpu") pricePerMemGBBig := parseBigInt(*pricePerMemGB, "--price-per-mem") createFeeBig := parseBigInt(*createFee, "--fee") - fmt.Printf("Provider: %s\n", providerAddr.Hex()) - fmt.Printf("AppId: %s\n", *appId) - fmt.Printf("Contract: %s\n", *contractHex) - fmt.Printf("Service URL: %s\n", *serviceURL) - fmt.Printf("CPU price/min: %s neuron\n", pricePerCPUBig.String()) - fmt.Printf("Mem price/min: %s neuron/GB\n", pricePerMemGBBig.String()) - fmt.Printf("Create fee: %s neuron\n", createFeeBig.String()) + fmt.Printf("App owner: %s\n", ownerAddr.Hex()) + fmt.Printf("Provider (signer): %s\n", signerAddr.Hex()) + fmt.Printf("AppId: %s\n", *appId) + fmt.Printf("Contract: %s\n", *contractHex) + fmt.Printf("Service URL: %s\n", *serviceURL) + fmt.Printf("CPU price/min: %s neuron\n", pricePerCPUBig.String()) + fmt.Printf("Mem price/min: %s neuron/GB\n", pricePerMemGBBig.String()) + fmt.Printf("Create fee: %s neuron\n", createFeeBig.String()) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() @@ -142,60 +181,172 @@ func runRegister(args []string) { auth := buildAuth(ctx, privKey, *chainID) fmt.Println("\n[1/1] AddOrUpdateService...") - tx, err := contract.AddOrUpdateService(auth, *serviceURL, *appId, pricePerCPUBig, createFeeBig, pricePerMemGBBig) + tx, err := contract.AddOrUpdateService(auth, signerAddr, *serviceURL, *appId, pricePerCPUBig, createFeeBig, pricePerMemGBBig) if err != nil { - fatalf("AddOrUpdateService: %v\n\nReminder: this requires tapp.registerApp(%s, ...) AND tapp.authorizeInvalidator(%s, %s) to have been called first.", err, *appId, *appId, *contractHex) + fatalf("AddOrUpdateService: %v\n\nReminders:\n - the caller key must be the TappRegistry owner of %s\n - %s must already be an active node of %s (tapp-cli add-node-onchain)\n - tapp.authorizeInvalidator(%s, %s) must have been called", err, *appId, signerAddr.Hex(), *appId, *appId, *contractHex) } fmt.Printf(" tx: %s\n", tx.Hash().Hex()) if _, err := bind.WaitMined(ctx, eth, tx); err != nil { fatalf("wait mined: %v", err) } fmt.Println(" confirmed ✓") - fmt.Printf("\nDone. Provider address: %s\n", providerAddr.Hex()) + fmt.Printf("\nDone. Provider (signer) address: %s\n", signerAddr.Hex()) } -// ── deregister ────────────────────────────────────────────────────────────────── +// ── remove-service ──────────────────────────────────────────────────────────── -// runDeregister clears the caller's own SandboxServing service entry so its -// appId can be changed (appId is set-once in addOrUpdateService). Soft clear: -// on-chain balances, pending refunds, settled nonces, and accrued earnings are -// preserved. After this, re-run `register` with the new appId. -func runDeregister(args []string) { - fs := flag.NewFlagSet("deregister", flag.ExitOnError) +// runRemoveService removes a node's service entry. Signed by the appId +// OWNER's key — the signer key itself may be gone (it dies with the machine). +// Sweeps pending earnings to the owner in the same tx; user balances stay +// refundable and nonce watermarks stay put. +func runRemoveService(args []string) { + fs := flag.NewFlagSet("remove-service", flag.ExitOnError) rpc := fs.String("rpc", defaultRPC, "RPC endpoint") chainID := fs.Int64("chain-id", defaultChainID, "Chain ID") contractHex := fs.String("contract", envOrDefault("SETTLEMENT_CONTRACT", ""), "Settlement contract address (required: --contract or SETTLEMENT_CONTRACT env)") - keyHex := fs.String("key", "", "Provider private key (hex); or set PROVIDER_KEY env") + keyHex := fs.String("key", "", "App owner private key (hex); or set OWNER_KEY env") + signerHex := fs.String("signer", "", "Node's TEE signer address whose service to remove (required)") _ = fs.Parse(args) - privKey := resolveKey(*keyHex, "PROVIDER_KEY") - providerAddr := crypto.PubkeyToAddress(privKey.PublicKey) + if *signerHex == "" { + fatalf("--signer is required") + } + privKey := resolveOwnerKey(*keyHex) + ownerAddr := crypto.PubkeyToAddress(privKey.PublicKey) + signerAddr := common.HexToAddress(*signerHex) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() eth, contract := dialContract(ctx, *rpc, *contractHex) defer eth.Close() - if exists, err := contract.ServiceExists(&bind.CallOpts{Context: ctx}, providerAddr); err != nil { + opts := &bind.CallOpts{Context: ctx} + if exists, err := contract.ServiceExists(opts, signerAddr); err != nil { fatalf("ServiceExists: %v", err) } else if !exists { - fatalf("no service registered for %s on %s — nothing to deregister", providerAddr.Hex(), *contractHex) + fatalf("no service registered for %s on %s — nothing to remove", signerAddr.Hex(), *contractHex) + } + earnings, err := contract.ProviderEarnings(opts, signerAddr) + if err != nil { + fatalf("ProviderEarnings: %v", err) } - fmt.Printf("Provider: %s\n", providerAddr.Hex()) - fmt.Printf("Contract: %s\n", *contractHex) - fmt.Println("\n[1/1] DeregisterService (clears url/appId/prices; balances & earnings preserved)...") + fmt.Printf("App owner: %s\n", ownerAddr.Hex()) + fmt.Printf("Provider (signer): %s\n", signerAddr.Hex()) + fmt.Printf("Pending earnings: %s neuron (swept to owner in the same tx)\n", earnings.String()) + fmt.Println("\n[1/1] RemoveService...") auth := buildAuth(ctx, privKey, *chainID) - tx, err := contract.DeregisterService(auth) + tx, err := contract.RemoveService(auth, signerAddr) if err != nil { - fatalf("DeregisterService: %v", err) + fatalf("RemoveService: %v", err) } fmt.Printf(" tx: %s\n", tx.Hash().Hex()) if _, err := bind.WaitMined(ctx, eth, tx); err != nil { fatalf("wait mined: %v", err) } fmt.Println(" confirmed ✓") - fmt.Printf("\nDone. Service cleared. Re-run `register` with the new appId.\n") + fmt.Printf("\nDone. Service cleared; %s neuron swept to %s.\nUser balances at %s stay refundable; remind users to requestRefund + re-deposit to the new node.\n", earnings.String(), ownerAddr.Hex(), signerAddr.Hex()) +} + +// ── rotate ──────────────────────────────────────────────────────────────────── + +// runRotate handles the SandboxServing side of a machine rebuild: the TEE key +// changed, so a new provider identity must take over the old one's commercial +// terms. It copies the old signer's service entry to the new signer, then +// removes the old entry (sweeping its pending earnings to the owner). +// +// Full rotation runbook (this command is step 4): +// +// 1. machine back up with the new key — the settler holds its queue until +// the new signer is registered on-chain +// 2. tapp-cli add-node-onchain (ADD the new signer; do NOT replace yet — +// old and new nodes coexist so both queues settle) +// 3. wait for the OLD signer's voucher queue to drain (/api/queue/summary) +// 4. provider rotate --old 0x --new 0x +// 5. tapp-cli remove-node-onchain for the old signer (stake unlocks ~1 day) +// 6. users requestRefund on the old bucket and re-deposit to the new signer +func runRotate(args []string) { + fs := flag.NewFlagSet("rotate", flag.ExitOnError) + rpc := fs.String("rpc", defaultRPC, "RPC endpoint") + chainID := fs.Int64("chain-id", defaultChainID, "Chain ID") + contractHex := fs.String("contract", envOrDefault("SETTLEMENT_CONTRACT", ""), "Settlement contract address (required: --contract or SETTLEMENT_CONTRACT env)") + keyHex := fs.String("key", "", "App owner private key (hex); or set OWNER_KEY env") + oldHex := fs.String("old", "", "Old (dead) signer address (required)") + newHex := fs.String("new", "", "New signer address (required; must already be an active node — tapp-cli add-node-onchain)") + urlOverride := fs.String("url", "", "New service URL (default: keep the old service's URL)") + _ = fs.Parse(args) + + if *oldHex == "" || *newHex == "" { + fatalf("--old and --new are required") + } + privKey := resolveOwnerKey(*keyHex) + ownerAddr := crypto.PubkeyToAddress(privKey.PublicKey) + oldAddr := common.HexToAddress(*oldHex) + newAddr := common.HexToAddress(*newHex) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + eth, contract := dialContract(ctx, *rpc, *contractHex) + defer eth.Close() + + opts := &bind.CallOpts{Context: ctx} + oldExists, err := contract.ServiceExists(opts, oldAddr) + if err != nil { + fatalf("ServiceExists(old): %v", err) + } + if !oldExists { + fatalf("no service registered for old signer %s — nothing to rotate from", oldAddr.Hex()) + } + svc, err := contract.Services(opts, oldAddr) + if err != nil { + fatalf("Services(old): %v", err) + } + earnings, err := contract.ProviderEarnings(opts, oldAddr) + if err != nil { + fatalf("ProviderEarnings(old): %v", err) + } + newURL := svc.Url + if *urlOverride != "" { + newURL = *urlOverride + } + + fmt.Printf("App owner: %s\n", ownerAddr.Hex()) + fmt.Printf("Old signer: %s (earnings %s neuron — swept to owner in step 2)\n", oldAddr.Hex(), earnings.String()) + fmt.Printf("New signer: %s\n", newAddr.Hex()) + fmt.Printf("AppId: %s\n", svc.AppId) + fmt.Printf("Service URL: %s\n", newURL) + fmt.Println("\n⚠ Precondition: the old signer's voucher queue must be EMPTY (/api/queue/summary).") + fmt.Println(" Vouchers still in flight settle fine until remove-node-onchain, but any that") + fmt.Println(" reference the old service after this rotate will fail PROVIDER_MISMATCH.") + + auth := buildAuth(ctx, privKey, *chainID) + + fmt.Println("\n[1/2] AddOrUpdateService(new signer, old terms)...") + tx, err := contract.AddOrUpdateService(auth, newAddr, newURL, svc.AppId, svc.PricePerCPUPerMin, svc.CreateFee, svc.PricePerMemGBPerMin) + if err != nil { + fatalf("AddOrUpdateService: %v\n\nReminder: %s must already be an active node of %s (tapp-cli add-node-onchain).", err, newAddr.Hex(), svc.AppId) + } + fmt.Printf(" tx: %s\n", tx.Hash().Hex()) + if _, err := bind.WaitMined(ctx, eth, tx); err != nil { + fatalf("wait mined: %v", err) + } + fmt.Println(" confirmed ✓ (same prices → no ack invalidation)") + + fmt.Println("[2/2] RemoveService(old signer)...") + tx, err = contract.RemoveService(auth, oldAddr) + if err != nil { + fatalf("RemoveService: %v", err) + } + fmt.Printf(" tx: %s\n", tx.Hash().Hex()) + if _, err := bind.WaitMined(ctx, eth, tx); err != nil { + fatalf("wait mined: %v", err) + } + fmt.Println(" confirmed ✓") + + fmt.Printf("\nDone. New provider identity: %s\n", newAddr.Hex()) + fmt.Println("Next steps:") + fmt.Printf(" - tapp-cli remove-node-onchain for %s (stake unlocks after ~1 day)\n", oldAddr.Hex()) + fmt.Printf(" - users with balance at %s: requestRefund → withdrawRefund (2h lock) → deposit to %s\n", oldAddr.Hex(), newAddr.Hex()) } // ── status ──────────────────────────────────────────────────────────────────── @@ -204,17 +355,14 @@ func runStatus(args []string) { fs := flag.NewFlagSet("status", flag.ExitOnError) rpc := fs.String("rpc", defaultRPC, "RPC endpoint") contractHex := fs.String("contract", envOrDefault("SETTLEMENT_CONTRACT", ""), "Settlement contract address (required: --contract or SETTLEMENT_CONTRACT env)") - keyHex := fs.String("key", "", "Provider private key; or set PROVIDER_KEY env") - addrHex := fs.String("address", "", "Provider address (alternative to --key)") + addrHex := fs.String("address", "", "Provider (signer) address to inspect (required; read-only, no key needed)") + tappHex := fs.String("tapp", envOrDefault("TAPP_REGISTRY", ""), "TappRegistry address (optional; shows the appId owner and node state)") _ = fs.Parse(args) - var providerAddr common.Address - if *addrHex != "" { - providerAddr = common.HexToAddress(*addrHex) - } else { - privKey := resolveKey(*keyHex, "PROVIDER_KEY") - providerAddr = crypto.PubkeyToAddress(privKey.PublicKey) + if *addrHex == "" { + fatalf("--address is required (the node's TEE signer address; see its /api/info `provider_address`)") } + providerAddr := common.HexToAddress(*addrHex) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -253,22 +401,49 @@ func runStatus(args []string) { fmt.Printf(" Mem price/min: %s neuron/GB\n", svc.PricePerMemGBPerMin.String()) fmt.Printf(" Create fee: %s neuron\n", svc.CreateFee.String()) fmt.Printf(" Earnings: %s neuron\n", earnings.String()) - fmt.Println(" (TEE signers + ack state now live in TappRegistry; query that contract for cluster info.)") + if *tappHex != "" && svc.AppId != "" { + tapp, err := chain.NewTappRegistry(common.HexToAddress(*tappHex), eth) + if err != nil { + fatalf("bind tappRegistry: %v", err) + } + appInfo, err := tapp.GetAppInfo(opts, svc.AppId) + if err != nil { + fatalf("tapp.getAppInfo: %v", err) + } + fmt.Printf(" App owner: %s (manages this service + withdraws earnings)\n", appInfo.Owner.Hex()) + node, err := tapp.GetNode(opts, svc.AppId, providerAddr) + if err != nil { + fatalf("tapp.getNode: %v", err) + } + if node.AddedAt.Sign() != 0 { + fmt.Printf(" Node: active (teeUrl %s, stake %s)\n", node.TeeUrl, node.StakeAmount.String()) + } else { + fmt.Println(" Node: NOT an active TappRegistry node — its vouchers cannot settle") + } + } } } // ── withdraw ────────────────────────────────────────────────────────────────── +// runWithdraw withdraws a node's accrued earnings to the app owner. Signed by +// the OWNER's key; the provider (signer) key never leaves the enclave and has +// no payout rights of its own. func runWithdraw(args []string) { fs := flag.NewFlagSet("withdraw", flag.ExitOnError) rpc := fs.String("rpc", defaultRPC, "RPC endpoint") chainID := fs.Int64("chain-id", defaultChainID, "Chain ID") contractHex := fs.String("contract", envOrDefault("SETTLEMENT_CONTRACT", ""), "Settlement contract address (required: --contract or SETTLEMENT_CONTRACT env)") - keyHex := fs.String("key", "", "Provider private key; or set PROVIDER_KEY env") + keyHex := fs.String("key", "", "App owner private key; or set OWNER_KEY env") + signerHex := fs.String("signer", "", "Node's TEE signer address whose earnings to withdraw (required)") _ = fs.Parse(args) - privKey := resolveKey(*keyHex, "PROVIDER_KEY") - providerAddr := crypto.PubkeyToAddress(privKey.PublicKey) + if *signerHex == "" { + fatalf("--signer is required") + } + privKey := resolveOwnerKey(*keyHex) + ownerAddr := crypto.PubkeyToAddress(privKey.PublicKey) + signerAddr := common.HexToAddress(*signerHex) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() @@ -276,7 +451,7 @@ func runWithdraw(args []string) { defer eth.Close() opts := &bind.CallOpts{Context: ctx} - earnings, err := contract.ProviderEarnings(opts, providerAddr) + earnings, err := contract.ProviderEarnings(opts, signerAddr) if err != nil { fatalf("ProviderEarnings: %v", err) } @@ -284,19 +459,20 @@ func runWithdraw(args []string) { fmt.Println("No earnings to withdraw.") return } - fmt.Printf("Provider: %s\n", providerAddr.Hex()) - fmt.Printf("Earnings: %s neuron\n", earnings.String()) + fmt.Printf("App owner: %s\n", ownerAddr.Hex()) + fmt.Printf("Provider (signer): %s\n", signerAddr.Hex()) + fmt.Printf("Earnings: %s neuron\n", earnings.String()) - fmt.Println("\nWithdrawing earnings...") - tx, err := contract.WithdrawEarnings(buildAuth(ctx, privKey, *chainID)) + fmt.Println("\nWithdrawing earnings to the owner...") + tx, err := contract.WithdrawEarnings(buildAuth(ctx, privKey, *chainID), signerAddr) if err != nil { - fatalf("WithdrawEarnings: %v", err) + fatalf("WithdrawEarnings: %v\n\nReminder: --key must be the TappRegistry owner of the appId this signer's service is bound to.", err) } fmt.Printf(" tx: %s\n", tx.Hash().Hex()) if _, err := bind.WaitMined(ctx, eth, tx); err != nil { fatalf("wait mined: %v", err) } - fmt.Printf(" confirmed ✓ (%s neuron withdrawn)\n", earnings.String()) + fmt.Printf(" confirmed ✓ (%s neuron paid to %s)\n", earnings.String(), ownerAddr.Hex()) } // `set-stake` was removed: stake collection moved to TappRegistry (per-node). @@ -403,7 +579,7 @@ var defaultTiers = []snapshotTier{ func runSnapshot(args []string) { fs := flag.NewFlagSet("snapshot", flag.ExitOnError) apiURL := fs.String("api", "http://localhost:8080", "0G Sandbox service URL") - keyHex := fs.String("key", "", "Provider private key (hex); or set PROVIDER_KEY env") + keyHex := fs.String("key", "", "Admin wallet key — the app owner or an ADMIN_ADDRESSES wallet (hex); or set OWNER_KEY env") image := fs.String("image", "", "Docker image name (required)") name := fs.String("name", "", "Snapshot name (defaults to image name)") tiers := fs.Bool("tiers", false, "Create small/medium/large variants automatically") @@ -415,7 +591,7 @@ func runSnapshot(args []string) { if *image == "" { fatalf("--image is required") } - privKey := resolveKey(*keyHex, "PROVIDER_KEY") + privKey := resolveOwnerKey(*keyHex) baseName := *image if *name != "" { @@ -494,10 +670,10 @@ func createSnapshot(privKey *ecdsa.PrivateKey, apiURL, imageName, name string, c func runListSnapshots(args []string) { fs := flag.NewFlagSet("snapshots", flag.ExitOnError) apiURL := fs.String("api", "http://localhost:8080", "0G Sandbox service URL") - keyHex := fs.String("key", "", "Provider private key (hex); or set PROVIDER_KEY env") + keyHex := fs.String("key", "", "Admin wallet key — the app owner or an ADMIN_ADDRESSES wallet (hex); or set OWNER_KEY env") _ = fs.Parse(args) - privKey := resolveKey(*keyHex, "PROVIDER_KEY") + privKey := resolveOwnerKey(*keyHex) msg, sig, walletAddr := signRequest(privKey, "list", "", json.RawMessage(`{}`)) req, err := http.NewRequest(http.MethodGet, *apiURL+"/api/snapshots", nil) @@ -540,14 +716,14 @@ func runListSnapshots(args []string) { func runDeleteSnapshot(args []string) { fs := flag.NewFlagSet("delete-snapshot", flag.ExitOnError) apiURL := fs.String("api", "http://localhost:8080", "0G Sandbox service URL") - keyHex := fs.String("key", "", "Provider private key (hex); or set PROVIDER_KEY env") + keyHex := fs.String("key", "", "Admin wallet key — the app owner or an ADMIN_ADDRESSES wallet (hex); or set OWNER_KEY env") id := fs.String("id", "", "Snapshot ID (required)") _ = fs.Parse(args) if *id == "" { fatalf("--id is required") } - privKey := resolveKey(*keyHex, "PROVIDER_KEY") + privKey := resolveOwnerKey(*keyHex) msg, sig, walletAddr := signRequest(privKey, "delete-snapshot", *id, json.RawMessage(`{}`)) req, err := http.NewRequest(http.MethodDelete, *apiURL+"/api/snapshots/"+*id, nil) @@ -575,11 +751,11 @@ func runDeleteSnapshot(args []string) { func runGCImages(args []string) { fs := flag.NewFlagSet("gc-images", flag.ExitOnError) apiURL := fs.String("api", "http://localhost:8080", "0G Sandbox service URL") - keyHex := fs.String("key", "", "Provider private key (hex); or set PROVIDER_KEY env") + keyHex := fs.String("key", "", "Admin wallet key — the app owner or an ADMIN_ADDRESSES wallet (hex); or set OWNER_KEY env") dryRun := fs.Bool("dry-run", false, "Preview deletions without actually removing tags") _ = fs.Parse(args) - privKey := resolveKey(*keyHex, "PROVIDER_KEY") + privKey := resolveOwnerKey(*keyHex) msg, sig, walletAddr := signRequest(privKey, "gc-images", "", json.RawMessage(`{}`)) url := *apiURL + "/api/registry/gc" @@ -685,20 +861,6 @@ func resolveEnv(flagVal, envVar, label string) string { return "" } -func resolveKey(flagVal, envVar string) *ecdsa.PrivateKey { - hex := flagVal - if hex == "" { - hex = os.Getenv(envVar) - } - if hex == "" { - fatalf("private key required: use --key or %s env", envVar) - } - privKey, err := crypto.HexToECDSA(strings.TrimPrefix(hex, "0x")) - if err != nil { - fatalf("parse private key: %v", err) - } - return privKey -} func parseBigInt(s, name string) *big.Int { v, ok := new(big.Int).SetString(s, 10) diff --git a/cmd/user/main.go b/cmd/user/main.go index 7193454..fa97cd9 100644 --- a/cmd/user/main.go +++ b/cmd/user/main.go @@ -51,6 +51,7 @@ import ( "math/big" "net/http" "os" + "strconv" "strings" "time" "unicode/utf8" @@ -305,10 +306,17 @@ func runAcknowledge(args []string) { if appInfo.Owner == (common.Address{}) { fatalf("app %q is not registered in TappRegistry (or was unregistered)", svc.AppId) } - // Sanity check: sandbox service's provider must be the app owner. - if appInfo.Owner != providerAddr { - fatalf("trust mismatch: sandbox provider %s ≠ tap app owner %s — refuse to ack", - providerAddr.Hex(), appInfo.Owner.Hex()) + // Sanity check (v2: provider IS the TEE signer): the provider address must + // be an active node of the appId — that is the machine whose vouchers will + // charge this deposit bucket. A provider that isn't a registered node can + // never settle, so an ack against it would be meaningless. + provNode, err := tapp.GetNode(opts, svc.AppId, providerAddr) + if err != nil { + fatalf("tapp.getNode: %v", err) + } + if provNode.AddedAt.Sign() == 0 { + fatalf("trust mismatch: provider %s is not an active TappRegistry node of app %q — refuse to ack", + providerAddr.Hex(), svc.AppId) } nodes, err := tapp.GetNodeList(opts, svc.AppId) if err != nil { @@ -334,7 +342,8 @@ func runAcknowledge(args []string) { fmt.Printf(" Mem price: %s neuron/GB/min\n", svc.PricePerMemGBPerMin.String()) fmt.Println() fmt.Printf("== Trust root (TappRegistry %s) ==\n", cf.tapp) - fmt.Printf(" App owner: %s (matches provider ✓)\n", appInfo.Owner.Hex()) + fmt.Printf(" App owner: %s\n", appInfo.Owner.Hex()) + fmt.Printf(" Provider node: %s (active node of this app ✓)\n", providerAddr.Hex()) fmt.Printf(" Registered: %s\n", time.Unix(appInfo.RegisteredAt.Int64(), 0).UTC().Format(time.RFC3339)) fmt.Printf(" Ack version: %s\n", ackVersion.String()) fmt.Printf(" Compose hash: 0x%s\n", hex.EncodeToString(appInfo.ComposeHash)) @@ -488,6 +497,7 @@ func runCreate(args []string) { disk := fs.Int("disk", 0, "Disk in GB (optional, overrides class)") sealed := fs.Bool("sealed", false, "Create a sealed sandbox (blocks SSH and toolbox access)") sealID := fs.String("seal-id", "", "Optional caller-chosen seal_id (64 hex chars); random if unset") + ports := fs.String("ports", "", "Comma-separated ports to expose publicly (e.g. 8080,3000); others require auth. Empty = all ports public") var envArgs multiString fs.Var(&envArgs, "env", "Env var KEY=VAL injected into container; repeatable") _ = fs.Parse(args) @@ -523,6 +533,17 @@ func runCreate(args []string) { if *sealID != "" { body["seal_id"] = *sealID } + if *ports != "" { + var portList []int + for _, p := range strings.Split(*ports, ",") { + n, err := strconv.Atoi(strings.TrimSpace(p)) + if err != nil { + fatalf("--ports must be comma-separated integers, got %q", p) + } + portList = append(portList, n) + } + body["publicPorts"] = portList + } if len(envArgs) > 0 { env := map[string]string{} for _, kv := range envArgs { diff --git a/contracts/README.md b/contracts/README.md old mode 100644 new mode 100755 index 1808426..7b2cc08 --- a/contracts/README.md +++ b/contracts/README.md @@ -14,8 +14,9 @@ Deployer/Owner: `0xB831371eb2703305f1d9F8542163633D0675CEd7` | Component | Address | |-----------|---------| -| **Proxy** (stable) | `0x2024eB0Cc14316fF8Cc425bFB7CC37FD8713E9b3` | -| Beacon | `0xaa77C82Dc6b4243Ff272d88619BD4f23455CCB6E` | +| **Proxy** (stable) | `0x3D0F2D62A60c8e62095671FfB23D15Cc4C98ca7c` | +| Beacon | `0xBF04734BC87E12aB81E21bb4018b9bFa4c118721` | +| TappRegistry | `0x2Ce80374318B1d7Fb3345724457a182E0ad165c9` | **Upgrade history:** @@ -23,9 +24,11 @@ Deployer/Owner: `0xB831371eb2703305f1d9F8542163633D0675CEd7` |------|------|-------| | initial | — | Initial deploy: per-provider balance isolation, owner model | | 2026-03-10 | `0x9a3D6C66e3e6E020D8D40d851Db76D76EBfa93f2` | Removed `msg.sender == provider` check in `settleFeesWithTEE`; TEE key signs settlement txs directly, no `PROVIDER_PRIVATE_KEY` needed | +| 2026-07-19 | `0x47a8E809Cd81b94eD19874da73C0E3F82DD90E5C` | **v2 redeploy (new proxy/beacon)**: provider IS the TEE signer; owner-managed register/remove/withdraw; payee-must-sign settlement. Previous dev proxy `0x2024eB0C…E9b3` retired (refund-only) | ```env -SETTLEMENT_CONTRACT=0x2024eB0Cc14316fF8Cc425bFB7CC37FD8713E9b3 +SETTLEMENT_CONTRACT=0x3D0F2D62A60c8e62095671FfB23D15Cc4C98ca7c +TAPP_REGISTRY=0x2Ce80374318B1d7Fb3345724457a182E0ad165c9 ``` --- @@ -36,20 +39,21 @@ SETTLEMENT_CONTRACT=0x2024eB0Cc14316fF8Cc425bFB7CC37FD8713E9b3 | Component | Address | |-----------|---------| -| **Proxy** (stable) | `0xA07b0033cA65B06B090535944C121D8677FDC12c` | -| Beacon | `0xfdc08C0CdF629589D05E03849846006c37E800D5` | +| **Proxy** (stable) | `0x3490B9053AC46F7Bf71A1ceBffcB2be2C1405b41` | +| Beacon | `0x79D6D7B5468AA134360bf73cc667FC63f704B62d` | +| TappRegistry | `0x2Ce80374318B1d7Fb3345724457a182E0ad165c9` | **Upgrade history:** | Date | Impl | Notes | |------|------|-------| -| 2026-06-08 | `0xf870247949B35dC8174212F338DcdE9fCa95d5Bb` | Redeploy on a fresh proxy (supersedes `0xd7e0CD22…`); per-resource pricing + TappRegistry trust root | -| 2026-06-08 | `0xe95DA05Bf17CAF09Cb129A706760bA52B55f14eE` | Add `deregisterService` — soft-clear a service entry so its (set-once) `appId` can be changed | +| 2026-07-20 | `0x7a1A5FC5B1A6AC1127e2D8b63400615B2ea49C47` | **v2 redeploy (new proxy/beacon)**: provider IS the TEE signer; owner-managed register/remove/withdraw; payee-must-sign settlement. Verified on chainscan. Bound to TappRegistry `0x2Ce8…65c9` (repointed from `0x95a0…` via setTappRegistry right after deploy). Supersedes the v1 testnet proxies `0xA07b0033…FC12c` and `0x3d4d8a05…cf6f` — both retired (refund-only) | -**Provider stake:** 100 0G (`100000000000000000000` neuron), held in TappRegistry per node (not in SandboxServing). +**Provider stake:** held in TappRegistry per node (not in SandboxServing); see `minStakeAmount()` on the registry (1 0G at the time of writing). ```env -SETTLEMENT_CONTRACT=0xA07b0033cA65B06B090535944C121D8677FDC12c +SETTLEMENT_CONTRACT=0x3490B9053AC46F7Bf71A1ceBffcB2be2C1405b41 +TAPP_REGISTRY=0x2Ce80374318B1d7Fb3345724457a182E0ad165c9 ``` --- @@ -106,17 +110,17 @@ Trust identity — the active TEE signer set and user acknowledgements — lives | Function | Notes | |---|---| -| `addOrUpdateService(url, appId, pricePerCPUPerMin, createFee, pricePerMemGBPerMin)` | Register/update; `appId` set-once; caller must be the appId's TappRegistry owner | -| `deregisterService()` | Soft-clear the caller's service so `appId` can change; balances/earnings/nonces preserved | -| `withdrawEarnings()` | Withdraw accrued settlement earnings | -| `services(provider)` / `serviceExists(provider)` | view — commercial terms | +| `addOrUpdateService(signer, url, appId, pricePerCPUPerMin, createFee, pricePerMemGBPerMin)` | App owner registers/updates a node's service; `signer` (= the provider address) must be an active TappRegistry node of the appId; `appId` set-once per signer | +| `removeService(signer)` | App owner removes a node's service (e.g. after a machine rebuild); sweeps pending earnings to the owner in the same tx; user balances stay refundable, nonce watermarks stay put | +| `withdrawEarnings(signer)` | App owner withdraws a node's accrued earnings to the owner's wallet | +| `services(provider)` / `serviceExists(provider)` | view — commercial terms (provider = the node's TEE signer address) | | `getProviderEarnings(provider)` → uint256 | view | **Settlement** | Function | Notes | |---|---| -| `settleFeesWithTEE(vouchers[])` → statuses[] | Permissionless; provider identified by `v.provider`; verifies the EIP-712 signature against the appId's active TEE node in TappRegistry | +| `settleFeesWithTEE(vouchers[])` → statuses[] | Permissionless; a voucher is valid only if signed BY its own payee (`recovered == v.provider`) and that address is an active TappRegistry node of the appId — one node can never settle vouchers naming another node | | `previewSettlementResults(vouchers[])` → statuses[] | view — dry-run statuses | **Admin / setup** @@ -128,7 +132,7 @@ Trust identity — the active TEE signer set and user acknowledgements — lives | `setTappRegistry(newRegistry)` | Repoint TappRegistry; ack state then reads from the new registry | | `tappRegistry()` / `domainSeparator()` / `LOCK_TIME()` | view | -**Events:** `Deposited`, `RefundRequested`, `RefundWithdrawn`, `VoucherSettled`, `EarningsWithdrawn`, `ServiceUpdated`, `ServiceDeregistered`, `OwnershipTransferred`, `TappRegistryUpdated`. +**Events:** `Deposited`, `RefundRequested`, `RefundWithdrawn`, `VoucherSettled`, `EarningsWithdrawn(provider, to, amount)`, `ServiceUpdated`, `ServiceRemoved(provider, appOwner)`, `OwnershipTransferred`, `TappRegistryUpdated`. --- @@ -242,4 +246,6 @@ wallet holds enough 0G to pay gas for settlement. - **Proxy address never changes** — upgrading only replaces the implementation; the proxy address is the stable external-facing address - **Open settlement** — `settleFeesWithTEE` can be called by anyone; the provider is identified by `v.provider` in the voucher, not `msg.sender` - **Trust root delegation** — SandboxServing holds only commercial terms; TEE signer identity and user acknowledgement live in TappRegistry and are queried on every voucher verification -- **`appId` is set-once** — once `addOrUpdateService` has bound a non-empty `appId`, subsequent calls must pass the same value (a provider can only update URL / prices / createFee in place, not the trust root). To bind a *different* `appId`, call **`deregisterService`** first: a soft clear of the caller's service entry (url/appId/prices/createFee) that preserves user balances, pending refunds, settled nonces, and accrued `providerEarnings` — all still withdrawable — then re-register. Emits `ServiceDeregistered(provider)`. +- **Provider IS the TEE signer (v2)** — every provider address (services key, voucher payee, balance bucket, earnings ledger) is the TEE signer address of one TappRegistry node. The signer key never leaves the enclave and dies with the machine, so all management (register/remove/withdraw) belongs to the appId's TappRegistry owner. One appId, many nodes: each signer has a fully isolated ledger — balances are deliberately NOT shared across nodes (independently-deployed billing proxies each run their own Redis reservation admission control and can't see each other's in-flight reservations; a shared balance would overcommit). +- **Rotation** — a machine rebuild produces a new signer. Runbook: add the new node in TappRegistry (old + new coexist), drain the old signer's voucher queue, `rotate` the service entry (cmd/provider), remove the old node. Users move balances off the dead signer via the normal refund flow; `removeService` sweeps its earnings to the owner. +- **`appId` is set-once per signer** — once `addOrUpdateService` has bound a non-empty `appId` to a signer, subsequent calls must pass the same value. To bind a *different* `appId`, `removeService` first (user balances, pending refunds, and settled nonces are preserved — nonces stay put so old vouchers can't be replayed after a re-register). diff --git a/contracts/README.zh.md b/contracts/README.zh.md old mode 100644 new mode 100755 index aac6c00..4c2ef9e --- a/contracts/README.zh.md +++ b/contracts/README.zh.md @@ -14,8 +14,9 @@ | 组件 | 地址 | |------|------| -| **Proxy**(稳定地址)| `0x2024eB0Cc14316fF8Cc425bFB7CC37FD8713E9b3` | -| Beacon | `0xaa77C82Dc6b4243Ff272d88619BD4f23455CCB6E` | +| **Proxy**(稳定地址)| `0x3D0F2D62A60c8e62095671FfB23D15Cc4C98ca7c` | +| Beacon | `0xBF04734BC87E12aB81E21bb4018b9bFa4c118721` | +| TappRegistry | `0x2Ce80374318B1d7Fb3345724457a182E0ad165c9` | **升级历史:** @@ -23,33 +24,36 @@ |------|------|---------| | 初始 | — | 首次部署:per-provider 余额隔离,owner 模型 | | 2026-03-10 | `0x9a3D6C66e3e6E020D8D40d851Db76D76EBfa93f2` | 移除 `settleFeesWithTEE` 中 `msg.sender == provider` 限制,TEE key 直接签结算 tx,无需 `PROVIDER_PRIVATE_KEY` | +| 2026-07-19 | `0x47a8E809Cd81b94eD19874da73C0E3F82DD90E5C` | **v2 重新部署(新 proxy/beacon)**:provider 即 TEE signer;注册/注销/提现归 owner;结算要求收款人本人签名。绑定 TappRegistry `0x2Ce80374318B1d7Fb3345724457a182E0ad165c9`。旧 dev proxy `0x2024eB0C…E9b3` 退役(仅退款) | ```env -SETTLEMENT_CONTRACT=0x2024eB0Cc14316fF8Cc425bFB7CC37FD8713E9b3 +SETTLEMENT_CONTRACT=0x3D0F2D62A60c8e62095671FfB23D15Cc4C98ca7c +TAPP_REGISTRY=0x2Ce80374318B1d7Fb3345724457a182E0ad165c9 ``` --- ## 测试网合约 -> 正式测试网部署,用于 provider 注册和真实计费测试。 +> 生产测试网部署,用于 provider 注册与真实计费测试。 | 组件 | 地址 | |------|------| -| **Proxy**(稳定地址)| `0xA07b0033cA65B06B090535944C121D8677FDC12c` | -| Beacon | `0xfdc08C0CdF629589D05E03849846006c37E800D5` | +| **Proxy**(稳定地址) | `0x3490B9053AC46F7Bf71A1ceBffcB2be2C1405b41` | +| Beacon | `0x79D6D7B5468AA134360bf73cc667FC63f704B62d` | +| TappRegistry | `0x2Ce80374318B1d7Fb3345724457a182E0ad165c9` | -**升级历史:** +**升级历史:** | 日期 | Impl | 说明 | |------|------|------| -| 2026-06-08 | `0xf870247949B35dC8174212F338DcdE9fCa95d5Bb` | 全新 proxy 重新部署(取代 `0xd7e0CD22…`);per-resource 定价 + TappRegistry trust root | -| 2026-06-08 | `0xe95DA05Bf17CAF09Cb129A706760bA52B55f14eE` | 新增 `deregisterService` —— 软清除 service,使(写一次的)`appId` 可更换 | +| 2026-07-20 | `0x7a1A5FC5B1A6AC1127e2D8b63400615B2ea49C47` | **v2 重新部署(新 proxy/beacon)**:provider 即 TEE signer;注册/注销/提现归 owner;结算要求收款人本人签名。已在 chainscan verify。取代 v1 测试网 proxy `0xA07b0033…FC12c`(绑 TappRegistry `0x2Ce8…`)与 `0x3d4d8a05…cf6f`——均退役(仅退款) | -**Provider 质押:** 100 0G(`100000000000000000000` neuron),按节点存在 TappRegistry 里(不在 SandboxServing)。 +**Provider 质押:** 按节点存于 TappRegistry(不在 SandboxServing);见 registry 的 `minStakeAmount()`(当前 1 0G)。 ```env -SETTLEMENT_CONTRACT=0xA07b0033cA65B06B090535944C121D8677FDC12c +SETTLEMENT_CONTRACT=0x3490B9053AC46F7Bf71A1ceBffcB2be2C1405b41 +TAPP_REGISTRY=0x2Ce80374318B1d7Fb3345724457a182E0ad165c9 ``` --- @@ -105,17 +109,17 @@ cast call "owner()(address)" | 函数 | 说明 | |---|---| -| `addOrUpdateService(url, appId, pricePerCPUPerMin, createFee, pricePerMemGBPerMin)` | 注册/更新;`appId` 写一次;调用者须是该 appId 的 TappRegistry owner | -| `deregisterService()` | 软清除自己的 service,使 `appId` 可更换;余额/earnings/nonce 保留 | -| `withdrawEarnings()` | 提取累计结算收益 | -| `services(provider)` / `serviceExists(provider)` | view — 业务条款 | +| `addOrUpdateService(signer, url, appId, pricePerCPUPerMin, createFee, pricePerMemGBPerMin)` | app owner 为节点注册/更新服务;`signer`(= provider 地址)必须是该 appId 的在册 TappRegistry 节点;`appId` 每个 signer 写一次 | +| `removeService(signer)` | app owner 注销节点服务(如机器重建后);同一笔 tx 把未提 earnings 清给 owner;用户余额仍可退款,nonce 水位保留 | +| `withdrawEarnings(signer)` | app owner 把节点累计收益提到 owner 钱包 | +| `services(provider)` / `serviceExists(provider)` | view — 业务条款(provider = 节点的 TEE signer 地址) | | `getProviderEarnings(provider)` → uint256 | view | **结算** | 函数 | 说明 | |---|---| -| `settleFeesWithTEE(vouchers[])` → statuses[] | 无需权限;provider 由 `v.provider` 标识;按 appId 的活跃 TEE 节点验 EIP-712 签名 | +| `settleFeesWithTEE(vouchers[])` → statuses[] | 无需权限;voucher 必须由收款人本人签名(`recovered == v.provider`)且该地址是 appId 在册节点——节点无法替别的节点结算 | | `previewSettlementResults(vouchers[])` → statuses[] | view — 试算结算状态 | **管理 / 初始化** @@ -127,7 +131,7 @@ cast call "owner()(address)" | `setTappRegistry(newRegistry)` | TappRegistry 重新部署后用来切换指向;ack 状态从新 registry 读 | | `tappRegistry()` / `domainSeparator()` / `LOCK_TIME()` | view | -**事件:** `Deposited`、`RefundRequested`、`RefundWithdrawn`、`VoucherSettled`、`EarningsWithdrawn`、`ServiceUpdated`、`ServiceDeregistered`、`OwnershipTransferred`、`TappRegistryUpdated`。 +**事件:** `Deposited`、`RefundRequested`、`RefundWithdrawn`、`VoucherSettled`、`EarningsWithdrawn(provider, to, amount)`、`ServiceUpdated`、`ServiceRemoved(provider, appOwner)`、`OwnershipTransferred`、`TappRegistryUpdated`。 --- @@ -240,4 +244,6 @@ PROVIDER_KEY=0x go run ./cmd/provider/ register \ - **Proxy 地址永不变** — 升级只替换 implementation,proxy 地址是对外稳定地址 - **结算开放** — `settleFeesWithTEE` 任何人可调用,provider 由 voucher 内的 `v.provider` 字段标识,与 `msg.sender` 无关 - **Trust root 委托** — SandboxServing 只持有商业条款;TEE 签名身份与用户 acknowledgement 都在 TappRegistry 中,每次 voucher 验签都会查询 -- **`appId` 写一次** — 一旦 `addOrUpdateService` 绑定了非空 `appId`,之后只能就地修改 URL / 价格 / createFee,无法替换 trust root。要绑定**不同的** `appId`,需先调 **`deregisterService`**:软清除调用者自己的 service 条目(url/appId/价格/createFee),但保留用户余额、待退款、已结算 nonce 和累计 `providerEarnings`(仍可提取),然后重新 register。触发 `ServiceDeregistered(provider)` 事件。 +- **provider 就是 TEE signer(v2)** — 合约里所有 provider 地址(services 键、voucher 收款人、余额桶、earnings 账)都是 TappRegistry 某个节点的 TEE signer 地址。signer 私钥不出 enclave、随机器消亡,所以注册/注销/提现全部归 appId 的 TappRegistry owner。一个 appId 多个节点:每个 signer 账本完全隔离——余额**刻意不共享**(各自独立部署的 billing proxy 用各自 Redis 做预留准入,互相看不见在途预留,共享余额会超卖)。 +- **轮换** — 机器重建产生新 signer。流程:TappRegistry 先 add 新节点(新旧并存)→ 排空旧 signer 的 voucher 队列 → `cmd/provider rotate` 迁移服务条目 → 摘旧节点。用户余额走退款通道离开死 signer;`removeService` 把它的 earnings 清给 owner。 +- **`appId` 每个 signer 写一次** — 绑定后只能就地改 URL/价格。要换 `appId` 先 `removeService`(用户余额、待退款、已结算 nonce 保留——nonce 不动,重注册后旧 voucher 无法重放)。 diff --git a/contracts/abi/SandboxServing.json b/contracts/abi/SandboxServing.json index c78785b..d782aa0 100755 --- a/contracts/abi/SandboxServing.json +++ b/contracts/abi/SandboxServing.json @@ -21,6 +21,11 @@ "type": "function", "name": "addOrUpdateService", "inputs": [ + { + "name": "signer", + "type": "address", + "internalType": "address" + }, { "name": "url", "type": "string", @@ -92,13 +97,6 @@ "outputs": [], "stateMutability": "payable" }, - { - "type": "function", - "name": "deregisterService", - "inputs": [], - "outputs": [], - "stateMutability": "nonpayable" - }, { "type": "function", "name": "domainSeparator", @@ -309,6 +307,19 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "removeService", + "inputs": [ + { + "name": "signer", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "requestRefund", @@ -478,7 +489,13 @@ { "type": "function", "name": "withdrawEarnings", - "inputs": [], + "inputs": [ + { + "name": "signer", + "type": "address", + "internalType": "address" + } + ], "outputs": [], "stateMutability": "nonpayable" }, @@ -536,6 +553,12 @@ "indexed": true, "internalType": "address" }, + { + "name": "to", + "type": "address", + "indexed": true, + "internalType": "address" + }, { "name": "amount", "type": "uint256", @@ -622,13 +645,19 @@ }, { "type": "event", - "name": "ServiceDeregistered", + "name": "ServiceRemoved", "inputs": [ { "name": "provider", "type": "address", "indexed": true, "internalType": "address" + }, + { + "name": "appOwner", + "type": "address", + "indexed": true, + "internalType": "address" } ], "anonymous": false diff --git a/contracts/src/SandboxServing.sol b/contracts/src/SandboxServing.sol index 8942453..9da1ac7 100755 --- a/contracts/src/SandboxServing.sol +++ b/contracts/src/SandboxServing.sol @@ -35,13 +35,31 @@ interface ITappRegistry { /// in TappRegistry. SandboxServing owns only commercial state: /// service URL, prices, balances, settlement. /// -/// A provider registers in three steps (each is a separate tx by the -/// provider's wallet): -/// 1. tappRegistry.registerApp(appId, ...) — stakes per node -/// 2. tappRegistry.authorizeInvalidator(appId, this) — lets us bump +/// Identity model (v2: provider IS the TEE signer) +/// ------------------------------------------------ +/// Every "provider" address in this contract — the services key, the +/// voucher payee, the (user, provider) balance bucket, the earnings +/// ledger — is the TEE-derived signer address of one node (one machine) +/// registered for the appId in TappRegistry. There is no separate +/// provider wallet. Consequences: +/// * a voucher is valid only if it was signed BY its own payee +/// (recovered == v.provider) and that address is an active node; +/// a node cannot settle vouchers naming another node as payee +/// * the TEE key cannot leave the enclave and rotates when the +/// machine is rebuilt, so all management (register / remove / +/// withdraw earnings) is done by the appId's TappRegistry owner, +/// not by the provider address itself +/// * on rotation the old signer's service is removed by the owner +/// (removeService sweeps pending earnings to the owner) and users +/// move their remaining balance via the normal refund flow +/// +/// The app owner registers a node's service in four steps: +/// 1. tappRegistry.registerApp(appId, ...) — stakes per node +/// 2. tappRegistry.addNode(appId, signer, teeUrl) — one per machine +/// 3. tappRegistry.authorizeInvalidator(appId, this) — lets us bump /// ackVersion on /// price changes -/// 3. sandboxServing.addOrUpdateService(url, appId, prices) +/// 4. sandboxServing.addOrUpdateService(signer, url, appId, prices) /// /// Voucher verification reads from TappRegistry at settle time (no local /// mirror of signer state — prevents the silent-drift incidents that @@ -126,9 +144,9 @@ contract SandboxServing { uint256 nonce, SettlementStatus status ); - event EarningsWithdrawn(address indexed provider, uint256 amount); + event EarningsWithdrawn(address indexed provider, address indexed to, uint256 amount); event ServiceUpdated(address indexed provider, string appId, string url); - event ServiceDeregistered(address indexed provider); + event ServiceRemoved(address indexed provider, address indexed appOwner); event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); event TappRegistryUpdated(address indexed previousRegistry, address indexed newRegistry); @@ -302,8 +320,10 @@ contract SandboxServing { return SettlementStatus.INSUFFICIENT_BALANCE; } - /// @dev ECDSA-recovers the voucher signer locally, then asks TappRegistry - /// whether that address is an active node of the provider's app. + /// @dev ECDSA-recovers the voucher signer locally. Valid iff the voucher + /// was signed by its own payee (provider IS the TEE signer) and that + /// address is an active node of the app in TappRegistry. A node can + /// therefore never settle vouchers naming another node as payee. function _verifySignature(SandboxVoucher calldata v, string memory appId) internal view returns (bool) { bytes32 structHash = keccak256(abi.encode( VOUCHER_TYPEHASH, @@ -315,7 +335,7 @@ contract SandboxServing { )); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", _domainSeparator, structHash)); address recovered = _ecrecover(digest, v.signature); - if (recovered == address(0)) return false; + if (recovered == address(0) || recovered != v.provider) return false; return tappRegistry.getNode(appId, recovered).addedAt != 0; } @@ -336,13 +356,26 @@ contract SandboxServing { // ─── Provider earnings ──────────────────────────────────────────────────── - function withdrawEarnings() external nonReentrant { - uint256 amount = providerEarnings[msg.sender]; + /// @notice Withdraw a node's accrued earnings to the app owner. The + /// provider address is a TEE signer whose key never leaves the + /// enclave (and dies with the machine), so payout authority + /// belongs to the appId's TappRegistry owner. + function withdrawEarnings(address signer) external nonReentrant { + require(_isAppOwnerOf(signer, msg.sender), "not app owner"); + uint256 amount = providerEarnings[signer]; require(amount > 0, "no earnings"); - providerEarnings[msg.sender] = 0; + providerEarnings[signer] = 0; (bool ok,) = msg.sender.call{value: amount}(""); require(ok, "transfer failed"); - emit EarningsWithdrawn(msg.sender, amount); + emit EarningsWithdrawn(signer, msg.sender, amount); + } + + /// @dev True iff `caller` is the TappRegistry owner of the appId that + /// `signer`'s service is bound to. Requires an existing service entry. + function _isAppOwnerOf(address signer, address caller) internal view returns (bool) { + string memory appId = services[signer].appId; + if (bytes(appId).length == 0) return false; + return tappRegistry.getAppInfo(appId).owner == caller; } // ─── Admin ──────────────────────────────────────────────────────────────── @@ -365,10 +398,12 @@ contract SandboxServing { // ─── Provider Management ────────────────────────────────────────────────── - /// @notice Register or update a provider service. Caller must already own - /// the appId in TappRegistry, and must have authorized this contract - /// as an invalidator (see ITappRegistry.authorizeInvalidator). - /// @dev appId is set-once on first call; later updates must pass the same + /// @notice Register or update a node's service. Called by the appId's + /// TappRegistry owner; `signer` is the node's TEE signer address + /// and must already be an active node of the appId. The contract + /// must also be authorized as an invalidator for the appId + /// (see ITappRegistry.authorizeInvalidator). + /// @dev appId is set-once per signer; later updates must pass the same /// appId or revert. Stake is collected by TappRegistry (per node); /// not collected here. /// @@ -377,6 +412,7 @@ contract SandboxServing { /// URL-only changes do NOT invalidate (URL drift can't redirect /// vouchers — signature/ack still root at the on-chain trust state). function addOrUpdateService( + address signer, string calldata url, string calldata appId, uint256 pricePerCPUPerMin, @@ -384,19 +420,20 @@ contract SandboxServing { uint256 pricePerMemGBPerMin ) external { require(tappRegistry.getAppInfo(appId).owner == msg.sender, "not app owner"); + require(tappRegistry.getNode(appId, signer).addedAt != 0, "signer not an active node"); require( tappRegistry.isAuthorizedInvalidator(appId, address(this)), "sandbox not authorized as invalidator" ); - Service storage svc = services[msg.sender]; + Service storage svc = services[signer]; bool isNew = bytes(svc.appId).length == 0; if (isNew) { svc.appId = appId; } else { require( keccak256(bytes(svc.appId)) == keccak256(bytes(appId)), - "appId immutable; deregister to change" + "appId immutable; remove to change" ); } @@ -410,28 +447,41 @@ contract SandboxServing { svc.pricePerCPUPerMin = pricePerCPUPerMin; svc.createFee = createFee; svc.pricePerMemGBPerMin = pricePerMemGBPerMin; - serviceExists[msg.sender] = true; + serviceExists[signer] = true; if (pricesChanged) { tappRegistry.invalidateAcks(appId); } - emit ServiceUpdated(msg.sender, appId, url); + emit ServiceUpdated(signer, appId, url); } - /// @notice Clear the caller's own service registration so it can be - /// re-registered under a different appId (appId is set-once in - /// addOrUpdateService). - /// @dev Soft clear: only the service entry (url/appId/prices/createFee) is - /// removed. User balances, pending refunds, settled nonces, and accrued - /// providerEarnings are keyed elsewhere and preserved — they remain - /// withdrawable, and nonces stay put so old vouchers can't be replayed + /// @notice Remove a node's service registration. Called by the appId's + /// TappRegistry owner — the signer key itself may be gone (it dies + /// with the machine), so removal cannot depend on it. Sweeps any + /// pending earnings to the owner in the same call: once the entry + /// is gone there is no appId left to authorize a later withdrawal + /// (and no new earnings can accrue — settlement requires + /// serviceExists). + /// @dev Soft clear otherwise: user balances, pending refunds, and settled + /// nonces are keyed elsewhere and preserved — balances remain + /// refundable, and nonces stay put so old vouchers can't be replayed /// after a re-register. - function deregisterService() external { - require(serviceExists[msg.sender], "no service to deregister"); - delete services[msg.sender]; - serviceExists[msg.sender] = false; - emit ServiceDeregistered(msg.sender); + function removeService(address signer) external nonReentrant { + require(serviceExists[signer], "no service to remove"); + require(_isAppOwnerOf(signer, msg.sender), "not app owner"); + + uint256 amount = providerEarnings[signer]; + providerEarnings[signer] = 0; + delete services[signer]; + serviceExists[signer] = false; + + if (amount > 0) { + (bool ok,) = msg.sender.call{value: amount}(""); + require(ok, "transfer failed"); + emit EarningsWithdrawn(signer, msg.sender, amount); + } + emit ServiceRemoved(signer, msg.sender); } // ─── View Functions ─────────────────────────────────────────────────────── diff --git a/contracts/src/SandboxServing.t.sol b/contracts/src/SandboxServing.t.sol index 6e463b9..8812d1d 100755 --- a/contracts/src/SandboxServing.t.sol +++ b/contracts/src/SandboxServing.t.sol @@ -24,6 +24,9 @@ contract MockTappRegistry is ITappRegistry { function addNode(string calldata appId, address signer) external { nodeAddedAt[appId][signer] = block.timestamp; } + function removeNode(string calldata appId, address signer) external { + nodeAddedAt[appId][signer] = 0; + } function setAck(address user, string calldata appId, bool v) external { userAcked[user][appId] = v; } @@ -57,11 +60,16 @@ contract SandboxServingTest is Test { MockTappRegistry public tap; address user = makeAddr("user"); - address provider = makeAddr("provider"); + address appOwner = makeAddr("appOwner"); - // TEE signing key (deterministic, for tests only) + // TEE signing key of node #1 (deterministic, tests only). Its address IS + // the provider: voucher payee, services key, balance bucket, earnings key. uint256 constant TEE_PRIV = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; - address teeSigner; + address provider; // = vm.addr(TEE_PRIV) + + // Second node's TEE key, for multi-node / cross-signing cases. + uint256 constant TEE2_PRIV = 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d; + address provider2; // = vm.addr(TEE2_PRIV) string constant APP_ID = "sandbox-test-app"; @@ -78,18 +86,22 @@ contract SandboxServingTest is Test { BeaconProxy proxy = new BeaconProxy(address(beacon), initData); serving = SandboxServing(payable(address(proxy))); - teeSigner = vm.addr(TEE_PRIV); + provider = vm.addr(TEE_PRIV); + provider2 = vm.addr(TEE2_PRIV); vm.deal(user, 10 ether); - vm.deal(provider, 10 ether); + vm.deal(appOwner, 10 ether); - // Provider's 3-step registration ceremony (here collapsed for tests). - tap.setAppOwner(APP_ID, provider); - tap.addNode(APP_ID, teeSigner); + // App owner's 4-step registration ceremony (collapsed for tests): + // registerApp + addNode + authorizeInvalidator in TappRegistry, + // then addOrUpdateService(signer, ...) here. + tap.setAppOwner(APP_ID, appOwner); + tap.addNode(APP_ID, provider); tap.authorize(APP_ID, address(serving)); - vm.prank(provider); + vm.prank(appOwner); serving.addOrUpdateService( + provider, "https://provider.example.com", APP_ID, 1000, // pricePerCPUPerMin @@ -155,9 +167,28 @@ contract SandboxServingTest is Test { assertEq(pending, 0.5 ether); } + /// Rotation path: after a machine rebuild the old signer's bucket is + /// drained via the normal refund flow — no service entry required. + function test_Refund_WorksAfterServiceRemoved() public { + vm.prank(user); + serving.deposit{value: 1 ether}(user, provider); + + vm.prank(appOwner); + serving.removeService(provider); + + vm.prank(user); + serving.requestRefund(provider, 1 ether); + vm.warp(block.timestamp + 2 hours + 1); + uint256 before = user.balance; + vm.prank(user); + serving.withdrawRefund(provider); + assertEq(user.balance - before, 1 ether); + } + // ── Settlement ────────────────────────────────────────────────────────── - function _makeVoucher( + function _makeVoucherSignedBy( + uint256 privKey, address _user, address _provider, uint256 totalFee, @@ -170,7 +201,7 @@ contract SandboxServingTest is Test { bytes32 digest = keccak256(abi.encodePacked( "\x19\x01", serving.domainSeparator(), structHash )); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(TEE_PRIV, digest); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privKey, digest); bytes memory sig = abi.encodePacked(r, s, v); return SandboxServing.SandboxVoucher({ @@ -183,13 +214,22 @@ contract SandboxServingTest is Test { }); } + function _makeVoucher( + address _user, + address _provider, + uint256 totalFee, + bytes32 usageHash, + uint256 nonce + ) internal view returns (SandboxServing.SandboxVoucher memory) { + return _makeVoucherSignedBy(TEE_PRIV, _user, _provider, totalFee, usageHash, nonce); + } + function _settle(SandboxServing.SandboxVoucher memory v) internal returns (SandboxServing.SettlementStatus) { SandboxServing.SandboxVoucher[] memory vs = new SandboxServing.SandboxVoucher[](1); vs[0] = v; - vm.prank(provider); SandboxServing.SettlementStatus[] memory statuses = serving.settleFeesWithTEE(vs); return statuses[0]; } @@ -257,42 +297,59 @@ contract SandboxServingTest is Test { assertEq(uint8(status), uint8(SandboxServing.SettlementStatus.INVALID_SIGNATURE)); } - function test_Settle_InvalidSignature_UnknownSigner() public { - // Voucher correctly signed by teeSigner, but tap no longer lists it as a node. + function test_Settle_InvalidSignature_StrangerKey() public { + // Correct payee, but signed by a key that is neither the payee nor a node. vm.prank(user); serving.deposit{value: 1 ether}(user, provider); tap.setAck(user, APP_ID, true); - // Remove the node from tap (set addedAt back to 0 by re-deploying mock state) - // Instead, just submit a voucher signed by a different key: - uint256 strangerPriv = 0xfeedbeef; - address stranger = vm.addr(strangerPriv); - tap.addNode(APP_ID, stranger); // need different node off — instead sign with stranger but stranger is also a node - // Reset: sign with TEE_PRIV but remove that node by overwriting (set time=0 by direct slot manipulation is too brittle). - // Cleaner: deploy a fresh mock with no nodes for this voucher's signer. - MockTappRegistry tap2 = new MockTappRegistry(); - tap2.setAppOwner(APP_ID, provider); - tap2.setAck(user, APP_ID, true); - tap2.authorize(APP_ID, address(serving)); - // tap2 has NO addNode for teeSigner → getNode(...).addedAt == 0 → INVALID_SIGNATURE. - // But serving is still bound to original tap; the easiest path is to verify the existing tap flow rejects unknown signers. - stranger; tap2; // suppress unused warnings — this branch documents the design; the actual unknown-signer test is below. - - // Submit a voucher signed by strangerPriv — strangerPriv is not a node in tap. - SandboxServing.SandboxVoucher memory bad = _makeVoucher(user, provider, 100, keccak256("u-stranger"), 1); - // Override signature with stranger's key - bytes32 structHash = keccak256(abi.encode( - VOUCHER_TYPEHASH, user, provider, bad.usageHash, bad.nonce, bad.totalFee - )); - bytes32 digest = keccak256(abi.encodePacked("\x19\x01", serving.domainSeparator(), structHash)); - uint256 strangerPriv2 = 0x1111111111111111111111111111111111111111111111111111111111111111; - (uint8 vv, bytes32 r, bytes32 s) = vm.sign(strangerPriv2, digest); - bad.signature = abi.encodePacked(r, s, vv); + uint256 strangerPriv = 0x1111111111111111111111111111111111111111111111111111111111111111; + SandboxServing.SandboxVoucher memory bad = + _makeVoucherSignedBy(strangerPriv, user, provider, 100, keccak256("u-stranger"), 1); SandboxServing.SettlementStatus status = _settle(bad); assertEq(uint8(status), uint8(SandboxServing.SettlementStatus.INVALID_SIGNATURE)); } + /// v2 core property: a voucher must be signed BY its own payee. Node #1 + /// signing a voucher that names node #2 as payee is rejected even though + /// node #1 is an active node of the same app. + function test_Settle_CrossSigningRejected() public { + tap.addNode(APP_ID, provider2); + vm.prank(appOwner); + serving.addOrUpdateService(provider2, "https://node2.example.com", APP_ID, 1000, 5000, 500); + + vm.prank(user); + serving.deposit{value: 1 ether}(user, provider2); + tap.setAck(user, APP_ID, true); + + // Payee is provider2 but signature comes from node #1's key. + SandboxServing.SandboxVoucher memory cross = + _makeVoucherSignedBy(TEE_PRIV, user, provider2, 1000, keccak256("cross"), 1); + SandboxServing.SettlementStatus status = _settle(cross); + assertEq(uint8(status), uint8(SandboxServing.SettlementStatus.INVALID_SIGNATURE)); + + // Signed by its own payee → settles. + SandboxServing.SandboxVoucher memory good = + _makeVoucherSignedBy(TEE2_PRIV, user, provider2, 1000, keccak256("own"), 1); + assertEq(uint8(_settle(good)), uint8(SandboxServing.SettlementStatus.SUCCESS)); + assertEq(serving.getProviderEarnings(provider2), 1000); + } + + function test_Settle_RemovedNodeRejected() public { + // Signer was a node when the service was registered, then got removed + // from TappRegistry (remove-node-onchain). Its vouchers must stop settling. + vm.prank(user); + serving.deposit{value: 1 ether}(user, provider); + tap.setAck(user, APP_ID, true); + + tap.removeNode(APP_ID, provider); + SandboxServing.SettlementStatus status = _settle( + _makeVoucher(user, provider, 100, keccak256("late"), 1) + ); + assertEq(uint8(status), uint8(SandboxServing.SettlementStatus.INVALID_SIGNATURE)); + } + function test_Settle_ProviderMismatch() public { vm.prank(user); serving.deposit{value: 1 ether}(user, provider); @@ -321,65 +378,184 @@ contract SandboxServingTest is Test { assertEq(serving.getProviderEarnings(provider), 400); } - function test_WithdrawEarnings() public { + // ── Earnings ───────────────────────────────────────────────────────────── + + function test_WithdrawEarnings_PaysAppOwner() public { vm.prank(user); serving.deposit{value: 1 ether}(user, provider); tap.setAck(user, APP_ID, true); _settle(_makeVoucher(user, provider, 5000, keccak256("u1"), 1)); - uint256 before = provider.balance; - vm.prank(provider); - serving.withdrawEarnings(); - assertEq(provider.balance - before, 5000); + uint256 before = appOwner.balance; + vm.prank(appOwner); + serving.withdrawEarnings(provider); + assertEq(appOwner.balance - before, 5000); assertEq(serving.getProviderEarnings(provider), 0); } + function test_WithdrawEarnings_RejectsNonAppOwner() public { + vm.prank(user); + serving.deposit{value: 1 ether}(user, provider); + tap.setAck(user, APP_ID, true); + _settle(_makeVoucher(user, provider, 5000, keccak256("u1"), 1)); + + address impostor = makeAddr("impostor"); + vm.expectRevert("not app owner"); + vm.prank(impostor); + serving.withdrawEarnings(provider); + + // The signer address itself has no special payout right either. + vm.deal(provider, 1 ether); + vm.expectRevert("not app owner"); + vm.prank(provider); + serving.withdrawEarnings(provider); + } + + function test_WithdrawEarnings_UnknownSignerRejected() public { + address unknown = makeAddr("unknown"); + vm.expectRevert("not app owner"); + vm.prank(appOwner); + serving.withdrawEarnings(unknown); + } + // ── Service registration ───────────────────────────────────────────────── function test_AddService_RejectsNonAppOwner() public { address impostor = makeAddr("impostor"); - // impostor does NOT own APP_ID in tap vm.expectRevert("not app owner"); vm.prank(impostor); - serving.addOrUpdateService("u", APP_ID, 1, 1, 1); + serving.addOrUpdateService(provider, "u", APP_ID, 1, 1, 1); + } + + function test_AddService_RejectsNonNodeSigner() public { + // Signer not registered as a node of the appId in TappRegistry. + address ghost = makeAddr("ghost"); + vm.expectRevert("signer not an active node"); + vm.prank(appOwner); + serving.addOrUpdateService(ghost, "u", APP_ID, 1, 1, 1); } function test_AddService_RejectsWhenNotAuthorized() public { - // Set up a new provider that owns a different app but never authorized us. + // A different app whose owner never authorized us as invalidator. string memory appId2 = "another-app"; - address p2 = makeAddr("p2"); - tap.setAppOwner(appId2, p2); + address owner2 = makeAddr("owner2"); + address signer2 = makeAddr("signer2"); + tap.setAppOwner(appId2, owner2); + tap.addNode(appId2, signer2); // No tap.authorize(appId2, serving) vm.expectRevert("sandbox not authorized as invalidator"); - vm.prank(p2); - serving.addOrUpdateService("u", appId2, 1, 1, 1); + vm.prank(owner2); + serving.addOrUpdateService(signer2, "u", appId2, 1, 1, 1); } function test_UpdateService_AppIdImmutable() public { - // Try to switch to a different appId — must revert. + // Try to rebind the same signer to a different appId — must revert. string memory appId2 = "another-app"; - tap.setAppOwner(appId2, provider); + tap.setAppOwner(appId2, appOwner); + tap.addNode(appId2, provider); tap.authorize(appId2, address(serving)); - vm.expectRevert("appId immutable; deregister to change"); - vm.prank(provider); - serving.addOrUpdateService("u", appId2, 1, 1, 1); + vm.expectRevert("appId immutable; remove to change"); + vm.prank(appOwner); + serving.addOrUpdateService(provider, "u", appId2, 1, 1, 1); } function test_UpdateService_PriceChangeInvalidatesAcks() public { uint256 before = tap.invalidateCount(); - vm.prank(provider); - serving.addOrUpdateService("u", APP_ID, 9999, 5000, 500); // CPU price changed + vm.prank(appOwner); + serving.addOrUpdateService(provider, "u", APP_ID, 9999, 5000, 500); // CPU price changed assertEq(tap.invalidateCount(), before + 1); } function test_UpdateService_UrlChangeDoesNotInvalidate() public { uint256 before = tap.invalidateCount(); - vm.prank(provider); - serving.addOrUpdateService("new-url", APP_ID, 1000, 5000, 500); // only URL changed + vm.prank(appOwner); + serving.addOrUpdateService(provider, "new-url", APP_ID, 1000, 5000, 500); // only URL changed assertEq(tap.invalidateCount(), before, "URL change must not invalidate acks"); } + /// One appId, many nodes: each signer gets its own isolated service entry, + /// balance bucket, nonce stream, and earnings — registered by the same owner. + function test_MultiNode_IsolatedLedgers() public { + tap.addNode(APP_ID, provider2); + vm.prank(appOwner); + serving.addOrUpdateService(provider2, "https://node2.example.com", APP_ID, 2000, 6000, 700); + + assertTrue(serving.serviceExists(provider2)); + (, string memory appId2,,,) = serving.services(provider2); + assertEq(appId2, APP_ID); + + vm.prank(user); + serving.deposit{value: 1 ether}(user, provider2); + tap.setAck(user, APP_ID, true); + + SandboxServing.SandboxVoucher memory v = + _makeVoucherSignedBy(TEE2_PRIV, user, provider2, 1000, keccak256("node2-usage"), 1); + assertEq(uint8(_settle(v)), uint8(SandboxServing.SettlementStatus.SUCCESS)); + + assertEq(serving.getProviderEarnings(provider2), 1000); + assertEq(serving.getProviderEarnings(provider), 0); // node1's ledger untouched + (uint256 bal2,,) = serving.getBalance(user, provider2); + (uint256 bal1,,) = serving.getBalance(user, provider); + assertEq(bal2, 1 ether - 1000); + assertEq(bal1, 0); // no shared pool + } + + // ── removeService ───────────────────────────────────────────────────────── + + function test_RemoveService_SweepsEarningsAndBlocksSettlement() public { + vm.prank(user); + serving.deposit{value: 1 ether}(user, provider); + tap.setAck(user, APP_ID, true); + _settle(_makeVoucher(user, provider, 5000, keccak256("u1"), 1)); + assertEq(serving.getProviderEarnings(provider), 5000); + + uint256 before = appOwner.balance; + vm.prank(appOwner); + serving.removeService(provider); + + // Pending earnings swept to the owner in the same call. + assertEq(appOwner.balance - before, 5000); + assertEq(serving.getProviderEarnings(provider), 0); + assertFalse(serving.serviceExists(provider)); + + // No further settlement against the removed signer. + SandboxServing.SettlementStatus status = _settle( + _makeVoucher(user, provider, 100, keccak256("u2"), 2) + ); + assertEq(uint8(status), uint8(SandboxServing.SettlementStatus.PROVIDER_MISMATCH)); + + // User balance is preserved and refundable (see test_Refund_WorksAfterServiceRemoved). + (uint256 bal,,) = serving.getBalance(user, provider); + assertEq(bal, 1 ether - 5000); + } + + function test_RemoveService_RejectsNonAppOwner() public { + address impostor = makeAddr("impostor"); + vm.expectRevert("not app owner"); + vm.prank(impostor); + serving.removeService(provider); + } + + function test_RemoveService_ThenReRegisterKeepsNonces() public { + vm.prank(user); + serving.deposit{value: 1 ether}(user, provider); + tap.setAck(user, APP_ID, true); + _settle(_makeVoucher(user, provider, 100, keccak256("u1"), 7)); + + vm.prank(appOwner); + serving.removeService(provider); + vm.prank(appOwner); + serving.addOrUpdateService(provider, "u", APP_ID, 1000, 5000, 500); + + // Old voucher can't be replayed: nonce watermark survived the remove. + SandboxServing.SettlementStatus status = _settle( + _makeVoucher(user, provider, 100, keccak256("u1"), 7) + ); + assertEq(uint8(status), uint8(SandboxServing.SettlementStatus.INVALID_NONCE)); + assertEq(serving.getLastNonce(user, provider), 7); + } + function test_IsTEEAcknowledged_Shim() public { assertFalse(serving.isTEEAcknowledged(user, provider)); tap.setAck(user, APP_ID, true); diff --git a/docker-compose.yml b/docker-compose.yml index d4faee0..a633d8a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,6 @@ services: SETTLEMENT_CONTRACT: ${SETTLEMENT_CONTRACT} CHAIN_ID: ${CHAIN_ID:-16602} RPC_URL: ${RPC_URL:-https://evmrpc-testnet.0g.ai} - PROVIDER_ADDRESS: ${PROVIDER_ADDRESS:-} # Billing parameters (neuron; 1 0G = 10^18 neuron) COMPUTE_PRICE_PER_SEC: ${COMPUTE_PRICE_PER_SEC:-16667} CREATE_FEE: ${CREATE_FEE:-5000000} diff --git a/docker/broker/docker-compose.yml b/docker/broker/docker-compose.yml index 6621b95..684cd8f 100644 --- a/docker/broker/docker-compose.yml +++ b/docker/broker/docker-compose.yml @@ -2,7 +2,7 @@ services: # ── Broker ──────────────────────────────────────────────────────────────────── broker: - image: eliza-registry-vpc.ap-southeast-1.cr.aliyuncs.com/eliza/0g-sandbox:broker + image: ${BROKER_IMAGE:-eliza-registry-vpc.ap-southeast-1.cr.aliyuncs.com/eliza/0g-sandbox:broker} pull_policy: always ports: - "${BROKER_PORT:-8082}:8081" @@ -41,7 +41,7 @@ services: # ── Broker Redis ─────────────────────────────────────────────────────────────── broker-redis: - image: redis@sha256:8b81dd37ff027bec4e516d41acfbe9fe2460070dc6d4a4570a2ac5b9d59df065 + image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/redis@sha256:8b81dd37ff027bec4e516d41acfbe9fe2460070dc6d4a4570a2ac5b9d59df065 volumes: - broker_redis_data:/data command: > diff --git a/docker/sandbox/.env.example b/docker/sandbox/.env.example index 3117ffc..d3cdaa2 100644 --- a/docker/sandbox/.env.example +++ b/docker/sandbox/.env.example @@ -16,10 +16,11 @@ REDIS_PASSWORD= # billing Redis password (leave blank for no auth in # TEE / Chain # Production: TEE key is fetched automatically from tapp-daemon via gRPC (BACKEND_TAPP_IP/PORT/APP_NAME). # Development: set MOCK_TEE=true and MOCK_APP_PRIVATE_KEY instead. -PROVIDER_ADDRESS= # REQUIRED — on-chain provider wallet (the address registered via `cmd/provider register`) -ADMIN_ADDRESSES= # comma-separated operator wallets allowed to call admin-only /api/* endpoints +ADMIN_ADDRESSES= # ADDITIVE comma-separated extra operator wallets for admin-only /api/* endpoints # (snapshot/registry mgmt, archive-all, force-delete, sessions, audit-log). - # Empty = falls back to [PROVIDER_ADDRESS] for single-key deployments. + # The appId owner is always an admin on top of this list — resolved on-chain + # from getAppInfo(BACKEND_APP_NAME).owner, never configured. The provider + # (settlement) identity is likewise derived from the TEE key at runtime. SETTLEMENT_CONTRACT=0x... # BeaconProxy address (output of `go run ./cmd/deploy/`) TAPP_REGISTRY=0x... # TappRegistry address (passed to ./cmd/deploy/ --tapp). # Required: SandboxServing delegates TEE signer identity @@ -56,6 +57,13 @@ BACKEND_APP_NAME= # tapp appId — must match the value used in # MOCK_TEE=true # MOCK_APP_PRIVATE_KEY=0x +# Registry prefix for ALL third-party images (daytona fork api/proxy, runner, +# ssh-gateway, redis, postgres, dex, minio, registry). Upstream Daytona went +# closed source, so every image is self-hosted; mirrors preserve upstream +# digests. Default: GCP Artifact Registry. Aliyun-VPC hosts use the eliza +# mirror instead: +# REGISTRY_PREFIX=eliza-registry-vpc.ap-southeast-1.cr.aliyuncs.com/eliza + # ── Daytona internals ───────────────────────────────────────────────────────── # These have safe defaults for local dev. Change in production. DAYTONA_ENCRYPTION_KEY=supersecretkey diff --git a/docker/sandbox/docker-compose.yml b/docker/sandbox/docker-compose.yml index 8baf936..568f7ae 100644 --- a/docker/sandbox/docker-compose.yml +++ b/docker/sandbox/docker-compose.yml @@ -21,10 +21,10 @@ services: TAPP_REGISTRY: ${TAPP_REGISTRY:?TAPP_REGISTRY is required} CHAIN_ID: ${CHAIN_ID:-16602} RPC_URL: ${RPC_URL:-https://evmrpc-testnet.0g.ai} - PROVIDER_ADDRESS: ${PROVIDER_ADDRESS:?PROVIDER_ADDRESS is required} - # Comma-separated list of operator wallets allowed to call admin-only - # endpoints (snapshot/registry management, archive-all, force-delete, - # sessions). Empty defaults to [PROVIDER_ADDRESS] for single-key setups. + # ADDITIVE comma-separated list of extra operator wallets allowed to + # call admin-only endpoints. The appId owner (resolved on-chain from + # BACKEND_APP_NAME) is always an admin on top of this list; the + # provider/settlement identity is derived from the TEE key at runtime. ADMIN_ADDRESSES: ${ADMIN_ADDRESSES:-} # Proxy domain — copied into sealed sandbox containers as # SANDBOX_PROXY_DOMAIN so bootstrap can compose its public URL. @@ -60,7 +60,7 @@ services: restart: unless-stopped sandbox-redis: - image: redis@sha256:8b81dd37ff027bec4e516d41acfbe9fe2460070dc6d4a4570a2ac5b9d59df065 + image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/redis@sha256:8b81dd37ff027bec4e516d41acfbe9fe2460070dc6d4a4570a2ac5b9d59df065 volumes: - ${SANDBOX_REDIS_DATA:-sandbox_redis_data}:/data command: > @@ -78,7 +78,12 @@ services: # Port 3000 is intentionally NOT published. All external API access must go # through the sandbox proxy on port 8080. api: - image: daytonaio/daytona-api:v0.189.0-amd64 + # 0g-daytona fork image (v0.189.0 + publicPorts). All third-party images + # are self-hosted under REGISTRY_PREFIX — upstream Daytona went closed + # source, so Docker Hub availability is not guaranteed. Mirrors preserve + # upstream digests (crane copy). Set REGISTRY_PREFIX per environment + # (GCP AR default; aliyun hosts use the eliza VPC registry mirror). + image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/daytona-api:v0.189.0-0g.1 environment: - OTEL_ENABLED=false # Snapshot used when a create request doesn't specify one. Per-env: a @@ -199,7 +204,7 @@ services: privileged: true runner: - image: daytonaio/daytona-runner:v0.189.0-amd64 + image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/daytona-runner:v0.189.0-amd64 # Sysbox at the outer boundary: host dockerd starts the runner via # sysbox-runc so the runner (and its DinD + user sandboxes) live in a # user namespace. Container uid 0 maps to a non-root uid on host, so @@ -207,7 +212,9 @@ services: # root. Preserves the DinD + internal-registry topology unchanged; # only the outer runtime shifts. Requires sysbox-runc registered on # the host dockerd (see 0g-tapp gcp-cvm build-gcp-tapp.sh ENABLE_SYSBOX). - runtime: sysbox-runc + # Set RUNNER_RUNTIME=sysbox-runc to enable (needs host kernel >=5.12 for + # shiftfs/idmapped-mounts; on 5.10 sysbox can't write resolv.conf). Default runc. + runtime: ${RUNNER_RUNTIME:-runc} environment: - ENVIRONMENT=production - API_PORT=3003 @@ -240,7 +247,7 @@ services: restart: always proxy: - image: daytonaio/daytona-proxy:v0.189.0-amd64 + image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/daytona-proxy:v0.189.0-0g.1 ports: - "4000:4000" environment: @@ -262,7 +269,7 @@ services: restart: always ssh-gateway: - image: daytonaio/daytona-ssh-gateway:v0.189.0-amd64 + image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/daytona-ssh-gateway:v0.189.0-amd64 ports: - "2222:2222" environment: @@ -276,7 +283,7 @@ services: restart: always dex: - image: dexidp/dex@sha256:1b4a6eee8550240b0faedad04d984ca939513650e1d9bd423502c67355e3822f + image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/dex@sha256:1b4a6eee8550240b0faedad04d984ca939513650e1d9bd423502c67355e3822f volumes: - ./config/dex/config.yaml:/etc/dex/config.yaml - ${DEX_DB_DATA:-dex_db}:/var/dex @@ -291,7 +298,7 @@ services: restart: unless-stopped db: - image: postgres@sha256:1090bc3a8ccfb0b55f78a494d76f8d603434f7e4553543d6e807bc7bd6bbd17f + image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/postgres@sha256:1090bc3a8ccfb0b55f78a494d76f8d603434f7e4553543d6e807bc7bd6bbd17f environment: - POSTGRES_PASSWORD=${DAYTONA_DB_PASSWORD} - POSTGRES_USER=${DAYTONA_DB_USER} @@ -313,7 +320,7 @@ services: # v0.189 permission enum once the api has finished migrations. Idempotent: # the WHERE clause skips the row after the first successful update. grant-admin-perms: - image: postgres:16-alpine + image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 depends_on: db: condition: service_healthy @@ -341,7 +348,7 @@ services: restart: on-failure:5 daytona-redis: - image: redis@sha256:8b81dd37ff027bec4e516d41acfbe9fe2460070dc6d4a4570a2ac5b9d59df065 + image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/redis@sha256:8b81dd37ff027bec4e516d41acfbe9fe2460070dc6d4a4570a2ac5b9d59df065 volumes: - ${DAYTONA_REDIS_DATA:-daytona_redis_data}:/data healthcheck: @@ -354,7 +361,7 @@ services: restart: unless-stopped minio: - image: minio/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e + image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e environment: - MINIO_ROOT_USER=${MINIO_ROOT_USER} - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD} @@ -371,7 +378,7 @@ services: restart: unless-stopped registry: - image: registry@sha256:bcece5dd3d4b6189e13e7ac71b2ccbc2aae649365f0c589852d687efeba6b290 + image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/registry@sha256:bcece5dd3d4b6189e13e7ac71b2ccbc2aae649365f0c589852d687efeba6b290 environment: REGISTRY_HTTP_ADDR: registry:6000 REGISTRY_STORAGE_DELETE_ENABLED: 'true' diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index b9717c0..50c0e1a 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -211,6 +211,8 @@ Server configuration and pricing. { "contract_address": "0x...", "provider_address": "0x...", + "owner_address": "0x...", + "app_id": "0g-sandbox-provider", "chain_id": 16602, "rpc_url": "https://evmrpc-testnet.0g.ai", "create_fee": "60000000000000000", @@ -229,10 +231,10 @@ Server configuration and pricing. > request that doesn't set `"sealed": true` is rejected with HTTP 400. #### `GET /api/providers` -On-chain service data for the provider this billing instance serves -(`PROVIDER_ADDRESS`). Returns an array — currently a single entry — read live -from `SandboxServing.services`. Returns `[]` if that provider has no active -service registration (e.g. after `deregisterService`). +On-chain service data for the provider this billing instance serves (the +node's TEE signer address, derived at runtime). Returns an array — currently a +single entry — read live from `SandboxServing.services`. Returns `[]` if that +provider has no active service registration (e.g. after `removeService`). ```json [ { @@ -267,8 +269,9 @@ All monetary amounts are in **neuron** (1 0G = 10¹⁸ neuron). **Body:** ```json { - "image": "ubuntu:22.04", - "sealed": false + "image": "ubuntu:22.04", + "sealed": false, + "publicPorts": [8080, 3000] } ``` All fields are optional. @@ -277,6 +280,16 @@ All fields are optional. |-------|------|-------------| | `image` | string | Docker image or snapshot name to use | | `sealed` | bool | If `true`, creates a sealed sandbox (see below) | +| `publicPorts` | int[] | Public port allowlist: only these ports are publicly reachable via the preview proxy; all others fall back to preview auth (owner tokens still work). Omit = all ports public. Max 16 entries, range 1-65535, system ports 22222/2280/33333 rejected, immutable after create. Sealed sandboxes must include 8080 | + +**Per-port public preview** (`"publicPorts": [...]`): +- Requires the provider to run the 0g-daytona fork images; against stock + Daytona the request fails with **502** (`publicPorts is not supported by + this provider's Daytona backend`) and no sandbox is left running +- The `200` response echoes `publicPorts` and adds + `preview_urls: {"8080": "http://8080-."}` +- Non-listed ports answer with a **307 redirect** to the provider's OIDC + endpoint (effectively blocked for public callers) **Sealed sandboxes** (`"sealed": true`): - Resolves the image to its content digest via the internal registry (hard failure if unresolvable) diff --git a/docs/API_REFERENCE.zh.md b/docs/API_REFERENCE.zh.md index c7cf2f3..c8e51e2 100644 --- a/docs/API_REFERENCE.zh.md +++ b/docs/API_REFERENCE.zh.md @@ -209,6 +209,8 @@ const resp = await fetch("http:///api/sandbox", { { "contract_address": "0x...", "provider_address": "0x...", + "owner_address": "0x...", + "app_id": "0g-sandbox-provider", "chain_id": 16602, "rpc_url": "https://evmrpc-testnet.0g.ai", "create_fee": "60000000000000000", @@ -227,9 +229,9 @@ const resp = await fetch("http:///api/sandbox", { > `"sealed": true` 的 create 请求都会被 HTTP 400 拒绝。 #### `GET /api/providers` -本 billing 实例所服务的那个 provider(`PROVIDER_ADDRESS`)的链上 service 数据。返回一个 +本 billing 实例所服务的那个 provider((节点 TEE signer 地址,运行时派生))的链上 service 数据。返回一个 数组——目前只有一条——实时读自 `SandboxServing.services`。若该 provider 没有有效的 -service 注册(例如调用 `deregisterService` 之后),返回 `[]`。 +service 注册(例如调用 `removeService` 之后),返回 `[]`。 ```json [ { @@ -263,8 +265,9 @@ service 注册(例如调用 `deregisterService` 之后),返回 `[]`。 **Body:** ```json { - "image": "ubuntu:22.04", - "sealed": false + "image": "ubuntu:22.04", + "sealed": false, + "publicPorts": [8080, 3000] } ``` 所有字段可选。 @@ -273,6 +276,14 @@ service 注册(例如调用 `deregisterService` 之后),返回 `[]`。 |------|------|------| | `image` | string | 要用的 Docker 镜像或 snapshot 名 | | `sealed` | bool | `true` 则创建 sealed 沙箱(见下) | +| `publicPorts` | int[] | 公开端口白名单:只有名单内端口可经预览代理公开访问,其余端口回落到预览认证(owner 的 token 仍可用)。不传 = 全端口公开。最多 16 个,范围 1-65535,系统端口 22222/2280/33333 拒绝,创建后不可改。sealed 沙箱必须包含 8080 | + +**端口级公开预览**(`"publicPorts": [...]`): +- 要求 provider 运行 0g-daytona fork 镜像;对官方 Daytona 该请求返回 **502** + (`publicPorts is not supported by this provider's Daytona backend`),且不会留下运行中的沙箱 +- `200` 响应会回显 `publicPorts` 并附上 + `preview_urls: {"8080": "http://8080-."}` +- 名单外端口返回 **307 跳转**到 provider 的 OIDC 端点(对公众等于被拦) **Sealed 沙箱**(`"sealed": true`): - 通过内部 registry 把镜像解析为 content digest(无法解析则硬失败) diff --git a/docs/CLI.md b/docs/CLI.md index 36b78f9..269eeb5 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -4,10 +4,16 @@ Two command-line tools are provided for operators and users: | Tool | Role | |------|------| -| `cmd/provider` | Provider operator: register/update service on-chain | +| `cmd/provider` | App owner: manage node services on-chain (register/rotate/withdraw) | | `cmd/user` | End user: manage balance and sandboxes | -Private keys can be passed via `--key` flag or environment variable (`PROVIDER_KEY` / `USER_KEY`). The `0x` prefix is optional. +Private keys can be passed via `--key` flag or environment variable (`OWNER_KEY` / `USER_KEY`; `PROVIDER_KEY` is accepted as a legacy alias for `OWNER_KEY`). The `0x` prefix is optional. + +> **v2 identity model:** the *provider address* is the node's TEE signer +> address — its key lives inside the enclave and dies with the machine. All +> management commands are therefore signed by the **appId owner's** key, and +> take the node's signer address via `--signer`. Get a node's signer from its +> `/api/info` (`provider_address`) or `tapp-cli get-app-key`. --- @@ -15,19 +21,22 @@ Private keys can be passed via `--key` flag or environment variable (`PROVIDER_K ### `register` / `init-service` -Bind a SandboxServing service to a TappRegistry appId and set its prices. +Register/update a node's service: bind its TEE signer address to a TappRegistry +appId and set URL + prices. Signed by the **app owner's** key. (`init-service` is an alias for `register`.) -Prerequisites (done on TappRegistry by the same provider wallet, in separate txs -via `tapp-cli`): +Prerequisites (done on TappRegistry by the app owner, in separate txs via +`tapp-cli`): 1. `tapp-cli register-onchain` — registers the app + first TEE node, pays stake. -2. `tapp-cli authorize-invalidator-onchain --invalidator ` — +2. `tapp-cli add-node-onchain` — one per additional machine. +3. `tapp-cli authorize-invalidator-onchain --invalidator ` — permits this contract to bump the app's ack version on price changes. ```bash -PROVIDER_KEY=0x go run ./cmd/provider/ register \ +OWNER_KEY=0x go run ./cmd/provider/ register \ --app-id \ + --signer 0x \ --url <0g-sandbox-url> \ [--price-per-cpu ] \ [--price-per-mem ] \ @@ -41,58 +50,76 @@ PROVIDER_KEY=0x go run ./cmd/provider/ register \ | Flag | Default | Description | |------|---------|-------------| -| `--key` | `PROVIDER_KEY` env | Provider private key (hex). Must derive to the app owner registered in TappRegistry. | -| `--app-id` | (required) | TappRegistry appId this service is bound to. Must already be registered + have this contract authorized as an invalidator. | +| `--key` | `OWNER_KEY` env | App owner private key (hex). Must be the appId's TappRegistry owner. | +| `--signer` | (required) | The node's TEE signer address = the provider address. From the node's `/api/info` (`provider_address`) or `tapp-cli get-app-key`. | +| `--app-id` | (required) | TappRegistry appId. The signer must already be an active node of it, and this contract must be an authorized invalidator. | | `--url` | (required) | Public URL of the billing proxy (e.g. `http://1.2.3.4:8080`) | | `--price-per-cpu` | `1000000000000000` | Price per CPU core per minute (neuron) | | `--price-per-mem` | `500000000000000` | Price per GB memory per minute (neuron) | | `--fee` | `60000000000000000` | Flat fee per sandbox creation (neuron) | | `--rpc` | `https://evmrpc-testnet.0g.ai` | EVM RPC endpoint | | `--chain-id` | `16602` | Chain ID | -| `--contract` | deployed testnet addr | Settlement contract (BeaconProxy) address | +| `--contract` | `SETTLEMENT_CONTRACT` env | Settlement contract (BeaconProxy) address | -The contract verifies on call: `tap.getAppInfo(appId).owner == msg.sender` and +The contract verifies on call: caller is `tap.getAppInfo(appId).owner`, the +signer is an active node (`tap.getNode(appId, signer).addedAt != 0`), and `tap.isAuthorizedInvalidator(appId, address(this)) == true`. Stake is not collected here — TappRegistry holds per-node stake. -The appId field on the service is **set-once**: the first call writes it, -subsequent calls must pass the same value or revert. To switch to a different -appId, the operator must deregister and re-register. +The appId field on a signer's service is **set-once**: the first call writes +it, subsequent calls must pass the same value or revert. To bind a different +appId, `remove-service` first. Each node (signer) gets its own fully isolated +service entry: separate URL/prices, user balances, voucher nonces, earnings. -**Example — testnet** +> **After calling `register`**: just (re)deploy the billing service — it +> derives its provider identity from the TEE key and resolves the app owner +> from `getAppInfo(BACKEND_APP_NAME).owner` automatically; nothing to configure. -```bash -# Steps 1 + 2: register the app in TappRegistry + authorize this contract. -# See the `tapp-cli` documentation in the 0g-tapp repo for the full command set. +--- -# Step 3: bind the service in SandboxServing. -PROVIDER_KEY=0x... go run ./cmd/provider/ register \ - --app-id my-sandbox-app \ - --url http://:8080 \ - --price-per-cpu 1000000000000000 \ - --price-per-mem 500000000000000 \ - --fee 60000000000000000 -``` +### `remove-service` -**Output** +Remove a node's service entry — e.g. the machine was rebuilt (its signer is +gone forever) or you're rebinding the signer to a different appId. Signed by +the app owner's key. **Sweeps any pending earnings to the owner in the same +tx** (once the entry is gone there is no appId left to authorize a later +withdrawal). User balances stay refundable; nonce watermarks stay put so old +vouchers can't replay after a re-register. +```bash +OWNER_KEY=0x go run ./cmd/provider/ remove-service \ + --signer 0x \ + [--contract ] ``` -Provider: 0xea69...1837 -AppId: my-sandbox-app -Contract: 0x -Service URL: http://:8080 -CPU price/min: 1000000000000000 neuron -Mem price/min: 500000000000000 neuron/GB -Create fee: 60000000000000000 neuron -[1/1] AddOrUpdateService... - tx: 0x... - confirmed ✓ +--- -Done. Provider address: 0xea69...1837 +### `rotate` + +One-command SandboxServing side of a machine rebuild: copies the old signer's +service entry (appId/URL/prices) to the new signer, then removes the old entry +(sweeping its earnings to the owner). Same prices → no ack invalidation. + +```bash +OWNER_KEY=0x go run ./cmd/provider/ rotate \ + --old 0x \ + --new 0x \ + [--url ] \ + [--contract ] ``` -> **After calling `register`**: set `PROVIDER_ADDRESS` in your `.env`, then redeploy the billing service. +Full rotation runbook (rotate is step 4): + +1. Machine back up with the new TEE key. The billing server's settler detects + its signer is not yet a registered node and **holds the voucher queue** + (no gas burned, no dead-lettered revenue). +2. `tapp-cli add-node-onchain` — **ADD** the new signer (don't replace): old + and new nodes coexist, so both signers' vouchers settle. +3. Wait for the old signer's queue to drain (`GET /api/queue/summary` → 0). +4. `provider rotate --old 0x… --new 0x…` +5. `tapp-cli remove-node-onchain` for the old signer (stake unlocks ~1 day). +6. Users with balance at the old signer: `requestRefund` → 2h lock → + `withdrawRefund` → `deposit` to the new signer. --- @@ -140,28 +167,26 @@ Service: ### `withdraw` -Withdraw all accumulated earnings from the settlement contract to the provider wallet. +Withdraw a node's accumulated earnings **to the app owner's wallet**. Signed +by the owner's key — the provider (signer) key never leaves the enclave and +has no payout rights of its own. ```bash -PROVIDER_KEY=0x go run ./cmd/provider/ withdraw \ +OWNER_KEY=0x go run ./cmd/provider/ withdraw \ + --signer 0x \ [--rpc ] \ [--chain-id ] \ [--contract ] ``` -**Example** - -```bash -PROVIDER_KEY=0x go run ./cmd/provider/ withdraw -``` - ``` -Provider: 0xea69...1837 -Earnings: 5000000000000000000 neuron +App owner: 0xea69...1837 +Provider (signer): 0x59d1...44dd +Earnings: 5000000000000000000 neuron -Withdrawing earnings... +Withdrawing earnings to the owner... tx: 0x... - confirmed ✓ (5000000000000000000 neuron withdrawn) + confirmed ✓ (5000000000000000000 neuron paid to 0xea69...1837) ``` --- @@ -546,6 +571,7 @@ go run ./cmd/user/ create \ | `--memory` | — | Memory in GB (overrides `--class`) | | `--disk` | — | Disk in GB (overrides `--class`) | | `--sealed` | `false` | Create a sealed sandbox: injects TEE attestation, blocks SSH and toolbox access | +| `--ports` | — | Comma-separated public port allowlist (e.g. `8080,3000`). Only these ports are publicly reachable; all others require preview auth. Empty = all ports public. Max 16; system ports 22222/2280/33333 rejected; immutable after create; sealed sandboxes must include 8080 | **Example** @@ -555,6 +581,21 @@ USER_KEY=0x go run ./cmd/user/ create --api http://:8080 # Sealed sandbox (SSH/toolbox blocked; TEE attestation injected) USER_KEY=0x go run ./cmd/user/ create --api http://:8080 --sealed + +# Only port 8080 publicly reachable; 9090 etc. fall back to preview auth +USER_KEY=0x go run ./cmd/user/ create --api http://:8080 --ports 8080 +``` + +With `--ports`, the create response echoes `publicPorts` (confirmation the +provider's Daytona supports it — providers on stock Daytona reject such +creates with 502) and includes ready-to-use URLs: + +```json +{ + "id": "54a4c0ee-…", + "publicPorts": [8080], + "preview_urls": { "8080": "http://8080-54a4c0ee-…." } +} ``` --- diff --git a/docs/provider-is-signer.zh.md b/docs/provider-is-signer.zh.md new file mode 100644 index 0000000..00e0f92 --- /dev/null +++ b/docs/provider-is-signer.zh.md @@ -0,0 +1,100 @@ +# V2 设计:provider = TEE signer + +> 状态:设计稿,待评审。分支 `feat/provider-is-signer`。 +> 前置结论(已确认):机器重启会换 TEE key,服务重启不会,频率不高。 + +## 一句话 + +砍掉独立的 provider 钱包:**记账身份(provider)直接用节点的 TEE signer 地址**, +管理与提现归 appOwner。voucher 的签名者就是收款人,"signer↔provider 绑定"问题 +从"需要校验"变成"根本不存在"。 + +## 身份模型 + +| 身份 | key 在哪 | 干什么 | 生命周期 | +|---|---|---|---| +| **owner**(appOwner) | 冷钱包 | 注册/注销服务、提现、授权 admin | 永久 | +| **provider = signer** | TEE enclave 内,不可导出 | 签 voucher、付结算 gas、余额/nonce/earnings 的账本 key | 跟机器走,重启机器即轮换 | + +- 概念和命名全部保留:voucher 的 `provider` 字段、`services`、`providerEarnings`、 + `(user, provider)` 余额桶都不改名,只是 provider 的值 = TEE 地址。 +- EIP-712 voucher 结构**不变**(字段类型一致,typehash 不动)。 + +## 合约改动(SandboxServing) + +破坏性变更,**新部署 proxy**,不走 upgradeTo(余额账本 key 的语义变了)。 + +1. `addOrUpdateService(signer, url, prices…)` — **owner 调**: + `require(msg.sender == tap.getAppInfo(appId).owner)` 且 `signer` 是该 appId + 的在册 TappRegistry 节点。appId 由 service 绑定,set-once 语义保留。 +2. `removeService(signer)` — **owner 调**(取代 provider 自签的 deregisterService; + 旧 key 重启即失,必须由 owner 能注销,否则僵尸 service 永远清不掉, + broker 会继续向死桶充值)。 +3. `withdrawEarnings(signer)` — **owner 调**,收益打给 owner。 +4. 结算验签:`recovered == v.provider && tap.getNode(appId, v.provider).addedAt != 0` + (appId 取自 `services[v.provider].appId`)。 +5. **删除** #56 的 `authorizeProviders / authorizeProvider / revokeProvider` + (委托钱包不存在了)。 +6. **不做** declareRotation / migrateBalance(已决定:轮换低频,用户走退款通道; + 以后有需要再加,beacon 增量升级即可)。 +7. 用户侧 deposit / requestRefund / withdrawRefund / 2h LOCK_TIME 全部不变。 + +## Go 侧(billing server) + +- `internal/chain`:重新 abigen;voucher 的 provider 字段和启动时的 service 查询 + 改用 **TEE key 自己的地址**(不再读 `PROVIDER_ADDRESS` env)。 +- `internal/settler`: + - Redis 队列键 `voucher:`(自动派生); + - **新增暂停逻辑**:自己的 signer 不在 TappRegistry 在册节点时,不提交结算、 + 只攒队列(现有 IsActiveNode 检查+告警之上加一个闸),堵住 + "服务已起、add-node 未上链"窗口的 DLQ 损失。 +- `internal/config` / `auth`:`PROVIDER_ADDRESS` 删除,且 **owner 也不配置**—— + 运行时从链上解析 `getAppInfo(BACKEND_APP_NAME).owner`(1 分钟 TTL 缓存, + RPC 失败时用上次的值,不会把 owner 锁在门外); + **owner 恒为 admin**,`ADMIN_ADDRESSES` 只是追加的额外运维钱包。 +- 启动校验:`services[signer].appId == BACKEND_APP_NAME`,不一致报错日志。 +- `/api/info` 暴露 `provider_address`(=当前 signer)、`owner_address`(链上解析)、`app_id`。 + +## CLI + +- `cmd/provider`: + - `register`/`removeService`/`withdraw` 改 owner key 签名,`register` 加 `--signer`; + - 删 `authorize-provider` / `revoke-provider`; + - 新增 `rotate`:机器重启后一条命令走完 + 「等旧队列排空 → add-node-onchain 新 signer → (用户走退款迁移)→ + 旧队列确认清零后 remove-node-onchain 旧 signer」。 +- `cmd/user`:deposit/balance/acknowledge 的目标地址从 `/api/info` 自动取当前 signer; + 文档明确"充值是充给某台节点的"。 + +## broker + +- provider 当不透明地址用的逻辑照跑; +- indexer/monitor:识别 service 被 owner `removeService` 后停止对旧桶 top-up; +- broker 栈合约同样新部署换地址。 + +## 轮换 runbook(机器重启后) + +``` +1. 服务用新 key 起来(settler 检测到 signer 不在册 → 自动暂停提交,只攒账) +2. add-node-onchain 新 signer(新旧节点并存,两边的账都合法) + → settler 检测到在册,放闸,新旧账都开始结 +3. 等旧 signer 的队列排空(/api/queue/summary 清零) +4. remove-node-onchain 旧 signer(质押锁 1 天后 withdraw) + + owner removeService(旧 signer) 清掉僵尸 service +5. 用户对旧桶余额走退款(requestRefund → 2h → withdrawRefund → 充到新地址) +``` + +## 已知取舍(评审确认过) + +- **gas 浮存陪葬**:TEE 地址的剩余 gas 随 key 丢失,小额勤充。 +- **用户轮换体验**:3 笔 tx + 2h 锁 + 服务中断;低频可接受,不为它加合约面。 +- **appId 转手**:新 owner 可提旧 owner 任期内 earnings(与现状同性质,文档标注)。 +- **结算边界仍是 appId 级**:同 appId 多节点互签在合约上可行,靠 attested 代码防; + 但 v2 下 payee=签名者,互签只能把钱签给"自己作为 payee"的桶,危害面比 #56 模型更小。 + +## 存量清算 / 上线顺序 + +1. 部署 v2 proxy(cmd/deploy),`.env` 换 `SETTLEMENT_CONTRACT`; +2. TappRegistry 不动:appId、节点、ack 全部沿用,**用户不用重新 ack**; +3. 老合约进入只退不进:停新建 → 结清最后一批 voucher → 通知用户退款; +4. sandbox 栈与 broker 栈各自走一遍 1-3。 diff --git a/internal/broker/monitor.go b/internal/broker/monitor.go index 71aa19b..f9dd3f4 100644 --- a/internal/broker/monitor.go +++ b/internal/broker/monitor.go @@ -29,6 +29,11 @@ type SessionEntry struct { // balanceChecker is the minimal chain interface the monitor needs. type balanceChecker interface { GetBalanceBatch(ctx context.Context, users []common.Address, provider common.Address) ([]*big.Int, error) + // GetServicePricing returns (nil, nil, nil, nil) when the provider has no + // registered service. Used to stop topping up buckets of providers whose + // service was removed — a rotated-out TEE signer can never settle again, + // so deposits to it are money the user must claw back via refund. + GetServicePricing(ctx context.Context, provider common.Address) (pricePerCPUPerSec, pricePerMemGBPerSec, createFee *big.Int, err error) } // Monitor polls on-chain balances for all registered sessions and triggers @@ -139,6 +144,22 @@ func (m *Monitor) check(ctx context.Context) { // For each provider, batch-fetch balances. for provAddrLower, users := range providerUsers { provider := common.HexToAddress(provAddrLower) + + // Provider = TEE signer: after a machine rotation the owner removes + // the old signer's service, and nothing can ever settle against it + // again. Topping up that bucket would strand funds behind the refund + // flow — skip it and let the stale sessions age out. + if cpuPrice, _, _, err := m.chain.GetServicePricing(ctx, provider); err != nil { + m.log.Warn("monitor: service lookup failed; skipping provider this tick", + zap.String("provider", provAddrLower), zap.Error(err)) + continue + } else if cpuPrice == nil { + m.log.Warn("monitor: provider service no longer registered (rotated out?) — not topping up", + zap.String("provider", provAddrLower), + zap.Int("sessions_affected", len(users))) + continue + } + balances, err := m.chain.GetBalanceBatch(ctx, users, provider) if err != nil { m.log.Warn("monitor: GetBalanceBatch failed", diff --git a/internal/broker/monitor_test.go b/internal/broker/monitor_test.go index 1bef921..5264148 100644 --- a/internal/broker/monitor_test.go +++ b/internal/broker/monitor_test.go @@ -21,6 +21,7 @@ type mockBalanceChecker struct { mu sync.Mutex calls []balanceBatchCall // recorded calls balances map[string][]*big.Int // provider hex → balances (ordered by users slice) + unregistered map[string]bool // provider hex → service removed on-chain err error } @@ -39,6 +40,17 @@ func (m *mockBalanceChecker) GetBalanceBatch(_ context.Context, users []common.A return m.balances[provider.Hex()], nil } +// GetServicePricing: registered by default (non-nil price) so existing tests +// exercise the top-up path; set unregistered to simulate a rotated-out signer. +func (m *mockBalanceChecker) GetServicePricing(_ context.Context, provider common.Address) (*big.Int, *big.Int, *big.Int, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.unregistered[provider.Hex()] { + return nil, nil, nil, nil + } + return big.NewInt(1), big.NewInt(1), big.NewInt(1), nil +} + func (m *mockBalanceChecker) callCount() int { m.mu.Lock() defer m.mu.Unlock() @@ -170,6 +182,33 @@ func TestCheck_belowThreshold_triggersTopup(t *testing.T) { } } +// v2: provider = TEE signer. After rotation the owner removes the old +// signer's service; the monitor must stop topping up that bucket (deposits +// there can never settle and would strand user funds behind the refund flow). +func TestCheck_removedService_noTopup(t *testing.T) { + rdb := newTestRedis(t) + seedSession(t, rdb, SessionEntry{ + SandboxID: "sb-1", User: userA.Hex(), Provider: provider1.Hex(), + PricePerSec: "100", VoucherIntervalSec: 60, + }) + mc := &mockBalanceChecker{ + unregistered: map[string]bool{provider1.Hex(): true}, + balances: map[string][]*big.Int{ + provider1.Hex(): {big.NewInt(0)}, // way below threshold + }, + } + mp := &mockPaymentLayer{} + mon := newMonitor(rdb, mc, mp) + mon.check(context.Background()) + + if mp.depositCount() != 0 { + t.Fatalf("RequestDeposit called %d times for a removed service, want 0", mp.depositCount()) + } + if mc.callCount() != 0 { + t.Errorf("GetBalanceBatch called %d times for a removed service, want 0", mc.callCount()) + } +} + func TestCheck_aggregatesMultipleSandboxes(t *testing.T) { rdb := newTestRedis(t) // Same (userA, provider1), two sandboxes: 100 + 200 = 300/sec diff --git a/internal/chain/client.go b/internal/chain/client.go index 7713dd5..69b27eb 100644 --- a/internal/chain/client.go +++ b/internal/chain/client.go @@ -58,7 +58,7 @@ type Client struct { tappAddr common.Address chainID *big.Int teeKey *ecdsa.PrivateKey // signs vouchers (EIP-712, off-chain) and settlement txs - providerAddr common.Address // registered provider address (from PROVIDER_ADDRESS) + providerAddr common.Address // = TEE key address: provider IS the TEE signer (v2) blockTimeMu sync.Mutex blockTimeSec float64 // cached avg block time in seconds @@ -76,14 +76,12 @@ func NewClient(cfg *config.Config) (*Client, error) { return nil, fmt.Errorf("parse tee private key: %w", err) } - // Provider address identifies which on-chain provider this client represents - // — the value goes into voucher.provider (EIP-712), the settler queue key, - // and provider-bound lookups (IsLocalTEEActiveNode, IsAcknowledged(user)). - // Required for the billing/sandbox provider; optional for clients that only - // inspect chain state across all providers (broker, indexer, dashboards). - // Methods that rely on it will receive the zero address and surface a - // clear runtime error to the caller if invoked without it being set. - providerAddr := common.HexToAddress(cfg.Chain.ProviderAddress) + // Provider identity is DERIVED from the TEE key: the provider address — + // voucher payee (EIP-712), settler queue key, services key, balance + // bucket — is the TEE signer's own address. There is no separately + // configured provider wallet; the ledger identity rotates with the + // machine's TEE key and management belongs to the appId owner. + providerAddr := crypto.PubkeyToAddress(teeKey.PublicKey) addr := common.HexToAddress(cfg.Chain.ContractAddress) contract, err := NewSandboxServing(addr, eth) @@ -135,6 +133,11 @@ func (c *Client) SettlerAddress() common.Address { return crypto.PubkeyToAddress(c.teeKey.PublicKey) } +// ProviderAddress returns this deployment's on-chain provider identity: the +// TEE signer's own address (provider IS the TEE signer). It keys the voucher +// payee field, the settler queue, and all provider-bound chain lookups. +func (c *Client) ProviderAddress() common.Address { return c.providerAddr } + // BalanceAt returns the latest balance for addr. Wraps eth.BalanceAt(nil) // so monitor code doesn't need direct access to the embedded ethclient. func (c *Client) BalanceAt(ctx context.Context, addr common.Address) (*big.Int, error) { @@ -159,10 +162,9 @@ func (c *Client) GetServiceAppId(ctx context.Context, provider common.Address) ( } // GetAppOwner returns the current owner of appId in TappRegistry, or the zero -// address if the app is not registered. TappRegistry is the source of truth for -// who currently owns an app: SandboxServing has no removeService, so a stale -// service entry (e.g. left behind after an owner transfer) is detected by -// comparing its provider address against this owner. +// address if the app is not registered. TappRegistry is the source of truth +// for who currently owns an app; the owner is the only wallet that can +// register/remove services and withdraw earnings in SandboxServing. func (c *Client) GetAppOwner(ctx context.Context, appId string) (common.Address, error) { opts := &bind.CallOpts{Context: ctx} info, err := c.tapp.GetAppInfo(opts, appId) diff --git a/internal/chain/sandbox_serving.go b/internal/chain/sandbox_serving.go index d2b47ef..2bc782f 100644 --- a/internal/chain/sandbox_serving.go +++ b/internal/chain/sandbox_serving.go @@ -41,7 +41,7 @@ type SandboxServingSandboxVoucher struct { // SandboxServingMetaData contains all meta data concerning the SandboxServing contract. var SandboxServingMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"constructor\",\"inputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"LOCK_TIME\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"addOrUpdateService\",\"inputs\":[{\"name\":\"url\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"appId\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"pricePerCPUPerMin\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"createFee\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"pricePerMemGBPerMin\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"balanceOfBatch\",\"inputs\":[{\"name\":\"users\",\"type\":\"address[]\",\"internalType\":\"address[]\"},{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"balances\",\"type\":\"uint256[]\",\"internalType\":\"uint256[]\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"deposit\",\"inputs\":[{\"name\":\"recipient\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"deregisterService\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"domainSeparator\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getBalance\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"balance\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"pendingRefund\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"refundUnlockAt\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLastNonce\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getProviderEarnings\",\"inputs\":[{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"initialize\",\"inputs\":[{\"name\":\"tappRegistry_\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"isTEEAcknowledged\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"owner\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"previewSettlementResults\",\"inputs\":[{\"name\":\"vouchers\",\"type\":\"tuple[]\",\"internalType\":\"structSandboxServing.SandboxVoucher[]\",\"components\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"totalFee\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"usageHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"nonce\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"signature\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[{\"name\":\"statuses\",\"type\":\"uint8[]\",\"internalType\":\"enumSandboxServing.SettlementStatus[]\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"providerEarnings\",\"inputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"requestRefund\",\"inputs\":[{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"serviceExists\",\"inputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"services\",\"inputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"url\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"appId\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"pricePerCPUPerMin\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"pricePerMemGBPerMin\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"createFee\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"setTappRegistry\",\"inputs\":[{\"name\":\"newRegistry\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"settleFeesWithTEE\",\"inputs\":[{\"name\":\"vouchers\",\"type\":\"tuple[]\",\"internalType\":\"structSandboxServing.SandboxVoucher[]\",\"components\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"totalFee\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"usageHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"nonce\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"signature\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[{\"name\":\"statuses\",\"type\":\"uint8[]\",\"internalType\":\"enumSandboxServing.SettlementStatus[]\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"tappRegistry\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractITappRegistry\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"transferOwnership\",\"inputs\":[{\"name\":\"newOwner\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"withdrawEarnings\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"withdrawRefund\",\"inputs\":[{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"Deposited\",\"inputs\":[{\"name\":\"recipient\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"provider\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"sender\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EarningsWithdrawn\",\"inputs\":[{\"name\":\"provider\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OwnershipTransferred\",\"inputs\":[{\"name\":\"previousOwner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"newOwner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"RefundRequested\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"provider\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"unlockAt\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"RefundWithdrawn\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"provider\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ServiceDeregistered\",\"inputs\":[{\"name\":\"provider\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ServiceUpdated\",\"inputs\":[{\"name\":\"provider\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"appId\",\"type\":\"string\",\"indexed\":false,\"internalType\":\"string\"},{\"name\":\"url\",\"type\":\"string\",\"indexed\":false,\"internalType\":\"string\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"TappRegistryUpdated\",\"inputs\":[{\"name\":\"previousRegistry\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"newRegistry\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"VoucherSettled\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"provider\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"totalFee\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"usageHash\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"nonce\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"status\",\"type\":\"uint8\",\"indexed\":false,\"internalType\":\"enumSandboxServing.SettlementStatus\"}],\"anonymous\":false}]", + ABI: "[{\"type\":\"constructor\",\"inputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"LOCK_TIME\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"addOrUpdateService\",\"inputs\":[{\"name\":\"signer\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"url\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"appId\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"pricePerCPUPerMin\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"createFee\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"pricePerMemGBPerMin\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"balanceOfBatch\",\"inputs\":[{\"name\":\"users\",\"type\":\"address[]\",\"internalType\":\"address[]\"},{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"balances\",\"type\":\"uint256[]\",\"internalType\":\"uint256[]\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"deposit\",\"inputs\":[{\"name\":\"recipient\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"domainSeparator\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getBalance\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"balance\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"pendingRefund\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"refundUnlockAt\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLastNonce\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getProviderEarnings\",\"inputs\":[{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"initialize\",\"inputs\":[{\"name\":\"tappRegistry_\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"isTEEAcknowledged\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"owner\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"previewSettlementResults\",\"inputs\":[{\"name\":\"vouchers\",\"type\":\"tuple[]\",\"internalType\":\"structSandboxServing.SandboxVoucher[]\",\"components\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"totalFee\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"usageHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"nonce\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"signature\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[{\"name\":\"statuses\",\"type\":\"uint8[]\",\"internalType\":\"enumSandboxServing.SettlementStatus[]\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"providerEarnings\",\"inputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"removeService\",\"inputs\":[{\"name\":\"signer\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"requestRefund\",\"inputs\":[{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"serviceExists\",\"inputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"services\",\"inputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"url\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"appId\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"pricePerCPUPerMin\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"pricePerMemGBPerMin\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"createFee\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"setTappRegistry\",\"inputs\":[{\"name\":\"newRegistry\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"settleFeesWithTEE\",\"inputs\":[{\"name\":\"vouchers\",\"type\":\"tuple[]\",\"internalType\":\"structSandboxServing.SandboxVoucher[]\",\"components\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"totalFee\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"usageHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"nonce\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"signature\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[{\"name\":\"statuses\",\"type\":\"uint8[]\",\"internalType\":\"enumSandboxServing.SettlementStatus[]\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"tappRegistry\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractITappRegistry\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"transferOwnership\",\"inputs\":[{\"name\":\"newOwner\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"withdrawEarnings\",\"inputs\":[{\"name\":\"signer\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"withdrawRefund\",\"inputs\":[{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"Deposited\",\"inputs\":[{\"name\":\"recipient\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"provider\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"sender\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EarningsWithdrawn\",\"inputs\":[{\"name\":\"provider\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"to\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OwnershipTransferred\",\"inputs\":[{\"name\":\"previousOwner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"newOwner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"RefundRequested\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"provider\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"unlockAt\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"RefundWithdrawn\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"provider\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ServiceRemoved\",\"inputs\":[{\"name\":\"provider\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"appOwner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ServiceUpdated\",\"inputs\":[{\"name\":\"provider\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"appId\",\"type\":\"string\",\"indexed\":false,\"internalType\":\"string\"},{\"name\":\"url\",\"type\":\"string\",\"indexed\":false,\"internalType\":\"string\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"TappRegistryUpdated\",\"inputs\":[{\"name\":\"previousRegistry\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"newRegistry\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"VoucherSettled\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"provider\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"totalFee\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"usageHash\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"nonce\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"status\",\"type\":\"uint8\",\"indexed\":false,\"internalType\":\"enumSandboxServing.SettlementStatus\"}],\"anonymous\":false}]", } // SandboxServingABI is the input ABI used to generate the binding from. @@ -641,25 +641,25 @@ func (_SandboxServing *SandboxServingCallerSession) TappRegistry() (common.Addre return _SandboxServing.Contract.TappRegistry(&_SandboxServing.CallOpts) } -// AddOrUpdateService is a paid mutator transaction binding the contract method 0x35fc37a4. +// AddOrUpdateService is a paid mutator transaction binding the contract method 0x1b5da3c4. // -// Solidity: function addOrUpdateService(string url, string appId, uint256 pricePerCPUPerMin, uint256 createFee, uint256 pricePerMemGBPerMin) returns() -func (_SandboxServing *SandboxServingTransactor) AddOrUpdateService(opts *bind.TransactOpts, url string, appId string, pricePerCPUPerMin *big.Int, createFee *big.Int, pricePerMemGBPerMin *big.Int) (*types.Transaction, error) { - return _SandboxServing.contract.Transact(opts, "addOrUpdateService", url, appId, pricePerCPUPerMin, createFee, pricePerMemGBPerMin) +// Solidity: function addOrUpdateService(address signer, string url, string appId, uint256 pricePerCPUPerMin, uint256 createFee, uint256 pricePerMemGBPerMin) returns() +func (_SandboxServing *SandboxServingTransactor) AddOrUpdateService(opts *bind.TransactOpts, signer common.Address, url string, appId string, pricePerCPUPerMin *big.Int, createFee *big.Int, pricePerMemGBPerMin *big.Int) (*types.Transaction, error) { + return _SandboxServing.contract.Transact(opts, "addOrUpdateService", signer, url, appId, pricePerCPUPerMin, createFee, pricePerMemGBPerMin) } -// AddOrUpdateService is a paid mutator transaction binding the contract method 0x35fc37a4. +// AddOrUpdateService is a paid mutator transaction binding the contract method 0x1b5da3c4. // -// Solidity: function addOrUpdateService(string url, string appId, uint256 pricePerCPUPerMin, uint256 createFee, uint256 pricePerMemGBPerMin) returns() -func (_SandboxServing *SandboxServingSession) AddOrUpdateService(url string, appId string, pricePerCPUPerMin *big.Int, createFee *big.Int, pricePerMemGBPerMin *big.Int) (*types.Transaction, error) { - return _SandboxServing.Contract.AddOrUpdateService(&_SandboxServing.TransactOpts, url, appId, pricePerCPUPerMin, createFee, pricePerMemGBPerMin) +// Solidity: function addOrUpdateService(address signer, string url, string appId, uint256 pricePerCPUPerMin, uint256 createFee, uint256 pricePerMemGBPerMin) returns() +func (_SandboxServing *SandboxServingSession) AddOrUpdateService(signer common.Address, url string, appId string, pricePerCPUPerMin *big.Int, createFee *big.Int, pricePerMemGBPerMin *big.Int) (*types.Transaction, error) { + return _SandboxServing.Contract.AddOrUpdateService(&_SandboxServing.TransactOpts, signer, url, appId, pricePerCPUPerMin, createFee, pricePerMemGBPerMin) } -// AddOrUpdateService is a paid mutator transaction binding the contract method 0x35fc37a4. +// AddOrUpdateService is a paid mutator transaction binding the contract method 0x1b5da3c4. // -// Solidity: function addOrUpdateService(string url, string appId, uint256 pricePerCPUPerMin, uint256 createFee, uint256 pricePerMemGBPerMin) returns() -func (_SandboxServing *SandboxServingTransactorSession) AddOrUpdateService(url string, appId string, pricePerCPUPerMin *big.Int, createFee *big.Int, pricePerMemGBPerMin *big.Int) (*types.Transaction, error) { - return _SandboxServing.Contract.AddOrUpdateService(&_SandboxServing.TransactOpts, url, appId, pricePerCPUPerMin, createFee, pricePerMemGBPerMin) +// Solidity: function addOrUpdateService(address signer, string url, string appId, uint256 pricePerCPUPerMin, uint256 createFee, uint256 pricePerMemGBPerMin) returns() +func (_SandboxServing *SandboxServingTransactorSession) AddOrUpdateService(signer common.Address, url string, appId string, pricePerCPUPerMin *big.Int, createFee *big.Int, pricePerMemGBPerMin *big.Int) (*types.Transaction, error) { + return _SandboxServing.Contract.AddOrUpdateService(&_SandboxServing.TransactOpts, signer, url, appId, pricePerCPUPerMin, createFee, pricePerMemGBPerMin) } // Deposit is a paid mutator transaction binding the contract method 0xf9609f08. @@ -683,27 +683,6 @@ func (_SandboxServing *SandboxServingTransactorSession) Deposit(recipient common return _SandboxServing.Contract.Deposit(&_SandboxServing.TransactOpts, recipient, provider) } -// DeregisterService is a paid mutator transaction binding the contract method 0xaa4ac7ad. -// -// Solidity: function deregisterService() returns() -func (_SandboxServing *SandboxServingTransactor) DeregisterService(opts *bind.TransactOpts) (*types.Transaction, error) { - return _SandboxServing.contract.Transact(opts, "deregisterService") -} - -// DeregisterService is a paid mutator transaction binding the contract method 0xaa4ac7ad. -// -// Solidity: function deregisterService() returns() -func (_SandboxServing *SandboxServingSession) DeregisterService() (*types.Transaction, error) { - return _SandboxServing.Contract.DeregisterService(&_SandboxServing.TransactOpts) -} - -// DeregisterService is a paid mutator transaction binding the contract method 0xaa4ac7ad. -// -// Solidity: function deregisterService() returns() -func (_SandboxServing *SandboxServingTransactorSession) DeregisterService() (*types.Transaction, error) { - return _SandboxServing.Contract.DeregisterService(&_SandboxServing.TransactOpts) -} - // Initialize is a paid mutator transaction binding the contract method 0xc4d66de8. // // Solidity: function initialize(address tappRegistry_) returns() @@ -725,6 +704,27 @@ func (_SandboxServing *SandboxServingTransactorSession) Initialize(tappRegistry_ return _SandboxServing.Contract.Initialize(&_SandboxServing.TransactOpts, tappRegistry_) } +// RemoveService is a paid mutator transaction binding the contract method 0x1b305173. +// +// Solidity: function removeService(address signer) returns() +func (_SandboxServing *SandboxServingTransactor) RemoveService(opts *bind.TransactOpts, signer common.Address) (*types.Transaction, error) { + return _SandboxServing.contract.Transact(opts, "removeService", signer) +} + +// RemoveService is a paid mutator transaction binding the contract method 0x1b305173. +// +// Solidity: function removeService(address signer) returns() +func (_SandboxServing *SandboxServingSession) RemoveService(signer common.Address) (*types.Transaction, error) { + return _SandboxServing.Contract.RemoveService(&_SandboxServing.TransactOpts, signer) +} + +// RemoveService is a paid mutator transaction binding the contract method 0x1b305173. +// +// Solidity: function removeService(address signer) returns() +func (_SandboxServing *SandboxServingTransactorSession) RemoveService(signer common.Address) (*types.Transaction, error) { + return _SandboxServing.Contract.RemoveService(&_SandboxServing.TransactOpts, signer) +} + // RequestRefund is a paid mutator transaction binding the contract method 0x99652de7. // // Solidity: function requestRefund(address provider, uint256 amount) returns() @@ -809,25 +809,25 @@ func (_SandboxServing *SandboxServingTransactorSession) TransferOwnership(newOwn return _SandboxServing.Contract.TransferOwnership(&_SandboxServing.TransactOpts, newOwner) } -// WithdrawEarnings is a paid mutator transaction binding the contract method 0xb73c6ce9. +// WithdrawEarnings is a paid mutator transaction binding the contract method 0x3d4d8d4f. // -// Solidity: function withdrawEarnings() returns() -func (_SandboxServing *SandboxServingTransactor) WithdrawEarnings(opts *bind.TransactOpts) (*types.Transaction, error) { - return _SandboxServing.contract.Transact(opts, "withdrawEarnings") +// Solidity: function withdrawEarnings(address signer) returns() +func (_SandboxServing *SandboxServingTransactor) WithdrawEarnings(opts *bind.TransactOpts, signer common.Address) (*types.Transaction, error) { + return _SandboxServing.contract.Transact(opts, "withdrawEarnings", signer) } -// WithdrawEarnings is a paid mutator transaction binding the contract method 0xb73c6ce9. +// WithdrawEarnings is a paid mutator transaction binding the contract method 0x3d4d8d4f. // -// Solidity: function withdrawEarnings() returns() -func (_SandboxServing *SandboxServingSession) WithdrawEarnings() (*types.Transaction, error) { - return _SandboxServing.Contract.WithdrawEarnings(&_SandboxServing.TransactOpts) +// Solidity: function withdrawEarnings(address signer) returns() +func (_SandboxServing *SandboxServingSession) WithdrawEarnings(signer common.Address) (*types.Transaction, error) { + return _SandboxServing.Contract.WithdrawEarnings(&_SandboxServing.TransactOpts, signer) } -// WithdrawEarnings is a paid mutator transaction binding the contract method 0xb73c6ce9. +// WithdrawEarnings is a paid mutator transaction binding the contract method 0x3d4d8d4f. // -// Solidity: function withdrawEarnings() returns() -func (_SandboxServing *SandboxServingTransactorSession) WithdrawEarnings() (*types.Transaction, error) { - return _SandboxServing.Contract.WithdrawEarnings(&_SandboxServing.TransactOpts) +// Solidity: function withdrawEarnings(address signer) returns() +func (_SandboxServing *SandboxServingTransactorSession) WithdrawEarnings(signer common.Address) (*types.Transaction, error) { + return _SandboxServing.Contract.WithdrawEarnings(&_SandboxServing.TransactOpts, signer) } // WithdrawRefund is a paid mutator transaction binding the contract method 0xa16c86f7. @@ -1084,38 +1084,47 @@ func (it *SandboxServingEarningsWithdrawnIterator) Close() error { // SandboxServingEarningsWithdrawn represents a EarningsWithdrawn event raised by the SandboxServing contract. type SandboxServingEarningsWithdrawn struct { Provider common.Address + To common.Address Amount *big.Int Raw types.Log // Blockchain specific contextual infos } -// FilterEarningsWithdrawn is a free log retrieval operation binding the contract event 0x48dc35af7b45e2a81fffad55f6e2fafacdb1d3d0d50d24ebdc16324f5ba757f1. +// FilterEarningsWithdrawn is a free log retrieval operation binding the contract event 0x0c8584df0a37548f26644c41809e5f908b58e8888dd441691e8a2174ec89f305. // -// Solidity: event EarningsWithdrawn(address indexed provider, uint256 amount) -func (_SandboxServing *SandboxServingFilterer) FilterEarningsWithdrawn(opts *bind.FilterOpts, provider []common.Address) (*SandboxServingEarningsWithdrawnIterator, error) { +// Solidity: event EarningsWithdrawn(address indexed provider, address indexed to, uint256 amount) +func (_SandboxServing *SandboxServingFilterer) FilterEarningsWithdrawn(opts *bind.FilterOpts, provider []common.Address, to []common.Address) (*SandboxServingEarningsWithdrawnIterator, error) { var providerRule []interface{} for _, providerItem := range provider { providerRule = append(providerRule, providerItem) } + var toRule []interface{} + for _, toItem := range to { + toRule = append(toRule, toItem) + } - logs, sub, err := _SandboxServing.contract.FilterLogs(opts, "EarningsWithdrawn", providerRule) + logs, sub, err := _SandboxServing.contract.FilterLogs(opts, "EarningsWithdrawn", providerRule, toRule) if err != nil { return nil, err } return &SandboxServingEarningsWithdrawnIterator{contract: _SandboxServing.contract, event: "EarningsWithdrawn", logs: logs, sub: sub}, nil } -// WatchEarningsWithdrawn is a free log subscription operation binding the contract event 0x48dc35af7b45e2a81fffad55f6e2fafacdb1d3d0d50d24ebdc16324f5ba757f1. +// WatchEarningsWithdrawn is a free log subscription operation binding the contract event 0x0c8584df0a37548f26644c41809e5f908b58e8888dd441691e8a2174ec89f305. // -// Solidity: event EarningsWithdrawn(address indexed provider, uint256 amount) -func (_SandboxServing *SandboxServingFilterer) WatchEarningsWithdrawn(opts *bind.WatchOpts, sink chan<- *SandboxServingEarningsWithdrawn, provider []common.Address) (event.Subscription, error) { +// Solidity: event EarningsWithdrawn(address indexed provider, address indexed to, uint256 amount) +func (_SandboxServing *SandboxServingFilterer) WatchEarningsWithdrawn(opts *bind.WatchOpts, sink chan<- *SandboxServingEarningsWithdrawn, provider []common.Address, to []common.Address) (event.Subscription, error) { var providerRule []interface{} for _, providerItem := range provider { providerRule = append(providerRule, providerItem) } + var toRule []interface{} + for _, toItem := range to { + toRule = append(toRule, toItem) + } - logs, sub, err := _SandboxServing.contract.WatchLogs(opts, "EarningsWithdrawn", providerRule) + logs, sub, err := _SandboxServing.contract.WatchLogs(opts, "EarningsWithdrawn", providerRule, toRule) if err != nil { return nil, err } @@ -1147,9 +1156,9 @@ func (_SandboxServing *SandboxServingFilterer) WatchEarningsWithdrawn(opts *bind }), nil } -// ParseEarningsWithdrawn is a log parse operation binding the contract event 0x48dc35af7b45e2a81fffad55f6e2fafacdb1d3d0d50d24ebdc16324f5ba757f1. +// ParseEarningsWithdrawn is a log parse operation binding the contract event 0x0c8584df0a37548f26644c41809e5f908b58e8888dd441691e8a2174ec89f305. // -// Solidity: event EarningsWithdrawn(address indexed provider, uint256 amount) +// Solidity: event EarningsWithdrawn(address indexed provider, address indexed to, uint256 amount) func (_SandboxServing *SandboxServingFilterer) ParseEarningsWithdrawn(log types.Log) (*SandboxServingEarningsWithdrawn, error) { event := new(SandboxServingEarningsWithdrawn) if err := _SandboxServing.contract.UnpackLog(event, "EarningsWithdrawn", log); err != nil { @@ -1621,9 +1630,9 @@ func (_SandboxServing *SandboxServingFilterer) ParseRefundWithdrawn(log types.Lo return event, nil } -// SandboxServingServiceDeregisteredIterator is returned from FilterServiceDeregistered and is used to iterate over the raw logs and unpacked data for ServiceDeregistered events raised by the SandboxServing contract. -type SandboxServingServiceDeregisteredIterator struct { - Event *SandboxServingServiceDeregistered // Event containing the contract specifics and raw log +// SandboxServingServiceRemovedIterator is returned from FilterServiceRemoved and is used to iterate over the raw logs and unpacked data for ServiceRemoved events raised by the SandboxServing contract. +type SandboxServingServiceRemovedIterator struct { + Event *SandboxServingServiceRemoved // Event containing the contract specifics and raw log contract *bind.BoundContract // Generic contract to use for unpacking event data event string // Event name to use for unpacking event data @@ -1637,7 +1646,7 @@ type SandboxServingServiceDeregisteredIterator struct { // Next advances the iterator to the subsequent event, returning whether there // are any more events found. In case of a retrieval or parsing error, false is // returned and Error() can be queried for the exact failure. -func (it *SandboxServingServiceDeregisteredIterator) Next() bool { +func (it *SandboxServingServiceRemovedIterator) Next() bool { // If the iterator failed, stop iterating if it.fail != nil { return false @@ -1646,7 +1655,7 @@ func (it *SandboxServingServiceDeregisteredIterator) Next() bool { if it.done { select { case log := <-it.logs: - it.Event = new(SandboxServingServiceDeregistered) + it.Event = new(SandboxServingServiceRemoved) if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { it.fail = err return false @@ -1661,7 +1670,7 @@ func (it *SandboxServingServiceDeregisteredIterator) Next() bool { // Iterator still in progress, wait for either a data or an error event select { case log := <-it.logs: - it.Event = new(SandboxServingServiceDeregistered) + it.Event = new(SandboxServingServiceRemoved) if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { it.fail = err return false @@ -1677,51 +1686,60 @@ func (it *SandboxServingServiceDeregisteredIterator) Next() bool { } // Error returns any retrieval or parsing error occurred during filtering. -func (it *SandboxServingServiceDeregisteredIterator) Error() error { +func (it *SandboxServingServiceRemovedIterator) Error() error { return it.fail } // Close terminates the iteration process, releasing any pending underlying // resources. -func (it *SandboxServingServiceDeregisteredIterator) Close() error { +func (it *SandboxServingServiceRemovedIterator) Close() error { it.sub.Unsubscribe() return nil } -// SandboxServingServiceDeregistered represents a ServiceDeregistered event raised by the SandboxServing contract. -type SandboxServingServiceDeregistered struct { +// SandboxServingServiceRemoved represents a ServiceRemoved event raised by the SandboxServing contract. +type SandboxServingServiceRemoved struct { Provider common.Address + AppOwner common.Address Raw types.Log // Blockchain specific contextual infos } -// FilterServiceDeregistered is a free log retrieval operation binding the contract event 0xd7e651500ba05e9dad5db5a5e0e22eeb2c054121ae21d59d7bc3d3a16c561507. +// FilterServiceRemoved is a free log retrieval operation binding the contract event 0xce18ed5f98092048b907492d6519072f293e784afa42ce7ba2cca26f3805a661. // -// Solidity: event ServiceDeregistered(address indexed provider) -func (_SandboxServing *SandboxServingFilterer) FilterServiceDeregistered(opts *bind.FilterOpts, provider []common.Address) (*SandboxServingServiceDeregisteredIterator, error) { +// Solidity: event ServiceRemoved(address indexed provider, address indexed appOwner) +func (_SandboxServing *SandboxServingFilterer) FilterServiceRemoved(opts *bind.FilterOpts, provider []common.Address, appOwner []common.Address) (*SandboxServingServiceRemovedIterator, error) { var providerRule []interface{} for _, providerItem := range provider { providerRule = append(providerRule, providerItem) } + var appOwnerRule []interface{} + for _, appOwnerItem := range appOwner { + appOwnerRule = append(appOwnerRule, appOwnerItem) + } - logs, sub, err := _SandboxServing.contract.FilterLogs(opts, "ServiceDeregistered", providerRule) + logs, sub, err := _SandboxServing.contract.FilterLogs(opts, "ServiceRemoved", providerRule, appOwnerRule) if err != nil { return nil, err } - return &SandboxServingServiceDeregisteredIterator{contract: _SandboxServing.contract, event: "ServiceDeregistered", logs: logs, sub: sub}, nil + return &SandboxServingServiceRemovedIterator{contract: _SandboxServing.contract, event: "ServiceRemoved", logs: logs, sub: sub}, nil } -// WatchServiceDeregistered is a free log subscription operation binding the contract event 0xd7e651500ba05e9dad5db5a5e0e22eeb2c054121ae21d59d7bc3d3a16c561507. +// WatchServiceRemoved is a free log subscription operation binding the contract event 0xce18ed5f98092048b907492d6519072f293e784afa42ce7ba2cca26f3805a661. // -// Solidity: event ServiceDeregistered(address indexed provider) -func (_SandboxServing *SandboxServingFilterer) WatchServiceDeregistered(opts *bind.WatchOpts, sink chan<- *SandboxServingServiceDeregistered, provider []common.Address) (event.Subscription, error) { +// Solidity: event ServiceRemoved(address indexed provider, address indexed appOwner) +func (_SandboxServing *SandboxServingFilterer) WatchServiceRemoved(opts *bind.WatchOpts, sink chan<- *SandboxServingServiceRemoved, provider []common.Address, appOwner []common.Address) (event.Subscription, error) { var providerRule []interface{} for _, providerItem := range provider { providerRule = append(providerRule, providerItem) } + var appOwnerRule []interface{} + for _, appOwnerItem := range appOwner { + appOwnerRule = append(appOwnerRule, appOwnerItem) + } - logs, sub, err := _SandboxServing.contract.WatchLogs(opts, "ServiceDeregistered", providerRule) + logs, sub, err := _SandboxServing.contract.WatchLogs(opts, "ServiceRemoved", providerRule, appOwnerRule) if err != nil { return nil, err } @@ -1731,8 +1749,8 @@ func (_SandboxServing *SandboxServingFilterer) WatchServiceDeregistered(opts *bi select { case log := <-logs: // New log arrived, parse the event and forward to the user - event := new(SandboxServingServiceDeregistered) - if err := _SandboxServing.contract.UnpackLog(event, "ServiceDeregistered", log); err != nil { + event := new(SandboxServingServiceRemoved) + if err := _SandboxServing.contract.UnpackLog(event, "ServiceRemoved", log); err != nil { return err } event.Raw = log @@ -1753,12 +1771,12 @@ func (_SandboxServing *SandboxServingFilterer) WatchServiceDeregistered(opts *bi }), nil } -// ParseServiceDeregistered is a log parse operation binding the contract event 0xd7e651500ba05e9dad5db5a5e0e22eeb2c054121ae21d59d7bc3d3a16c561507. +// ParseServiceRemoved is a log parse operation binding the contract event 0xce18ed5f98092048b907492d6519072f293e784afa42ce7ba2cca26f3805a661. // -// Solidity: event ServiceDeregistered(address indexed provider) -func (_SandboxServing *SandboxServingFilterer) ParseServiceDeregistered(log types.Log) (*SandboxServingServiceDeregistered, error) { - event := new(SandboxServingServiceDeregistered) - if err := _SandboxServing.contract.UnpackLog(event, "ServiceDeregistered", log); err != nil { +// Solidity: event ServiceRemoved(address indexed provider, address indexed appOwner) +func (_SandboxServing *SandboxServingFilterer) ParseServiceRemoved(log types.Log) (*SandboxServingServiceRemoved, error) { + event := new(SandboxServingServiceRemoved) + if err := _SandboxServing.contract.UnpackLog(event, "ServiceRemoved", log); err != nil { return nil, err } event.Raw = log diff --git a/internal/config/config.go b/internal/config/config.go index 48be881..244ca28 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -50,53 +50,32 @@ type ChainConfig struct { ContractAddress string `mapstructure:"contract_address"` TappRegistry string `mapstructure:"tapp_registry"` TEEPrivateKey string `mapstructure:"tee_private_key"` - ProviderAddress string `mapstructure:"provider_address"` - // AdminAddresses is the comma-separated list of wallet addresses that may - // invoke operator-only endpoints (snapshot/registry management, - // archive-all, force-delete, sessions). When empty, falls back to - // [ProviderAddress] for backward compatibility with single-key - // deployments. Distinct from ProviderAddress (the on-chain settlement - // identity) so multiple operators can manage infrastructure without - // holding the provider's settlement key. + // AdminAddresses is an ADDITIVE comma-separated list of extra wallet + // addresses that may invoke operator-only endpoints (snapshot/registry + // management, archive-all, force-delete, sessions). The appId's + // TappRegistry owner is ALWAYS an admin on top of this list — it is + // resolved from the chain at runtime (getAppInfo(BACKEND_APP_NAME).owner), + // never configured, so it can't drift from the on-chain truth. AdminAddresses string `mapstructure:"admin_addresses"` ChainID int64 `mapstructure:"chain_id"` } -// AdminList returns the parsed admin wallet addresses (lowercased hex). -// When ADMIN_ADDRESSES is unset, defaults to [ProviderAddress] so existing -// single-key deployments keep working. +// AdminList returns the parsed extra admin wallet addresses (lowercased hex). +// The appId owner is NOT in this list — it is resolved from the chain at +// runtime and is always an admin on top of these. func (c *ChainConfig) AdminList() []string { - raw := strings.TrimSpace(c.AdminAddresses) - if raw == "" { - if c.ProviderAddress == "" { - return nil - } - return []string{strings.ToLower(c.ProviderAddress)} - } - parts := strings.Split(raw, ",") - out := make([]string, 0, len(parts)) - for _, p := range parts { - p = strings.TrimSpace(p) - if p != "" { - out = append(out, strings.ToLower(p)) + var out []string + seen := map[string]bool{} + for _, p := range strings.Split(c.AdminAddresses, ",") { + p = strings.ToLower(strings.TrimSpace(p)) + if p != "" && !seen[p] { + seen[p] = true + out = append(out, p) } } return out } -// IsAdmin reports whether wallet is in the admin list (case-insensitive). -func (c *ChainConfig) IsAdmin(wallet string) bool { - if wallet == "" { - return false - } - target := strings.ToLower(wallet) - for _, a := range c.AdminList() { - if a == target { - return true - } - } - return false -} type ServerConfig struct { Port int `mapstructure:"port"` @@ -159,7 +138,6 @@ func Load() (*Config, error) { "chain.rpc_url": "RPC_URL", "chain.contract_address": "SETTLEMENT_CONTRACT", "chain.tapp_registry": "TAPP_REGISTRY", - "chain.provider_address": "PROVIDER_ADDRESS", "chain.admin_addresses": "ADMIN_ADDRESSES", "chain.chain_id": "CHAIN_ID", "server.port": "PORT", @@ -262,7 +240,6 @@ func (c *Config) validate() error { {c.Daytona.AdminKey, "DAYTONA_ADMIN_KEY"}, {c.Chain.RPCURL, "RPC_URL"}, {c.Chain.ContractAddress, "SETTLEMENT_CONTRACT"}, - {c.Chain.ProviderAddress, "PROVIDER_ADDRESS"}, } { if r.val == "" { return fmt.Errorf("required config missing: %s", r.name) diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index a485e3b..d7e5418 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -42,11 +42,14 @@ type ProviderRecord struct { type chainClient interface { GetServiceUpdatedEvents(ctx context.Context, fromBlock uint64) ([]chain.ProviderEvent, uint64, error) GetServiceInfo(ctx context.Context, provider common.Address) (*chain.ServiceInfo, error) - // GetAppOwner returns the current TappRegistry owner of appId (zero address - // if unregistered). Used to drop stale providers: SandboxServing service - // entries are permanent, so a provider is only "live" while it is still the - // app owner in TappRegistry — the real source of truth for (de)registration. - GetAppOwner(ctx context.Context, appId string) (common.Address, error) + // IsActiveNode reports whether `signer` is an active TappRegistry node of + // appId. Used to drop stale providers: in the v2 identity model the + // provider address IS the node's TEE signer (not the app owner), so a + // provider is only "live" while its address is still in the appId's node + // list — the real source of truth for (de)registration. removeService in + // SandboxServing also clears entries, but node membership catches the + // rotation window before the owner has cleaned up. + IsActiveNode(ctx context.Context, appId string, signer common.Address) (bool, error) } // Indexer maintains a live in-memory index of all providers registered on-chain, @@ -91,22 +94,24 @@ func (idx *Indexer) Run(ctx context.Context) { } } -// isLiveProvider reports whether provider `addr` is still the live owner of -// `appId` in TappRegistry — the source of truth for (de)registration. Returns +// isLiveProvider reports whether provider `addr` is still an active +// TappRegistry node of `appId` — the source of truth for (de)registration. +// v2 identity model: the provider address is the node's TEE signer, NOT the +// app owner, so owner comparison would drop every live provider. Returns // true on RPC error (fail-open: a transient lookup failure must not drop a // provider; revalidate rechecks next cycle). False when no appId is bound or -// the current app owner is a different address (stale/superseded provider). +// the address is no longer in the appId's node list (rotated-out/stale). func (idx *Indexer) isLiveProvider(ctx context.Context, addrKey, appId string, provider common.Address) bool { if appId == "" { return false } - owner, err := idx.chain.GetAppOwner(ctx, appId) + live, err := idx.chain.IsActiveNode(ctx, appId, provider) if err != nil { - idx.log.Warn("indexer: GetAppOwner failed; keeping provider this round", + idx.log.Warn("indexer: IsActiveNode failed; keeping provider this round", zap.String("provider", addrKey), zap.String("app_id", appId), zap.Error(err)) return true } - return strings.EqualFold(owner.Hex(), provider.Hex()) + return live } // removeProvider drops a provider from the in-memory store and the Redis cache. diff --git a/internal/indexer/indexer_test.go b/internal/indexer/indexer_test.go index cc9ac33..b9df61f 100644 --- a/internal/indexer/indexer_test.go +++ b/internal/indexer/indexer_test.go @@ -37,15 +37,12 @@ func (m *mockChain) GetServiceInfo(_ context.Context, provider common.Address) ( return svc, nil } -// GetAppOwner maps appId → the provider whose service declares it, so each -// indexed provider is treated as the live owner of its own appId. -func (m *mockChain) GetAppOwner(_ context.Context, appId string) (common.Address, error) { - for hexAddr, svc := range m.services { - if svc.AppId == appId { - return common.HexToAddress(hexAddr), nil - } - } - return common.Address{}, nil +// IsActiveNode treats every provider whose service declares the appId as an +// active node of it (v2: provider = the node's TEE signer), so indexed +// providers stay live unless a test removes their service. +func (m *mockChain) IsActiveNode(_ context.Context, appId string, signer common.Address) (bool, error) { + svc, ok := m.services[signer.Hex()] + return ok && svc.AppId == appId, nil } // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -232,8 +229,8 @@ func (m *mockChainCapture) GetServiceUpdatedEvents(_ context.Context, fromBlock func (m *mockChainCapture) GetServiceInfo(_ context.Context, _ common.Address) (*chain.ServiceInfo, error) { return nil, nil } -func (m *mockChainCapture) GetAppOwner(_ context.Context, _ string) (common.Address, error) { - return common.Address{}, nil +func (m *mockChainCapture) IsActiveNode(_ context.Context, _ string, _ common.Address) (bool, error) { + return false, nil } func TestSync_deduplicatesEvents(t *testing.T) { diff --git a/internal/proxy/handler.go b/internal/proxy/handler.go index b901f87..4fa0e68 100644 --- a/internal/proxy/handler.go +++ b/internal/proxy/handler.go @@ -85,6 +85,13 @@ type Handler struct { // config (env SEALED_ONLY=true). Off by default — providers serve both // sealed and unsealed workloads unless explicitly opted in. SealedOnly bool + + // AppOwner resolves the appId's current TappRegistry owner (lowercased + // hex). Set by cmd/billing as a TTL-cached chain lookup on + // getAppInfo(BACKEND_APP_NAME).owner. The owner is ALWAYS an admin on + // top of adminAddresses — resolved live so it can never drift from the + // on-chain truth. nil (tests, mock setups) = static admin list only. + AppOwner func(ctx context.Context) (string, error) } func NewHandler(dtona *daytona.Client, bh BillingHooks, balCheck BalanceChecker, ackCheck AckChecker, eventFetcher EventFetcher, createFee, pricePerCPUPerSec, pricePerMemGBPerSec, computePricePerSec *big.Int, providerAddress string, adminAddresses []string, sshGatewayHost string, rdb *redis.Client, log *zap.Logger, brokerURL string, teeKey *ecdsa.PrivateKey, voucherIntervalSec int64) *Handler { @@ -125,7 +132,14 @@ func NewHandler(dtona *daytona.Client, bh BillingHooks, balCheck BalanceChecker, return &Handler{dtona: dtona, billing: bh, rp: rp, balCheck: balCheck, ackCheck: ackCheck, eventFetcher: eventFetcher, createFee: createFee, pricePerCPUPerSec: pricePerCPUPerSec, pricePerMemGBPerSec: pricePerMemGBPerSec, voucherIntervalSec: voucherIntervalSec, computePricePerSec: computePricePerSec, providerAddress: providerAddress, adminAddresses: admins, sshGatewayHost: sshGatewayHost, rdb: rdb, teeKey: teeKey, broker: broker, log: log} } -// isAdmin reports whether wallet is configured as an admin (case-insensitive). +// IsAdmin is the exported admin check for routes registered outside this +// handler (cmd/billing's registry/queue/session endpoints) — one source of +// truth for "who is an operator". +func (h *Handler) IsAdmin(wallet string) bool { return h.isAdmin(wallet) } + +// isAdmin reports whether wallet may call operator-only endpoints: either in +// the configured ADMIN_ADDRESSES list, or the appId's current TappRegistry +// owner (resolved live via AppOwner, case-insensitive). func (h *Handler) isAdmin(wallet string) bool { if wallet == "" { return false @@ -136,6 +150,15 @@ func (h *Handler) isAdmin(wallet string) bool { return true } } + if h.AppOwner != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if owner, err := h.AppOwner(ctx); err == nil && owner == target { + return true + } else if err != nil { + h.log.Warn("isAdmin: app owner lookup failed; falling back to static admin list", zap.Error(err)) + } + } return false } @@ -293,6 +316,10 @@ func (h *Handler) handleCreate(c *gin.Context) { // Sealed containers: resolve image hash and inject TEE attestation + keypair // before forwarding to Daytona. sealed := extractSealed(body) + if err := ValidatePublicPorts(body, sealed); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } if !sealed && h.SealedOnly { c.JSON(http.StatusBadRequest, gin.H{ "error": "this provider only accepts sealed sandboxes; set \"sealed\": true in the create request", @@ -366,6 +393,33 @@ func (h *Handler) handleCreate(c *gin.Context) { respBytes = stripped } } + + // publicPorts round-trip check: a stock Daytona API silently strips the + // field (whitelist validation), which would hand the user an unrestricted + // sandbox while they believe ports are locked down. Fail the create loudly + // instead, and stop the orphan so it doesn't run unbilled. + if _, requestedPorts, _ := parsePublicPorts(body); requestedPorts && result.StatusCode >= 200 && result.StatusCode < 300 { + decorated, supported, derr := decoratePublicPorts(respBytes) + if derr == nil && !supported { + id := extractID(respBytes) + if id != "" { + go func() { + ctx := context.WithoutCancel(c.Request.Context()) + if serr := h.dtona.StopSandbox(ctx, id); serr != nil { + h.log.Error("stop sandbox after unsupported publicPorts", zap.String("id", id), zap.Error(serr)) + } + }() + } + h.log.Error("publicPorts requested but Daytona backend dropped the field — fork images not deployed?", zap.String("id", id)) + c.JSON(http.StatusBadGateway, gin.H{ + "error": "publicPorts is not supported by this provider's Daytona backend; the sandbox was not started", + }) + return + } + if derr == nil { + respBytes = decorated + } + } for k, vs := range result.Header { if strings.EqualFold(k, "Content-Length") { continue // recomputed below from actual body length diff --git a/internal/proxy/ports.go b/internal/proxy/ports.go new file mode 100644 index 0000000..652bd99 --- /dev/null +++ b/internal/proxy/ports.go @@ -0,0 +1,128 @@ +package proxy + +import ( + "encoding/json" + "fmt" + "math" + "os" + "strconv" +) + +// reservedPreviewPorts are Daytona system ports that always require auth at +// the preview proxy; publicPorts may never include them. +var reservedPreviewPorts = map[int]bool{22222: true, 2280: true, 33333: true} + +const maxPublicPorts = 16 + +// agentPort is the agent-fronting proxy port sealed containers serve on; a +// sealed sandbox restricted with publicPorts must keep it reachable. +const agentPort = 8080 + +// ValidatePublicPorts checks the optional publicPorts field of a create +// request. Full normalization (dedupe/sort) happens in the patched Daytona +// API; this boundary check exists to return clear 400s and to enforce the +// sealed rule, which Daytona knows nothing about. Returns nil when the field +// is absent. +func ValidatePublicPorts(body []byte, sealed bool) error { + ports, present, err := parsePublicPorts(body) + if err != nil { + return err + } + if !present { + return nil + } + if len(ports) == 0 { + return fmt.Errorf("publicPorts must not be empty; omit it for a fully public sandbox") + } + if len(ports) > maxPublicPorts { + return fmt.Errorf("publicPorts allows at most %d entries", maxPublicPorts) + } + hasAgentPort := false + for _, p := range ports { + if p < 1 || p > 65535 { + return fmt.Errorf("publicPorts entry %d out of range 1-65535", p) + } + if reservedPreviewPorts[p] { + return fmt.Errorf("publicPorts entry %d is a reserved system port", p) + } + if p == agentPort { + hasAgentPort = true + } + } + if sealed && !hasAgentPort { + return fmt.Errorf("sealed sandboxes with publicPorts must include port %d (the agent-fronting proxy)", agentPort) + } + return nil +} + +// parsePublicPorts extracts publicPorts from a JSON body. present is false +// when the field is absent or null. Rejects non-array values and non-integer +// elements. +func parsePublicPorts(body []byte) (ports []int, present bool, err error) { + if len(body) == 0 { + return nil, false, nil + } + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + return nil, false, fmt.Errorf("invalid request body") + } + raw, ok := m["publicPorts"] + if !ok || raw == nil { + return nil, false, nil + } + arr, ok := raw.([]any) + if !ok { + return nil, false, fmt.Errorf("publicPorts must be an array of integers") + } + for _, v := range arr { + f, ok := v.(float64) + if !ok || f != math.Trunc(f) { + return nil, false, fmt.Errorf("publicPorts must be an array of integers") + } + ports = append(ports, int(f)) + } + return ports, true, nil +} + +// decoratePublicPorts post-processes a 2xx create response for a request +// that asked for publicPorts. supported is false when the response carries +// no publicPorts field — the tell that the Daytona backend silently dropped +// the restriction (stock image, whitelist validation), in which case the +// caller must fail the create rather than hand out an unrestricted sandbox. +// When supported, a preview_urls map is attached so callers get ready-to-use +// URLs for each opened port (skipped when PROXY_DOMAIN is unset). +func decoratePublicPorts(respBytes []byte) (out []byte, supported bool, err error) { + var m map[string]any + if err := json.Unmarshal(respBytes, &m); err != nil { + return nil, false, err + } + rawPorts, ok := m["publicPorts"].([]any) + if !ok || len(rawPorts) == 0 { + return respBytes, false, nil + } + + domain := os.Getenv("PROXY_DOMAIN") + id, _ := m["id"].(string) + if domain != "" && id != "" { + scheme := os.Getenv("PROXY_PROTOCOL") + if scheme == "" { + scheme = "http" + } + urls := make(map[string]string, len(rawPorts)) + for _, v := range rawPorts { + f, ok := v.(float64) + if !ok { + continue + } + port := strconv.Itoa(int(f)) + urls[port] = fmt.Sprintf("%s://%s-%s.%s", scheme, port, id, domain) + } + m["preview_urls"] = urls + } + + out, err = json.Marshal(m) + if err != nil { + return nil, false, err + } + return out, true, nil +} diff --git a/internal/proxy/ports_test.go b/internal/proxy/ports_test.go new file mode 100644 index 0000000..d83211e --- /dev/null +++ b/internal/proxy/ports_test.go @@ -0,0 +1,104 @@ +package proxy + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +func TestValidatePublicPorts(t *testing.T) { + cases := []struct { + name string + body string + sealed bool + wantErr string // empty = no error + }{ + {"absent", `{"snapshot":"x"}`, false, ""}, + {"null", `{"publicPorts":null}`, false, ""}, + {"valid", `{"publicPorts":[8080,3000]}`, false, ""}, + {"empty array", `{"publicPorts":[]}`, false, "must not be empty"}, + {"not an array", `{"publicPorts":"8080"}`, false, "array of integers"}, + {"float element", `{"publicPorts":[8080.5]}`, false, "array of integers"}, + {"string element", `{"publicPorts":["8080"]}`, false, "array of integers"}, + {"out of range", `{"publicPorts":[70000]}`, false, "out of range"}, + {"zero", `{"publicPorts":[0]}`, false, "out of range"}, + {"reserved terminal", `{"publicPorts":[22222]}`, false, "reserved"}, + {"reserved toolbox", `{"publicPorts":[2280]}`, false, "reserved"}, + {"reserved recording", `{"publicPorts":[33333]}`, false, "reserved"}, + {"too many", `{"publicPorts":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17]}`, false, "at most"}, + {"sealed with 8080", `{"publicPorts":[8080]}`, true, ""}, + {"sealed without 8080", `{"publicPorts":[3000]}`, true, "must include port 8080"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := ValidatePublicPorts([]byte(c.body), c.sealed) + if c.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), c.wantErr) { + t.Fatalf("error = %v, want containing %q", err, c.wantErr) + } + }) + } +} + +func TestDecoratePublicPorts_Supported(t *testing.T) { + os.Setenv("PROXY_DOMAIN", "1.2.3.4.nip.io:4000") + defer os.Unsetenv("PROXY_DOMAIN") + + resp := `{"id":"abc-123","publicPorts":[8080,3000]}` + out, supported, err := decoratePublicPorts([]byte(resp)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !supported { + t.Fatal("response with publicPorts must report supported") + } + var m map[string]any + if err := json.Unmarshal(out, &m); err != nil { + t.Fatalf("output not JSON: %v", err) + } + urls, ok := m["preview_urls"].(map[string]any) + if !ok { + t.Fatalf("preview_urls missing: %s", out) + } + if urls["8080"] != "http://8080-abc-123.1.2.3.4.nip.io:4000" { + t.Errorf("preview_urls[8080] = %v", urls["8080"]) + } + if urls["3000"] != "http://3000-abc-123.1.2.3.4.nip.io:4000" { + t.Errorf("preview_urls[3000] = %v", urls["3000"]) + } +} + +func TestDecoratePublicPorts_UnsupportedBackend(t *testing.T) { + // Stock Daytona strips the field: response carries no publicPorts. + resp := `{"id":"abc-123","state":"started"}` + out, supported, err := decoratePublicPorts([]byte(resp)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if supported { + t.Fatal("response without publicPorts must report unsupported") + } + if string(out) != resp { + t.Error("body must be unchanged when unsupported") + } +} + +func TestDecoratePublicPorts_NoProxyDomain(t *testing.T) { + os.Unsetenv("PROXY_DOMAIN") + resp := `{"id":"abc-123","publicPorts":[8080]}` + out, supported, err := decoratePublicPorts([]byte(resp)) + if err != nil || !supported { + t.Fatalf("supported=%v err=%v", supported, err) + } + var m map[string]any + _ = json.Unmarshal(out, &m) + if _, has := m["preview_urls"]; has { + t.Error("preview_urls must be skipped without PROXY_DOMAIN") + } +} diff --git a/internal/settler/consumer.go b/internal/settler/consumer.go index b8caa6e..fdeba24 100644 --- a/internal/settler/consumer.go +++ b/internal/settler/consumer.go @@ -22,18 +22,53 @@ const maxBatchSize = 50 // alerter receives operator alerts on tx failures and bug-class settle outcomes; // pass alert.Nop{} to disable. func Run(ctx context.Context, cfg *config.Config, rdb *redis.Client, onchain ChainClient, nonceSigner NonceSigner, stopCh chan<- StopSignal, alerter alert.Alerter, log *zap.Logger) { - queueKey := fmt.Sprintf(voucher.VoucherQueueKeyFmt, cfg.Chain.ProviderAddress) + queueKey := fmt.Sprintf(voucher.VoucherQueueKeyFmt, onchain.ProviderAddress().Hex()) // lockTime/2 as BLPOP timeout (half the lock window for responsiveness) blpopTimeout := time.Duration(cfg.Billing.VoucherIntervalSec) * time.Second / 2 log.Info("settler started", zap.String("queue", queueKey)) + // Rotation gate state: throttle the on-chain node check and the warn log + // so a long not-yet-registered window doesn't spam RPC or logs. + var lastNodeCheck time.Time + var nodeActive bool + for { if ctx.Err() != nil { log.Info("settler stopped") return } + // Rotation gate: while our signer is not a registered TappRegistry + // node (fresh machine, add-node-onchain not run yet), hold the queue + // instead of submitting — every voucher would settle + // INVALID_SIGNATURE and dead-letter real revenue. Fail open on RPC + // errors: a flaky RPC must not stall settlement. + if time.Since(lastNodeCheck) > 30*time.Second { + firstCheck := lastNodeCheck.IsZero() + active, err := onchain.IsLocalTEEActiveNode(ctx) + if err != nil { + log.Warn("settler: node-membership check failed; assuming active", zap.Error(err)) + active = true + } + if !active && (firstCheck || nodeActive) { + log.Warn("settler: local TEE signer is not an active TappRegistry node — holding voucher queue until add-node-onchain lands") + } + if active && !nodeActive && !firstCheck { + log.Info("settler: local TEE signer registered on-chain — resuming settlement") + } + nodeActive = active + lastNodeCheck = time.Now() + } + if !nodeActive { + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Second): + } + continue + } + // BLPOP blocks until an item appears or timeout results, err := rdb.BLPop(ctx, blpopTimeout, queueKey).Result() if err != nil { diff --git a/internal/settler/types.go b/internal/settler/types.go index 5f74afe..ebffd6d 100644 --- a/internal/settler/types.go +++ b/internal/settler/types.go @@ -3,6 +3,8 @@ package settler import ( "context" + "github.com/ethereum/go-ethereum/common" + "github.com/0gfoundation/0g-sandbox/internal/chain" "github.com/0gfoundation/0g-sandbox/internal/voucher" ) @@ -18,6 +20,16 @@ type StopSignal struct { // without a live RPC connection. type ChainClient interface { SettleFeesWithTEE(ctx context.Context, vouchers []voucher.SandboxVoucher) ([]chain.SettlementStatus, error) + // ProviderAddress is this deployment's provider identity (= the TEE + // signer address); it keys the voucher queue the settler drains. + ProviderAddress() common.Address + // IsLocalTEEActiveNode reports whether our signer is currently a + // registered node of the app in TappRegistry. While false, the settler + // holds the queue instead of submitting: after a machine rebuild the + // new signer's vouchers would all settle INVALID_SIGNATURE until the + // operator runs add-node-onchain — holding them avoids burning gas and + // dead-lettering real revenue during that window. + IsLocalTEEActiveNode(ctx context.Context) (bool, error) } // NonceSigner assigns a monotone nonce and cryptographically signs a voucher diff --git a/web/dashboard.html b/web/dashboard.html index 5ba7c9b..6ebbbab 100644 --- a/web/dashboard.html +++ b/web/dashboard.html @@ -203,7 +203,7 @@
🏭
-

Connect your operator wallet to manage the provider service, snapshots, sandboxes, and settlement events.

+

Connect the app owner wallet (or an ADMIN_ADDRESSES wallet) to manage this node's service, snapshots, sandboxes, and settlement events. The provider identity is this node's TEE signer — it has no key to connect with.

@@ -220,7 +220,8 @@
Status
-
Provider Addr
+
Provider (TEE signer)
+
App Owner (admin)
Service URL
AppId (on-chain)
@@ -253,7 +254,7 @@
Address
Chain ID
-
Owner
+
Contract Admin
@@ -443,8 +444,9 @@ "function services(address) view returns (string url, string appId, uint256 pricePerCPUPerMin, uint256 pricePerMemGBPerMin, uint256 createFee)", "function serviceExists(address) view returns (bool)", "function providerEarnings(address) view returns (uint256)", - "function addOrUpdateService(string url, string appId, uint256 pricePerCPUPerMin, uint256 createFee, uint256 pricePerMemGBPerMin)", - "function withdrawEarnings()", + "function addOrUpdateService(address signer, string url, string appId, uint256 pricePerCPUPerMin, uint256 createFee, uint256 pricePerMemGBPerMin)", + "function withdrawEarnings(address signer)", + "function removeService(address signer)", "function owner() view returns (address)", "function tappRegistry() view returns (address)", ]; @@ -460,6 +462,7 @@ const cEl = document.getElementById('infoContract'); cEl.textContent = shortAddr(info.contract_address||'') || '—'; cEl.title = info.contract_address || ''; document.getElementById('infoChain').textContent = info.chain_id || '—'; const pEl = document.getElementById('infoProvider'); pEl.textContent = shortAddr(info.provider_address||'') || '—'; pEl.title = info.provider_address || ''; + const oEl = document.getElementById('infoAppOwner'); oEl.textContent = shortAddr(info.owner_address||'') || '—'; oEl.title = (info.owner_address || '') + ' — the appId\'s TappRegistry owner: log in with this wallet (or an ADMIN_ADDRESSES wallet) to operate this dashboard'; // Pre-fill AppId from /api/info.signer.app_id (set after first registration). if (info.signer && info.signer.app_id) { document.getElementById('svcAppIdInput').value = info.signer.app_id; @@ -582,15 +585,23 @@ } // ── Provider Data ───────────────────────────────────────────────────────────── +// v2 identity model: the provider address is this node's TEE signer, taken +// from /api/info — NEVER the connected wallet. The wallet is the appId owner +// (or an extra admin): it signs admin API requests and owner-only txs +// (register / withdraw), but is not a ledger identity. +function nodeProviderAddr() { + return info && info.provider_address ? info.provider_address : null; +} async function loadProviderData() { - if (!contract || !walletAddr) return; + const prov = nodeProviderAddr(); + if (!contract || !walletAddr || !prov) return; try { - const exists = await contract.serviceExists(walletAddr); + const exists = await contract.serviceExists(prov); document.getElementById('svcRegistered').innerHTML = exists ? '✓ Registered' : '✗ Not Registered'; if (exists) { - const svc = await contract.services(walletAddr); + const svc = await contract.services(prov); document.getElementById('svcUrl').textContent = svc.url || '—'; const teeEl = document.getElementById('svcTEE'); teeEl.textContent = svc.appId || '—'; teeEl.title = svc.appId || ''; @@ -599,7 +610,7 @@ document.getElementById('svcFee').textContent = fmtNeuron(svc.createFee.toString()); } const [earn, owner] = await Promise.all([ - contract.providerEarnings(walletAddr), + contract.providerEarnings(prov), contract.owner().catch(() => null), ]); document.getElementById('providerEarnings').textContent = fmtNeuron(earn.toString()); @@ -623,14 +634,14 @@ const fee = document.getElementById('svcFeeInput').value; if (!url || !appId) { setErr('serviceErr', 'URL and AppId are required'); return; } setErr('serviceErr', ''); - await sendTx('Service update', () => contract.addOrUpdateService(url, appId, BigInt(cpuPrice), BigInt(fee), BigInt(memPrice)), { + await sendTx('Service update', () => contract.addOrUpdateService(nodeProviderAddr(), url, appId, BigInt(cpuPrice), BigInt(fee), BigInt(memPrice)), { onSuccess: async () => { closeModal('serviceModal'); await loadProviderData(); }, errEl: 'serviceErr', }); } async function withdrawEarnings() { - await sendTx('Withdraw earnings', () => contract.withdrawEarnings(), { onSuccess: () => loadProviderData() }); + await sendTx('Withdraw earnings', () => contract.withdrawEarnings(nodeProviderAddr()), { onSuccess: () => loadProviderData() }); } // ── Archive All ─────────────────────────────────────────────────────────────── diff --git a/web/user.html b/web/user.html index 4a8b6f0..e58160e 100644 --- a/web/user.html +++ b/web/user.html @@ -660,6 +660,11 @@

Just tell Claude
what you want.

Select a pre-built environment.
+
+ + +
Comma-separated. Only these ports are publicly reachable; all others require auth. Leave blank to keep every port public.
+