diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23b5888..6804afe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,5 +143,6 @@ jobs: - run: npm ci - name: Install shared client type dependencies run: npm --prefix ../client ci + - run: npm test - run: npm run build - run: npm audit --audit-level=high diff --git a/DESIGN.md b/DESIGN.md index 189ebd5..8fd73d2 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,17 +47,21 @@ 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. ## Components -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. +The unauthenticated primary action is “Create a recovery reserve.” Owner setup progressively reveals Ready X connection, realistic `NORMAL` timing or clearly labelled `FAST_DEMO` timing, denomination, 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. 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. -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. +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. +Owner controls expose heartbeat, veto, and exact-note private cancellation only when valid. Cancellation requires an explicit irreversible-action confirmation and the restored owner key. 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. Wallet reviews must repeat the expected role, network, token, amount, privacy consequence, and fee boundary immediately before confirmation. After an action, show the useful result first and place its receipt, contract address, and transaction hash in a contextual expandable region. @@ -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/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b294ae1 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Security policy + +Please report suspected vulnerabilities privately through +[GitHub's security-advisory form](https://github.com/dolepee/afterlight/security/advisories/new). +Do not open a public issue for an undisclosed vulnerability and do not include +wallet seeds, private keys, application-key backups, proof material, or other +secrets in a report. + +Include the affected release or commit, impact, reproduction conditions, and +the smallest safe proof needed to understand the issue. Reports are reviewed on +a best-effort basis; this repository does not promise a bounty or response SLA. + +Only the current public Mainnet release is in scope. Third-party wallet, +STRK20, RPC, Cloudflare, Starknet, and browser vulnerabilities should also be +reported to their respective maintainers when appropriate. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b7a2854..d368e69 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -9,7 +9,7 @@ Afterlight separates private value movement, application authorization, and tran | Ready X and STRK20 | Shielded balances, private invocation, proof preparation, and exact open-note settlement | Owner or successor application secrets outside the user's device | | Afterlight Cairo contract | Vault state, signed authorization, liabilities, timing, exact-note binding, and terminal settlement | Ready wallet identity assumptions | | Client library | Per-vault key generation, typed authorization hashes, STRK20 action assembly, proof-envelope/call binding, and independent managed-exit receipt reconciliation | Relayer account key | -| Neutral relayer | Submit bounded `HEARTBEAT`, `REQUEST`, `VETO`, checkpoints, and strictly validated exact-note claim packages from one neutral account | Owner/successor secrets, Ready addresses, or authority over contract state | +| Neutral relayer | Submit bounded `HEARTBEAT`, `REQUEST`, `VETO`, checkpoints, and strictly validated exact-note claim/cancellation packages from one neutral account | Owner/successor secrets, Ready addresses, or authority over contract state | ## Action routing @@ -17,14 +17,20 @@ Afterlight separates private value movement, application authorization, and tran `HEARTBEAT`, `REQUEST`, and `VETO` are public state transitions authorized by per-vault Stark signatures. Any submitter may relay a valid authorization; the submitter is never the authority. -For a public claim, Ready creates the exact OPEN note and proof locally. The -successor application key binds that literal note to the current vault, epoch, -nonce, token and amount. A neutral sponsor accepts only the locked pool call, +For a private exit, Ready creates the exact OPEN note and proof locally. The +successor application key binds a claim—or the owner application key binds a +cancellation—to that literal note, current vault, epoch, nonce, token and +amount. A neutral sponsor accepts only the locked pool call, proof facts, application signature, live state, exact allowance and bounded 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 +77,14 @@ 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 total liability is zero and one claim or cancellation is covered by the exact pool allowance, +the bounded outer fee, and the post-spend health floor. The supported public route serializes one funding admission at a time. This is an availability +guard, not an onchain authorization or per-vault capacity reservation: the deployed contract intentionally permits anyone to refresh its one-shot +checkpoint, and multiple fully collateralized vault liabilities may exist. A caller bypassing the supported route cannot consume another vault's +backing, but neither that caller nor an ordinary user is promised immediate neutral-sponsor capacity at a future exit. Claim and cancellation fail +closed and may wait until the operator restores the exact allowance, balance, and daily budget. 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/READY_X_ONBOARDING.md b/docs/READY_X_ONBOARDING.md index c1b3dc2..9d12b9e 100644 --- a/docs/READY_X_ONBOARDING.md +++ b/docs/READY_X_ONBOARDING.md @@ -4,10 +4,10 @@ This is the Ready X flow implemented by the public Afterlight interface and exercised by the deployed Mainnet mechanism. The contract and a complete -founder-operated lifecycle exist. The public interface is live, while a fresh -end-to-end E3 completion through that interface and unrelated-user E4 -completion remain pending. Nothing in this document is a standing instruction -to fund or sign; users must review the live wallet request and current fees. +founder-operated E3 lifecycle through the public interface exist. An unrelated +owner-successor E4 completion remains pending. Nothing in this document is a +standing instruction to fund or sign; users must review the live wallet request +and current fees. ## Accounts and STRK20 prerequisites @@ -27,6 +27,11 @@ then-current account, registration, protocol-fee, and gas route. Fees and account state must be freshly quoted in Ready X; this document deliberately does not present an old estimate as a funding instruction. +The public app requires Ready X `5.33.9` or a later compatible Ready `5.x` +release and verifies the Wallet API capabilities it uses. An incompatible +major release fails closed with the detected version instead of silently +attempting a transaction. + Keep the Ready account key, the Afterlight application key, and destination note material separate. Never paste a Ready seed or private key into Afterlight, the relayer, a repository, or an evidence file. @@ -47,8 +52,8 @@ The current client library can export a key only through the explicit That JSON contains the application private key and is **not encrypted by the library**. Treat it like a signing secret: encrypt it with a user-controlled method, store it offline, test restoration before funding, and never upload it -to the relayer. A production UI must make backup/import and this plaintext -library boundary explicit. +to the relayer. The public UI labels this plaintext boundary and requires a +successful local restore before it enables funding. ## Owner journey diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 83370f6..f0d7ea4 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -64,7 +64,11 @@ 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 | +| Browser reload loses the only exact retry artifact | The opaque checkpoint admission owner and any ambiguous exact cancellation/claim package are retained in tab-scoped session storage until terminal reconciliation; exact-exit packages are privacy-sensitive but contain no owner or successor application secret and are never sent to logs or analytics | +| Reserve demand exceeds current neutral-sponsor capacity | The supported UI and checkpoint route fail closed unless live allowance, balance, exit cap, health floor, liability and lease state reconcile. The permissionless contract checkpoint can still admit another fully backed vault outside that route, so sponsorship is explicitly capacity-limited and an exit may queue until capacity is restored; no vault can consume another vault's backing. | | 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..bce9081 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. @@ -44,8 +44,11 @@ Install `RELAYER_ACCOUNT_PRIVATE_KEY` and `STARKNET_RPC_AUTH_TOKEN` only with `w ## Nonce and receipt discipline - One relayer account has one active nonce lane. A `RESERVED` or `SUBMITTED` operation blocks a different sponsored operation until it is released or finalized. +- Every funding attempt generates a fresh 256-bit admission owner in the browser and keeps it in tab-scoped session storage through ambiguous outcomes and reloads. The checkpoint plan and ten-minute funding lease are bound to that owner: an exact retry bypasses only the owner-blind browser health preflight, while the Worker repeats the owner-aware capacity check and atomic lease acquisition. An adopted hashless reservation excludes only its own exact fingerprint from that check and renews the same admission owner before broadcast. Another browser receives a distinct semantic key and cannot reuse the admitted checkpoint. Public health and every request without the matching owner continue to report the lease as occupied. The owner is never returned in the response, placed in calldata, or derived from a wallet, note, vault, or application key. - 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. +- Retry only the exact original request. A `SUBMITTED` exit reconciles its stored hash without another broadcast. A crash-left `RESERVED` transaction returns its stored deterministic hash while the original two-minute owner lease remains live. Only after an atomic stale-owner takeover may a retry revalidate and rebroadcast the exact signed artifact persisted before the first broadcast attempt, then reconcile that hash. +- RPC duplicate and unknown-result errors are transport-ambiguous, never proof of rejection. Keep their reservations locked until receipt and nonce evidence resolves them. +- Before broadcasting any control or exit transaction, the relayer atomically stores the deterministic outer transaction hash and exact signed transaction on the owner-token-bound reservation. A hashless or prepared `RESERVED` row remains fenced to its live owner: an exact retry returns the existing state and hash without signing or rebroadcasting. After the two-minute liveness lease expires, the retry must atomically take ownership before it may prepare a hashless row or revalidate and rebroadcast a prepared artifact. This fence applies equally to controls and prepared private exits, and the displaced request can no longer prepare or release the row. A definitive RPC rejection releases and deletes a prepared control or exit artifact only while the rejecting request still owns it; an accepted or ambiguous broadcast keeps the exact stored hash serialized and only transitions it to `SUBMITTED` through acknowledged submission or receipt reconciliation. The public app keeps the exact ambiguous cancellation or claim package in tab-scoped session storage so retry uses the same note and authorization instead of creating another package. Terminal reconciliation deletes the server artifact and clears the browser package. Owner tokens and artifacts contain no application signing key and must never be logged or exported; artifacts are not retained after reconciliation. - 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 +66,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 supported-UI funding whenever `fundingStatus` is not `ready`. The browser checks this state and the checkpoint route freshly rechecks it immediately before atomically acquiring the deployment-wide ten-minute funding lease. The exact ready allowance is `12 STRK`, covering one claim or cancellation, and funding is ready only when observed total locked liability is zero, sponsorship is unfrozen, neither control nor exit has an active shared-nonce reservation, no funding lease is active, and the exit ledger can still reserve the full `7.5 STRK` ceiling that UTC day. The release pins the contract's `300`-second checkpoint age. An observed liability consumes the lease; abandonment rolls back only after ten minutes, when the checkpoint has already been stale for five minutes. This lease serializes the supported route but cannot authorize the permissionless contract checkpoint. Monitor for unexpected checkpoint and funding events; an externally created vault remains fully backed but its exit is queued until exact sponsor capacity is restored. After either exit, the remaining `6 STRK` allowance and same-day ledger spend intentionally exhaust capacity until a newly reviewed replenishment on a later UTC day. - relayer public STRK balance below the configured threshold; - Durable Object sponsorship freeze state; - reservations remaining `RESERVED` or `SUBMITTED` beyond the receipt window; @@ -77,6 +81,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..8412420 100644 --- a/relayer/README.md +++ b/relayer/README.md @@ -4,7 +4,7 @@ This directory contains the tested neutral control-plane relayer implementation. It consumes the canonical `afterlight-relay/1` control schema from `../client`, accepts `HEARTBEAT`, `REQUEST`, and `VETO`, and includes a fail-closed Starknet v3 signer/RPC adapter. It also accepts a strict `afterlight-prepared-neutral-exit/1` -package for public `CLAIM` settlement. The production Worker is deployed and +package for public `CLAIM` or `CANCEL_REFUND` settlement. The production Worker is deployed and funded; a separate submission-disabled configuration remains available for inert packaging and health tests. @@ -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. @@ -24,8 +24,11 @@ inert packaging and health tests. - The Cairo contract remains authoritative: the executor simulates the exact call, atomically reserves its maximum fee, and only then signs with the neutral relayer account. - 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/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. The browser keeps one opaque admission token in session storage across reloads and ambiguous responses, and the deployment-wide Durable Object atomically grants that owner one ten-minute funding-admission lease before the checkpoint is submitted; an explicit rejection cannot be replaced with another user's public checkpoint event. An exact retry bypasses the owner-blind public health preflight, but the checkpoint endpoint repeats the authoritative owner-aware capacity check. If it adopts its own expired hashless reservation, that check excludes only the adopted exact fingerprint and atomically renews the same admission owner before broadcast. The release pins the deployed contract's five-minute checkpoint lifetime: observed liability consumes the lease, while an abandoned lease cannot expire until the checkpoint has been unusable for a further five-minute safety interval. This serializes the supported public route only; it is not a contract-wide admission authorization. +- `POST /v1/exit` accepts only a designated successor-key `CLAIM` or designated owner-key `CANCEL_REFUND` 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. +- Exit requests first consume a global ingress limit. The scarce per-vault/action quota is consumed only after the RPC simulation has authenticated the proof and designated application-key signature; structurally valid forgeries cannot exhaust a victim vault's quota. Receipt-only reconciliation bypasses that scarce quota. +- `SUBMIT_ENABLED=false` disables checkpoint, control, and exit broadcasts. A retry of a stored `SUBMITTED` transaction only reconciles its existing hash. A `RESERVED` control or prepared exit whose exact signed artifact was durably stored remains fenced to its live owner for two minutes; only an atomic stale-owner takeover may rebroadcast that byte-equivalent artifact while submission remains enabled. This prevents an overlapping retry from landing the same nonce and then letting the original owner release its accepted exposure. The browser retains an ambiguous cancellation or claim package in session storage and resubmits that exact package for receipt reconciliation; it does not prepare another note or authorization. The artifact is deleted after terminal reconciliation or a proven pre-acceptance release. Duplicate and unknown RPC errors remain ambiguous and keep the reservation locked. +- `/health` exposes collapsed exit and funding states inside `claimCapacity`. Both the browser and the neutral checkpoint route refuse new funding unless the observed live neutral balance and exact `12 STRK` allowance can cover one bounded claim or cancellation, the health floor, neither control nor exit has an active shared-nonce reservation, the exit ledger has no same-day spend, sponsorship is unfrozen, the contract has zero outstanding liability, and no funding-admission lease is active. The checkpoint route performs this fresh read and atomically acquires the lease immediately before sponsorship, closing stale-tab and concurrent ordinary-client admission. A direct caller can still use the deployed contract's permissionless checkpoint and create another fully collateralized liability outside this supported route; this cannot consume another vault's backing, but its sponsored exit may queue until capacity is restored. A successful exit consumes `6 STRK` of allowance and exhausts capacity until a reviewed replenishment on a later UTC day. ## Production configuration diff --git a/relayer/src/budget.ts b/relayer/src/budget.ts index a3a45da..f4e2e48 100644 --- a/relayer/src/budget.ts +++ b/relayer/src/budget.ts @@ -9,12 +9,14 @@ export type ReservationState = | "breached"; export type BudgetReserveInput = Readonly<{ + budgetClass: "control" | "exit"; dayKey: string; semanticKey: string; exactFingerprint: string; maxFeeFri: string; perCallCapFri: string; dailyBudgetFri: string; + ownerToken: string; nowMs: number; }>; @@ -35,6 +37,8 @@ export type BudgetMutationResult = Readonly<{ outcome: | "released" | "already_released" + | "prepared" + | "already_prepared" | "submitted" | "already_submitted" | "committed" @@ -56,6 +60,7 @@ export type BudgetLookupResult = Readonly< dayKey: string; exactFingerprint: string; transactionHash: string | null; + preparedPayload: string | null; maxFeeFri: string; actualFeeFri: string | null; sponsorshipFrozen: boolean; @@ -69,6 +74,8 @@ export type ActiveBudgetLookupResult = Readonly< semanticKey: string; state: "reserved" | "submitted"; transactionHash: string | null; + preparedPayload: string | null; + maxFeeFri: string; } >; @@ -84,6 +91,20 @@ export type BudgetSnapshot = Readonly<{ sponsorshipFrozen: boolean; }>; +export type ActiveBudgetSnapshot = Readonly<{ + reservedCount: number; + submittedCount: number; + sponsorshipFrozen: boolean; +}>; + +export type FundingAdmissionResult = Readonly<{ + acquired: boolean; + active: boolean; + expiresAtMs: number | null; +}>; + +export type ReservationTakeoverResult = Readonly<{ acquired: boolean }>; + export class BudgetError extends Error { readonly code: | "invalid_budget_input" @@ -95,6 +116,7 @@ export class BudgetError extends Error { | "reservation_missing" | "reservation_not_releasable" | "reservation_not_submitted" + | "reservation_owner_mismatch" | "exact_fingerprint_mismatch"; constructor(code: BudgetError["code"]) { @@ -106,12 +128,16 @@ 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; actual_fee_fri: string | null; status: "RESERVED" | "SUBMITTED" | "RELEASED" | "COMMITTED" | "REVERTED" | "BREACHED"; transaction_hash: string | null; + prepared_payload: string | null; + owner_token: string | null; + updated_at_ms: number; }>; type TotalsRow = Readonly<{ @@ -155,6 +181,59 @@ 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) + ); + + CREATE TABLE IF NOT EXISTS funding_admission ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + expires_at_ms INTEGER, + owner_token TEXT + ); + INSERT OR IGNORE INTO funding_admission (singleton, expires_at_ms, owner_token) + VALUES (1, NULL, NULL); + `); + 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'))", + ); + } + if (!reservationColumns.some(({ name }) => name === "prepared_payload")) { + this.ctx.storage.sql.exec( + "ALTER TABLE reservations ADD COLUMN prepared_payload TEXT", + ); + } + if (!reservationColumns.some(({ name }) => name === "owner_token")) { + this.ctx.storage.sql.exec("ALTER TABLE reservations ADD COLUMN owner_token TEXT"); + } + const fundingColumns = this.ctx.storage.sql + .exec<{ name: string }>("PRAGMA table_info(funding_admission)") + .toArray(); + if (!fundingColumns.some(({ name }) => name === "owner_token")) { + this.ctx.storage.sql.exec("ALTER TABLE funding_admission ADD COLUMN owner_token TEXT"); + } + // The legacy ledger did not identify whether a reservation paid for a + // control or exit transaction. Preserve its aggregate against both class + // ceilings until UTC rollover. Double-counting across independent class + // ceilings is deliberately conservative and prevents an in-place upgrade + // from resetting either kind of same-day exposure. + this.migrateLegacyTotals(); + } + + private migrateLegacyTotals(): void { + 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 + UNION ALL + SELECT 'exit', day_key, reserved_fri, spent_fri FROM daily_totals `); } @@ -168,6 +247,7 @@ export class RelayBudget extends DurableObject { dayKey: row.day_key, exactFingerprint: row.exact_fingerprint, transactionHash: row.transaction_hash, + preparedPayload: row.prepared_payload, maxFeeFri: row.max_fee_fri, actualFeeFri: row.actual_fee_fri, sponsorshipFrozen: frozen, @@ -178,8 +258,8 @@ 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, - status, transaction_hash + `SELECT semantic_key, budget_class, exact_fingerprint, day_key, max_fee_fri, actual_fee_fri, + status, transaction_hash, prepared_payload, owner_token, updated_at_ms FROM reservations WHERE exact_fingerprint = ? AND status IN ('RESERVED', 'SUBMITTED')`, exact, @@ -193,6 +273,8 @@ export class RelayBudget extends DurableObject { semanticKey: row.semantic_key, state: row.status === "SUBMITTED" ? "submitted" : "reserved", transactionHash: row.transaction_hash, + preparedPayload: row.prepared_payload, + maxFeeFri: row.max_fee_fri, }; } @@ -203,7 +285,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 +299,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,24 +308,28 @@ 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, - status, transaction_hash, updated_at_ms) - VALUES (?, ?, ?, ?, NULL, 'RESERVED', NULL, ?)`, + (semantic_key, budget_class, exact_fingerprint, day_key, max_fee_fri, actual_fee_fri, + status, transaction_hash, prepared_payload, owner_token, updated_at_ms) + VALUES (?, ?, ?, ?, ?, NULL, 'RESERVED', NULL, NULL, ?, ?)`, normalized.semanticKey, + normalized.budgetClass, normalized.exactFingerprint, normalized.dayKey, normalized.maxFeeFri, + normalized.ownerToken, normalized.nowMs, ); } else { this.ctx.storage.sql.exec( `UPDATE reservations - SET exact_fingerprint = ?, day_key = ?, max_fee_fri = ?, actual_fee_fri = NULL, - status = 'RESERVED', transaction_hash = NULL, updated_at_ms = ? + SET budget_class = ?, exact_fingerprint = ?, day_key = ?, max_fee_fri = ?, actual_fee_fri = NULL, + status = 'RESERVED', transaction_hash = NULL, prepared_payload = NULL, owner_token = ?, updated_at_ms = ? WHERE semantic_key = ?`, + normalized.budgetClass, normalized.exactFingerprint, normalized.dayKey, normalized.maxFeeFri, + normalized.ownerToken, normalized.nowMs, normalized.semanticKey, ); @@ -252,7 +338,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 +355,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"); @@ -278,6 +364,9 @@ export class RelayBudget extends DurableObject { const terminal = terminalMutationOutcome(row.status); if (terminal !== undefined) return mutationResult(terminal, totals, this.isFrozen()); if (row.status !== "RESERVED") throw new BudgetError("reservation_not_submitted"); + if (row.transaction_hash !== null && row.transaction_hash !== hash) { + throw new BudgetError("idempotency_conflict"); + } this.ctx.storage.sql.exec( `UPDATE reservations SET status = 'SUBMITTED', transaction_hash = ?, updated_at_ms = ? @@ -290,14 +379,54 @@ export class RelayBudget extends DurableObject { }); } - release(semanticKey: string, exactFingerprint: string, nowMs: number): BudgetMutationResult { + markPrepared( + semanticKey: string, + exactFingerprint: string, + expectedTransactionHash: string, + preparedPayload: string, + ownerToken: string, + nowMs: number, + ): BudgetMutationResult { const semantic = validKey(semanticKey); const exact = validKey(exactFingerprint); + const hash = validTransactionHash(expectedTransactionHash); + const payload = validPreparedPayload(preparedPayload); + const owner = validKey(ownerToken); 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.owner_token !== owner) throw new BudgetError("reservation_owner_mismatch"); + if (row.status === "RESERVED" && row.transaction_hash === hash && row.prepared_payload === payload) { + return mutationResult("already_prepared", totals, this.isFrozen()); + } + if (row.status !== "RESERVED" || row.transaction_hash !== null) { + throw new BudgetError("reservation_not_releasable"); + } + this.ctx.storage.sql.exec( + `UPDATE reservations + SET transaction_hash = ?, prepared_payload = ?, updated_at_ms = ? + WHERE semantic_key = ?`, + hash, + payload, + timestamp, + semantic, + ); + return mutationResult("prepared", totals, this.isFrozen()); + }); + } + + release(semanticKey: string, exactFingerprint: string, ownerToken: string, nowMs: number): BudgetMutationResult { + const semantic = validKey(semanticKey); + const exact = validKey(exactFingerprint); + const owner = validKey(ownerToken); + const timestamp = validTimestamp(nowMs); + return this.ctx.storage.transactionSync(() => { + const row = this.requiredReservation(semantic); + const totals = this.readTotals(row.budget_class, row.day_key); + if (row.exact_fingerprint !== exact) throw new BudgetError("exact_fingerprint_mismatch"); + if (row.owner_token !== owner) throw new BudgetError("reservation_owner_mismatch"); if (row.status === "RELEASED") return mutationResult("already_released", totals, this.isFrozen()); const terminal = terminalMutationOutcome(row.status); if (terminal !== undefined) return mutationResult(terminal, totals, this.isFrozen()); @@ -305,15 +434,85 @@ export class RelayBudget extends DurableObject { const next = removeReservation(totals, row.max_fee_fri); this.ctx.storage.sql.exec( - "UPDATE reservations SET status = 'RELEASED', updated_at_ms = ? WHERE semantic_key = ?", + "UPDATE reservations SET status = 'RELEASED', transaction_hash = NULL, prepared_payload = NULL, updated_at_ms = ? WHERE semantic_key = ?", timestamp, semantic, ); - this.writeTotals(row.day_key, next); + this.writeTotals(row.budget_class, row.day_key, next); return mutationResult("released", next, this.isFrozen()); }); } + takeoverHashless( + semanticKey: string, + exactFingerprint: string, + newOwnerToken: string, + nowMs: number, + staleAfterMs: number, + ): ReservationTakeoverResult { + const semantic = validKey(semanticKey); + const exact = validKey(exactFingerprint); + const owner = validKey(newOwnerToken); + const timestamp = validTimestamp(nowMs); + if (!Number.isSafeInteger(staleAfterMs) || staleAfterMs < 60_000 || staleAfterMs > 600_000) { + throw new BudgetError("invalid_budget_input"); + } + return this.ctx.storage.transactionSync(() => { + const row = this.requiredReservation(semantic); + if ( + row.exact_fingerprint !== exact || + row.status !== "RESERVED" || + row.transaction_hash !== null || + row.prepared_payload !== null || + timestamp - row.updated_at_ms < staleAfterMs + ) { + return { acquired: false }; + } + this.ctx.storage.sql.exec( + "UPDATE reservations SET owner_token = ?, updated_at_ms = ? WHERE semantic_key = ?", + owner, + timestamp, + semantic, + ); + return { acquired: true }; + }); + } + + takeoverPrepared( + semanticKey: string, + exactFingerprint: string, + newOwnerToken: string, + nowMs: number, + staleAfterMs: number, + ): ReservationTakeoverResult { + const semantic = validKey(semanticKey); + const exact = validKey(exactFingerprint); + const owner = validKey(newOwnerToken); + const timestamp = validTimestamp(nowMs); + if (!Number.isSafeInteger(staleAfterMs) || staleAfterMs < 60_000 || staleAfterMs > 600_000) { + throw new BudgetError("invalid_budget_input"); + } + return this.ctx.storage.transactionSync(() => { + const row = this.requiredReservation(semantic); + if ( + row.exact_fingerprint !== exact || + row.status !== "RESERVED" || + row.transaction_hash === null || + row.prepared_payload === null || + timestamp - row.updated_at_ms < staleAfterMs + ) { + return { acquired: false }; + } + this.ctx.storage.sql.exec( + "UPDATE reservations SET owner_token = ?, updated_at_ms = ? WHERE semantic_key = ?", + owner, + timestamp, + semantic, + ); + return { acquired: true }; + }); + } + finalize( semanticKey: string, exactFingerprint: string, @@ -332,7 +531,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()) { @@ -361,14 +560,14 @@ export class RelayBudget extends DurableObject { const terminalStatus = breached ? "BREACHED" : execution === "reverted" ? "REVERTED" : "COMMITTED"; this.ctx.storage.sql.exec( `UPDATE reservations - SET actual_fee_fri = ?, status = ?, updated_at_ms = ? + SET actual_fee_fri = ?, status = ?, prepared_payload = NULL, updated_at_ms = ? WHERE semantic_key = ?`, actualFee.toString(), terminalStatus, 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 +582,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 +608,100 @@ export class RelayBudget extends DurableObject { }; } - private readTotals(dayKey: string): TotalsRow { + activeSnapshot(ignoredExactFingerprint?: string): ActiveBudgetSnapshot { + const ignored = ignoredExactFingerprint === undefined + ? undefined + : validKey(ignoredExactFingerprint); + const counts = this.ctx.storage.sql + .exec<{ status: string; count: number }>( + ignored === undefined + ? "SELECT status, COUNT(*) AS count FROM reservations WHERE status IN ('RESERVED', 'SUBMITTED') GROUP BY status" + : "SELECT status, COUNT(*) AS count FROM reservations WHERE status IN ('RESERVED', 'SUBMITTED') AND exact_fingerprint != ? GROUP BY status", + ...(ignored === undefined ? [] : [ignored]), + ) + .toArray(); + const count = (status: "RESERVED" | "SUBMITTED"): number => + counts.find((row) => row.status === status)?.count ?? 0; + return { + reservedCount: count("RESERVED"), + submittedCount: count("SUBMITTED"), + sponsorshipFrozen: this.isFrozen(), + }; + } + + acquireFundingAdmission(nowMs: number, ttlMs: number, ownerToken: string): FundingAdmissionResult { + const now = validTimestamp(nowMs); + const owner = validKey(ownerToken); + if (!Number.isSafeInteger(ttlMs) || ttlMs < 1 || ttlMs > 15 * 60_000) { + throw new BudgetError("invalid_budget_input"); + } + return this.ctx.storage.transactionSync(() => { + const current = this.fundingAdmissionState(); + if (current.expiresAtMs !== null && current.expiresAtMs > now && current.ownerToken === owner) { + return { acquired: true, active: true, expiresAtMs: current.expiresAtMs }; + } + if (current.expiresAtMs !== null && current.expiresAtMs > now) { + return { acquired: false, active: true, expiresAtMs: current.expiresAtMs }; + } + const expiresAtMs = now + ttlMs; + this.ctx.storage.sql.exec( + "UPDATE funding_admission SET expires_at_ms = ?, owner_token = ? WHERE singleton = 1", + expiresAtMs, + owner, + ); + return { acquired: true, active: true, expiresAtMs }; + }); + } + + fundingAdmissionSnapshot(nowMs: number, ownerToken?: string): FundingAdmissionResult { + const now = validTimestamp(nowMs); + const owner = ownerToken === undefined ? undefined : validKey(ownerToken); + const current = this.fundingAdmissionState(); + const expiresAtMs = current.expiresAtMs; + const active = expiresAtMs !== null && expiresAtMs > now; + if (active && owner !== undefined && current.ownerToken === owner) { + return { acquired: false, active: false, expiresAtMs: null }; + } + return { acquired: false, active, expiresAtMs: active ? expiresAtMs : null }; + } + + consumeFundingAdmission(nowMs: number): FundingAdmissionResult { + validTimestamp(nowMs); + return this.ctx.storage.transactionSync(() => { + const current = this.fundingAdmissionState().expiresAtMs; + this.ctx.storage.sql.exec( + "UPDATE funding_admission SET expires_at_ms = NULL, owner_token = NULL WHERE singleton = 1", + ); + return { acquired: false, active: false, expiresAtMs: current }; + }); + } + + private fundingAdmissionState(): { expiresAtMs: number | null; ownerToken: string | null } { + const row = this.ctx.storage.sql + .exec<{ expires_at_ms: number | null; owner_token: string | null }>( + "SELECT expires_at_ms, owner_token FROM funding_admission WHERE singleton = 1", + ) + .one(); + return { expiresAtMs: row.expires_at_ms, ownerToken: row.owner_token }; + } + + 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,8 +711,8 @@ 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, - status, transaction_hash + `SELECT semantic_key, budget_class, exact_fingerprint, day_key, max_fee_fri, actual_fee_fri, + status, transaction_hash, prepared_payload, owner_token, updated_at_ms FROM reservations WHERE semantic_key = ?`, semanticKey, ) @@ -458,16 +738,23 @@ 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), maxFeeFri: decimal(input.maxFeeFri).toString(), perCallCapFri: decimal(input.perCallCapFri).toString(), dailyBudgetFri: decimal(input.dailyBudgetFri).toString(), + ownerToken: validKey(input.ownerToken), nowMs: validTimestamp(input.nowMs), }; } +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"; @@ -515,6 +802,21 @@ function validTransactionHash(value: string): string { return value; } +function validPreparedPayload(value: string): string { + if (typeof value !== "string" || value.length < 2 || value.length > 3_000_000) { + throw new BudgetError("invalid_budget_input"); + } + try { + const decoded = JSON.parse(value); + if (typeof decoded !== "object" || decoded === null || Array.isArray(decoded)) { + throw new Error("invalid"); + } + } catch { + throw new BudgetError("invalid_budget_input"); + } + return value; +} + function validTimestamp(value: number): number { if (!Number.isSafeInteger(value) || value < 0) throw new BudgetError("invalid_budget_input"); return value; diff --git a/relayer/src/core.ts b/relayer/src/core.ts index 9f8d064..d2b88f0 100644 --- a/relayer/src/core.ts +++ b/relayer/src/core.ts @@ -12,6 +12,7 @@ export const CHECKPOINT_PATH = "/v1/checkpoint"; export const HEALTH_PATH = "/health"; export const RELAY_INTENT_HEADER = "relay-control"; export const CHECKPOINT_INTENT_HEADER = "funding-checkpoint"; +export const CHECKPOINT_ADMISSION_HEADER = "x-afterlight-admission"; export type RelayPlanOperation = RelayOperation | "CHECKPOINT"; const EXPECTED_STATE: Readonly> = Object.freeze({ @@ -59,11 +60,14 @@ export class RelayHttpError extends Error { } } -/** Builds a global, payload-free checkpoint call with no wallet or vault correlation key. */ -export async function prepareCheckpointPlan(env: Env, nowMs: number): Promise { +/** Builds a payload-free checkpoint bound only to a fresh, opaque funding-attempt owner. */ +export async function prepareCheckpointPlan(env: Env, nowMs: number, admissionToken: string): Promise { if (!Number.isSafeInteger(nowMs) || nowMs < 0) { throw new RelayHttpError(500, "invalid_checkpoint_time"); } + if (!/^[0-9a-f]{64}$/.test(admissionToken)) { + throw new RelayHttpError(400, "invalid_admission_token"); + } const maxSponsoredFeeFri = parsePositiveDecimal(env.MAX_SPONSORED_FEE_FRI, "fee_cap"); const dailySponsorBudgetFri = parsePositiveDecimal( env.DAILY_SPONSOR_BUDGET_FRI, @@ -78,15 +82,17 @@ export async function prepareCheckpointPlan(env: Env, nowMs: number): Promise { +export async function requireCheckpointHeaders(request: Request, env: Env): Promise { if (!isAllowedOrigin(request.headers.get("origin"), env.ALLOWED_ORIGIN)) { throw new RelayHttpError(403, "origin_not_allowed"); } if (request.headers.get("x-afterlight-intent") !== CHECKPOINT_INTENT_HEADER) { throw new RelayHttpError(400, "intent_header_required"); } + const admissionToken = request.headers.get(CHECKPOINT_ADMISSION_HEADER); + if (admissionToken === null || !/^[0-9a-f]{64}$/.test(admissionToken)) { + throw new RelayHttpError(400, "invalid_admission_token"); + } const contentLength = request.headers.get("content-length"); if (contentLength !== null && (!/^[0-9]+$/.test(contentLength) || contentLength !== "0")) { throw new RelayHttpError(400, "checkpoint_payload_forbidden"); } - if (request.body === null) return; + if (request.body === null) return admissionToken; const reader = request.body.getReader(); try { while (true) { const { done, value } = await reader.read(); - if (done) return; + if (done) return admissionToken; if (value.byteLength > 0) { await reader.cancel("checkpoint payload forbidden"); throw new RelayHttpError(400, "checkpoint_payload_forbidden"); @@ -302,7 +312,7 @@ export function corsHeaders(origin: string): HeadersInit { return { "access-control-allow-origin": origin, "access-control-allow-methods": "POST, OPTIONS", - "access-control-allow-headers": "content-type, x-afterlight-intent", + "access-control-allow-headers": "content-type, x-afterlight-intent, x-afterlight-admission", "access-control-max-age": "600", vary: "Origin", }; diff --git a/relayer/src/executor.ts b/relayer/src/executor.ts index 1d5937d..f9f1050 100644 --- a/relayer/src/executor.ts +++ b/relayer/src/executor.ts @@ -1,5 +1,7 @@ import type { ActiveBudgetLookupResult, + ActiveBudgetSnapshot, + FundingAdmissionResult, BudgetMutationResult, BudgetReserveInput, BudgetReserveResult, @@ -11,6 +13,7 @@ import { BudgetError } from "./budget.js"; import type { RelayPlan } from "./core.js"; import { StarknetV3RelayAdapter } from "./starknet-adapter.js"; import { SponsorshipError, authorizeSponsorship } from "./sponsorship.js"; +import { classifyBroadcastFailure } from "./rpc-errors.js"; export type ExactFeeQuote = Readonly<{ nonce: string; @@ -61,7 +64,14 @@ export interface StarknetRelayAdapter { plan: RelayPlan, simulation: SuccessfulExactSimulation, transactionMaxFeeFri: string, + persistPrepared: (expectedTransactionHash: string, preparedPayload: string) => Promise, ): Promise; + rebroadcastPreparedExact( + plan: RelayPlan, + transactionMaxFeeFri: string, + expectedTransactionHash: string, + preparedPayload: string, + ): Promise; reconcileReceipt(transactionHash: string, plan: RelayPlan): Promise; readRelayerBalance(): Promise; } @@ -76,11 +86,34 @@ export interface BudgetCoordinator { transactionHash: string, nowMs: number, ): Promise; + markPrepared( + semanticKey: string, + exactFingerprint: string, + expectedTransactionHash: string, + preparedPayload: string, + ownerToken: string, + nowMs: number, + ): Promise; release( semanticKey: string, exactFingerprint: string, + ownerToken: string, nowMs: number, ): Promise; + takeoverHashless( + semanticKey: string, + exactFingerprint: string, + newOwnerToken: string, + nowMs: number, + staleAfterMs: number, + ): Promise<{ acquired: boolean }>; + takeoverPrepared( + semanticKey: string, + exactFingerprint: string, + newOwnerToken: string, + nowMs: number, + staleAfterMs: number, + ): Promise<{ acquired: boolean }>; finalize( semanticKey: string, exactFingerprint: string, @@ -89,7 +122,11 @@ export interface BudgetCoordinator { execution: "succeeded" | "reverted", nowMs: number, ): Promise; - snapshot(dayKey: string): Promise; + snapshot(dayKey: string, budgetClass?: "control" | "exit"): Promise; + activeSnapshot(ignoredExactFingerprint?: string): Promise; + acquireFundingAdmission(nowMs: number, ttlMs: number, ownerToken: string): Promise; + fundingAdmissionSnapshot(nowMs: number, ownerToken?: string): Promise; + consumeFundingAdmission(nowMs: number): Promise; } export type ExecutorPolicy = Readonly<{ @@ -144,9 +181,12 @@ export async function executeRelayPlan( adapter: StarknetRelayAdapter, budget: BudgetCoordinator, nowMs: number, + beforeExecutionAdmission?: (ignoredActiveFingerprint?: string) => Promise, ): Promise { if (!policy.submitEnabled) throw new ExecutorError("submission_disabled"); const dayKey = utcDayKey(nowMs); + const ownerToken = reservationOwnerToken(); + let adoptedMaxFeeFri: string | null = null; const prior = await budget.lookup(plan.semanticKey); if (prior.outcome === "found" && prior.state !== "released") { if ( @@ -162,27 +202,101 @@ export async function executeRelayPlan( nowMs, ); } - return duplicateResult(prior.state, prior.transactionHash); + if ( + prior.state === "reserved" && + prior.transactionHash !== null && + prior.preparedPayload !== null && + prior.exactFingerprint === plan.fingerprint + ) { + const takeover = await budget.takeoverPrepared( + plan.semanticKey, + plan.fingerprint, + ownerToken, + nowMs, + 120_000, + ); + if (!takeover.acquired) return duplicateResult(prior.state, prior.transactionHash); + return rebroadcastPreparedControl(plan, prior.maxFeeFri, adapter, budget, prior.transactionHash, prior.preparedPayload, nowMs); + } + if ( + prior.state === "reserved" && + prior.transactionHash === null && + prior.preparedPayload === null && + prior.exactFingerprint === plan.fingerprint + ) { + const takeover = await budget.takeoverHashless( + plan.semanticKey, + plan.fingerprint, + ownerToken, + nowMs, + 120_000, + ); + if (!takeover.acquired) return duplicateResult(prior.state, prior.transactionHash); + adoptedMaxFeeFri = prior.maxFeeFri; + } else { + return duplicateResult(prior.state, prior.transactionHash); + } } if (prior.sponsorshipFrozen) throw new ExecutorError("sponsorship_frozen"); // A Worker request can end after broadcast but before receipt reconciliation. // Recover the one serialized SUBMITTED exposure by its stable exact call // fingerprint before reserving a new time-bucket semantic key. - const active = await budget.findActiveByFingerprint?.(plan.fingerprint); + const active = adoptedMaxFeeFri === null + ? await budget.findActiveByFingerprint?.(plan.fingerprint) + : undefined; if (active?.outcome === "found") { + const activePlan = Object.freeze({ ...plan, semanticKey: active.semanticKey }); if (active.state === "submitted" && active.transactionHash !== null) { return reconcileSubmitted( active.transactionHash, - Object.freeze({ ...plan, semanticKey: active.semanticKey }), + activePlan, adapter, budget, nowMs, ); } - return duplicateResult(active.state, active.transactionHash); + if (active.state === "reserved" && active.transactionHash !== null && active.preparedPayload !== null) { + const takeover = await budget.takeoverPrepared( + active.semanticKey, + plan.fingerprint, + ownerToken, + nowMs, + 120_000, + ); + if (!takeover.acquired) return duplicateResult(active.state, active.transactionHash); + return rebroadcastPreparedControl( + activePlan, + active.maxFeeFri, + adapter, + budget, + active.transactionHash, + active.preparedPayload, + nowMs, + ); + } + if (active.state === "reserved" && active.transactionHash === null && active.preparedPayload === null) { + const takeover = await budget.takeoverHashless( + active.semanticKey, + plan.fingerprint, + ownerToken, + nowMs, + 120_000, + ); + if (!takeover.acquired) return duplicateResult(active.state, active.transactionHash); + plan = activePlan; + adoptedMaxFeeFri = active.maxFeeFri; + } else { + return duplicateResult(active.state, active.transactionHash); + } } + // Admission checks that would reject an already-submitted retry belong only + // on the fresh path, after both semantic and fingerprint reconciliation. + await beforeExecutionAdmission?.( + adoptedMaxFeeFri === null ? undefined : plan.fingerprint, + ); + const simulation = await adapter.simulateExact(plan); if (!simulation.ok) throw new ExecutorError("simulation_failed"); if (simulation.callFingerprint !== plan.fingerprint) { @@ -206,15 +320,20 @@ export async function executeRelayPlan( } const maxFeeFri = authorization.transactionMaxFeeFri.toString(); - let reservation: BudgetReserveResult; - try { + if (adoptedMaxFeeFri !== null && BigInt(maxFeeFri) > BigInt(adoptedMaxFeeFri)) { + throw new ExecutorError("fee_policy_rejected"); + } + let reservation: BudgetReserveResult | undefined; + if (adoptedMaxFeeFri === null) try { reservation = await budget.reserve({ + budgetClass: "control", dayKey, semanticKey: plan.semanticKey, exactFingerprint: plan.fingerprint, maxFeeFri, perCallCapFri: policy.perCallCapFri.toString(), dailyBudgetFri: policy.dailyBudgetFri.toString(), + ownerToken, nowMs, }); } catch (error) { @@ -223,7 +342,7 @@ export async function executeRelayPlan( } throw error; } - if (reservation.outcome !== "reserved") { + if (reservation !== undefined && reservation.outcome !== "reserved") { const raced = await budget.lookup(plan.semanticKey); if (raced.outcome === "found" && raced.state !== "released") { return duplicateResult(raced.state, raced.transactionHash); @@ -233,14 +352,49 @@ export async function executeRelayPlan( let submission: ExactSubmission; try { - submission = await adapter.signAndSubmitExact(plan, simulation, maxFeeFri); - } catch { + submission = await adapter.signAndSubmitExact( + plan, + simulation, + maxFeeFri, + async (expectedTransactionHash, preparedPayload) => { + const prepared = await budget.markPrepared( + plan.semanticKey, + plan.fingerprint, + expectedTransactionHash, + preparedPayload, + ownerToken, + nowMs, + ); + if (prepared.outcome !== "prepared" && prepared.outcome !== "already_prepared") { + throw new ExecutorError("submission_not_started"); + } + }, + ); + } catch (error) { + if (classifyBroadcastFailure(error).definitiveReject) { + try { + const released = await budget.release( + plan.semanticKey, + plan.fingerprint, + ownerToken, + nowMs, + ); + if (released.outcome === "released" || released.outcome === "already_released") { + throw new ExecutorError("submission_not_started"); + } + } catch (releaseError) { + if (releaseError instanceof ExecutorError) throw releaseError; + // A later owner may have atomically taken over a stale hashless row. + // Never let the displaced request release or reclassify that row. + throw new ExecutorError("submission_uncertain"); + } + } // An unexpected transport failure may have occurred after broadcast. Keep // the reservation until an operator reconciles the account nonce/receipt. throw new ExecutorError("submission_uncertain"); } if (!submission.submitted) { - await budget.release(plan.semanticKey, plan.fingerprint, nowMs); + await budget.release(plan.semanticKey, plan.fingerprint, ownerToken, nowMs); throw new ExecutorError("submission_not_started"); } if ( @@ -270,6 +424,42 @@ export async function executeRelayPlan( ); } +export function reservationOwnerToken(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join(""); +} + +async function rebroadcastPreparedControl( + plan: RelayPlan, + transactionMaxFeeFri: string, + adapter: StarknetRelayAdapter, + budget: BudgetCoordinator, + expectedTransactionHash: string, + preparedPayload: string, + nowMs: number, +): Promise { + try { + await adapter.rebroadcastPreparedExact( + plan, + transactionMaxFeeFri, + expectedTransactionHash, + preparedPayload, + ); + } catch { + // The exact stored transaction is idempotent. A duplicate response or a + // transport loss may mean either broadcast landed, so the receipt remains + // authoritative and the reservation must stay locked. + } + await budget.markSubmitted( + plan.semanticKey, + plan.fingerprint, + expectedTransactionHash, + nowMs, + ); + return reconcileSubmitted(expectedTransactionHash, plan, adapter, budget, nowMs); +} + async function reconcileSubmitted( transactionHash: string, plan: RelayPlan, diff --git a/relayer/src/exit-executor.ts b/relayer/src/exit-executor.ts index f3db291..89ad620 100644 --- a/relayer/src/exit-executor.ts +++ b/relayer/src/exit-executor.ts @@ -1,6 +1,9 @@ import { Account, RpcProvider, transaction, type Call } from "starknet"; -import type { BudgetCoordinator } from "./executor.js"; +import { reservationOwnerToken, type BudgetCoordinator } from "./executor.js"; +import { classifyBroadcastFailure, rpcErrorCode } from "./rpc-errors.js"; +export { classifyBroadcastFailure } from "./rpc-errors.js"; import { + LOCKED_POOL_CLASS_HASH, addResourceMargins, assertOuterSignatureMatchesHash, assertProofFreshness, @@ -20,7 +23,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", @@ -56,6 +59,7 @@ const POOL = EXIT_POLICY.poolAddress; const TOKEN = EXIT_POLICY.tokenAddress; const AFTERLIGHT = EXIT_POLICY.afterlightAddress; const NEUTRAL = EXIT_POLICY.neutralAddress; +const FUNDING_CHECKPOINT_MAX_AGE_SECONDS = 300n; export class ExitExecutorError extends Error { constructor(readonly code: "invalid_exit" | "exit_unavailable" | "exit_busy" | "exit_uncertain" | "exit_reverted") { @@ -70,23 +74,141 @@ 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" | "ledger" | "configuration"; + fundingStatus: "ready" | "exhausted" | "unknown"; + fundingReason: "ready" | "outstanding_liability" | "exit_capacity" | "configuration"; +}>; + +export async function readClaimCapacity( + env: Env, + budget?: Pick, + admissionOwner?: string, + ignoredActiveFingerprint?: string, +): 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 unknownCapacity(); + const block = latest.block_number; + const call = (contractAddress: string, entrypoint: string, calldata: string[] = []) => + provider.callContract({ contractAddress, entrypoint, calldata }, block); + const [poolClass, neutralClass, afterlightClass, balanceRaw, allowanceRaw, liabilityRaw, feeRaw, collectorRaw, configRaw] = await Promise.all([ + provider.getClassHashAt(POOL, block), + provider.getClassHashAt(NEUTRAL, block), + provider.getClassHashAt(AFTERLIGHT, block), + call(TOKEN, "balance_of", [NEUTRAL]), + call(TOKEN, "allowance", [NEUTRAL, POOL]), + call(AFTERLIGHT, "get_locked_by_token", [TOKEN]), + call(POOL, "get_fee_amount"), + call(POOL, "get_fee_collector"), + call(AFTERLIGHT, "get_config"), + ]); + if ( + normalizeHex(poolClass) !== normalizeHex(LOCKED_POOL_CLASS_HASH) || + normalizeHex(neutralClass) !== normalizeHex(EXIT_POLICY.neutralClassHash) || + normalizeHex(afterlightClass) !== normalizeHex(EXIT_POLICY.afterlightClassHash) || + normalizeHex(collectorRaw[0] ?? "0x0") !== normalizeHex(EXIT_POLICY.poolFeeCollector) || + configRaw.length !== 10 || + BigInt(configRaw[9] ?? -1) !== FUNDING_CHECKPOINT_MAX_AGE_SECONDS + ) return unknownCapacity(); + const fee = BigInt(feeRaw[0] ?? -1); + if (fee !== BigInt(EXIT_POLICY.poolFeeEachFri)) return unknownCapacity(); + const allowance = parseU256Result(allowanceRaw, "allowance"); + if (allowance !== BigInt(EXIT_POLICY.initialPoolAllowanceFri)) return exhaustedCapacity("allowance"); + const balance = parseU256Result(balanceRaw, "balance"); + const required = fee + BigInt(EXIT_POLICY.maxNetworkFeePerExitFri) + BigInt(EXIT_POLICY.postSpendHealthFloorFri); + if (balance < required) return exhaustedCapacity("balance"); + const liability = parseU256Result(liabilityRaw, "liability"); + if (liability !== 0n && budget !== undefined) { + // A nonzero exact contract liability is authoritative evidence that the + // admitted FUND consumed the one-shot checkpoint. Clear the operational + // lease; funding remains exhausted by the liability itself. + await budget.consumeFundingAdmission(Date.now()); + } + const chainCapacity: ClaimCapacity = liability === 0n + ? { status: "ready", reason: "ready", fundingStatus: "ready", fundingReason: "ready" } + : { status: "ready", reason: "ready", fundingStatus: "exhausted", fundingReason: "outstanding_liability" }; + if (budget === undefined) return chainCapacity; + const dayKey = new Date().toISOString().slice(0, 10); + const fundingSnapshot = admissionOwner === undefined + ? budget.fundingAdmissionSnapshot(Date.now()) + : budget.fundingAdmissionSnapshot(Date.now(), admissionOwner); + const [exitLedger, activeLedger, fundingAdmission] = await Promise.all([ + budget.snapshot(dayKey, "exit"), + budget.activeSnapshot(ignoredActiveFingerprint), + fundingSnapshot, + ]); + return applyLedgerCapacity(chainCapacity, { + ...exitLedger, + reservedCount: activeLedger.reservedCount, + submittedCount: activeLedger.submittedCount, + sponsorshipFrozen: activeLedger.sponsorshipFrozen, + fundingAdmissionActive: fundingAdmission.active, + }); + } catch { + return unknownCapacity(); + } +} + +export function applyLedgerCapacity( + chainCapacity: ClaimCapacity, + snapshot: Readonly<{ + reservedTodayFri: string; + spentTodayFri: string; + reservedCount: number; + submittedCount: number; + sponsorshipFrozen: boolean; + fundingAdmissionActive?: boolean; + }>, +): ClaimCapacity { + if (chainCapacity.status !== "ready") return chainCapacity; + const active = snapshot.reservedCount + snapshot.submittedCount > 0; + const projected = BigInt(snapshot.reservedTodayFri) + BigInt(snapshot.spentTodayFri) + BigInt(EXIT_POLICY.maxNetworkFeePerExitFri); + if (snapshot.sponsorshipFrozen || active || projected > BigInt(EXIT_POLICY.maxNetworkFeePerExitFri)) { + return { status: "exhausted", reason: "ledger", fundingStatus: "exhausted", fundingReason: "exit_capacity" }; + } + if (snapshot.fundingAdmissionActive) { + return { ...chainCapacity, fundingStatus: "exhausted", fundingReason: "exit_capacity" }; + } + return chainCapacity; +} + +function unknownCapacity(): ClaimCapacity { + return { status: "unknown", reason: "configuration", fundingStatus: "unknown", fundingReason: "configuration" }; +} + +function exhaustedCapacity(reason: "allowance" | "balance"): ClaimCapacity { + return { status: "exhausted", reason, fundingStatus: "exhausted", fundingReason: "exit_capacity" }; +} + +export function validatePreparedExitPayload(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); - if (validated.action !== "CLAIM") throw new Error("only_claim_is_public"); + const validated = validatePreparedExitPackage(decoded, EXIT_POLICY); + if (validated.action !== "CLAIM" && validated.action !== "CANCEL_REFUND") throw new Error("unsupported_exit"); + return validated; } catch { throw new ExitExecutorError("invalid_exit"); } +} + +export async function executePreparedExit( + payload: string, + env: Env, + budget: BudgetCoordinator, + prevalidated?: ValidatedExit, + afterAuthenticated?: () => Promise, +): Promise { + const validated = prevalidated ?? validatePreparedExitPayload(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 +217,67 @@ export async function executePreparedClaim(payload: string, env: Env, budget: Bu headers: { authorization: `Bearer ${env.STARKNET_RPC_AUTH_TOKEN}` }, plugins: false, }); + const ownerToken = reservationOwnerToken(); + let adoptedMaxFeeFri: string | null = null; + 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 reconcileSubmittedExit(provider, budget, validated, prior.transactionHash); + } + if ( + prior.state === "reserved" && + prior.transactionHash !== null && + prior.preparedPayload !== null && + prior.exactFingerprint === validated.bindingSha256 + ) { + if (env.SUBMIT_ENABLED !== "true") { + return { status: "duplicate", transactionHash: prior.transactionHash }; + } + const takeover = await budget.takeoverPrepared( + semanticKey, + validated.bindingSha256, + ownerToken, + Date.now(), + 120_000, + ); + if (!takeover.acquired) { + return { status: "duplicate", transactionHash: prior.transactionHash }; + } + return rebroadcastPreparedExit( + provider, + budget, + validated, + prior.transactionHash, + prior.preparedPayload, + ); + } + if ( + prior.state === "reserved" && + prior.transactionHash === null && + prior.preparedPayload === null && + prior.exactFingerprint === validated.bindingSha256 + ) { + const takeover = await budget.takeoverHashless( + semanticKey, + validated.bindingSha256, + ownerToken, + Date.now(), + 120_000, + ); + if (!takeover.acquired) return { status: "duplicate", transactionHash: null }; + adoptedMaxFeeFri = prior.maxFeeFri; + } else { + return { status: "duplicate", transactionHash: prior.transactionHash }; + } + } + // The kill switch blocks every fresh signature and broadcast, but cannot + // strand a transaction already recorded as SUBMITTED. Receipt-only + // reconciliation above is safe while submission is disabled. + if (env.SUBMIT_ENABLED !== "true") throw new ExitExecutorError("exit_unavailable"); try { if (normalizeHex(await provider.getChainId()) !== normalizeHex(EXIT_POLICY.chainId)) throw new Error("wrong_chain"); } catch { throw exitStage("chain"); } @@ -149,18 +332,26 @@ export async function executePreparedClaim(payload: string, env: Env, budget: Bu networkCap = resourceCapFri(bounds); if (networkCap > policy.networkCapFri) throw new Error("network_cap"); validateBalanceForExit(snapshot.balance, networkCap, BigInt(EXIT_POLICY.postSpendHealthFloorFri)); + if (adoptedMaxFeeFri !== null && networkCap > BigInt(adoptedMaxFeeFri)) throw new Error("adopted_network_cap"); } catch { throw exitStage("fee_and_balance"); } - const reservation = await budget.reserve({ + // A successful authenticated simulation proves the application signature, + // proof, exact note and live state before consuming the victim-specific + // vault/action quota. Invalid callers remain bounded only by global ingress. + await afterAuthenticated?.(); + + const reservation = adoptedMaxFeeFri === null ? await budget.reserve({ + budgetClass: "exit", dayKey: new Date().toISOString().slice(0, 10), semanticKey, exactFingerprint: validated.bindingSha256, maxFeeFri: networkCap.toString(), perCallCapFri: policy.networkCapFri.toString(), dailyBudgetFri: policy.networkCapFri.toString(), + ownerToken, nowMs: Date.now(), - }); - if (reservation.outcome !== "reserved") { + }) : undefined; + if (reservation !== undefined && reservation.outcome !== "reserved") { const duplicate = await budget.lookup(semanticKey); return { status: "duplicate", transactionHash: duplicate.outcome === "found" ? duplicate.transactionHash : null }; } @@ -198,6 +389,19 @@ export async function executePreparedClaim(payload: string, env: Env, budget: Bu }); submissionStage = "outer_hash"; const expectedHash = assertOuterSignatureMatchesHash(signed); + const preparedPayload = serializeSignedExitForStorage(signed); + submissionStage = "persist_expected_hash"; + const prepared = await budget.markPrepared( + semanticKey, + validated.bindingSha256, + expectedHash, + preparedPayload, + ownerToken, + Date.now(), + ); + if (prepared.outcome !== "prepared" && prepared.outcome !== "already_prepared") { + throw new ExitExecutorError("exit_unavailable"); + } submissionStage = "broadcast"; broadcastStarted = true; let response; @@ -216,25 +420,17 @@ export async function executePreparedClaim(payload: string, env: Env, budget: Bu throw new ExitExecutorError("exit_uncertain"); } const transactionHash = normalizeHex(response.transaction_hash); - if (transactionHash !== expectedHash) throw new ExitExecutorError("exit_uncertain"); + if (transactionHash !== expectedHash) { + throw new ExitExecutorError("exit_uncertain"); + } 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 reconcileSubmittedExit(provider, budget, validated, transactionHash); } catch (error) { if (!broadcastStarted) { try { - await budget.release(semanticKey, validated.bindingSha256, Date.now()); + await budget.release(semanticKey, validated.bindingSha256, ownerToken, Date.now()); } catch { // Preserve the original pre-broadcast failure. Cleanup diagnostics are // payload-free and must never turn a safe failed attempt into a generic @@ -248,11 +444,127 @@ export async function executePreparedClaim(payload: string, env: Env, budget: Bu } } +async function rebroadcastPreparedExit( + provider: RpcProvider, + budget: BudgetCoordinator, + validated: ValidatedExit, + expectedHash: string, + preparedPayload: string, +): Promise { + const signed = parseStoredSignedExit(preparedPayload); + try { + validateStoredSignedExit(signed, validated, expectedHash); + } catch { + throw new ExitExecutorError("exit_uncertain"); + } + try { + const response = await provider.invokeSignedTx(signed as Parameters[0]); + if (normalizeHex(response.transaction_hash) !== normalizeHex(expectedHash)) { + throw new ExitExecutorError("exit_uncertain"); + } + await budget.markSubmitted( + validated.bindingSha256, + validated.bindingSha256, + expectedHash, + Date.now(), + ); + } catch (error) { + // Replaying the exact persisted transaction is idempotent. A duplicate, + // validation response, or transport loss may all mean the first broadcast + // landed, so receipt reconciliation remains authoritative and the durable + // reservation is never released here. + console.error(JSON.stringify({ + event: "exit_prepared_rebroadcast_not_acknowledged", + category: classifyBroadcastFailure(error).category, + })); + } + return reconcileSubmittedExit(provider, budget, validated, expectedHash); +} + +type StoredSignedExit = Record; + +export function serializeSignedExitForStorage(signed: unknown): string { + const serialized = JSON.stringify(signed, (_key, value) => + typeof value === "bigint" ? value.toString() : value, + ); + if (serialized.length < 2 || serialized.length > 3_000_000) { + throw new ExitExecutorError("exit_unavailable"); + } + return serialized; +} + +function parseStoredSignedExit(serialized: string): StoredSignedExit { + try { + const parsed: unknown = JSON.parse(serialized); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("shape"); + return parsed as StoredSignedExit; + } catch { + throw new ExitExecutorError("exit_uncertain"); + } +} + +export function validateStoredSignedExit( + signed: StoredSignedExit, + validated: ValidatedExit, + expectedHash: string, + publicKey: string = EXIT_POLICY.neutralPublicKey, +): true { + const bounds = parseResourceBounds(signed.resource_bounds); + assertSignedExitTransaction(signed, { + nonce: BigInt(String(signed.nonce)), + executeCalldata: transaction.getExecuteCalldata([{ + contractAddress: validated.call.contractAddress, + entrypoint: validated.call.entrypoint, + calldata: validated.call.calldata.map(hex), + }], "1"), + proof: validated.proof.data, + proofFacts: validated.proof.facts.facts.map(hex), + resourceBounds: bounds, + networkCapFri: BigInt(EXIT_POLICY.maxNetworkFeePerExitFri), + }); + if (normalizeHex(assertOuterSignatureMatchesHash(signed, publicKey)) !== normalizeHex(expectedHash)) { + throw new Error("stored_hash_mismatch"); + } + return true; +} + +export async function reconcileSubmittedExit( + 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); + await budget.markSubmitted( + validated.bindingSha256, + validated.bindingSha256, + transactionHash, + Date.now(), + ); + 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 +573,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); @@ -273,10 +589,23 @@ async function readSnapshot(provider: RpcProvider, validated: ValidatedExit, blo function hex(value: string | bigint): string { return `0x${BigInt(value).toString(16)}`; } function readFee(value: unknown): string { - if (typeof value === "string") return BigInt(value).toString(); - if (typeof value !== "object" || value === null) return "0"; - const amount = (value as Record).amount; - return typeof amount === "string" ? BigInt(amount).toString() : "0"; + if (typeof value === "object" && value !== null) { + const unit = (value as Record).unit; + if (unit !== "FRI") throw new ExitExecutorError("exit_uncertain"); + } + const amount = typeof value === "string" + ? value + : typeof value === "object" && value !== null + ? (value as Record).amount + : undefined; + if (typeof amount !== "string") throw new ExitExecutorError("exit_uncertain"); + try { + const fee = BigInt(amount); + if (fee <= 0n) throw new Error("non_positive_fee"); + return fee.toString(); + } catch { + throw new ExitExecutorError("exit_uncertain"); + } } function exitStage(stage: string): ExitExecutorError { @@ -304,25 +633,3 @@ function classifyEstimateFailure(error: unknown): string { if (message.includes("timeout") || message.includes("network") || message.includes("fetch")) return "transport"; return "unknown"; } - -function rpcErrorCode(error: unknown): number | undefined { - const record = typeof error === "object" && error !== null ? error as Record : undefined; - const base = typeof record?.baseError === "object" && record.baseError !== null - ? record.baseError as Record - : undefined; - return typeof record?.code === "number" ? record.code : typeof base?.code === "number" ? base.code : 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"; - definitiveReject: boolean; -}> { - const code = rpcErrorCode(error); - if (code === 41) return { category: "rpc_execution", definitiveReject: true }; - if (code === 52) return { category: "rpc_transaction_nonce", definitiveReject: true }; - 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 }; - return { category: "transport_or_unknown", definitiveReject: false }; -} diff --git a/relayer/src/index.ts b/relayer/src/index.ts index 3d16ccd..33ef387 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 { executePreparedExit, ExitExecutorError, readClaimCapacity, validatePreparedExitPayload } 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); @@ -63,17 +63,42 @@ export default { let plan: RelayPlan; let estimateOnly = false; + let beforeExecutionAdmission: ((ignoredActiveFingerprint?: string) => Promise) | undefined; 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 = validatePreparedExitPayload(payload); + 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 executePreparedExit(payload, env, budget, validated, async () => { + await rateLimitValidatedExit(env, await exitRateLimitIdentity(validated.action, validated.metadata.vaultId)); + }); return jsonResponse({ status: "relayed", result }, 200, corsHeaders(requestOrigin)); } else if (url.pathname === CHECKPOINT_PATH) { - await requireCheckpointHeaders(request, env); + const admissionToken = await requireCheckpointHeaders(request, env); await rateLimitCheckpoint(env); - plan = await prepareCheckpointPlan(env, Date.now()); + plan = await prepareCheckpointPlan(env, Date.now(), admissionToken); + if (isSubmissionEnabled(env.SUBMIT_ENABLED)) { + beforeExecutionAdmission = async (ignoredActiveFingerprint) => { + const budget: BudgetCoordinator = env.RELAY_BUDGET.getByName(budgetObjectName(env)); + requireFundingAdmission(await readClaimCapacity( + env, + budget, + admissionToken, + ignoredActiveFingerprint, + )); + const ttlMs = parsePositiveDecimal(env.FUNDING_ADMISSION_TTL_MS, "funding_admission_ttl", 900_000n); + if (ttlMs !== 600_000n) throw new RelayHttpError(503, "invalid_funding_admission_ttl"); + const admission = await budget.acquireFundingAdmission( + Date.now(), + Number(ttlMs), + admissionToken, + ); + if (!admission.acquired) throw new RelayHttpError(503, "funding_unavailable"); + }; + } } else { requireRelayHeaders(request, env); estimateOnly = url.searchParams.get("mode") === "estimate"; @@ -132,6 +157,7 @@ export default { createStarknetRelayAdapter(env), budget, nowMs, + beforeExecutionAdmission, ); return jsonResponse( { status: "relayed", result }, @@ -155,7 +181,14 @@ export default { error instanceof RelayHttpError ? error : error instanceof ExitExecutorError - ? new RelayHttpError(error.code === "invalid_exit" ? 422 : error.code === "exit_busy" ? 503 : 502, error.code) + ? new RelayHttpError( + error.code === "invalid_exit" + ? 422 + : error.code === "exit_busy" || error.code === "exit_unavailable" + ? 503 + : 502, + error.code, + ) : executorCode !== undefined ? new RelayHttpError(executorCode === "relayer_busy" ? 503 : 502, executorCode) : new RelayHttpError(500, "internal_error"); @@ -195,18 +228,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, fundingStatus: "unknown" as const, fundingReason: "configuration" as const }) + : readClaimCapacity(env, env.RELAY_BUDGET.getByName(budgetObjectName(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 +253,7 @@ async function health(env: Env): Promise { submission: submitDisabled ? "disabled" : "enabled", executor: readiness, balance, + claimCapacity, privacy: { payloadLogging: false, appKeysHeld: false, @@ -222,6 +261,7 @@ async function health(env: Env): Promise { }, }, submitDisabled || ready ? 200 : 503, + isAllowedOrigin(origin, env.ALLOWED_ORIGIN) ? corsHeaders(origin ?? "") : undefined, ); } @@ -229,6 +269,12 @@ function isSubmissionEnabled(value: string): boolean { return value === "true"; } +export function requireFundingAdmission(capacity: Awaited>): void { + if (capacity.fundingStatus !== "ready") { + throw new RelayHttpError(503, "funding_unavailable"); + } +} + function preflight(request: Request, env: Env): Response { const origin = request.headers.get("origin"); const intent = request.headers.get("access-control-request-headers")?.toLowerCase() ?? ""; @@ -257,12 +303,23 @@ 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"); +} + +/** Stable across signatures, proofs, expiries, and destination variants. */ +export async function exitRateLimitIdentity(action: string, vaultId: string): Promise { + if (action !== "CLAIM" && action !== "CANCEL_REFUND") throw new RelayHttpError(422, "invalid_exit"); + const canonicalVault = `0x${BigInt(vaultId).toString(16)}`; + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(`afterlight-exit-rate-limit-v1:${action}:${canonicalVault}`), + ); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); } 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..c9854a6 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; @@ -70,8 +73,8 @@ const ACTION_POLICY = Object.freeze({ roleNonceIndex: 14, eventName: "RecoveryClaimed", eventSelector: RECOVERY_CLAIMED_SELECTOR, - allowanceBeforeFri: LOCKED_POOL_FEE_FRI, - allowanceAfterFri: 0n, + allowanceBeforeFri: LOCKED_INITIAL_ALLOWANCE_FRI, + allowanceAfterFri: LOCKED_POOL_FEE_FRI, }), }); @@ -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/src/rpc-errors.ts b/relayer/src/rpc-errors.ts new file mode 100644 index 0000000..d1c40d4 --- /dev/null +++ b/relayer/src/rpc-errors.ts @@ -0,0 +1,26 @@ +export function rpcErrorCode(error: unknown): number | undefined { + const record = typeof error === "object" && error !== null + ? error as Record + : undefined; + const base = typeof record?.baseError === "object" && record.baseError !== null + ? record.baseError as Record + : undefined; + return typeof record?.code === "number" + ? record.code + : typeof base?.code === "number" ? base.code : undefined; +} + +export function classifyBroadcastFailure(error: unknown): Readonly<{ + 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); + if (code === 41) return { category: "rpc_execution", definitiveReject: true }; + if (code === 52) return { category: "rpc_transaction_nonce", definitiveReject: true }; + 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 === 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/starknet-adapter.ts b/relayer/src/starknet-adapter.ts index 6d353b8..bb20ba3 100644 --- a/relayer/src/starknet-adapter.ts +++ b/relayer/src/starknet-adapter.ts @@ -9,6 +9,7 @@ import { } from "starknet"; import type { RelayPlan } from "./core.js"; +import { assertOuterSignatureMatchesHash } from "./neutral-exit-policy.mjs"; import { type ExactFeeQuote, type ExactReceipt, @@ -70,8 +71,10 @@ export class StarknetV3RelayAdapter implements StarknetRelayAdapter { plan: RelayPlan, simulation: SuccessfulExactSimulation, transactionMaxFeeFri: string, + persistPrepared: (expectedTransactionHash: string, preparedPayload: string) => Promise, ): Promise { let signed; + let expectedHash: string; try { await this.assertChain(plan); if (simulation.callFingerprint !== plan.fingerprint) return { submitted: false }; @@ -96,20 +99,47 @@ export class StarknetV3RelayAdapter implements StarknetRelayAdapter { ) { return { submitted: false }; } + expectedHash = normalizeHex(assertOuterSignatureMatchesHash(signed)); + await persistPrepared(expectedHash, serializeSignedTransaction(signed)); } catch { - // Every operation above precedes broadcast, so releasing the reservation - // is safe. Only invokeSignedTx transport errors are submission-uncertain. + // Every operation above precedes broadcast, including durable persistence + // of the exact signed artifact and deterministic transaction hash. return { submitted: false }; } const response = await this.provider.invokeSignedTx(signed); + if (normalizeHex(response.transaction_hash) !== expectedHash) throw new Error("submission_mismatch"); return { submitted: true, - transactionHash: normalizeHex(response.transaction_hash), + transactionHash: expectedHash, callFingerprint: plan.fingerprint, transactionMaxFeeFri, }; } + async rebroadcastPreparedExact( + plan: RelayPlan, + transactionMaxFeeFri: string, + expectedTransactionHash: string, + preparedPayload: string, + ): Promise { + const signed = parseSignedTransaction(preparedPayload); + if ( + normalizeHex(String(signed.sender_address)) !== normalizeHex(this.account.address) || + !Array.isArray(signed.calldata) || + !sameFelts(signed.calldata as string[], transaction.getExecuteCalldata([toCall(plan)], this.cairoVersion)) || + resourceCap(stark.resourceBoundsToBigInt( + signed.resource_bounds as Parameters[0], + )) > strictPositiveDecimal(transactionMaxFeeFri) || + normalizeHex(assertOuterSignatureMatchesHash(signed)) !== normalizeHex(expectedTransactionHash) + ) { + throw new Error("prepared_submission_mismatch"); + } + const response = await this.provider.invokeSignedTx(signed as Parameters[0]); + if (normalizeHex(response.transaction_hash) !== normalizeHex(expectedTransactionHash)) { + throw new Error("submission_mismatch"); + } + } + async reconcileReceipt(transactionHash: string, plan: RelayPlan): Promise { await this.assertChain(plan); const normalizedHash = normalizeHex(transactionHash); @@ -261,13 +291,28 @@ function strictNonNegativeDecimal(value: string): bigint { return BigInt(value); } -function readActualFeeFri(value: unknown): string | undefined { +export function readActualFeeFri(value: unknown): string | undefined { if (typeof value === "string" && /^0x[0-9a-f]+$/i.test(value)) return BigInt(value).toString(); if (typeof value !== "object" || value === null) return undefined; const record = value as Record; const amount = record.amount; - if (record.unit !== undefined && record.unit !== "FRI") return undefined; + if (record.unit !== "FRI") return undefined; return typeof amount === "string" && /^0x[0-9a-f]+$/i.test(amount) ? BigInt(amount).toString() : undefined; } + +function serializeSignedTransaction(signed: unknown): string { + const payload = JSON.stringify(signed, (_key, value) => typeof value === "bigint" ? value.toString() : value); + if (payload.length < 2 || payload.length > 100_000) throw new TypeError("invalid prepared transaction"); + return payload; +} + +function parseSignedTransaction(payload: string): Record { + if (payload.length < 2 || payload.length > 100_000) throw new TypeError("invalid prepared transaction"); + const parsed: unknown = JSON.parse(payload); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new TypeError("invalid prepared transaction"); + } + return parsed as Record; +} diff --git a/relayer/test/budget.spec.ts b/relayer/test/budget.spec.ts index 7abbcc3..21e608c 100644 --- a/relayer/test/budget.spec.ts +++ b/relayer/test/budget.spec.ts @@ -8,6 +8,8 @@ const dayTwo = "2026-08-25"; const semantic = "a".repeat(64); const exact = "b".repeat(64); const alternateExact = "c".repeat(64); +const owner = "d".repeat(64); +const alternateOwner = "e".repeat(64); describe("deployment-wide sponsorship coordinator", () => { it("reserves, deduplicates, releases, records SUBMITTED, and commits exactly once", async () => { @@ -21,8 +23,8 @@ describe("deployment-wide sponsorship coordinator", () => { expect((await budget.reserve(reserveInput(semantic, alternateExact))).outcome).toBe( "duplicate_reserved", ); - expect((await budget.release(semantic, exact, 2)).outcome).toBe("released"); - expect((await budget.release(semantic, exact, 3)).outcome).toBe("already_released"); + expect((await budget.release(semantic, exact, owner, 2)).outcome).toBe("released"); + expect((await budget.release(semantic, exact, owner, 3)).outcome).toBe("already_released"); expect((await budget.reserve(reserveInput(semantic, alternateExact, "100", dayOne, 4))).outcome) .toBe("reserved"); @@ -36,7 +38,7 @@ describe("deployment-wide sponsorship coordinator", () => { transactionHash: "0xabc", }); await runInDurableObject(budget, async (instance: RelayBudget) => { - expect(() => instance.release(semantic, alternateExact, 6)).toThrowError( + expect(() => instance.release(semantic, alternateExact, owner, 6)).toThrowError( "reservation_not_releasable", ); }); @@ -59,6 +61,72 @@ describe("deployment-wide sponsorship coordinator", () => { }); }); + it("persists a recoverable expected hash before broadcast and releases a definitive reject", async () => { + const budget = freshBudget(); + await budget.reserve(reserveInput()); + const prepared = JSON.stringify({ signed: "exact-artifact" }); + expect((await budget.markPrepared(semantic, exact, "0xabc", prepared, owner, 2)).outcome).toBe("prepared"); + expect((await budget.markPrepared(semantic, exact, "0xabc", prepared, owner, 3)).outcome).toBe("already_prepared"); + expect(await budget.lookup(semantic)).toMatchObject({ + state: "reserved", + transactionHash: "0xabc", + preparedPayload: prepared, + }); + await runInDurableObject(budget, async (instance: RelayBudget) => { + expect(() => instance.markSubmitted(semantic, exact, "0xdef", 4)).toThrowError( + "idempotency_conflict", + ); + }); + expect((await budget.release(semantic, exact, owner, 5)).outcome).toBe("released"); + expect(await budget.snapshot(dayOne)).toMatchObject({ reservedTodayFri: "0" }); + }); + + it("atomically grants only one live funding admission until its lease expires", async () => { + const budget = freshBudget(); + expect(await budget.fundingAdmissionSnapshot(1_000)).toEqual({ + acquired: false, + active: false, + expiresAtMs: null, + }); + expect(await budget.acquireFundingAdmission(1_000, 600_000, owner)).toEqual({ + acquired: true, + active: true, + expiresAtMs: 601_000, + }); + expect(await budget.acquireFundingAdmission(2_000, 600_000, owner)).toEqual({ + acquired: true, + active: true, + expiresAtMs: 601_000, + }); + expect(await budget.fundingAdmissionSnapshot(2_000, owner)).toEqual({ + acquired: false, + active: false, + expiresAtMs: null, + }); + expect(await budget.fundingAdmissionSnapshot(2_000, alternateOwner)).toEqual({ + acquired: false, + active: true, + expiresAtMs: 601_000, + }); + expect(await budget.fundingAdmissionSnapshot(2_000)).toEqual({ + acquired: false, + active: true, + expiresAtMs: 601_000, + }); + expect(await budget.acquireFundingAdmission(2_000, 600_000, alternateOwner)).toEqual({ + acquired: false, + active: true, + expiresAtMs: 601_000, + }); + expect((await budget.acquireFundingAdmission(601_000, 600_000, alternateOwner)).acquired).toBe(true); + expect(await budget.consumeFundingAdmission(601_001)).toEqual({ + acquired: false, + active: false, + expiresAtMs: 1_201_000, + }); + expect((await budget.fundingAdmissionSnapshot(601_002)).active).toBe(false); + }); + it("enforces per-call and daily exposure inside the atomic reservation", async () => { const budget = freshBudget(); await runInDurableObject(budget, async (instance: RelayBudget) => { @@ -84,6 +152,45 @@ 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("preserves an unclassified legacy day's exposure against both class ceilings", async () => { + const budget = freshBudget(); + await runInDurableObject(budget, async (instance: RelayBudget) => { + const internal = instance as unknown as { + ctx: DurableObjectState; + migrateLegacyTotals: () => void; + }; + internal.ctx.storage.sql.exec( + "INSERT INTO daily_totals (day_key, reserved_fri, spent_fri) VALUES (?, ?, ?)", + dayOne, + "0", + "70", + ); + internal.ctx.storage.sql.exec( + "DELETE FROM class_daily_totals WHERE day_key = ?", + dayOne, + ); + internal.migrateLegacyTotals(); + expect(instance.snapshot(dayOne, "control")).toMatchObject({ spentTodayFri: "70" }); + expect(instance.snapshot(dayOne, "exit")).toMatchObject({ spentTodayFri: "70" }); + expect(() => instance.reserve({ + ...reserveInput(semantic, exact, "40", dayOne, 2, "exit"), + dailyBudgetFri: "100", + })).toThrowError("daily_budget"); + }); + }); + it("admits only one active Starknet nonce lane at a time", async () => { const budget = freshBudget(); await budget.reserve(reserveInput()); @@ -92,13 +199,71 @@ describe("deployment-wide sponsorship coordinator", () => { instance.reserve(reserveInput("d".repeat(64), alternateExact, "10", dayOne, 2)), ).toThrowError("relayer_busy"); }); - await budget.release(semantic, exact, 3); + await budget.release(semantic, exact, owner, 3); expect( (await budget.reserve(reserveInput("d".repeat(64), alternateExact, "10", dayOne, 4))) .outcome, ).toBe("reserved"); }); + it("reports the active nonce lane globally across class and UTC rollover", async () => { + const budget = freshBudget(); + await budget.reserve(reserveInput(semantic, exact, "60", dayOne, 1, "control")); + expect(await budget.snapshot(dayTwo, "exit")).toMatchObject({ + reservedCount: 0, + submittedCount: 0, + }); + expect(await budget.activeSnapshot()).toEqual({ + reservedCount: 1, + submittedCount: 0, + sponsorshipFrozen: false, + }); + await budget.markSubmitted(semantic, exact, "0xabc", 2); + expect(await budget.activeSnapshot()).toMatchObject({ + reservedCount: 0, + submittedCount: 1, + }); + expect(await budget.activeSnapshot(exact)).toMatchObject({ + reservedCount: 0, + submittedCount: 0, + sponsorshipFrozen: false, + }); + expect(await budget.activeSnapshot(alternateExact)).toMatchObject({ + reservedCount: 0, + submittedCount: 1, + }); + }); + + it("allows only an expired hashless owner lease to be taken over", async () => { + const budget = freshBudget(); + await budget.reserve(reserveInput()); + expect(await budget.takeoverHashless(semantic, exact, alternateOwner, 120_000, 120_000)).toEqual({ acquired: false }); + expect(await budget.takeoverHashless(semantic, exact, alternateOwner, 120_001, 120_000)).toEqual({ acquired: true }); + await runInDurableObject(budget, async (instance: RelayBudget) => { + expect(() => instance.markPrepared(semantic, exact, "0xabc", "{}", owner, 120_002)).toThrowError( + "reservation_owner_mismatch", + ); + expect(() => instance.release(semantic, exact, owner, 120_002)).toThrowError( + "reservation_owner_mismatch", + ); + }); + expect((await budget.markPrepared(semantic, exact, "0xabc", "{}", alternateOwner, 120_003)).outcome).toBe("prepared"); + }); + + it("fences prepared rebroadcasts until the live owner lease expires", async () => { + const budget = freshBudget(); + await budget.reserve(reserveInput()); + await budget.markPrepared(semantic, exact, "0xabc", "{}", owner, 1); + expect(await budget.takeoverPrepared(semantic, exact, alternateOwner, 120_000, 120_000)).toEqual({ acquired: false }); + expect(await budget.takeoverPrepared(semantic, exact, alternateOwner, 120_001, 120_000)).toEqual({ acquired: true }); + await runInDurableObject(budget, async (instance: RelayBudget) => { + expect(() => instance.release(semantic, exact, owner, 120_002)).toThrowError( + "reservation_owner_mismatch", + ); + }); + expect((await budget.markSubmitted(semantic, exact, "0xabc", 120_003)).outcome).toBe("submitted"); + }); + it("keeps semantic idempotency global while UTC totals roll over", async () => { const budget = freshBudget(); await budget.reserve(reserveInput(semantic, exact, "100", dayOne)); @@ -178,14 +343,17 @@ function reserveInput( maxFeeFri = "100", dayKey = dayOne, nowMs = 1, + budgetClass: "control" | "exit" = "control", ) { return { + budgetClass, dayKey, semanticKey, exactFingerprint, maxFeeFri, perCallCapFri: "200", dailyBudgetFri: "1000", + ownerToken: owner, nowMs, } as const; } diff --git a/relayer/test/executor.spec.ts b/relayer/test/executor.spec.ts index 5d8bbd6..8807e33 100644 --- a/relayer/test/executor.spec.ts +++ b/relayer/test/executor.spec.ts @@ -2,6 +2,7 @@ import { env } from "cloudflare:test"; import { describe, expect, it, vi } from "vitest"; import type { RelayPlan } from "../src/core.js"; +import { readActualFeeFri } from "../src/starknet-adapter.js"; import { ExecutorError, assessBalanceHealth, @@ -18,6 +19,7 @@ import { const nowMs = Date.UTC(2026, 7, 24, 12); const day = "2026-08-24"; +const seededOwner = "a".repeat(64); const plan: RelayPlan = Object.freeze({ schema: "afterlight-relay-plan/1", operation: "HEARTBEAT", @@ -43,6 +45,12 @@ const policy = { } as const; describe("fail-closed exact-call executor", () => { + it("accepts only scalar or explicitly FRI control receipt fees", () => { + expect(readActualFeeFri("0x46")).toBe("70"); + expect(readActualFeeFri({ amount: "0x46", unit: "FRI" })).toBe("70"); + expect(readActualFeeFri({ amount: "0x46" })).toBeUndefined(); + expect(readActualFeeFri({ amount: "0x46", unit: "WEI" })).toBeUndefined(); + }); it("does nothing when submission is disabled", async () => { const adapter = fakeAdapter(); const budget = freshBudget(); @@ -88,6 +96,39 @@ describe("fail-closed exact-call executor", () => { expect(await budget.snapshot(day)).toMatchObject({ reservedTodayFri: "0", spentTodayFri: "0" }); }); + it.each([41, 52, 53, 54, 55])( + "releases a prepared control reservation after definitive RPC rejection %i", + async (code) => { + const adapter = fakeAdapter({ submissionError: { baseError: { code } } }); + const budget = freshBudget(); + await expect(executeRelayPlan(plan, policy, adapter, budget, nowMs)).rejects.toThrowError( + new ExecutorError("submission_not_started"), + ); + expect(await budget.snapshot(day)).toMatchObject({ reservedTodayFri: "0", spentTodayFri: "0" }); + expect(await budget.lookup(plan.semanticKey)).toMatchObject({ outcome: "found", state: "released" }); + expect(adapter.reconcileReceipt).not.toHaveBeenCalled(); + }, + ); + + it("keeps a prepared control reservation locked after an ambiguous broadcast failure", async () => { + const adapter = fakeAdapter({ submissionError: new Error("network unavailable") }); + const budget = freshBudget(); + await expect(executeRelayPlan(plan, policy, adapter, budget, nowMs)).rejects.toThrowError( + new ExecutorError("submission_uncertain"), + ); + expect(await budget.snapshot(day)).toMatchObject({ + reservedTodayFri: "110", + spentTodayFri: "0", + reservedCount: 1, + }); + expect(await budget.lookup(plan.semanticKey)).toMatchObject({ + outcome: "found", + state: "reserved", + transactionHash: "0xabc", + preparedPayload: "{}", + }); + }); + it("keeps ambiguous receipt exposure reserved for exact-request reconciliation", async () => { const adapter = fakeAdapter({ receipt: { status: "pending" } }); const budget = freshBudget(); @@ -126,6 +167,75 @@ describe("fail-closed exact-call executor", () => { expect(adapter.reconcileReceipt).toHaveBeenCalledTimes(2); }); + it("takes over an exact hashless reservation only after its owner lease expires", async () => { + const adapter = fakeAdapter(); + const budget = freshBudget(); + await budget.reserve({ + budgetClass: "control", + dayKey: day, + semanticKey: plan.semanticKey, + exactFingerprint: plan.fingerprint, + maxFeeFri: "110", + perCallCapFri: "200", + dailyBudgetFri: "1000", + ownerToken: seededOwner, + nowMs, + }); + + await expect(executeRelayPlan(plan, policy, adapter, budget, nowMs + 1)).resolves.toEqual({ + status: "duplicate", + state: "reserved", + transactionHash: null, + }); + const beforeExecutionAdmission = vi.fn(async (ignoredActiveFingerprint?: string) => { + expect(ignoredActiveFingerprint).toBe(plan.fingerprint); + }); + await expect(executeRelayPlan(plan, policy, adapter, budget, nowMs + 120_001, beforeExecutionAdmission)).resolves.toMatchObject({ + status: "accepted", + transactionHash: "0xabc", + }); + expect(beforeExecutionAdmission).toHaveBeenCalledOnce(); + expect(adapter.signAndSubmitExact).toHaveBeenCalledOnce(); + }); + + it("rebroadcasts and reconciles an exact prepared checkpoint across semantic buckets", async () => { + const adapter = fakeAdapter(); + const budget = freshBudget(); + await budget.reserve({ + budgetClass: "control", + dayKey: day, + semanticKey: plan.semanticKey, + exactFingerprint: plan.fingerprint, + maxFeeFri: "110", + perCallCapFri: "200", + dailyBudgetFri: "1000", + ownerToken: seededOwner, + nowMs, + }); + await budget.markPrepared(plan.semanticKey, plan.fingerprint, "0xabc", "{}", seededOwner, nowMs + 1); + const nextBucket = { ...plan, semanticKey: "b".repeat(64) }; + + await expect(executeRelayPlan(nextBucket, policy, adapter, budget, nowMs + 2)).resolves.toEqual({ + status: "duplicate", + state: "reserved", + transactionHash: "0xabc", + }); + expect(adapter.rebroadcastPreparedExact).not.toHaveBeenCalled(); + + await expect(executeRelayPlan(nextBucket, policy, adapter, budget, nowMs + 120_002)).resolves.toMatchObject({ + status: "accepted", + transactionHash: "0xabc", + }); + expect(adapter.rebroadcastPreparedExact).toHaveBeenCalledWith( + expect.objectContaining({ semanticKey: plan.semanticKey }), + "110", + "0xabc", + "{}", + ); + expect(adapter.simulateExact).not.toHaveBeenCalled(); + expect(adapter.signAndSubmitExact).not.toHaveBeenCalled(); + }); + it("recovers a submitted checkpoint after its time-bucket semantic key changes", async () => { const adapter = fakeAdapter(); adapter.reconcileReceipt = vi @@ -133,15 +243,17 @@ describe("fail-closed exact-call executor", () => { .mockResolvedValueOnce({ status: "pending" }) .mockResolvedValueOnce(successfulReceipt()); const budget = freshBudget(); - await expect(executeRelayPlan(plan, policy, adapter, budget, nowMs)).rejects.toThrowError( + const beforeFreshExecution = vi.fn(async () => {}); + await expect(executeRelayPlan(plan, policy, adapter, budget, nowMs, beforeFreshExecution)).rejects.toThrowError( new ExecutorError("receipt_unreconciled"), ); const nextBucket: RelayPlan = { ...plan, semanticKey: "b".repeat(64) }; - await expect(executeRelayPlan(nextBucket, policy, adapter, budget, nowMs + 15_000)).resolves.toMatchObject({ + await expect(executeRelayPlan(nextBucket, policy, adapter, budget, nowMs + 15_000, beforeFreshExecution)).resolves.toMatchObject({ status: "accepted", transactionHash: "0xabc", actualFeeFri: "70", }); + expect(beforeFreshExecution).toHaveBeenCalledTimes(1); expect(adapter.simulateExact).toHaveBeenCalledTimes(1); expect(adapter.signAndSubmitExact).toHaveBeenCalledTimes(1); expect(adapter.reconcileReceipt).toHaveBeenCalledTimes(2); @@ -272,21 +384,26 @@ function successfulSimulation(quotedFeeFri = "100"): ExactSimulation { function fakeAdapter(overrides: { simulation?: ExactSimulation; submission?: ExactSubmission; + submissionError?: unknown; receipt?: ExactReceipt; } = {}): StarknetRelayAdapter & { simulateExact: ReturnType>; signAndSubmitExact: ReturnType>; + rebroadcastPreparedExact: ReturnType>; } { return { simulateExact: vi.fn(async () => overrides.simulation ?? successfulSimulation()), - signAndSubmitExact: vi.fn(async (_plan, _simulation, transactionMaxFeeFri) => - overrides.submission ?? { + signAndSubmitExact: vi.fn(async (_plan, _simulation, transactionMaxFeeFri, persistPrepared) => { + await persistPrepared("0xabc", "{}"); + if (overrides.submissionError !== undefined) throw overrides.submissionError; + return overrides.submission ?? { submitted: true, transactionHash: "0xabc", callFingerprint: plan.fingerprint, transactionMaxFeeFri, - }, - ), + }; + }), + rebroadcastPreparedExact: vi.fn(async () => {}), reconcileReceipt: vi.fn(async () => overrides.receipt ?? successfulReceipt()), readRelayerBalance: vi.fn(async () => "1000"), }; diff --git a/relayer/test/neutral-exit-signing.spec.ts b/relayer/test/neutral-exit-signing.spec.ts index a9bdab8..a111812 100644 --- a/relayer/test/neutral-exit-signing.spec.ts +++ b/relayer/test/neutral-exit-signing.spec.ts @@ -1,11 +1,22 @@ -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, + validateAllowanceForAction, + validatePreparedExitPackage, } from "../src/neutral-exit-policy.mjs"; -import { classifyBroadcastFailure } from "../src/exit-executor.js"; +import { EXIT_POLICY, applyLedgerCapacity, classifyBroadcastFailure, executePreparedExit, reconcileSubmittedExit, serializeSignedExitForStorage, validateStoredSignedExit } from "../src/exit-executor.js"; const MAINNET_CHAIN_ID = "0x534e5f4d41494e"; const LOCKED_NEUTRAL_ADDRESS = "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46"; @@ -21,6 +32,217 @@ 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("accepts the same exact-note boundary for an owner-authorized cancellation", () => { + const cancellation = structuredClone(preparedClaimPackage()); + cancellation.action = "CANCEL_REFUND"; + cancellation.expectedState = "1"; + cancellation.prepared.call.calldata[15] = "1"; + cancellation.prepared.call.calldata[19] = "1"; + cancellation.prepared.proof.output[16] = "1"; + cancellation.prepared.proof.output[20] = "1"; + cancellation.locks = buildExitLocks(cancellation); + expect(validatePreparedExitPackage(cancellation, EXIT_POLICY).action).toBe("CANCEL_REFUND"); + }); + + it("admits exactly one bounded claim or cancellation from the same replenished allowance", () => { + const readyAllowance = 12n * 10n ** 18n; + const exhaustedAllowance = 6n * 10n ** 18n; + expect(validateAllowanceForAction("CLAIM", readyAllowance)).toBe(exhaustedAllowance); + expect(validateAllowanceForAction("CANCEL_REFUND", readyAllowance)).toBe(exhaustedAllowance); + expect(() => validateAllowanceForAction("CLAIM", exhaustedAllowance)).toThrow(/wrong_exact_pool_allowance/); + }); + + it("reports capacity exhausted when the sponsorship ledger cannot reserve a full exit", () => { + const ready = { status: "ready", reason: "ready", fundingStatus: "ready", fundingReason: "ready" } as const; + const base = { reservedTodayFri: "0", spentTodayFri: "0", reservedCount: 0, submittedCount: 0, sponsorshipFrozen: false }; + expect(applyLedgerCapacity(ready, base)).toEqual(ready); + for (const unavailable of [ + { ...base, reservedTodayFri: "1", reservedCount: 1 }, + { ...base, submittedCount: 1 }, + { ...base, spentTodayFri: "1" }, + { ...base, sponsorshipFrozen: true }, + ]) { + expect(applyLedgerCapacity(ready, unavailable)).toMatchObject({ + status: "exhausted", + reason: "ledger", + fundingStatus: "exhausted", + }); + } + expect(applyLedgerCapacity(ready, { ...base, fundingAdmissionActive: true })).toEqual({ + status: "ready", + reason: "ready", + fundingStatus: "exhausted", + fundingReason: "exit_capacity", + }); + }); + + it("reconciles an already-submitted private exit 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", unit: "FRI" } }, + }), + } as any; + const budget = { + markSubmitted: vi.fn().mockResolvedValue({ outcome: "submitted" }), + finalize: vi.fn().mockResolvedValue({ outcome: "committed" }), + } as any; + await expect(reconcileSubmittedExit(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.each([ + ["missing", undefined], + ["malformed object", {}], + ["malformed amount", { amount: "not-a-fee" }], + ["zero", { amount: "0x0" }], + ["missing structured unit", { amount: "70" }], + ["non-FRI unit", { amount: "70", unit: "WEI" }], + ["malformed unit", { amount: "70", unit: null }], + ])("keeps an accepted receipt with a %s actual fee uncertain", async (_label, actualFee) => { + const transactionHash = "0xabc"; + const binding = "a".repeat(64); + const provider = { + waitForTransaction: vi.fn().mockResolvedValue({ + isError: () => false, + isReverted: () => false, + value: { transaction_hash: transactionHash, actual_fee: actualFee }, + }), + } as any; + const budget = { + markSubmitted: vi.fn().mockResolvedValue({ outcome: "submitted" }), + finalize: vi.fn().mockResolvedValue({ outcome: "committed" }), + } as any; + await expect(reconcileSubmittedExit(provider, budget, { bindingSha256: binding } as any, transactionHash)).rejects.toMatchObject({ + code: "exit_uncertain", + }); + expect(budget.markSubmitted).not.toHaveBeenCalled(); + expect(budget.finalize).not.toHaveBeenCalled(); + }); + + it("reconciles a stored SUBMITTED exit while fresh submission is disabled", async () => { + const transactionHash = "0xabc"; + const validated = validatePreparedExitPackage(preparedClaimPackage(), EXIT_POLICY); + vi.spyOn(RpcProvider.prototype, "waitForTransaction").mockResolvedValue({ + isError: () => false, + isReverted: () => false, + value: { transaction_hash: transactionHash, actual_fee: { amount: "70", unit: "FRI" } }, + } as any); + const budget = { + lookup: vi.fn().mockResolvedValue({ + outcome: "found", + state: "submitted", + exactFingerprint: validated.bindingSha256, + transactionHash, + }), + markSubmitted: vi.fn().mockResolvedValue({ outcome: "submitted" }), + finalize: vi.fn().mockResolvedValue({ outcome: "committed" }), + } as any; + const afterAuthenticated = vi.fn(async () => {}); + await expect(executePreparedExit("{}", { + SUBMIT_ENABLED: "false", + EXIT_RPC_URL: "https://rpc.invalid", + STARKNET_RPC_AUTH_TOKEN: "configured", + } as any, budget, validated, afterAuthenticated)).resolves.toMatchObject({ + status: "accepted", + transactionHash, + }); + expect(budget.finalize).toHaveBeenCalledOnce(); + expect(afterAuthenticated).not.toHaveBeenCalled(); + }); + + it("does not take over a live hashless pre-broadcast exit reservation", async () => { + const validated = validatePreparedExitPackage(preparedClaimPackage(), EXIT_POLICY); + const budget = { + lookup: vi.fn().mockResolvedValue({ + outcome: "found", + state: "reserved", + exactFingerprint: validated.bindingSha256, + transactionHash: null, + preparedPayload: null, + }), + takeoverHashless: vi.fn().mockResolvedValue({ acquired: false }), + } as any; + await expect(executePreparedExit("{}", { + SUBMIT_ENABLED: "false", + EXIT_RPC_URL: "https://rpc.invalid", + STARKNET_RPC_AUTH_TOKEN: "configured", + } as any, budget, validated)).resolves.toEqual({ status: "duplicate", transactionHash: null }); + expect(budget.takeoverHashless).toHaveBeenCalledWith( + validated.bindingSha256, + validated.bindingSha256, + expect.stringMatching(/^[0-9a-f]{64}$/), + expect.any(Number), + 120_000, + ); + }); + + it("does not rebroadcast a prepared exit while its owner lease is live", async () => { + const validated = validatePreparedExitPackage(preparedClaimPackage(), EXIT_POLICY); + const budget = { + lookup: vi.fn().mockResolvedValue({ + outcome: "found", + state: "reserved", + exactFingerprint: validated.bindingSha256, + transactionHash: "0xabc", + preparedPayload: "{}", + }), + takeoverPrepared: vi.fn().mockResolvedValue({ acquired: false }), + } as any; + const invoke = vi.spyOn(RpcProvider.prototype, "invokeSignedTx"); + await expect(executePreparedExit("{}", { + SUBMIT_ENABLED: "true", + EXIT_RPC_URL: "https://rpc.invalid", + STARKNET_RPC_AUTH_TOKEN: "configured", + } as any, budget, validated)).resolves.toEqual({ status: "duplicate", transactionHash: "0xabc" }); + expect(budget.takeoverPrepared).toHaveBeenCalledWith( + validated.bindingSha256, + validated.bindingSha256, + expect.stringMatching(/^[0-9a-f]{64}$/), + expect.any(Number), + 120_000, + ); + expect(invoke).not.toHaveBeenCalled(); + }); + + it("validates before the kill switch and never signs a fresh disabled exit", async () => { + await expect(executePreparedExit("{}", { SUBMIT_ENABLED: "false" } as any, {} as any)).rejects.toMatchObject({ code: "invalid_exit" }); }); it("signs the real proof facts and reconstructs the exact outer hash offline", async () => { @@ -72,4 +294,95 @@ describe("neutral exact-exit signing boundary", () => { const publicKey = ec.starkCurve.getStarkKey(TEST_SIGNER); expect(assertOuterSignatureMatchesHash(signed, publicKey)).toMatch(/^0x[0-9a-f]+$/); }); + + it("persists and revalidates the exact signed exit artifact required for safe rebroadcast", async () => { + const provider = new RpcProvider({ nodeUrl: "http://127.0.0.1:1", plugins: false }); + vi.spyOn(provider, "getChainId").mockResolvedValue(MAINNET_CHAIN_ID); + const account = new Account({ + provider, + address: LOCKED_NEUTRAL_ADDRESS, + signer: TEST_SIGNER, + cairoVersion: "1", + transactionVersion: "0x3", + plugins: false, + }); + const validated = validatePreparedExitPackage(preparedClaimPackage(), EXIT_POLICY); + const call: Call = { + contractAddress: validated.call.contractAddress, + entrypoint: validated.call.entrypoint, + calldata: validated.call.calldata.map((value) => `0x${BigInt(value).toString(16)}`), + }; + const proofFacts = validated.proof.facts.facts.map((value) => `0x${BigInt(value).toString(16)}`); + const resourceBounds = { + l1_gas: { max_amount: 2n, max_price_per_unit: 3n }, + l1_data_gas: { max_amount: 5n, max_price_per_unit: 7n }, + l2_gas: { max_amount: 11n, max_price_per_unit: 13n }, + }; + const signed = await account.getSignedTransaction(call, { + nonce: 7n, + resourceBounds, + tip: 0, + paymasterData: [], + accountDeploymentData: [], + nonceDataAvailabilityMode: "L1", + feeDataAvailabilityMode: "L1", + proof: validated.proof.data, + proofFacts, + }); + const publicKey = ec.starkCurve.getStarkKey(TEST_SIGNER); + const expectedHash = assertOuterSignatureMatchesHash(signed, publicKey); + const serialized = serializeSignedExitForStorage(signed); + const restored = JSON.parse(serialized) as Record; + expect(validateStoredSignedExit(restored, validated, expectedHash, publicKey)).toBe(true); + + const tampered = structuredClone(restored); + (tampered.calldata as string[])[0] = "0x999"; + expect(() => validateStoredSignedExit(tampered, validated, expectedHash, publicKey)).toThrow(); + }); }); + +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..c0bf8b9 100644 --- a/relayer/test/relayer.spec.ts +++ b/relayer/test/relayer.spec.ts @@ -1,3 +1,4 @@ +import { env } from "cloudflare:test"; import { exports } from "cloudflare:workers"; import { describe, expect, it } from "vitest"; @@ -7,12 +8,19 @@ import { encodeRelayRequest, type RelayRequest, } from "../src/schema.js"; -import { isAllowedOrigin, rateLimitRelay, RelayHttpError } from "../src/core.js"; +import { + isAllowedOrigin, + prepareCheckpointPlan, + rateLimitRelay, + RelayHttpError, +} from "../src/core.js"; +import { exitRateLimitIdentity, requireFundingAdmission } from "../src/index.js"; const origin = "https://afterlight.invalid"; const contract = "0x1234"; const token = "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d"; const amount = 10_000_000_000_000_000_000n; +const admissionToken = "a".repeat(64); describe("Afterlight Phase A relay Worker", () => { it("matches only exact origins from the configured transition allowlist", () => { @@ -24,11 +32,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","fundingStatus":"unknown","fundingReason":"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); @@ -108,6 +120,7 @@ describe("Afterlight Phase A relay Worker", () => { headers: { origin, "x-afterlight-intent": "funding-checkpoint", + "x-afterlight-admission": admissionToken, }, }), ); @@ -128,6 +141,51 @@ describe("Afterlight Phase A relay Worker", () => { expect(JSON.stringify(body)).not.toMatch(/wallet|vault|signature|ready/i); }); + it("binds checkpoint idempotency to one unexposed funding admission owner", async () => { + const fixedTime = 1_787_999_999_000; + const first = await prepareCheckpointPlan(env, fixedTime, admissionToken); + const sameOwnerRetry = await prepareCheckpointPlan(env, fixedTime, admissionToken); + const otherOwner = await prepareCheckpointPlan(env, fixedTime, "b".repeat(64)); + + expect(sameOwnerRetry.fingerprint).toBe(first.fingerprint); + expect(sameOwnerRetry.semanticKey).toBe(first.semanticKey); + expect(otherOwner.fingerprint).not.toBe(first.fingerprint); + expect(otherOwner.semanticKey).not.toBe(first.semanticKey); + expect(otherOwner.call).toEqual(first.call); + expect(JSON.stringify(first)).not.toContain(admissionToken); + }); + + it.each([undefined, "", "A".repeat(64), "a".repeat(63), "g".repeat(64)])( + "rejects a missing or malformed checkpoint admission token (%s)", + async (candidate) => { + const headers: Record = { + origin, + "x-afterlight-intent": "funding-checkpoint", + }; + if (candidate !== undefined) headers["x-afterlight-admission"] = candidate; + const response = await exports.default.fetch( + new Request("https://relay.invalid/v1/checkpoint", { method: "POST", headers }), + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ status: "error", code: "invalid_admission_token" }); + }, + ); + + it("fails closed before a live funding checkpoint when capacity is not ready", () => { + expect(() => requireFundingAdmission({ + status: "ready", + reason: "ready", + fundingStatus: "ready", + fundingReason: "ready", + })).not.toThrow(); + for (const capacity of [ + { status: "ready", reason: "ready", fundingStatus: "exhausted", fundingReason: "outstanding_liability" }, + { status: "unknown", reason: "configuration", fundingStatus: "unknown", fundingReason: "configuration" }, + ] as const) { + expect(() => requireFundingAdmission(capacity)).toThrowError("funding_unavailable"); + } + }); + it("accepts a zero-byte streamed checkpoint body emitted by real HTTP clients", async () => { const response = await exports.default.fetch( new Request("https://relay.invalid/v1/checkpoint", { @@ -136,6 +194,7 @@ describe("Afterlight Phase A relay Worker", () => { origin, "content-length": "0", "x-afterlight-intent": "funding-checkpoint", + "x-afterlight-admission": admissionToken, }, body: "", }), @@ -241,6 +300,18 @@ describe("Afterlight Phase A relay Worker", () => { expect(wrongOrigin.status).toBe(403); }); + it("rate-limits exits by stable vault and action rather than variable package material", async () => { + const claimA = await exitRateLimitIdentity("CLAIM", "0x00abc"); + const claimB = await exitRateLimitIdentity("CLAIM", "0xabc"); + const cancellation = await exitRateLimitIdentity("CANCEL_REFUND", "0xabc"); + expect(claimA).toMatch(/^[0-9a-f]{64}$/); + expect(claimA).toBe(claimB); + expect(cancellation).not.toBe(claimA); + await expect(exitRateLimitIdentity("HEARTBEAT", "0xabc")).rejects.toMatchObject({ + code: "invalid_exit", + }); + }); + it("derives exact fingerprints from normalized fields, not attacker-controlled JSON ordering", async () => { const canonical = buildRelayRequest(RelayOperation.Heartbeat, contract, { ...validArgs(1n), diff --git a/relayer/worker-configuration.d.ts b/relayer/worker-configuration.d.ts index fd285d4..6670779 100644 --- a/relayer/worker-configuration.d.ts +++ b/relayer/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 4c0ae7137bd79a78774459e014d5b55b) +// Generated by Wrangler by running `wrangler types` (hash: af6b315143b266cb7e66b480ef81f077) // Runtime types generated with workerd@1.20260828.1 2026-08-28 nodejs_compat interface __BaseEnv_Env { RELAY_RATE_LIMITER: RateLimit; @@ -15,6 +15,7 @@ interface __BaseEnv_Env { MAX_RELAY_TTL_SECONDS: "900"; MAX_RELAY_PAYLOAD_BYTES: "2048"; MAX_EXIT_PAYLOAD_BYTES: "2097152"; + FUNDING_ADMISSION_TTL_MS: "600000"; MAX_SPONSORED_FEE_FRI: "400000000000000000"; DAILY_SPONSOR_BUDGET_FRI: "1600000000000000000"; SPONSOR_FEE_MARGIN_BPS: "11000"; @@ -41,7 +42,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/relayer/wrangler.jsonc b/relayer/wrangler.jsonc index c9b7e47..eaa241e 100644 --- a/relayer/wrangler.jsonc +++ b/relayer/wrangler.jsonc @@ -14,6 +14,7 @@ "MAX_RELAY_TTL_SECONDS": "900", "MAX_RELAY_PAYLOAD_BYTES": "2048", "MAX_EXIT_PAYLOAD_BYTES": "2097152", + "FUNDING_ADMISSION_TTL_MS": "600000", "MAX_SPONSORED_FEE_FRI": "400000000000000000", "DAILY_SPONSOR_BUDGET_FRI": "1600000000000000000", "SPONSOR_FEE_MARGIN_BPS": "11000", diff --git a/relayer/wrangler.staging.jsonc b/relayer/wrangler.staging.jsonc index a2035e1..7685770 100644 --- a/relayer/wrangler.staging.jsonc +++ b/relayer/wrangler.staging.jsonc @@ -16,6 +16,7 @@ "MAX_RELAY_TTL_SECONDS": "900", "MAX_RELAY_PAYLOAD_BYTES": "2048", "MAX_EXIT_PAYLOAD_BYTES": "2097152", + "FUNDING_ADMISSION_TTL_MS": "600000", "MAX_SPONSORED_FEE_FRI": "200000000000000000", "DAILY_SPONSOR_BUDGET_FRI": "1200000000000000000", "SPONSOR_FEE_MARGIN_BPS": "11000", diff --git a/web/index.html b/web/index.html index e4c3f9b..5befaa7 100644 --- a/web/index.html +++ b/web/index.html @@ -8,6 +8,7 @@ + diff --git a/web/package-lock.json b/web/package-lock.json index c5f0f72..53c1f22 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,7 +15,8 @@ }, "devDependencies": { "typescript": "5.9.3", - "vite": "7.3.6" + "vite": "7.3.6", + "vitest": "^4.1.11" } }, "node_modules/@adraffy/ens-normalize": { @@ -466,6 +467,13 @@ "node": ">=18" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@module-federation/error-codes": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.12.0.tgz", @@ -1090,6 +1098,13 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@starknet-io/get-starknet-discovery": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@starknet-io/get-starknet-discovery/-/get-starknet-discovery-6.0.4.tgz", @@ -1267,6 +1282,24 @@ "integrity": "sha512-DHfzg/d6s1NYeQpbBpLwXxYBVgCMhMIW2RjbSS2ukwz8XbPZQCLwj8ArAH4Tx4dmkJpn4J6YVRhZGFgjO26TVQ==", "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1274,6 +1307,119 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@wallet-standard/base": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@wallet-standard/base/-/base-1.1.1.tgz", @@ -1361,6 +1507,16 @@ "integrity": "sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==", "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-mutex": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", @@ -1383,6 +1539,16 @@ "cdl": "bin/cdl.js" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -1415,12 +1581,26 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -1485,12 +1665,32 @@ "node": ">=4" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1580,6 +1780,16 @@ "integrity": "sha512-SqD/Bg3ZfltBJ2Z14hJ/BihnvtV553WO4g9/ePtlp4lrnl9jF3AdIJt53A/Wkg/0Li+LMfxaBqgx1MiFZdQlpQ==", "license": "MIT" }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -1599,6 +1809,20 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/ox": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/ox/-/ox-1.7.2.tgz", @@ -1631,6 +1855,13 @@ } } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1744,6 +1975,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1754,6 +1992,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/starknet": { "version": "10.7.0", "resolved": "https://registry.npmjs.org/starknet/-/starknet-10.7.0.tgz", @@ -1938,6 +2183,13 @@ } } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -1964,6 +2216,23 @@ "node": ">=8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1981,6 +2250,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2085,6 +2364,113 @@ } } }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/web/package.json b/web/package.json index 73e8a14..fc4bcc8 100644 --- a/web/package.json +++ b/web/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite --host 127.0.0.1", + "test": "vitest run", "build": "tsc --noEmit && vite build", "preview": "vite preview --host 127.0.0.1" }, @@ -16,6 +17,7 @@ }, "devDependencies": { "typescript": "5.9.3", - "vite": "7.3.6" + "vite": "7.3.6", + "vitest": "^4.1.11" } } diff --git a/web/public/.well-known/security.txt b/web/public/.well-known/security.txt new file mode 100644 index 0000000..6cfe2df --- /dev/null +++ b/web/public/.well-known/security.txt @@ -0,0 +1,5 @@ +Contact: https://github.com/dolepee/afterlight/security/advisories/new +Expires: 2027-08-28T23:59:00Z +Canonical: https://afterlight.dolepee.com/.well-known/security.txt +Policy: https://github.com/dolepee/afterlight/security/policy +Preferred-Languages: en diff --git a/web/public/_headers b/web/public/_headers new file mode 100644 index 0000000..ca2658b --- /dev/null +++ b/web/public/_headers @@ -0,0 +1,17 @@ +/* + Strict-Transport-Security: max-age=31536000; includeSubDomains + X-Content-Type-Options: nosniff + X-Frame-Options: DENY + Referrer-Policy: no-referrer + Permissions-Policy: camera=(), geolocation=(), microphone=(), payment=(), usb=() + Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Resource-Policy: same-origin + +/afterlight-card.png + Cache-Control: public, max-age=604800, immutable + +/favicon.svg + Cache-Control: public, max-age=604800, immutable + +/apple-touch-icon.png + Cache-Control: public, max-age=604800, immutable diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest new file mode 100644 index 0000000..e22ce61 --- /dev/null +++ b/web/public/manifest.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "Afterlight", + "short_name": "Afterlight", + "description": "Create and control a private Starknet recovery reserve.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f3efe6", + "theme_color": "#f3efe6", + "icons": [ + { + "src": "/apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png" + }, + { + "src": "/favicon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/web/public/robots.txt b/web/public/robots.txt new file mode 100644 index 0000000..eaadee0 --- /dev/null +++ b/web/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://afterlight.dolepee.com/sitemap.xml diff --git a/web/public/sitemap.xml b/web/public/sitemap.xml new file mode 100644 index 0000000..ad2467d --- /dev/null +++ b/web/public/sitemap.xml @@ -0,0 +1,6 @@ + + + + https://afterlight.dolepee.com/ + + diff --git a/web/src/chain.ts b/web/src/chain.ts index edf7ad6..95eabba 100644 --- a/web/src/chain.ts +++ b/web/src/chain.ts @@ -4,6 +4,13 @@ import type { VaultSnapshot } from "./model.ts"; export const provider = new RpcProvider({ nodeUrl: RPC_URL }); +export class TransactionExecutionError extends Error { + constructor() { + super("The Mainnet transaction was confirmed but did not succeed."); + this.name = "TransactionExecutionError"; + } +} + function hex(value: unknown): string { return num.toHex(BigInt(String(value ?? 0))); } @@ -43,5 +50,5 @@ export async function readLiability(): Promise { export async function waitForSuccess(transactionHash: string): Promise { const receipt = await provider.waitForTransaction(transactionHash, { retryInterval: 4_000 }); const value = (receipt as unknown as { value?: Record }).value ?? receipt as unknown as Record; - if (String(value.execution_status ?? "") !== "SUCCEEDED") throw new Error("The Mainnet transaction did not succeed."); + if (String(value.execution_status ?? "") !== "SUCCEEDED") throw new TransactionExecutionError(); } diff --git a/web/src/checkpoint-policy.ts b/web/src/checkpoint-policy.ts new file mode 100644 index 0000000..c6834b9 --- /dev/null +++ b/web/src/checkpoint-policy.ts @@ -0,0 +1,29 @@ +const RETRYABLE_CHECKPOINT_CODES = new Set([ + "fee_policy_rejected", + "internal_error", + "receipt_unreconciled", + "receipt_reverted", + "relayer_busy", + "simulation_failed", + "simulation_mismatch", + "signer_adapter_unavailable", + "sponsorship_frozen", + "sponsorship_invariant_breach", + "submission_mismatch", + "submission_not_started", + "submission_uncertain", +]); + +export function isRetryableCheckpointCode(code: string | undefined): boolean { + return code !== undefined && RETRYABLE_CHECKPOINT_CODES.has(code); +} + +export function isHashlessRelayedResult( + status: string | undefined, + resultStatus: string | undefined, + transactionHash: string | null | undefined, +): boolean { + return status === "relayed" + && (resultStatus === "accepted" || resultStatus === "duplicate") + && !transactionHash; +} diff --git a/web/src/compatibility.ts b/web/src/compatibility.ts new file mode 100644 index 0000000..a82e019 --- /dev/null +++ b/web/src/compatibility.ts @@ -0,0 +1,15 @@ +import { READY_MIN_VERSION } from "./config.ts"; + +export function isCompatibleReadyVersion(value: unknown): boolean { + const match = /^(\d+)\.(\d+)\.(\d+)([-+].*)?$/.exec(String(value)); + if (!match) return false; + if (match[4]?.startsWith("-")) return false; + const observed = match.slice(1, 4).map(Number); + const minimum = READY_MIN_VERSION.split(".").map(Number); + if (observed[0] !== minimum[0]) return false; + for (let index = 0; index < 3; index += 1) { + if (observed[index]! > minimum[index]!) return true; + if (observed[index]! < minimum[index]!) return false; + } + return true; +} diff --git a/web/src/config.ts b/web/src/config.ts index a153c45..dd02cbe 100644 --- a/web/src/config.ts +++ b/web/src/config.ts @@ -8,5 +8,7 @@ export const AMOUNT_FRI = "1000000000000000000"; export const POOL_FEE_FRI = "6000000000000000000"; export const FAST_INACTIVITY_SECONDS = "300"; export const FAST_GRACE_SECONDS = "300"; +export const NORMAL_INACTIVITY_SECONDS = "2592000"; +export const NORMAL_GRACE_SECONDS = "604800"; export const AUTH_TTL_SECONDS = 600; -export const READY_VERSION = "5.33.9"; +export const READY_MIN_VERSION = "5.33.9"; diff --git a/web/src/main.ts b/web/src/main.ts index bd32940..9d71f97 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,10 +1,10 @@ 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"; -import { exportKey, fundRecoveryDrill, generateKey, prepareClaimPackage, relayControl, restoreKey, submitClaimPackage } from "./operations.ts"; +import { ExitSubmissionError, exportKey, fundRecoveryReserve, generateKey, hasPendingCheckpointReconciliation, prepareExitPackage, relayControl, restoreKey, submitExitPackage } from "./operations.ts"; import type { LocalStarkKey } from "../../client/src/keys.ts"; let role: Role = (new URL(location.href).searchParams.get("role") === "successor") ? "successor" : "owner"; @@ -17,9 +17,101 @@ let invitationText = localStorage.getItem("afterlight:invitation:v1") ?? ""; let vault: VaultSnapshot | undefined; let notice = "Loading the live Starknet Mainnet product…"; let busy = false; -let transactionHash = ""; let costAcknowledged = false; -let claimRetryBlocked = false; +let backupState: "needed" | "downloaded" | "verified" = "needed"; + +type ReceiptEvidence = Readonly<{ + hash: string; + label: string; + vaultId?: string; + recordedAt: string; +}>; + +const RECEIPT_STORAGE_KEY = "afterlight:receipts:v1"; + +function restoreReceipts(): ReceiptEvidence[] { + try { + const value = JSON.parse(localStorage.getItem(RECEIPT_STORAGE_KEY) ?? "[]") as unknown; + if (!Array.isArray(value)) return []; + return value.filter((entry): entry is ReceiptEvidence => { + if (typeof entry !== "object" || entry === null) return false; + const item = entry as Partial; + return /^0x[0-9a-f]{1,64}$/i.test(item.hash ?? "") + && typeof item.label === "string" + && item.label.length > 0 + && item.label.length <= 80 + && (item.vaultId === undefined || /^0x[0-9a-f]{1,64}$/i.test(item.vaultId)) + && typeof item.recordedAt === "string"; + }).slice(0, 8); + } catch { + return []; + } +} + +let receipts = restoreReceipts(); + +function recordReceipt(hash: string, label: string, vaultId?: string): void { + const evidence = Object.freeze({ hash: num.toHex(BigInt(hash)), label, vaultId, recordedAt: new Date().toISOString() }); + receipts = [evidence, ...receipts.filter((item) => item.hash !== evidence.hash)].slice(0, 8); + localStorage.setItem(RECEIPT_STORAGE_KEY, JSON.stringify(receipts)); +} +type PendingExit = Readonly<{ + action: "CANCEL_REFUND" | "CLAIM"; + vaultId: string; + exitPackage: Readonly>; + balanceBefore: string; +}>; + +const PENDING_EXIT_STORAGE_KEY = "afterlight:pending-exit:v1"; + +function restorePendingExit(): PendingExit | undefined { + try { + const value = JSON.parse(sessionStorage.getItem(PENDING_EXIT_STORAGE_KEY) ?? "null") as Partial | null; + if ( + value === null || + (value.action !== "CANCEL_REFUND" && value.action !== "CLAIM") || + !/^0x[0-9a-f]{1,64}$/i.test(value.vaultId ?? "") || + typeof value.exitPackage !== "object" || + value.exitPackage === null || + !/^[0-9]+$/.test(value.balanceBefore ?? "") + ) return undefined; + return value as PendingExit; + } catch { + return undefined; + } +} + +let pendingExit = restorePendingExit(); + +function retainPendingExit(value: PendingExit | undefined): void { + pendingExit = value; + if (value === undefined) sessionStorage.removeItem(PENDING_EXIT_STORAGE_KEY); + else sessionStorage.setItem(PENDING_EXIT_STORAGE_KEY, JSON.stringify(value)); +} +let exitCapacity: "checking" | "ready" | "exhausted" | "unknown" = "checking"; +let fundingCapacity: "checking" | "ready" | "exhausted" | "unknown" = "checking"; +let reserveMode: "NORMAL" | "FAST_DEMO" = "NORMAL"; + +async function refreshSponsorCapacity(): Promise { + const response = await fetch(`${RELAYER_URL}/health`, { + cache: "no-store", + credentials: "omit", + referrerPolicy: "no-referrer", + }); + const body = await response.json() as { + submission?: string; + claimCapacity?: { status?: string; fundingStatus?: string }; + }; + if (!response.ok || body.submission !== "enabled" || body.claimCapacity === undefined) { + throw new Error("The neutral sponsor capacity could not be verified. No wallet request was made."); + } + exitCapacity = body.claimCapacity.status === "ready" + ? "ready" + : body.claimCapacity.status === "exhausted" ? "exhausted" : "unknown"; + fundingCapacity = body.claimCapacity.fundingStatus === "ready" + ? "ready" + : body.claimCapacity.fundingStatus === "exhausted" ? "exhausted" : "unknown"; +} const appRoot = document.querySelector("#app"); if (!appRoot) throw new Error("Afterlight app root is missing."); @@ -39,6 +131,44 @@ function strk(value: bigint): string { return `${whole}${fraction ? `.${fraction.slice(0, 4)}` : ""} STRK`; } +function assertInvitationMatchesVault(invitation: RecoveryInvitation, snapshot: VaultSnapshot): void { + const mode = invitation.mode === "NORMAL" ? "0" : "1"; + for (const [label, observed, expected] of [ + ["owner key", snapshot.ownerKey, invitation.ownerKey], + ["successor key", snapshot.successorKey, invitation.successorKey], + ["token", snapshot.token, STRK], + ["amount", snapshot.amount, 10n ** 18n], + ["mode", snapshot.mode, mode], + ["inactivity", snapshot.inactivitySeconds, invitation.inactivitySeconds], + ["grace", snapshot.graceSeconds, invitation.graceSeconds], + ] as const) { + if (num.toHex(BigInt(observed)) !== num.toHex(BigInt(expected))) throw new Error(`Invitation ${label} does not match Mainnet state.`); + } +} + +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,34 +189,50 @@ 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.

    - ${applicationKey && isCurrent ? `${escapeHtml(applicationKey.publicKey)}
    ` : ``} - + 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)}

    ${backupState === "verified" ? "Backup restored and verified on this device." : backupState === "downloaded" ? "Now restore the downloaded file below to verify it before funding." : "Download and restore this key backup before funding."}

    ` : ``} + +
    Restore and verify a key backup
    `; } function walletRow(): string { - return `
    Ready X${escapeHtml(walletCopy())}
    `; + return `
    01
    Connect Ready X${escapeHtml(walletCopy())}
    `; +} + +function canFundReserve(): boolean { + return (fundingCapacity === "ready" || hasPendingCheckpointReconciliation()) + && walletStatus === "connected" + && privateBalance !== undefined + && privateBalance >= 7n * 10n ** 18n + && applicationKey !== undefined + && backupState === "verified" + && /^0x[0-9a-f]{1,64}$/i.test(successorPublicKey) + && costAcknowledged; } 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 = canFundReserve(); + const pendingFunding = hasPendingCheckpointReconciliation(); const liveControls = invitation.valid && vault?.exists; - return `
    -

    Owner · live Mainnet journey

    -

    Create a recovery reserve

    + return `
    +

    Owner · live on Mainnet

    Create a recovery reserve

    ${reserveMode === "NORMAL" ? "Long-term reserve" : "Recovery Drill"} · 1 STRK

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

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

    Both modes use fixed, contract-enforced terms.

    +
    Choose a timing mode
    - + - + + ${pendingFunding ? `

    This tab has an unresolved funding checkpoint. Continue only to reconcile that exact attempt; the Worker still performs the authoritative owner-aware capacity check.

    ` : fundingCapacity === "exhausted" ? `

    The supported route already has an outstanding reserve or private-exit capacity needs replenishment. New funding stays paused; existing vault controls remain available.

    ` : fundingCapacity === "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.

    ` : ""} + ${applicationKey && backupState !== "verified" ? `

    Download and restore the owner key backup before funding.

    ` : ""}
    ${invitation.valid ? controlPanel(invitation.invitation, liveControls ? vault : undefined) : ""}
    `; @@ -94,20 +240,30 @@ function ownerView(): string { function successorView(): string { const parsed = parseInvitation(invitationText); - return `
    -

    Successor · live Mainnet journey

    -

    Recover a reserve privately

    + return `
    +

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

    `; + } + const timing = parsed.invitation.mode === "NORMAL" ? "30 days + 7 days" : "5 + 5 min drill"; + return `
    02
    Invitation verified

    Contract, designated key and recovery terms match Afterlight Mainnet.

    Verified
    Vault${short(parsed.invitation.vaultId)}Reserve1 STRKTiming${timing}
    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 pendingCancellation = role === "owner" && pendingExit?.action === "CANCEL_REFUND" && pendingExit.vaultId === invitation.vaultId; + const pendingClaim = role === "successor" && pendingExit?.action === "CLAIM" && pendingExit.vaultId === invitation.vaultId; const now = Math.floor(Date.now() / 1000); const inactiveAt = Number(snapshot.lastHeartbeat) + Number(snapshot.inactivitySeconds); const requestReady = current === "ACTIVE" && now >= inactiveAt; @@ -118,12 +274,15 @@ 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" ? `` : ""} + ${pendingCancellation ? `

    This reuses the exact retained note and authorization; it does not prepare another exit.

    ` : ""} + ${pendingClaim ? `

    This reuses the exact retained note and authorization; it does not prepare another exit.

    ` : ""} + ${role === "owner" && current === "ACTIVE" && !pendingCancellation ? `${exitCapacity !== "ready" ? `

    Private cancellation is paused until sponsor exit capacity is restored.

    ` : ""}` : ""} ${role === "owner" && current === "GRACE" ? `` : ""} ${role === "successor" && current === "ACTIVE" ? `` : ""} - ${role === "successor" && current === "GRACE" ? `` : ""} + ${role === "successor" && current === "GRACE" && !pendingClaim ? `${exitCapacity !== "ready" && claimReady ? `

    Private recovery is paused until sponsor exit capacity is restored.

    ` : ""}` : ""}

    Heartbeat, request and veto use your local signature through the neutral relayer. The Ready wallet address is not sent.

    `; } @@ -131,19 +290,25 @@ function controlPanel(invitation: RecoveryInvitation, snapshot?: VaultSnapshot): function statusPanel(): string { const parsed = parseInvitation(invitationText); const current = vault?.exists ? stateName(vault.state) : "Not loaded"; - return `