From 491bda7477436f69649746a7186dc82171ee83ea Mon Sep 17 00:00:00 2001 From: qdee Date: Sat, 29 Aug 2026 08:22:51 +0100 Subject: [PATCH 01/26] Harden recovery sponsor and refine public journey --- DESIGN.md | 34 ++-- docs/ARCHITECTURE.md | 10 ++ docs/THREAT_MODEL.md | 5 +- relayer/OPERATIONS.md | 9 +- relayer/README.md | 6 +- relayer/src/budget.ts | 80 ++++++--- relayer/src/executor.ts | 3 +- relayer/src/exit-executor.ts | 125 ++++++++++--- relayer/src/index.ts | 42 +++-- relayer/src/neutral-exit-policy.d.mts | 16 ++ relayer/src/neutral-exit-policy.mjs | 34 +++- relayer/test/budget.spec.ts | 14 ++ relayer/test/neutral-exit-signing.spec.ts | 112 +++++++++++- relayer/test/relayer.spec.ts | 6 +- web/src/main.ts | 90 +++++++--- web/src/style.css | 210 +++++++++++++++++++++- 16 files changed, 679 insertions(+), 117 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 189ebd5..f7960d2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -7,9 +7,18 @@ colors: muted: "#68685f" paper: "#FFFDF8" line: "#D8D3C8" - accent: "#D86F31" - accent-dark: "#8E3514" + accent: "#C8642F" + accent-dark: "#843515" soft: "#EBE6DB" +spacing: + compact: "8px" + control: "16px" + panel: "24px" + section: "48px" +rounded: + control: "7px" + panel: "12px" + shell: "18px" typography: body: fontFamily: Inter, ui-sans-serif, system-ui, sans-serif @@ -18,13 +27,10 @@ typography: fontWeight: 500 lineHeight: 0.98 letterSpacing: -0.045em -omitted: - - section: spacing - reason: Layout spacing is evidenced, but no named shared spacing scale exists. - - section: rounded - reason: Repeated radii exist, but no named shared radius scale exists. - - section: components - reason: Shared component behavior is documented in prose without a token contract. +components: + journeyProgress: "Three concise role-specific stages with complete, current and upcoming states." + activityBanner: "Inline wallet/network/action status; never overlays the vault or primary action." + liveState: "Outcome-first state block with one valid primary action and contextual metrics." --- ## Overview @@ -41,7 +47,9 @@ Use the body family for controls, instructions, wallet state and receipts. Use t ## Layout -Use one role-aware application shell with separate owner and successor journeys. Show the current vault state, the next meaningful deadline, and one primary action before secondary controls or transaction details. Preserve the same reading order at mobile and desktop widths; wider layouts may place contextual status beside the action, but must not turn the journey into a dashboard grid. +Use one role-aware application shell with separate owner and successor journeys. Begin each role with a three-stage progress strip and show the current vault state, the next meaningful deadline, and one primary action before secondary controls or transaction details. Preserve the same reading order at mobile and desktop widths; wider layouts may place contextual status beside the action, but must not turn the journey into a dashboard grid. + +Use the compact, control, panel and section spacing values as an optical rhythm rather than a rigid mathematical grid. Controls use the tight radius, nested panels use the middle radius, and the main journey shell uses the softest radius with one tighter corner to give Afterlight a recognizable silhouette. Every remote or wallet-dependent region needs loading, empty, error, wrong-network, insufficient-balance, interrupted-flow, and reload-recovery states. The owner and successor journeys must remain usable at 320px, 768px, 1024px, and 1440px widths. @@ -49,7 +57,9 @@ Every remote or wallet-dependent region needs loading, empty, error, wrong-netwo The unauthenticated primary action is “Create a recovery reserve.” Owner setup progressively reveals Ready X connection, reserve mode and denomination, inactivity and grace periods, the successor public key, recovery-package backup, and private funding. Do not expose later actions before their prerequisites are satisfied. -Vault status presents `ACTIVE`, `GRACE`, `CLAIMED`, or `CANCELLED` in plain language together with the relevant heartbeat, request, grace, or terminal outcome. Do not use color alone to distinguish states. +Vault status presents `ACTIVE`, `GRACE`, `CLAIMED`, or `CANCELLED` in plain language together with the relevant heartbeat, request, grace, or terminal outcome. Pair the machine state with a human result such as “Reserve protected,” “Owner still has control,” or “Recovery complete.” Do not use color alone to distinguish states. + +When an invitation validates, collapse its raw JSON into a compact summary of vault, reserve and timing. Keep replacement available through progressive disclosure. Never make raw JSON the largest element in a successful journey. Owner controls expose heartbeat, veto, and private cancellation only when valid. Successor controls expose key generation, invitation import, request, exact destination-note preparation, and claim only when valid. Explain why an action is unavailable instead of leaving a dead control. @@ -59,6 +69,8 @@ The privacy boundary belongs near reserve creation and recovery confirmation. St Interactive controls use native elements, visible labels, visible keyboard focus, reduced-motion-safe feedback, and touch targets at least 44px in both dimensions. Dialogs and wallet-return flows restore focus to the action that opened them, and status changes are announced without moving focus unexpectedly. +Wallet, network and transaction feedback appears in the inline activity banner immediately above the journey. It must not float over or conceal a state, action, receipt or privacy explanation. + ## Do's and Don'ts Use outcome-first copy, ordinary wallet language, and contextual receipts. Make heartbeat, inactivity, grace, veto, cancellation, and exact private recovery understandable without requiring contract terminology. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b7a2854..0b32fad 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -25,6 +25,11 @@ resource quote, then signs and broadcasts the outer transaction once. The contract and pool remain authoritative; package preparation alone is not execution evidence. +The sponsor independently pins the live STRK20 pool class and permits exactly +`WriteOnce`, `EmitOpenNoteCreated`, then `Invoke`. The first action must write +the canonical packed token value to the storage key derived from the signed +destination note. Extra actions or writes fail before signing. + ```text Ready X + STRK20 pool |-- FUND ------------------------------> Afterlight liability + ACTIVE vault @@ -71,6 +76,11 @@ held token balance >= existing locked liabilities + new fixed reserve Only the configured reserve becomes a vault liability. Donated surplus neither blocks funding nor creates a claim. Claim and cancellation reduce the liability exactly once; failed settlement reverts the state change. +The public product also reads the sponsor's collapsed claim-capacity status. +It disables new funding unless one claim is covered by the exact pool allowance, +the bounded outer fee, and the post-spend health floor. This is an availability +guard, not an authorization primitive; the contract and pool remain final. + There is no administrative withdrawal path. Accidental donations remain unaccounted surplus: they cannot create a vault claim, block a user action, or change the exact locked liability. Donors should not expect protocol-level diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 83370f6..5fa39ef 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -64,7 +64,10 @@ Relayer availability is operationally important but not trusted for correctness. | Owner veto races a mature claim | First valid included transition wins; the other sees changed state | | Failed token/pool settlement leaves false state | Cairo transaction rollback restores state and liability | | Tokens are donated directly to the helper | No administrative withdrawal exists; donated surplus remains inert and cannot change locked liabilities | -| Relayer drains sponsorship | Schema limits, expiry, rate limits, per-call cap, daily budget, nonce serialization, and breach freeze | +| Relayer drains sponsorship | Schema limits, expiry, validation before scarce per-vault rate limiting, separate control/exit daily caps, shared nonce serialization, and breach freeze | +| Prepared proof swaps the pool implementation or adds actions | Pinned live pool class plus an exact `WriteOnce → EmitOpenNoteCreated → Invoke` action sequence and canonical destination-note storage write | +| Ambiguous broadcast is retried or released | `SUBMITTED` retains its hash and reservation; duplicate/unknown RPC results reconcile without signing or rebroadcasting | +| Product creates a reserve the sponsor cannot settle | Live claim-capacity health check; funding fails closed unless exact allowance, balance, exit cap, and health floor reconcile | | Application logs correlate a wallet or vault | Request bodies, signatures, IPs, wallet addresses, vault IDs, and fingerprints are excluded from application logs; infrastructure metadata remains a separate limit | ## Relayer and hosting metadata boundary diff --git a/relayer/OPERATIONS.md b/relayer/OPERATIONS.md index 79117e1..d88afcd 100644 --- a/relayer/OPERATIONS.md +++ b/relayer/OPERATIONS.md @@ -33,7 +33,7 @@ Install `RELAYER_ACCOUNT_PRIVATE_KEY` and `STARKNET_RPC_AUTH_TOKEN` only with `w 1. Run `npm ci`, `npm run types`, `npm run check`, and `npm audit` on the exact commit. 2. For a new environment, keep `SUBMIT_ENABLED=false`; deploy the Worker and Durable Object migration. -3. Verify `/health` reports the intended submission state and exposes no address, endpoint, exact balance, secret state, wallet, vault, or request identifier. +3. Verify `/health` reports the intended submission state and collapsed claim-capacity state, while exposing no address, endpoint, exact balance, secret state, wallet, vault, or request identifier. 4. Verify the configured-origin preflight and each route with empty or invalid requests only. 5. Fund the bounded relayer account within the approved spike cap. 6. Install secrets, verify configuration readiness, and obtain fresh no-submit quotes. @@ -46,6 +46,7 @@ Install `RELAYER_ACCOUNT_PRIVATE_KEY` and `STARKNET_RPC_AUTH_TOKEN` only with `w - One relayer account has one active nonce lane. A `RESERVED` or `SUBMITTED` operation blocks a different sponsored operation until it is released or finalized. - A timed-out receipt remains `SUBMITTED`; its full maximum stays reserved. - Retry only the exact original request. The executor reuses its stored transaction hash and reconciles the receipt without simulating, signing, or broadcasting again. +- RPC duplicate and unknown-result errors are transport-ambiguous, never proof of rejection. Keep their reservations locked until receipt and nonce evidence resolves them. - A request with the same semantic operation but different signature, expiry, or exact fingerprint cannot reconcile the submitted transaction. - Never release a submitted reservation based only on an RPC timeout. Confirm the transaction or account nonce before any manual recovery. - A receipt fee above its reservation records the full spend and freezes all new sponsorship. @@ -63,6 +64,7 @@ promotion; a green inert check is never production readiness evidence. Monitor through at least 2026-09-04: - `/health` availability and collapsed balance status; +- `/health.claimCapacity`; stop new reserves whenever it is not `ready`. A zero pool allowance after a completed claim is an intentional exhausted state until one newly reviewed, bounded allowance is provisioned. - relayer public STRK balance below the configured threshold; - Durable Object sponsorship freeze state; - reservations remaining `RESERVED` or `SUBMITTED` beyond the receipt window; @@ -77,6 +79,11 @@ pool allowance, and a `1 STRK` post-spend balance floor. Replace any cap only from fresh quotes; never enable exposure larger than the funded operational bound. +Control and exit spend use separate UTC-day totals because their policy ceilings +are intentionally different. They still share the same reservation table and +single active nonce lane; splitting accounting does not permit concurrent +broadcasts from the neutral account. + Do not log request bodies, signatures, IP addresses, wallet addresses, application keys, vault IDs, transaction fingerprints, RPC authorization, or exact private-flow timing correlations. Disable or minimize Cloudflare request diff --git a/relayer/README.md b/relayer/README.md index f6d85e8..fe9e161 100644 --- a/relayer/README.md +++ b/relayer/README.md @@ -14,7 +14,7 @@ inert packaging and health tests. - Mainnet chain configuration is explicit in every relay plan; Cairo signatures independently bind the chain. - Bounded streaming body read; an advertised or observed oversized body is rejected. - Cloudflare Rate Limiting bindings for both per-vault/operation and global abuse control. Keys are hashes, never client IP addresses. -- A deterministic fee policy that caps each transaction and the daily exposure. It reserves the transaction maximum, not the optimistic quote. +- A deterministic fee policy that caps each transaction and the daily exposure. It reserves the transaction maximum, not the optimistic quote. Control calls and private exits have separate daily ledgers while sharing one serialized account-nonce lane. - One deployment-wide SQLite Durable Object. Semantic operation identities survive UTC rollover while reserve/spend totals remain day-bucketed. - Separate semantic and exact-call fingerprints: changing only expiry or signature cannot create a second operation, while simulation, submission, and receipts must still match the exact calldata. - Atomic `RESERVED → SUBMITTED(tx hash) → COMMITTED | REVERTED | BREACHED` reconciliation. Simulation failure spends nothing; a proven pre-submit failure releases; ambiguous receipt state remains submitted with its hash; reverted transactions remain terminal. @@ -25,7 +25,9 @@ inert packaging and health tests. - Starknet v3 simulation freezes the nonce and exact resource bounds. The budget reserves a larger policy maximum; signing reuses the frozen bounds without estimating again and rejects encoded exposure above the reservation. - Submitted and mined account transactions are reconciled against the neutral account and exact Cairo execute calldata before the receipt is accepted. - `POST /v1/checkpoint` builds the permissionless `sync_funding_checkpoint` call with no request body, wallet, vault, note, or signature identifier. A global 15-second idempotency bucket and dedicated rate limiter bound sponsorship. -- `POST /v1/exit` accepts only a designated-key-authorized `CLAIM` package from the configured product origin. It locks the exact pool call, proof data/output/facts, application signature, destination note, live vault state, classes, allowance, balance, resource quote, and outer transaction hash before one broadcast. +- `POST /v1/exit` accepts only a designated-key-authorized `CLAIM` package from the configured product origin. It locks the exact three-action pool call (`WriteOnce`, `EmitOpenNoteCreated`, `Invoke`), canonical note storage write, proof data/output/facts, pinned pool class, application signature, destination note, live vault state, allowance, balance, resource quote, and outer transaction hash before one broadcast. +- `SUBMIT_ENABLED=false` disables checkpoint, control, and exit broadcasts. A retry of a stored `SUBMITTED` exit only reconciles its existing hash; it never signs or broadcasts again. Duplicate and unknown RPC errors remain ambiguous and keep the reservation locked. +- `/health` exposes a collapsed `claimCapacity` state. The product refuses new funding unless the live neutral balance and exact pool allowance can cover one bounded claim plus the health floor. ## Production configuration diff --git a/relayer/src/budget.ts b/relayer/src/budget.ts index a3a45da..94e1fe8 100644 --- a/relayer/src/budget.ts +++ b/relayer/src/budget.ts @@ -9,6 +9,7 @@ export type ReservationState = | "breached"; export type BudgetReserveInput = Readonly<{ + budgetClass: "control" | "exit"; dayKey: string; semanticKey: string; exactFingerprint: string; @@ -106,6 +107,7 @@ export class BudgetError extends Error { type ReservationRow = Readonly<{ semantic_key: string; + budget_class: "control" | "exit"; exact_fingerprint: string; day_key: string; max_fee_fri: string; @@ -155,6 +157,28 @@ export class RelayBudget extends DurableObject { ); CREATE INDEX IF NOT EXISTS reservations_day_status ON reservations (day_key, status); + + CREATE TABLE IF NOT EXISTS class_daily_totals ( + budget_class TEXT NOT NULL CHECK (budget_class IN ('control', 'exit')), + day_key TEXT NOT NULL, + reserved_fri TEXT NOT NULL, + spent_fri TEXT NOT NULL, + PRIMARY KEY (budget_class, day_key) + ); + `); + const reservationColumns = this.ctx.storage.sql + .exec<{ name: string }>("PRAGMA table_info(reservations)") + .toArray(); + if (!reservationColumns.some(({ name }) => name === "budget_class")) { + this.ctx.storage.sql.exec( + "ALTER TABLE reservations ADD COLUMN budget_class TEXT NOT NULL DEFAULT 'control' CHECK (budget_class IN ('control', 'exit'))", + ); + } + // Preserve all exposure from the pre-class ledger conservatively as + // control spend. This migration never resets an existing daily ceiling. + this.ctx.storage.sql.exec(` + INSERT OR IGNORE INTO class_daily_totals (budget_class, day_key, reserved_fri, spent_fri) + SELECT 'control', day_key, reserved_fri, spent_fri FROM daily_totals `); } @@ -178,7 +202,7 @@ export class RelayBudget extends DurableObject { const exact = validKey(exactFingerprint); const rows = this.ctx.storage.sql .exec( - `SELECT semantic_key, exact_fingerprint, day_key, max_fee_fri, actual_fee_fri, + `SELECT semantic_key, budget_class, exact_fingerprint, day_key, max_fee_fri, actual_fee_fri, status, transaction_hash FROM reservations WHERE exact_fingerprint = ? AND status IN ('RESERVED', 'SUBMITTED')`, @@ -203,7 +227,7 @@ export class RelayBudget extends DurableObject { if (existing !== undefined && existing.status !== "RELEASED") { return reserveResult( duplicateOutcome(existing.status), - this.readTotals(existing.day_key), + this.readTotals(existing.budget_class, existing.day_key), this.isFrozen(), ); } @@ -217,7 +241,7 @@ export class RelayBudget extends DurableObject { // exposure prevents two requests from signing the same pending nonce. if (active !== 0) throw new BudgetError("relayer_busy"); - const totals = this.readTotals(normalized.dayKey); + const totals = this.readTotals(normalized.budgetClass, normalized.dayKey); const maximum = BigInt(normalized.maxFeeFri); if (maximum > BigInt(normalized.perCallCapFri)) throw new BudgetError("per_call_cap"); const projected = BigInt(totals.reserved_fri) + BigInt(totals.spent_fri) + maximum; @@ -226,10 +250,11 @@ export class RelayBudget extends DurableObject { if (existing === undefined) { this.ctx.storage.sql.exec( `INSERT INTO reservations - (semantic_key, exact_fingerprint, day_key, max_fee_fri, actual_fee_fri, + (semantic_key, budget_class, exact_fingerprint, day_key, max_fee_fri, actual_fee_fri, status, transaction_hash, updated_at_ms) - VALUES (?, ?, ?, ?, NULL, 'RESERVED', NULL, ?)`, + VALUES (?, ?, ?, ?, ?, NULL, 'RESERVED', NULL, ?)`, normalized.semanticKey, + normalized.budgetClass, normalized.exactFingerprint, normalized.dayKey, normalized.maxFeeFri, @@ -238,9 +263,10 @@ export class RelayBudget extends DurableObject { } else { this.ctx.storage.sql.exec( `UPDATE reservations - SET exact_fingerprint = ?, day_key = ?, max_fee_fri = ?, actual_fee_fri = NULL, + SET budget_class = ?, exact_fingerprint = ?, day_key = ?, max_fee_fri = ?, actual_fee_fri = NULL, status = 'RESERVED', transaction_hash = NULL, updated_at_ms = ? WHERE semantic_key = ?`, + normalized.budgetClass, normalized.exactFingerprint, normalized.dayKey, normalized.maxFeeFri, @@ -252,7 +278,7 @@ export class RelayBudget extends DurableObject { reserved_fri: (BigInt(totals.reserved_fri) + maximum).toString(), spent_fri: totals.spent_fri, }; - this.writeTotals(normalized.dayKey, next); + this.writeTotals(normalized.budgetClass, normalized.dayKey, next); return reserveResult("reserved", next, false); }); } @@ -269,7 +295,7 @@ export class RelayBudget extends DurableObject { const timestamp = validTimestamp(nowMs); return this.ctx.storage.transactionSync(() => { const row = this.requiredReservation(semantic); - const totals = this.readTotals(row.day_key); + const totals = this.readTotals(row.budget_class, row.day_key); if (row.exact_fingerprint !== exact) throw new BudgetError("exact_fingerprint_mismatch"); if (row.status === "SUBMITTED") { if (row.transaction_hash !== hash) throw new BudgetError("idempotency_conflict"); @@ -296,7 +322,7 @@ export class RelayBudget extends DurableObject { const timestamp = validTimestamp(nowMs); return this.ctx.storage.transactionSync(() => { const row = this.requiredReservation(semantic); - const totals = this.readTotals(row.day_key); + const totals = this.readTotals(row.budget_class, row.day_key); if (row.exact_fingerprint !== exact) throw new BudgetError("exact_fingerprint_mismatch"); if (row.status === "RELEASED") return mutationResult("already_released", totals, this.isFrozen()); const terminal = terminalMutationOutcome(row.status); @@ -309,7 +335,7 @@ export class RelayBudget extends DurableObject { timestamp, semantic, ); - this.writeTotals(row.day_key, next); + this.writeTotals(row.budget_class, row.day_key, next); return mutationResult("released", next, this.isFrozen()); }); } @@ -332,7 +358,7 @@ export class RelayBudget extends DurableObject { } return this.ctx.storage.transactionSync(() => { const row = this.requiredReservation(semantic); - const totals = this.readTotals(row.day_key); + const totals = this.readTotals(row.budget_class, row.day_key); if (row.exact_fingerprint !== exact) throw new BudgetError("exact_fingerprint_mismatch"); if (row.status === "COMMITTED" || row.status === "REVERTED" || row.status === "BREACHED") { if (row.transaction_hash !== hash || row.actual_fee_fri !== actualFee.toString()) { @@ -368,7 +394,7 @@ export class RelayBudget extends DurableObject { timestamp, semantic, ); - this.writeTotals(row.day_key, next); + this.writeTotals(row.budget_class, row.day_key, next); if (breached) { this.ctx.storage.sql.exec( "UPDATE sponsorship_control SET frozen = 1, frozen_at_ms = ? WHERE singleton = 1", @@ -383,12 +409,14 @@ export class RelayBudget extends DurableObject { }); } - snapshot(dayKey: string): BudgetSnapshot { + snapshot(dayKey: string, budgetClass: "control" | "exit" = "control"): BudgetSnapshot { const day = validDay(dayKey); - const totals = this.readTotals(day); + const kind = validBudgetClass(budgetClass); + const totals = this.readTotals(kind, day); const counts = this.ctx.storage.sql .exec<{ status: string; count: number }>( - "SELECT status, COUNT(*) AS count FROM reservations WHERE day_key = ? GROUP BY status", + "SELECT status, COUNT(*) AS count FROM reservations WHERE budget_class = ? AND day_key = ? GROUP BY status", + kind, day, ) .toArray(); @@ -407,21 +435,23 @@ export class RelayBudget extends DurableObject { }; } - private readTotals(dayKey: string): TotalsRow { + private readTotals(budgetClass: "control" | "exit", dayKey: string): TotalsRow { return this.ctx.storage.sql .exec( - "SELECT reserved_fri, spent_fri FROM daily_totals WHERE day_key = ?", + "SELECT reserved_fri, spent_fri FROM class_daily_totals WHERE budget_class = ? AND day_key = ?", + budgetClass, dayKey, ) .toArray()[0] ?? { reserved_fri: "0", spent_fri: "0" }; } - private writeTotals(dayKey: string, totals: TotalsRow): void { + private writeTotals(budgetClass: "control" | "exit", dayKey: string, totals: TotalsRow): void { this.ctx.storage.sql.exec( - `INSERT INTO daily_totals (day_key, reserved_fri, spent_fri) - VALUES (?, ?, ?) - ON CONFLICT(day_key) DO UPDATE + `INSERT INTO class_daily_totals (budget_class, day_key, reserved_fri, spent_fri) + VALUES (?, ?, ?, ?) + ON CONFLICT(budget_class, day_key) DO UPDATE SET reserved_fri = excluded.reserved_fri, spent_fri = excluded.spent_fri`, + budgetClass, dayKey, totals.reserved_fri, totals.spent_fri, @@ -431,7 +461,7 @@ export class RelayBudget extends DurableObject { private readReservation(semanticKey: string): ReservationRow | undefined { return this.ctx.storage.sql .exec( - `SELECT semantic_key, exact_fingerprint, day_key, max_fee_fri, actual_fee_fri, + `SELECT semantic_key, budget_class, exact_fingerprint, day_key, max_fee_fri, actual_fee_fri, status, transaction_hash FROM reservations WHERE semantic_key = ?`, semanticKey, @@ -458,6 +488,7 @@ export class RelayBudget extends DurableObject { function validateReserveInput(input: BudgetReserveInput): BudgetReserveInput { return { + budgetClass: validBudgetClass(input.budgetClass), dayKey: validDay(input.dayKey), semanticKey: validKey(input.semanticKey), exactFingerprint: validKey(input.exactFingerprint), @@ -468,6 +499,11 @@ function validateReserveInput(input: BudgetReserveInput): BudgetReserveInput { }; } +function validBudgetClass(value: string): "control" | "exit" { + if (value !== "control" && value !== "exit") throw new BudgetError("invalid_budget_input"); + return value; +} + function externalState(status: ReservationRow["status"]): ReservationState { if (status === "RESERVED") return "reserved"; if (status === "SUBMITTED") return "submitted"; diff --git a/relayer/src/executor.ts b/relayer/src/executor.ts index 1d5937d..c6e3414 100644 --- a/relayer/src/executor.ts +++ b/relayer/src/executor.ts @@ -89,7 +89,7 @@ export interface BudgetCoordinator { execution: "succeeded" | "reverted", nowMs: number, ): Promise; - snapshot(dayKey: string): Promise; + snapshot(dayKey: string, budgetClass?: "control" | "exit"): Promise; } export type ExecutorPolicy = Readonly<{ @@ -209,6 +209,7 @@ export async function executeRelayPlan( let reservation: BudgetReserveResult; try { reservation = await budget.reserve({ + budgetClass: "control", dayKey, semanticKey: plan.semanticKey, exactFingerprint: plan.fingerprint, diff --git a/relayer/src/exit-executor.ts b/relayer/src/exit-executor.ts index f3db291..72da210 100644 --- a/relayer/src/exit-executor.ts +++ b/relayer/src/exit-executor.ts @@ -1,6 +1,7 @@ import { Account, RpcProvider, transaction, type Call } from "starknet"; import type { BudgetCoordinator } from "./executor.js"; import { + LOCKED_POOL_CLASS_HASH, addResourceMargins, assertOuterSignatureMatchesHash, assertProofFreshness, @@ -20,7 +21,7 @@ import { type ValidatedExit, } from "./neutral-exit-policy.mjs"; -const EXIT_POLICY = Object.freeze({ +export const EXIT_POLICY = Object.freeze({ schema: "afterlight-neutral-exit-policy/1", chainId: "0x534e5f4d41494e", neutralAddress: "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46", @@ -70,23 +71,66 @@ export type ExitResult = Readonly<{ actualFeeFri?: string; }>; -export async function executePreparedClaim(payload: string, env: Env, budget: BudgetCoordinator): Promise { +export type ClaimCapacity = Readonly<{ + status: "ready" | "exhausted" | "unknown"; + reason: "ready" | "allowance" | "balance" | "configuration"; +}>; + +export async function readClaimCapacity(env: Env): Promise { + try { + const provider = new RpcProvider({ + nodeUrl: env.EXIT_RPC_URL, + headers: { authorization: `Bearer ${env.STARKNET_RPC_AUTH_TOKEN}` }, + plugins: false, + }); + const latest = await provider.getBlockWithTxHashes("latest"); + if (!("block_number" in latest)) return { status: "unknown", reason: "configuration" }; + const block = latest.block_number; + const call = (contractAddress: string, entrypoint: string, calldata: string[] = []) => + provider.callContract({ contractAddress, entrypoint, calldata }, block); + const [poolClass, neutralClass, balanceRaw, allowanceRaw, feeRaw, collectorRaw] = await Promise.all([ + provider.getClassHashAt(POOL, block), + provider.getClassHashAt(NEUTRAL, block), + call(TOKEN, "balance_of", [NEUTRAL]), + call(TOKEN, "allowance", [NEUTRAL, POOL]), + call(POOL, "get_fee_amount"), + call(POOL, "get_fee_collector"), + ]); + if ( + normalizeHex(poolClass) !== normalizeHex(LOCKED_POOL_CLASS_HASH) || + normalizeHex(neutralClass) !== normalizeHex(EXIT_POLICY.neutralClassHash) || + normalizeHex(collectorRaw[0] ?? "0x0") !== normalizeHex(EXIT_POLICY.poolFeeCollector) + ) return { status: "unknown", reason: "configuration" }; + const fee = BigInt(feeRaw[0] ?? -1); + if (fee !== BigInt(EXIT_POLICY.poolFeeEachFri)) return { status: "unknown", reason: "configuration" }; + const allowance = parseU256Result(allowanceRaw, "allowance"); + if (allowance !== fee) return { status: "exhausted", reason: "allowance" }; + const balance = parseU256Result(balanceRaw, "balance"); + const required = fee + BigInt(EXIT_POLICY.maxNetworkFeePerExitFri) + BigInt(EXIT_POLICY.postSpendHealthFloorFri); + if (balance < required) return { status: "exhausted", reason: "balance" }; + return { status: "ready", reason: "ready" }; + } catch { + return { status: "unknown", reason: "configuration" }; + } +} + +export function validatePreparedClaimPayload(payload: string): ValidatedExit { let decoded: unknown; try { decoded = JSON.parse(payload); } catch { throw new ExitExecutorError("invalid_exit"); } - let validated: ValidatedExit; try { - validated = validatePreparedExitPackage(decoded, EXIT_POLICY); + const validated = validatePreparedExitPackage(decoded, EXIT_POLICY); if (validated.action !== "CLAIM") throw new Error("only_claim_is_public"); + return validated; } catch { throw new ExitExecutorError("invalid_exit"); } +} + +export async function executePreparedClaim(payload: string, env: Env, budget: BudgetCoordinator, prevalidated?: ValidatedExit): Promise { + if (env.SUBMIT_ENABLED !== "true") throw new ExitExecutorError("exit_unavailable"); + const validated = prevalidated ?? validatePreparedClaimPayload(payload); // The binding is already a domain-separated SHA-256 over the complete exit // package. Budget keys deliberately accept only canonical 64-hex digests. const semanticKey = validated.bindingSha256; - const prior = await budget.lookup(semanticKey); - if (prior.outcome === "found" && prior.state !== "released") { - return { status: "duplicate", transactionHash: prior.transactionHash }; - } - const provider = new RpcProvider({ // Real Ready PROOF1 exit envelopes require the audited RPC 0.10.3 path. // Keep its credential-bearing URL in a Worker secret rather than the @@ -95,6 +139,17 @@ export async function executePreparedClaim(payload: string, env: Env, budget: Bu headers: { authorization: `Bearer ${env.STARKNET_RPC_AUTH_TOKEN}` }, plugins: false, }); + const prior = await budget.lookup(semanticKey); + if (prior.outcome === "found" && prior.state !== "released") { + if ( + prior.state === "submitted" && + prior.transactionHash !== null && + prior.exactFingerprint === validated.bindingSha256 + ) { + return reconcileSubmittedClaim(provider, budget, validated, prior.transactionHash); + } + return { status: "duplicate", transactionHash: prior.transactionHash }; + } try { if (normalizeHex(await provider.getChainId()) !== normalizeHex(EXIT_POLICY.chainId)) throw new Error("wrong_chain"); } catch { throw exitStage("chain"); } @@ -152,6 +207,7 @@ export async function executePreparedClaim(payload: string, env: Env, budget: Bu } catch { throw exitStage("fee_and_balance"); } const reservation = await budget.reserve({ + budgetClass: "exit", dayKey: new Date().toISOString().slice(0, 10), semanticKey, exactFingerprint: validated.bindingSha256, @@ -220,17 +276,7 @@ export async function executePreparedClaim(payload: string, env: Env, budget: Bu submissionStage = "mark_submitted"; await budget.markSubmitted(semanticKey, validated.bindingSha256, transactionHash, Date.now()); submissionStage = "receipt"; - const receipt = await provider.waitForTransaction(transactionHash, { retries: 45, retryInterval: 2_000 }); - if (receipt.isError()) throw new ExitExecutorError("exit_uncertain"); - const raw = receipt.value as unknown as Record; - const fee = readFee(raw.actual_fee); - if (receipt.isReverted()) { - await budget.finalize(semanticKey, validated.bindingSha256, transactionHash, fee, "reverted", Date.now()); - throw new ExitExecutorError("exit_reverted"); - } - submissionStage = "finalize"; - await budget.finalize(semanticKey, validated.bindingSha256, transactionHash, fee, "succeeded", Date.now()); - return { status: "accepted", transactionHash, actualFeeFri: fee }; + return await reconcileSubmittedClaim(provider, budget, validated, transactionHash); } catch (error) { if (!broadcastStarted) { try { @@ -248,11 +294,37 @@ export async function executePreparedClaim(payload: string, env: Env, budget: Bu } } +export async function reconcileSubmittedClaim( + provider: RpcProvider, + budget: BudgetCoordinator, + validated: ValidatedExit, + transactionHash: string, +): Promise { + let receipt; + try { + receipt = await provider.waitForTransaction(transactionHash, { retries: 45, retryInterval: 2_000 }); + } catch { + throw new ExitExecutorError("exit_uncertain"); + } + if (receipt.isError()) throw new ExitExecutorError("exit_uncertain"); + const raw = receipt.value as unknown as Record; + const observedHash = typeof raw.transaction_hash === "string" ? normalizeHex(raw.transaction_hash) : transactionHash; + if (observedHash !== normalizeHex(transactionHash)) throw new ExitExecutorError("exit_uncertain"); + const fee = readFee(raw.actual_fee); + if (receipt.isReverted()) { + await budget.finalize(validated.bindingSha256, validated.bindingSha256, transactionHash, fee, "reverted", Date.now()); + throw new ExitExecutorError("exit_reverted"); + } + await budget.finalize(validated.bindingSha256, validated.bindingSha256, transactionHash, fee, "succeeded", Date.now()); + return { status: "accepted", transactionHash, actualFeeFri: fee }; +} + async function readSnapshot(provider: RpcProvider, validated: ValidatedExit, blockNumber: bigint, timestamp: bigint) { const call = (contractAddress: string, entrypoint: string, calldata: string[] = []) => provider.callContract({ contractAddress, entrypoint, calldata }, Number(blockNumber)); - const [neutralClass, afterlightClass, nonce, balanceRaw, allowanceRaw, feeRaw, collectorRaw, validityRaw, vaultRaw] = await Promise.all([ + const [neutralClass, afterlightClass, poolClass, nonce, balanceRaw, allowanceRaw, feeRaw, collectorRaw, validityRaw, vaultRaw] = await Promise.all([ provider.getClassHashAt(NEUTRAL, Number(blockNumber)), provider.getClassHashAt(AFTERLIGHT, Number(blockNumber)), + provider.getClassHashAt(POOL, Number(blockNumber)), provider.getNonceForAddress(NEUTRAL, Number(blockNumber)), call(TOKEN, "balance_of", [NEUTRAL]), call(TOKEN, "allowance", [NEUTRAL, POOL]), @@ -261,7 +333,11 @@ async function readSnapshot(provider: RpcProvider, validated: ValidatedExit, blo call(POOL, "get_proof_validity_blocks"), call(AFTERLIGHT, "get_vault", [hex(validated.metadata.vaultId)]), ]); - if (normalizeHex(neutralClass) !== normalizeHex(EXIT_POLICY.neutralClassHash) || normalizeHex(afterlightClass) !== normalizeHex(EXIT_POLICY.afterlightClassHash)) throw new ExitExecutorError("exit_unavailable"); + if ( + normalizeHex(neutralClass) !== normalizeHex(EXIT_POLICY.neutralClassHash) || + normalizeHex(afterlightClass) !== normalizeHex(EXIT_POLICY.afterlightClassHash) || + normalizeHex(poolClass) !== normalizeHex(LOCKED_POOL_CLASS_HASH) + ) throw new ExitExecutorError("exit_unavailable"); if (BigInt(feeRaw[0] ?? -1) !== 6n * 10n ** 18n || normalizeHex(collectorRaw[0] ?? "0x0") !== normalizeHex(EXIT_POLICY.poolFeeCollector)) throw new ExitExecutorError("exit_unavailable"); const allowance = parseU256Result(allowanceRaw, "allowance"); validateAllowanceForAction(validated.action, allowance); @@ -314,7 +390,7 @@ function rpcErrorCode(error: unknown): number | undefined { } export function classifyBroadcastFailure(error: unknown): Readonly<{ - category: "rpc_execution" | "rpc_transaction_nonce" | "rpc_validate_resources" | "rpc_account_balance" | "rpc_validation" | "rpc_other" | "transport_or_unknown"; + category: "rpc_execution" | "rpc_transaction_nonce" | "rpc_validate_resources" | "rpc_account_balance" | "rpc_validation" | "rpc_duplicate" | "rpc_other" | "transport_or_unknown"; definitiveReject: boolean; }> { const code = rpcErrorCode(error); @@ -323,6 +399,7 @@ export function classifyBroadcastFailure(error: unknown): Readonly<{ if (code === 53) return { category: "rpc_validate_resources", definitiveReject: true }; if (code === 54) return { category: "rpc_account_balance", definitiveReject: true }; if (code === 55) return { category: "rpc_validation", definitiveReject: true }; - if (code !== undefined) return { category: "rpc_other", definitiveReject: true }; + if (code === 59) return { category: "rpc_duplicate", definitiveReject: false }; + if (code !== undefined) return { category: "rpc_other", definitiveReject: false }; return { category: "transport_or_unknown", definitiveReject: false }; } diff --git a/relayer/src/index.ts b/relayer/src/index.ts index 3d16ccd..bbc4eeb 100644 --- a/relayer/src/index.ts +++ b/relayer/src/index.ts @@ -28,7 +28,7 @@ import { readBalanceHealth, type BudgetCoordinator, } from "./executor.js"; -import { executePreparedClaim, ExitExecutorError } from "./exit-executor.js"; +import { executePreparedClaim, ExitExecutorError, readClaimCapacity, validatePreparedClaimPayload } from "./exit-executor.js"; export { RelayBudget } from "./budget.js"; @@ -41,7 +41,7 @@ export default { const requestOrigin = request.headers.get("origin") ?? ""; try { if (request.method === "GET" && url.pathname === HEALTH_PATH) { - return health(env); + return health(request, env); } if (request.method === "OPTIONS" && url.pathname === RELAY_PATH) { return preflight(request, env); @@ -65,10 +65,15 @@ export default { let estimateOnly = false; if (url.pathname === EXIT_PATH) { requireExitHeaders(request, env); - await rateLimitExit(env); + await rateLimitExitIngress(env); const payload = await readUtf8BodyLimited(request, Number(parsePositiveDecimal(env.MAX_EXIT_PAYLOAD_BYTES, "exit_payload_limit", 2_097_152n))); + const validated = validatePreparedClaimPayload(payload); + await rateLimitValidatedExit(env, validated.bindingSha256); + if (!isSubmissionEnabled(env.SUBMIT_ENABLED)) throw new RelayHttpError(503, "submission_disabled"); + const readiness = executorReadiness(env); + if (!readiness.executable) throw new RelayHttpError(503, "executor_unavailable"); const budget: BudgetCoordinator = env.RELAY_BUDGET.getByName(budgetObjectName(env)); - const result = await executePreparedClaim(payload, env, budget); + const result = await executePreparedClaim(payload, env, budget, validated); return jsonResponse({ status: "relayed", result }, 200, corsHeaders(requestOrigin)); } else if (url.pathname === CHECKPOINT_PATH) { await requireCheckpointHeaders(request, env); @@ -195,18 +200,23 @@ function executorErrorCode(error: unknown): ExecutorError["code"] | undefined { : undefined; } -async function health(env: Env): Promise { +async function health(request: Request, env: Env): Promise { const submitDisabled = !isSubmissionEnabled(env.SUBMIT_ENABLED); const readiness = executorReadiness(env); - const balance = + const [balance, claimCapacity] = await Promise.all([ submitDisabled || !readiness.executable ? assessBalanceHealth(undefined, env.MIN_RELAYER_BALANCE_FRI, !submitDisabled) : await readBalanceHealth( createStarknetRelayAdapter(env), env.MIN_RELAYER_BALANCE_FRI, true, - ); + ), + submitDisabled || !readiness.executable + ? Promise.resolve({ status: "unknown" as const, reason: "configuration" as const }) + : readClaimCapacity(env), + ]); const ready = !submitDisabled && readiness.executable && balance.status === "ok"; + const origin = request.headers.get("origin"); return jsonResponse( { status: submitDisabled ? "ok" : ready ? "ok" : "degraded", @@ -215,6 +225,7 @@ async function health(env: Env): Promise { submission: submitDisabled ? "disabled" : "enabled", executor: readiness, balance, + claimCapacity, privacy: { payloadLogging: false, appKeysHeld: false, @@ -222,6 +233,7 @@ async function health(env: Env): Promise { }, }, submitDisabled || ready ? 200 : 503, + isAllowedOrigin(origin, env.ALLOWED_ORIGIN) ? corsHeaders(origin ?? "") : undefined, ); } @@ -257,12 +269,12 @@ function requireExitHeaders(request: Request, env: Env): void { if (contentType !== "application/json") throw new RelayHttpError(415, "invalid_content_type"); } -async function rateLimitExit(env: Env): Promise { - const [globalOutcome, exitOutcome] = await Promise.all([ - env.RELAY_GLOBAL_LIMITER.limit({ key: "afterlight-relay-global-v1" }), - env.EXIT_RATE_LIMITER.limit({ key: "afterlight-claim-exit-global-v1" }), - ]); - if (!globalOutcome.success || !exitOutcome.success) { - throw new RelayHttpError(429, "rate_limited"); - } +async function rateLimitExitIngress(env: Env): Promise { + const outcome = await env.RELAY_GLOBAL_LIMITER.limit({ key: "afterlight-relay-global-v1" }); + if (!outcome.success) throw new RelayHttpError(429, "rate_limited"); +} + +async function rateLimitValidatedExit(env: Env, bindingSha256: string): Promise { + const outcome = await env.EXIT_RATE_LIMITER.limit({ key: bindingSha256 }); + if (!outcome.success) throw new RelayHttpError(429, "rate_limited"); } diff --git a/relayer/src/neutral-exit-policy.d.mts b/relayer/src/neutral-exit-policy.d.mts index 74560ef..189f1bd 100644 --- a/relayer/src/neutral-exit-policy.d.mts +++ b/relayer/src/neutral-exit-policy.d.mts @@ -31,7 +31,23 @@ export type ResourceBounds = Readonly<{ l2_gas: Readonly<{ max_amount: bigint; max_price_per_unit: bigint }>; }>; +export const LOCKED_AFTERLIGHT_ADDRESS: string; +export const LOCKED_AMOUNT_FRI: bigint; +export const LOCKED_NEUTRAL_ADDRESS: string; +export const LOCKED_POOL_ADDRESS: string; +export const LOCKED_POOL_CLASS_HASH: string; +export const LOCKED_TOKEN_ADDRESS: string; +export const OPEN_NOTE_PACKED_VALUE: bigint; +export const PROOF1_HEADER: string; + export function validatePolicy(policy: unknown): ExitPolicyResult; +export function buildExitLocks(input: unknown): Readonly<{ + callSha256: string; + proofDataSha256: string; + proofOutputSha256: string; + proofFactsSha256: string; + bindingSha256: string; +}>; export function validatePreparedExitPackage(input: unknown, policy: unknown): ValidatedExit; export function proofFactsForFeeEstimate(raw: readonly string[]): readonly string[]; export function parseResourceBounds(value: unknown): ResourceBounds; diff --git a/relayer/src/neutral-exit-policy.mjs b/relayer/src/neutral-exit-policy.mjs index ed0acef..6234d29 100644 --- a/relayer/src/neutral-exit-policy.mjs +++ b/relayer/src/neutral-exit-policy.mjs @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { ec, hash } from "starknet"; +import { constants, ec, hash } from "starknet"; export const MAINNET_CHAIN_ID = "0x534e5f4d41494e"; export const LOCKED_NEUTRAL_ADDRESS = @@ -10,6 +10,8 @@ export const LOCKED_AFTERLIGHT_ADDRESS = "0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25"; export const LOCKED_POOL_ADDRESS = "0x040337b1af3c663e86e333bab5a4b28da8d4652a15a69beee2b677776ffe812a"; +export const LOCKED_POOL_CLASS_HASH = + "0x067dddd89d80fedadc06b6f160798f94800a4a70164e5a24301cd0d6076b554d"; export const LOCKED_POOL_FEE_COLLECTOR = "0x0d79041634625e5288296fbc648088788710ba44903a3a49468a66567749e77"; export const LOCKED_TOKEN_ADDRESS = @@ -21,6 +23,7 @@ export const LOCKED_NEUTRAL_CLASS_HASH = export const LOCKED_AMOUNT_FRI = 1_000_000_000_000_000_000n; export const LOCKED_POOL_FEE_FRI = 6_000_000_000_000_000_000n; export const LOCKED_INITIAL_ALLOWANCE_FRI = 12_000_000_000_000_000_000n; +export const OPEN_NOTE_PACKED_VALUE = 1n << 128n; export const LOCKED_HEALTH_FLOOR_FRI = 1_000_000_000_000_000_000n; export const ABSOLUTE_NETWORK_CAP_PER_EXIT_FRI = 9_027_538_581_262_736_234n; export const MAX_ESTIMATE_AGE_BLOCKS = 300n; @@ -411,13 +414,15 @@ export function validatePreparedExitPackage(input, policy) { if (call.contractAddress !== normalizeHex(LOCKED_POOL_ADDRESS)) throw new Error("wrong_pool_call"); if (call.entrypoint !== "apply_actions") throw new Error("wrong_pool_entrypoint"); const parsed = parseServerActions(call.calldata); - const allowedVariants = new Set([0n, 1n, 7n, 10n]); - if (parsed.actions.some((item) => !allowedVariants.has(item.variant))) { - throw new Error("unexpected_value_or_event_action"); - } + const exactVariants = [0n, 7n, 10n]; + if ( + parsed.actions.length !== exactVariants.length || + parsed.actions.some((item, index) => item.variant !== exactVariants[index]) + ) throw new Error("exit_requires_exact_write_note_invoke_shape"); + const writes = parsed.actions.filter((item) => item.variant === 0n); const notes = parsed.actions.filter((item) => item.variant === 7n); const invokes = parsed.actions.filter((item) => item.variant === 10n); - if (notes.length !== 1 || invokes.length !== 1) throw new Error("exit_action_cardinality"); + if (writes.length !== 1 || notes.length !== 1 || invokes.length !== 1) throw new Error("exit_action_cardinality"); const note = notes[0]; if ( normalizeHex(`0x${note.fields[3].toString(16)}`) !== normalizeHex(LOCKED_TOKEN_ADDRESS) || @@ -425,6 +430,18 @@ export function validatePreparedExitPackage(input, policy) { ) { throw new Error("open_note_token_or_id_mismatch"); } + const write = writes[0]; + const noteId = BigInt(metadata.destinationNoteId); + const expectedStorageAddress = BigInt( + hash.computePedersenHash(hash.starknetKeccak("notes"), noteId), + ) % constants.ADDR_BOUND; + if ( + write.fields.length !== 4 || + write.fields[0] !== expectedStorageAddress || + write.fields[1] !== 2n || + write.fields[2] !== OPEN_NOTE_PACKED_VALUE || + normalizeHex(`0x${write.fields[3].toString(16)}`) !== normalizeHex(LOCKED_TOKEN_ADDRESS) + ) throw new Error("wrong_open_note_write_once"); const invoke = invokes[0]; if (note.index >= invoke.index) throw new Error("open_note_must_precede_invoke"); if (normalizeHex(`0x${invoke.fields[0].toString(16)}`) !== normalizeHex(LOCKED_AFTERLIGHT_ADDRESS)) { @@ -453,7 +470,10 @@ export function validatePreparedExitPackage(input, policy) { if (typeof proof !== "object" || proof === null) throw new Error("missing_prepared_proof"); const proofBytes = decodeCanonicalProofData(proof.data); const proofOutput = feltArray(proof.output, "proof_output").map(BigInt); - if (proofOutput.length !== parsed.serializedActions.length + 1 || proofOutput[0] === 0n) { + if ( + proofOutput.length !== parsed.serializedActions.length + 1 || + normalizeHex(`0x${proofOutput[0].toString(16)}`) !== normalizeHex(LOCKED_POOL_CLASS_HASH) + ) { throw new Error("proof_output_shape"); } for (let index = 0; index < parsed.serializedActions.length; index += 1) { diff --git a/relayer/test/budget.spec.ts b/relayer/test/budget.spec.ts index 7abbcc3..93a54ef 100644 --- a/relayer/test/budget.spec.ts +++ b/relayer/test/budget.spec.ts @@ -84,6 +84,18 @@ describe("deployment-wide sponsorship coordinator", () => { }); }); + it("keeps control and exit daily totals separate while sharing one nonce lane", async () => { + const budget = freshBudget(); + await budget.reserve(reserveInput(semantic, exact, "60", dayOne, 1, "exit")); + await budget.markSubmitted(semantic, exact, "0xabc", 2); + await budget.finalize(semantic, exact, "0xabc", "60", "succeeded", 3); + + const controlSemantic = "d".repeat(64); + expect((await budget.reserve(reserveInput(controlSemantic, alternateExact, "20", dayOne, 4, "control"))).outcome).toBe("reserved"); + expect(await budget.snapshot(dayOne, "exit")).toMatchObject({ reservedTodayFri: "0", spentTodayFri: "60" }); + expect(await budget.snapshot(dayOne, "control")).toMatchObject({ reservedTodayFri: "20", spentTodayFri: "0" }); + }); + it("admits only one active Starknet nonce lane at a time", async () => { const budget = freshBudget(); await budget.reserve(reserveInput()); @@ -178,8 +190,10 @@ function reserveInput( maxFeeFri = "100", dayKey = dayOne, nowMs = 1, + budgetClass: "control" | "exit" = "control", ) { return { + budgetClass, dayKey, semanticKey, exactFingerprint, diff --git a/relayer/test/neutral-exit-signing.spec.ts b/relayer/test/neutral-exit-signing.spec.ts index a9bdab8..80bfab4 100644 --- a/relayer/test/neutral-exit-signing.spec.ts +++ b/relayer/test/neutral-exit-signing.spec.ts @@ -1,11 +1,21 @@ -import { Account, RpcProvider, ec, transaction, type Call } from "starknet"; +import { Account, RpcProvider, constants, ec, hash, transaction, type Call } from "starknet"; import { describe, expect, it, vi } from "vitest"; import { + LOCKED_AFTERLIGHT_ADDRESS, + LOCKED_AMOUNT_FRI, + LOCKED_NEUTRAL_ADDRESS as POLICY_NEUTRAL, + LOCKED_POOL_ADDRESS, + LOCKED_POOL_CLASS_HASH, + LOCKED_TOKEN_ADDRESS, + OPEN_NOTE_PACKED_VALUE, + PROOF1_HEADER, assertOuterSignatureMatchesHash, assertSignedExitTransaction, + buildExitLocks, + validatePreparedExitPackage, } from "../src/neutral-exit-policy.mjs"; -import { classifyBroadcastFailure } from "../src/exit-executor.js"; +import { EXIT_POLICY, classifyBroadcastFailure, executePreparedClaim, reconcileSubmittedClaim } from "../src/exit-executor.js"; const MAINNET_CHAIN_ID = "0x534e5f4d41494e"; const LOCKED_NEUTRAL_ADDRESS = "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46"; @@ -21,6 +31,58 @@ describe("neutral exact-exit signing boundary", () => { category: "transport_or_unknown", definitiveReject: false, }); + expect(classifyBroadcastFailure({ baseError: { code: 59 } })).toEqual({ + category: "rpc_duplicate", + definitiveReject: false, + }); + expect(classifyBroadcastFailure({ code: 999 })).toEqual({ + category: "rpc_other", + definitiveReject: false, + }); + }); + + it("accepts only the exact WriteOnce, open-note, Afterlight Invoke package and pinned pool class", () => { + const valid = preparedClaimPackage(); + expect(validatePreparedExitPackage(valid, EXIT_POLICY).action).toBe("CLAIM"); + + const extraAction = structuredClone(valid); + const raw = extraAction.prepared.call.calldata; + raw[0] = "4"; + raw.splice(6, 0, "1", "1", "2", "3", "4"); + expect(() => validatePreparedExitPackage(extraAction, EXIT_POLICY)).toThrow(/exact_write_note_invoke_shape/); + + const wrongWrite = structuredClone(valid); + wrongWrite.prepared.call.calldata[2] = "0x123"; + expect(() => validatePreparedExitPackage(wrongWrite, EXIT_POLICY)).toThrow(/wrong_open_note_write_once/); + + const wrongPoolClass = structuredClone(valid); + wrongPoolClass.prepared.proof.output[0] = "0x123"; + expect(() => validatePreparedExitPackage(wrongPoolClass, EXIT_POLICY)).toThrow(/proof_output_shape/); + }); + + it("reconciles an already-submitted claim instead of leaving the nonce lane blocked", async () => { + const transactionHash = "0xabc"; + const binding = "a".repeat(64); + const provider = { + waitForTransaction: vi.fn().mockResolvedValue({ + isError: () => false, + isReverted: () => false, + value: { transaction_hash: transactionHash, actual_fee: { amount: "70" } }, + }), + } as any; + const budget = { + finalize: vi.fn().mockResolvedValue({ outcome: "committed" }), + } as any; + await expect(reconcileSubmittedClaim(provider, budget, { bindingSha256: binding } as any, transactionHash)).resolves.toEqual({ + status: "accepted", + transactionHash, + actualFeeFri: "70", + }); + expect(budget.finalize).toHaveBeenCalledWith(binding, binding, transactionHash, "70", "succeeded", expect.any(Number)); + }); + + it("does not enter claim validation or signing when the deployment kill switch is off", async () => { + await expect(executePreparedClaim("{}", { SUBMIT_ENABLED: "false" } as any, {} as any)).rejects.toMatchObject({ code: "exit_unavailable" }); }); it("signs the real proof facts and reconstructs the exact outer hash offline", async () => { @@ -73,3 +135,49 @@ describe("neutral exact-exit signing boundary", () => { expect(assertOuterSignatureMatchesHash(signed, publicKey)).toMatch(/^0x[0-9a-f]+$/); }); }); + +function preparedClaimPackage() { + const noteId = 0x123n; + const vaultId = 0x456n; + const validUntil = 2_000_000_000n; + const storageAddress = BigInt(hash.computePedersenHash(hash.starknetKeccak("notes"), noteId)) % constants.ADDR_BOUND; + const actions = [ + 3n, + 0n, storageAddress, 2n, OPEN_NOTE_PACKED_VALUE, BigInt(LOCKED_TOKEN_ADDRESS), + 7n, 1n, 2n, 3n, BigInt(LOCKED_TOKEN_ADDRESS), noteId, + 10n, BigInt(LOCKED_AFTERLIGHT_ADDRESS), 11n, + 2n, vaultId, BigInt(LOCKED_TOKEN_ADDRESS), LOCKED_AMOUNT_FRI, 2n, 1n, 3n, noteId, validUntil, 4n, 5n, + ]; + const input: any = { + schema: "afterlight-prepared-neutral-exit/1", + evidence: "APPLICATION_AUTHORIZED_OUTER_UNSIGNED_NOT_SUBMITTED", + action: "CLAIM", + chainId: MAINNET_CHAIN_ID, + neutralAddress: POLICY_NEUTRAL, + afterlightAddress: LOCKED_AFTERLIGHT_ADDRESS, + poolAddress: LOCKED_POOL_ADDRESS, + tokenAddress: LOCKED_TOKEN_ADDRESS, + amountFri: LOCKED_AMOUNT_FRI.toString(), + vaultId: `0x${vaultId.toString(16)}`, + expectedState: "2", + expectedEpoch: "1", + expectedRoleNonce: "3", + destinationNoteId: `0x${noteId.toString(16)}`, + validUntil: validUntil.toString(), + preparedAtBlock: "100", + prepared: { + call: { + contractAddress: LOCKED_POOL_ADDRESS, + entrypoint: "apply_actions", + calldata: [...actions.map(String), "1"], + }, + proof: { + data: "AQ==", + output: [LOCKED_POOL_CLASS_HASH, ...actions.map(String)], + proof_facts: [PROOF1_HEADER, "1", "1", "1", "100", "1", "1", "1", "1"], + }, + }, + }; + input.locks = buildExitLocks(input); + return input; +} diff --git a/relayer/test/relayer.spec.ts b/relayer/test/relayer.spec.ts index 1bcc5b8..e142a0c 100644 --- a/relayer/test/relayer.spec.ts +++ b/relayer/test/relayer.spec.ts @@ -24,11 +24,15 @@ describe("Afterlight Phase A relay Worker", () => { }); it("reports structured health without secret, wallet, IP or account material", async () => { - const response = await exports.default.fetch("https://relay.invalid/health"); + const response = await exports.default.fetch(new Request("https://relay.invalid/health", { + headers: { origin }, + })); expect(response.status).toBe(200); + expect(response.headers.get("access-control-allow-origin")).toBe(origin); const body = await response.text(); expect(body).toContain('"status":"ok"'); expect(body).toContain('"submission":"disabled"'); + expect(body).toContain('"claimCapacity":{"status":"unknown","reason":"configuration"}'); expect(body).toContain('"payloadLogging":false'); expect(body).not.toMatch(/private_key|wallet_address|ip_address|relayer_account/i); expect(body).not.toMatch(/secretConfigured|maxSponsoredFee|dailySponsorBudget|"limits"/i); diff --git a/web/src/main.ts b/web/src/main.ts index bd32940..9b297ae 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,6 +1,6 @@ import "./style.css"; import { num } from "starknet"; -import { STRK } from "./config.ts"; +import { RELAYER_URL, STRK } from "./config.ts"; import { assertMainnet, readLiability, readVault } from "./chain.ts"; import { parseInvitation, stateName, type RecoveryInvitation, type Role, type VaultSnapshot, type WalletStatus } from "./model.ts"; import { connectReady, detectReady, type ReadySession } from "./wallet.ts"; @@ -20,6 +20,7 @@ let busy = false; let transactionHash = ""; let costAcknowledged = false; let claimRetryBlocked = false; +let claimCapacity: "checking" | "ready" | "exhausted" | "unknown" = "checking"; const appRoot = document.querySelector("#app"); if (!appRoot) throw new Error("Afterlight app root is missing."); @@ -39,6 +40,29 @@ function strk(value: bigint): string { return `${whole}${fraction ? `.${fraction.slice(0, 4)}` : ""} STRK`; } +function progressStep(label: string, detail: string, complete: boolean, current: boolean): string { + const state = complete ? "complete" : current ? "current" : "upcoming"; + return `
  • ${label}${detail}
  • `; +} + +function journeyProgress(): string { + const invitationValid = parseInvitation(invitationText).valid; + const hasWallet = walletStatus === "connected"; + const hasKey = Boolean(applicationKey); + if (role === "owner") { + return `
      + ${progressStep("Connect", "Ready X", hasWallet, !hasWallet)} + ${progressStep("Set up", "Keys and terms", invitationValid, hasWallet && !invitationValid)} + ${progressStep("Protect", "Fund and stay active", vault?.exists === true, invitationValid)} +
    `; + } + return `
      + ${progressStep("Prepare", "Your recovery key", hasKey, !hasKey)} + ${progressStep("Verify", "Recovery invitation", invitationValid, hasKey && !invitationValid)} + ${progressStep("Recover", "Live vault and claim", vault?.exists === true, invitationValid)} +
    `; +} + function download(filename: string, contents: string): void { const blob = new Blob([`${contents}\n`], { type: "application/json" }); const link = document.createElement("a"); @@ -59,33 +83,34 @@ function walletCopy(): string { function keyPanel(person: "owner" | "successor"): string { const isCurrent = role === person; - return `
    -
    ${person === "owner" ? "Owner control key" : "Successor recovery key"}

    Generated locally for one vault. The secret never reaches the relayer or Ready X.

    + return `
    ${person === "owner" ? "02" : "01"} +
    ${person === "owner" ? "Create your owner control key" : "Prepare your successor key"}

    Generated locally for one vault. The secret never reaches the relayer or Ready X.

    ${applicationKey && isCurrent ? `${escapeHtml(applicationKey.publicKey)}
    ` : ``} - +
    Already have a key backup?
    `; } function walletRow(): string { - return `
    Ready X${escapeHtml(walletCopy())}
    `; + return `
    01
    Connect Ready X${escapeHtml(walletCopy())}
    `; } function ownerView(): string { const invitation = parseInvitation(invitationText); - const canFund = walletStatus === "connected" && privateBalance !== undefined && privateBalance >= 7n * 10n ** 18n && applicationKey && /^0x[0-9a-f]{1,64}$/i.test(successorPublicKey); + const canFund = claimCapacity === "ready" && walletStatus === "connected" && privateBalance !== undefined && privateBalance >= 7n * 10n ** 18n && applicationKey && /^0x[0-9a-f]{1,64}$/i.test(successorPublicKey); const liveControls = invitation.valid && vault?.exists; return `
    -

    Owner · live Mainnet journey

    -

    Create a recovery reserve

    +

    Owner · live on Mainnet

    Create a recovery reserve

    Recovery Drill · 1 STRK

    Privately set aside 1 STRK. Heartbeat while active and veto a recovery request during grace.

    + ${journeyProgress()} ${walletRow()} ${keyPanel("owner")} -
    -
    Recovery Drill terms
    1 STRK reserveFixed by the deployed contract
    5 min + 5 minInactivity, then grace
    +
    03
    Choose the recovery terms

    The live drill uses fixed, contract-enforced terms.

    +
    Recovery Drill terms
    1 STRK reserveFixed by the deployed contract
    5 min inactivity + 5 min graceShort Mainnet demonstration mode
    - + + ${claimCapacity === "exhausted" ? `

    New reserves are paused until the neutral claim capacity is replenished. Existing vault controls remain available.

    ` : claimCapacity === "unknown" ? `

    Recovery capacity could not be verified. Funding stays disabled to protect users.

    ` : ""} ${privateBalance !== undefined && privateBalance < 7n * 10n ** 18n ? `

    At least 7 private STRK is required for this action.

    ` : ""}
    ${invitation.valid ? controlPanel(invitation.invitation, liveControls ? vault : undefined) : ""} @@ -95,18 +120,25 @@ function ownerView(): string { function successorView(): string { const parsed = parseInvitation(invitationText); return `
    -

    Successor · live Mainnet journey

    -

    Recover a reserve privately

    +

    Successor · live on Mainnet

    Recover a reserve privately

    Designated key only

    Generate your own per-vault key, import the invitation, and request only after authenticated inactivity.

    + ${journeyProgress()} ${keyPanel("successor")} - -
    ${parsed.valid ? "Invitation verified locally" : "Invitation not verified"}

    ${escapeHtml(parsed.valid ? "Contract, vault, designated key, token, amount and mode match Afterlight Mainnet." : parsed.reason)}

    - ${parsed.valid ? `${walletRow()}${controlPanel(parsed.invitation, vault)}` : ""} + ${invitationPanel(parsed)} + ${parsed.valid ? `${walletRow()}${controlPanel(parsed.invitation, vault)}` : ""}
    `; } +function invitationPanel(parsed: ReturnType): string { + const editor = ``; + if (!parsed.valid) { + return `
    02
    Verify the recovery invitation

    Paste the package received from the owner.

    ${editor}
    Waiting for a valid invitation

    ${escapeHtml(parsed.reason)}

    `; + } + return `
    02
    Invitation verified

    Contract, designated key and recovery terms match Afterlight Mainnet.

    Verified
    Vault${short(parsed.invitation.vaultId)}Reserve1 STRKTiming5 + 5 min
    Replace invitation${editor}
    `; +} + function controlPanel(invitation: RecoveryInvitation, snapshot?: VaultSnapshot): string { - if (!snapshot) return `
    Live controls

    Read vault ${short(invitation.vaultId)} to continue.

    `; + if (!snapshot) return `
    04
    Read the live reserve

    Confirm the current Mainnet state before taking action.

    `; const current = stateName(snapshot.state); const now = Math.floor(Date.now() / 1000); const inactiveAt = Number(snapshot.lastHeartbeat) + Number(snapshot.inactivitySeconds); @@ -118,7 +150,8 @@ function controlPanel(invitation: RecoveryInvitation, snapshot?: VaultSnapshot): : current === "ACTIVE" ? new Date(inactiveAt * 1000).toLocaleTimeString() : current; - return `
    ${current}

    Vault ${short(invitation.vaultId)} · epoch ${snapshot.epoch}

    + const stateCopy = current === "ACTIVE" ? "Protected and listening for an authenticated heartbeat." : current === "GRACE" ? "Recovery requested. The owner can still veto before settlement." : current === "CLAIMED" ? "Recovery completed exactly once to the designated private note." : "The reserve returned privately to its owner."; + return `
    ${current}

    ${stateCopy}

    Vault ${short(invitation.vaultId)} · epoch ${snapshot.epoch}
    Reserve1 STRK
    ${timingLabel}${timingValue}
    ${role === "owner" && current === "ACTIVE" ? `` : ""} ${role === "owner" && current === "GRACE" ? `` : ""} @@ -131,18 +164,19 @@ function controlPanel(invitation: RecoveryInvitation, snapshot?: VaultSnapshot): function statusPanel(): string { const parsed = parseInvitation(invitationText); const current = vault?.exists ? stateName(vault.state) : "Not loaded"; - return `