diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db5478e..23b5888 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,3 +121,27 @@ jobs: run: npm --prefix ../client ci - run: npm run check - run: npm audit --audit-level=high + + web: + name: Public product gate + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: web + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.13.1 + cache: npm + cache-dependency-path: web/package-lock.json + + - run: npm ci + - name: Install shared client type dependencies + run: npm --prefix ../client ci + - run: npm run build + - run: npm audit --audit-level=high diff --git a/.github/workflows/relayer-health.yml b/.github/workflows/relayer-health.yml index 70b6a9a..7f10780 100644 --- a/.github/workflows/relayer-health.yml +++ b/.github/workflows/relayer-health.yml @@ -1,4 +1,4 @@ -name: Inert relayer staging health +name: Post-spike relayer health on: schedule: @@ -9,12 +9,12 @@ permissions: contents: read concurrency: - group: inert-relayer-health + group: afterlight-relayer-health cancel-in-progress: true jobs: health: - name: Verify fail-closed staging + name: Verify production submission and privacy boundary runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -40,10 +40,12 @@ jobs: .status == "ok" and .service == "afterlight-neutral-relayer" and .schema == "afterlight-relay/1" and - .submission == "disabled" and - .executor.configurationReady == false and - .executor.executable == false and - .balance.status == "disabled" and + .submission == "enabled" and + .executor.configurationReady == true and + .executor.signerAdapterAvailable == true and + .executor.executable == true and + .balance.status == "ok" and + .balance.alert == false and .privacy.payloadLogging == false and .privacy.appKeysHeld == false and .privacy.walletAddressesRequired == false diff --git a/.gitignore b/.gitignore index 6d180e7..6ba0662 100644 --- a/.gitignore +++ b/.gitignore @@ -2,11 +2,18 @@ target/ .snfoundry_cache/ client/node_modules/ client/dist/ +web/node_modules/ +web/dist/ client/tools/ready-quote.js client/tools/mainnet-operator.js +client/tools/declarer-funding.* +client/tools/quote-declarer-funding.mjs +client/tools/verify-declarer-funding.mjs client/tools/mainnet-operator-config.local.json client/tools/private-exit-preflight.js client/tools/private-exit-preflight.meta.json +client/tools/successor-activation +private-ready-lifecycle .DS_Store *.log .env diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..189ebd5 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,66 @@ +--- +version: alpha +name: Afterlight +description: User-first interaction guidance for a private Starknet recovery reserve. +colors: + ink: "#191915" + muted: "#68685f" + paper: "#FFFDF8" + line: "#D8D3C8" + accent: "#D86F31" + accent-dark: "#8E3514" + soft: "#EBE6DB" +typography: + body: + fontFamily: Inter, ui-sans-serif, system-ui, sans-serif + headline: + fontFamily: Georgia, Times New Roman, serif + 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. +--- + +## Overview + +Afterlight is an ordinary self-custody recovery product, not an evaluator surface. Lead with the useful result: create a bounded private recovery reserve, remain in control through heartbeat and veto, and let only the designated successor key authorize recovery after inactivity and grace. Keep STRK20 receipts and technical proof contextual to the action that produced them. + +## Colors + +Use warm paper and soft neutral surfaces to keep the recovery journey calm and legible. Ink carries primary text and actions; muted supports explanatory copy; the earthy accent identifies privacy guidance and selected controls without becoming a decorative gradient. Reserve distinct success and error treatments for state feedback, and never rely on color alone. + +## Typography + +Use the body family for controls, instructions, wallet state and receipts. Use the headline family only for the product promise and major journey titles; its restrained weight and tight setting establish hierarchy without turning operational screens into marketing pages. + +## 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. + +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. + +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. + +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. + +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. + +The privacy boundary belongs near reserve creation and recovery confirmation. State what remains public—contract, token, denomination, timing, application public keys, and state transitions—and what STRK20 keeps unlinked for public observers. Never describe Afterlight as automatic legal inheritance. + +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. + +## 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. + +Do not add “Judge,” “Judging,” “Proof,” or “Evidence” primary navigation. Do not lead with raw hashes, architecture, a generic explorer, or a separate evaluator dashboard. Do not create a one-use demo path that cannot recover after reload or an interrupted Ready X flow. Do not imply hidden amounts, hidden timing, invisible authorization keys, proof that a note belongs to a precommitted wallet, or legal inheritance guarantees. diff --git a/README.md b/README.md index ec42391..9716dd1 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,15 @@ Afterlight is a private, bounded recovery reserve for Starknet self-custody wall An owner privately funds a fixed reserve through STRK20 and remains in control through authenticated heartbeats and a veto window. If no authenticated heartbeat arrives during the configured interval, only the designated successor application key can authorize recovery to one exact private destination after the grace period. +[Open Afterlight](https://afterlight.dolepee.com) · [Mainnet evidence](docs/MAINNET.md) · [Deployed contract](https://starkscan.co/contract/0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25) + +[![CI](https://github.com/dolepee/afterlight/actions/workflows/ci.yml/badge.svg)](https://github.com/dolepee/afterlight/actions/workflows/ci.yml) + ## Current status -**Evidence level: E1 (locally repeatable implementation evidence).** The recovery contract, application-key client, structural prepared-exit envelope validator, and neutral-relayer boundary have deterministic local test and build coverage. The locked deployment artifacts also reproduce locally. +**Evidence level: E3 public completion.** The complete recovery mechanism has run through the deployed public app on Starknet Mainnet. Five successful transactions touch both the canonical STRK20 pool and Afterlight. The fresh public Recovery Drill completed private funding, heartbeat, request, veto, a second request, and [exact-note recovery](https://voyager.online/tx/0x722033f7fd0397ff4d3845428c98cad885b6a63824f7c78a2b7e1d7d6f5c1b6); Ready X then showed the successor's shielded balance increase from `7 STRK` to `8 STRK` while the neutral sponsor paid the pool and network fees. -**Release status: pre-deployment and not user-ready.** The Afterlight class and contract are not declared or deployed, no funded mainnet lifecycle has run, no qualifying transaction hashes exist, and no E2 mainnet result is claimed. The public relayer endpoint is an inert, submission-disabled packaging and health check; it is not a live recovery service. +**Release status: deployed public Mainnet product.** Afterlight is [deployed on Mainnet](https://starkscan.co/contract/0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25). The bounded neutral relayer executed the public control and recovery path without using either Ready role as the outer sender. The public app supports real Ready X connection, local per-vault keys, private funding, live state, relayed controls, exact-note recovery, contextual receipts, and post-claim balance reconciliation. ## Recovery flow @@ -39,10 +43,11 @@ Afterlight does not claim legal inheritance, invisible authorization keys, proof - `tests/` — Cairo unit and integration tests - `client/` — application keys, typed authorization messages, Ready/STRK20 action assembly - `relayer/` — fail-closed neutral-relayer implementation and operational controls +- `web/` — user-first owner and successor journeys for the deployed Mainnet release - `docs/ARCHITECTURE.md` — component boundaries, action routing, state and accounting model - `docs/THREAT_MODEL.md` — protected assets, trust assumptions, privacy limits, and failure handling -- `docs/READY_X_ONBOARDING.md` — pre-deployment Ready X prerequisites and planned owner/successor flow -- `docs/MAINNET.md` — pinned Mainnet dependencies, locked artifacts, and intentionally empty evidence slots +- `docs/READY_X_ONBOARDING.md` — Ready X prerequisites and owner/successor flow +- `docs/MAINNET.md` — pinned Mainnet dependencies, deployed artifacts, and transaction evidence ## Design documentation @@ -74,13 +79,16 @@ npm --prefix client test npm --prefix relayer ci npm --prefix relayer run check + +npm --prefix web ci +npm --prefix web run build ``` The public CI workflow also rebuilds the locked `spike-inline-56` deployment profile and verifies its exact Sierra and compiled class hashes before running -the Cairo, client, and relayer tests. It audits both npm trees, checks relayer -types and lint rules, and builds a submission-disabled Cloudflare Worker dry -run. +the Cairo, client, relayer, and web checks. It audits every npm tree, checks +relayer types and lint rules, builds both Worker configurations without +submitting, and compiles the production web bundle. No wallet seed, application private key, or relayer secret belongs in source control. The relayer remains unable to submit transactions unless its complete production configuration is installed and its explicit submission switch is enabled. diff --git a/client/README.md b/client/README.md index c54c97d..80f06ee 100644 --- a/client/README.md +++ b/client/README.md @@ -1,11 +1,12 @@ -# Afterlight client and local operator primitives (E1) +# Afterlight client and local operator primitives This package contains deterministic application-key, authorization, relay-schema, STRK20 action, structural prepared-exit envelope validation, read-only quote, and explicit local operator primitives. It has no public product UI or production storage. Its quote, preflight, and operator tools can read Mainnet; only the explicitly labelled operator buttons can request a wallet declaration or deployment, and each still -requires a visible Ready X confirmation. No mainnet deployment is claimed. +requires a visible Ready X confirmation. The deployed Mainnet release and its +four qualifying receipts are recorded in [`../docs/MAINNET.md`](../docs/MAINNET.md). ```sh npm ci @@ -17,10 +18,10 @@ The tested toolchain is Node.js `22.13.1` with the committed npm lockfile. The live exit canary is intentionally strict: `CANCEL_REFUND` and `CLAIM` give Ready exactly an `OPEN` transfer followed by the helper invoke. They never add a public self-withdraw. Ready adds its own private paymaster-fee withdrawal during -submission. A successful mainnet Wrenchless claim demonstrates this app-action -shape ([source](https://github.com/Timidan/wrenchless/blob/b61e989ad9f24d9ba4803bf28b796564579bd220/packages/canary-core/src/refill-claim.ts), -[mainnet receipt](https://voyager.online/tx/0x02e969f712d5ff8f3091bd42b06978c285c8ad221081da5f575afbc72f87888e)), -but Afterlight must still reproduce it with its own helper before promotion. +submission. Afterlight reproduced this exact-note route through its own helper +for both `CANCEL_REFUND` and `CLAIM` on Mainnet. The contract states, liability, +pool allowance, exact destination notes, and four qualifying receipts reconcile; +Ready's wallet-visible post-exit balance remains the final E2 visibility check. Application secrets are held by `LocalStarkKey`; ordinary serialization exposes only the public key. Raw secret export requires the explicitly named backup method and confirmation constant. Relayer requests contain only signed public calldata for `HEARTBEAT`, `REQUEST`, or `VETO`. @@ -33,6 +34,13 @@ only to the alternative route where a dApp/paymaster submits the exact Ready `wallet_strk20InvokeTransaction`, which accepts actions and generates a separate fee-bearing proof. +`assertManagedReadyExitEvidence` is the independent post-receipt gate for that +normal Ready route. It parses the actual outer transaction, rejects either +Ready role account as sender, locks the observed Ready sponsor +forwarder/selector, exact pool call, signed note and Afterlight calldata, and +then requires a succeeded receipt plus exact reserve-minus-fee shielded-balance +and liability deltas. A prepared action or wallet review cannot satisfy it. + The mainnet quote tool is simulation-only. It has no signing or submission method. Build the selected Scarb profile first, then set a deployed public account as the intended deployer to derive its exact UDC address: @@ -45,9 +53,9 @@ AFTERLIGHT_SIMULATION_SENDER=0x... \ npm run quote:mainnet ``` -`AFTERLIGHT_SURPLUS_ADMIN` defaults to that same public address and may be set -separately. The tool prints the full constructor calldata so the eventual -wallet review can be reconciled field-for-field. +The tool prints the full constructor calldata so the eventual wallet review can +be reconciled field-for-field. The contract has no surplus administrator or +administrative withdrawal path. ## Local mainnet operator @@ -56,7 +64,7 @@ deployment operator. It does not auto-sign or auto-submit. Before enabling its two wallet-impacting buttons it recomputes the Sierra and CASM hashes, derives the exact UDC address, verifies Mainnet, checks the intended deployer, and reads whether the class and contract already exist. Once deployed, the same read-only -refresh fails closed unless Mainnet returns the locked class hash and all eleven +refresh fails closed unless Mainnet returns the locked class hash and all ten constructor-derived configuration fields exactly. Copy `tools/mainnet-operator-config.example.json` to the ignored @@ -74,7 +82,7 @@ separate, visible Ready wallet confirmation. ## Local private-exit preflight -`tools/private-exit-preflight.html` is an E1-only, no-submit check for +`tools/private-exit-preflight.html` is a no-submit structural check for `CANCEL_REFUND` and `CLAIM`. It reads the currently selected Ready account directly and silently before and after each Prepare call, enforces the operation's plausible state/epoch/nonce and 900-second authorization window, diff --git a/client/src/actions.ts b/client/src/actions.ts index 7dd7f79..c4a7c32 100644 --- a/client/src/actions.ts +++ b/client/src/actions.ts @@ -18,18 +18,15 @@ export const CANONICAL_STRK20_POOL = "0x040337b1af3c663e86e333bab5a4b28da8d4652a15a69beee2b677776ffe812a"; export const PINNED_STRK20_POOL_CLASS_HASH = "0x067dddd89d80fedadc06b6f160798f94800a4a70164e5a24301cd0d6076b554d"; +/** Empirical Ready X 5.33.9 Mainnet sponsor envelope, independently observed onchain. */ +export const LOCKED_READY_SPONSOR_FORWARDER = + "0x0127021a1b5a52d3174c2ab077c2b043c80369250d29428cee956d76ee51584f"; +export const LOCKED_READY_SPONSOR_SELECTOR = + "0x03bd4b5033e788e9cc450fefa99ea20e3bed0fa358c8b280c0488f0c4647472e"; -const STARKNET_MAINNET_CHAIN_ID = 0x534e5f4d41494en; -const STRK_TOKEN = 0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938dn; const OPEN_NOTE_PACKED_VALUE = 1n << 128n; -const PROOF_VERSION = toBigInt(shortString.encodeShortString("PROOF0")); const VIRTUAL_SNOS = toBigInt(shortString.encodeShortString("VIRTUAL_SNOS")); const VIRTUAL_SNOS0 = toBigInt(shortString.encodeShortString("VIRTUAL_SNOS0")); -const VIRTUAL_PROGRAM_HASH = - 0x3e98c2d7703b03a7edb73ed7f075f97f1dcbaa8f717cdf6e1a57bf058265473n; -const STARKNET_OS_CONFIG_HASH_VERSION = toBigInt( - shortString.encodeShortString("StarknetOsConfig3"), -); /** The wallet API may expose its prepared call in wire or Starknet.js form. */ export type PreparedPoolCall = @@ -117,6 +114,119 @@ export function buildClaimActions( return buildExitActions(PrivateAction.Claim, contract, noteRecipient, args); } +/** + * Rebuild a Ready-managed exit after a sentinel prepare has resolved the exact + * open-note ID and the role key has signed it. Ready still creates the OPEN + * note, while the helper calldata carries the literal signed destination. + * Ready's private paymaster appends and sponsors its fee action; the dApp must + * reconcile that outer envelope and the exact note from the mainnet receipt. + */ +export function buildManagedCancelRefundActions( + contract: FeltInput, + noteRecipient: FeltInput, + args: ExitArgs, +): readonly STRK20_ACTION[] { + return buildManagedExitActions(PrivateAction.CancelRefund, contract, noteRecipient, args); +} + +export function buildManagedClaimActions( + contract: FeltInput, + noteRecipient: FeltInput, + args: ExitArgs, +): readonly STRK20_ACTION[] { + return buildManagedExitActions(PrivateAction.Claim, contract, noteRecipient, args); +} + +export type ManagedReadyExitTransaction = Readonly<{ + transactionHash: FeltInput; + senderAddress: FeltInput; + calldata: readonly FeltInput[]; +}>; + +export type ManagedReadyExitReceipt = Readonly<{ + transactionHash: FeltInput; + finalityStatus: "ACCEPTED_ON_L1" | "ACCEPTED_ON_L2"; + executionStatus: "SUCCEEDED" | "REVERTED"; + poolEventCount: number; + afterlightEventCount: number; + openNoteCreatedEventCount: number; + poolFeeWithdrawalCount: number; + poolFeeCollectorTransferCount: number; +}>; + +export type ManagedReadyExitEvidence = Readonly<{ + transaction: ManagedReadyExitTransaction; + receipt: ManagedReadyExitReceipt; + readyAccounts: readonly FeltInput[]; + contract: FeltInput; + kind: PrivateAction.CancelRefund | PrivateAction.Claim; + signedArgs: ExitArgs; + poolFee: FeltInput; + shieldedBalanceBefore: FeltInput; + shieldedBalanceAfter: FeltInput; + lockedLiabilityBefore: FeltInput; + lockedLiabilityAfter: FeltInput; +}>; + +/** + * Post-receipt kill gate for a Ready-managed exact-note exit. This parses the + * actual outer transaction independently of the action builder, rejects a + * Ready account as sender, and reconciles the successful receipt plus value + * deltas. It is deliberately unusable as pre-sign proof: only an accepted + * Mainnet receipt can satisfy it. + */ +export function assertManagedReadyExitEvidence(input: ManagedReadyExitEvidence): void { + const transactionHash = felt(input.transaction.transactionHash, "managed exit transaction hash"); + if (transactionHash === "0x0" || felt(input.receipt.transactionHash, "managed exit receipt hash") !== transactionHash) { + throw new Error("managed exit transaction and receipt hashes differ"); + } + if ( + input.receipt.executionStatus !== "SUCCEEDED" || + (input.receipt.finalityStatus !== "ACCEPTED_ON_L1" && + input.receipt.finalityStatus !== "ACCEPTED_ON_L2") + ) { + throw new Error("managed exit receipt is not accepted and succeeded"); + } + if ( + input.receipt.poolEventCount < 1 || + input.receipt.afterlightEventCount !== 1 || + input.receipt.openNoteCreatedEventCount !== 1 || + input.receipt.poolFeeWithdrawalCount !== 1 || + input.receipt.poolFeeCollectorTransferCount !== 1 + ) { + throw new Error("managed exit receipt does not prove the exact pool/helper/note/fee result"); + } + + const sender = address(input.transaction.senderAddress, "managed exit outer sender"); + if (toBigInt(sender, "managed exit outer sender") === 0n) { + throw new Error("managed exit outer sender is zero"); + } + if (input.readyAccounts.some((candidate) => address(candidate, "Ready account") === sender)) { + throw new Error("managed exit exposed a Ready account as outer sender"); + } + + const poolFee = u128(input.poolFee, "managed exit pool fee"); + const amount = u128(input.signedArgs.amount, "managed exit amount"); + const shieldedBefore = u128(input.shieldedBalanceBefore, "managed exit shielded balance before"); + const shieldedAfter = u128(input.shieldedBalanceAfter, "managed exit shielded balance after"); + const lockedBefore = u128(input.lockedLiabilityBefore, "managed exit liability before"); + const lockedAfter = u128(input.lockedLiabilityAfter, "managed exit liability after"); + if (shieldedBefore + amount !== shieldedAfter + poolFee) { + throw new Error("managed exit shielded-balance delta is not reserve minus fee"); + } + if (lockedBefore !== lockedAfter + amount) { + throw new Error("managed exit liability did not decrease by the exact reserve"); + } + + assertManagedReadyExitRelayEnvelope( + input.transaction.calldata, + input.contract, + input.kind, + input.signedArgs, + poolFee, + ); +} + /** Parse the one Afterlight exit in a prepared `pool.apply_actions` call. */ export function resolvePreparedExitNoteId( preparedCall: PreparedPoolCall, @@ -467,6 +577,33 @@ function buildExitActions( return Object.freeze(actions); } +function buildManagedExitActions( + kind: PrivateAction.CancelRefund | PrivateAction.Claim, + contract: FeltInput, + noteRecipient: FeltInput, + args: ExitArgs, +): readonly STRK20_ACTION[] { + if (args.note_id === OPEN_NOTE_PLACEHOLDER) { + throw new Error("managed Ready exit requires the resolved, signed open-note ID"); + } + const target = address(contract, "Afterlight contract"); + const actions: STRK20_ACTION[] = [ + { + type: "transfer", + token: address(args.token, "token"), + amount: "OPEN", + recipient: address(noteRecipient, "open-note recipient"), + }, + { + type: "invoke", + contract: target, + calldata: serializeExit(kind, args), + }, + ]; + assertNoSelfWithdraw(actions, noteRecipient); + return Object.freeze(actions); +} + /** A live-path invariant: private exits never add NIGHTSHIFT's public self-withdraw canary. */ export function assertNoSelfWithdraw( actions: readonly STRK20_ACTION[], @@ -730,7 +867,15 @@ function assertProofOutputMatchesCall( } } -/** Validate the pinned nine-felt blockifier/Cairo ProofFacts serialization. */ +/** + * Validate the nine-felt ProofFacts fields enforced by the canonical pool. + * The pool deliberately deserializes but ignores proof_version (field 0), + * virtual_program_hash (field 2), base_block_hash (field 5), and + * starknet_os_config_hash (field 6). The SNIP-36 proof verifier remains + * authoritative for those metadata fields. We still independently bind field + * 5 to the accepted Mainnet block used by the proof; field 6 must remain + * unpinned because the canonical pool does not define that invariant. + */ function assertCanonicalProofFacts( prepared: PreparedCallAndProof, validated: ValidatedPreparedExit, @@ -748,14 +893,6 @@ function assertCanonicalProofFacts( throw new Error("proof base block must identify a nonzero mainnet block"); } - const configHash = toBigInt( - hash.computeHashOnElements([ - STARKNET_OS_CONFIG_HASH_VERSION, - STARKNET_MAINNET_CHAIN_ID, - STRK_TOKEN, - ]), - "Starknet OS config hash", - ); const actions = validated.normalized.calldata.slice(0, validated.actionsEnd); const poolClassHash = toBigInt(PINNED_STRK20_POOL_CLASS_HASH, "pinned pool class hash"); const payload = [poolClassHash, ...actions]; @@ -766,13 +903,13 @@ function assertCanonicalProofFacts( ...payload, ]); const expected = [ - PROOF_VERSION, + undefined, VIRTUAL_SNOS, - VIRTUAL_PROGRAM_HASH, + undefined, VIRTUAL_SNOS0, expectedBaseBlockNumber, expectedBaseBlockHash, - configHash, + undefined, 1n, messageHash, ] as const; @@ -804,6 +941,144 @@ function notesStorageAddress(noteId: bigint): bigint { return raw % constants.ADDR_BOUND; } +function assertManagedReadyExitRelayEnvelope( + calldata: readonly FeltInput[], + contract: FeltInput, + kind: PrivateAction.CancelRefund | PrivateAction.Claim, + signedArgs: ExitArgs, + poolFee: bigint, +): void { + if (signedArgs.note_id === OPEN_NOTE_PLACEHOLDER) { + throw new Error("managed exit evidence requires the literal signed note ID"); + } + const raw = calldata.map((entry, index) => + toBigInt(entry, `managed exit outer calldata[${index}]`), + ); + let cursor = 0; + const take = (label: string): bigint => { + const value = raw[cursor]; + if (value === undefined) throw new Error(`managed exit outer calldata truncated at ${label}`); + cursor += 1; + return value; + }; + const takeSpan = (length: bigint, label: string): readonly bigint[] => { + if (length < 0n || length > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error(`managed exit outer calldata has invalid ${label} length`); + } + const numeric = Number(length); + if (cursor + numeric > raw.length) { + throw new Error(`managed exit outer calldata truncated at ${label}`); + } + const span = raw.slice(cursor, cursor + numeric); + cursor += numeric; + return span; + }; + + if (take("call count") !== 2n) throw new Error("managed exit sponsor must submit exactly two outer calls"); + const feeTarget = take("fee target"); + const feeSelector = take("fee selector"); + const feeCalldata = takeSpan(take("fee calldata length"), "fee calldata"); + const relayTarget = take("relay target"); + const relaySelector = take("relay selector"); + const relay = takeSpan(take("relay calldata length"), "relay calldata"); + if (cursor !== raw.length) throw new Error("managed exit sponsor outer calldata has a trailing call"); + + const token = toBigInt(signedArgs.token, "managed exit token"); + const lockedForwarder = toBigInt(LOCKED_READY_SPONSOR_FORWARDER, "Ready sponsor forwarder"); + if ( + feeTarget !== token || + feeSelector !== toBigInt(hash.getSelectorFromName("transfer"), "transfer selector") || + feeCalldata.length !== 3 || + feeCalldata[0] !== lockedForwarder || + feeCalldata[1] !== poolFee || + feeCalldata[2] !== 0n || + relayTarget !== lockedForwarder || + relaySelector !== toBigInt(LOCKED_READY_SPONSOR_SELECTOR, "Ready sponsor selector") + ) { + throw new Error("managed exit sponsor fee or forwarder envelope differs"); + } + + if ( + relay.length < 10 || + relay[0] !== 1n || + relay[1] !== toBigInt(CANONICAL_STRK20_POOL, "canonical pool") || + relay[2] !== toBigInt(hash.getSelectorFromName("apply_actions"), "apply_actions selector") + ) { + throw new Error("managed exit sponsor does not forward one canonical pool call"); + } + const poolLength = relay[3]!; + if (poolLength < 0n || poolLength > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error("managed exit pool calldata length is invalid"); + } + const poolEnd = 4 + Number(poolLength); + if (relay.length !== poolEnd + 5) throw new Error("managed exit relay trailer length differs"); + const pool = relay.slice(4, poolEnd); + const trailer = relay.slice(poolEnd); + if ( + trailer[0] !== token || + trailer[1] !== poolFee || + trailer[2] !== 0n || + trailer[3] !== 1n || + trailer[4] === 0n + ) { + throw new Error("managed exit relay fee trailer differs"); + } + + const parsed = parseServerInvokes(pool, "strict-none-suffix"); + if (parsed.openNotes.length !== 1 || parsed.writeOnceActions.length !== 1 || parsed.invokes.length !== 1) { + throw new Error("managed exit must create one exact open note and invoke Afterlight once"); + } + const openNote = parsed.openNotes[0]!; + const signedNoteId = felt(signedArgs.note_id, "managed exit signed note ID"); + if (openNote.token !== address(signedArgs.token, "managed exit note token") || openNote.noteId !== signedNoteId) { + throw new Error("managed exit open note differs from the signed destination"); + } + const writeOnce = parsed.writeOnceActions[0]!; + if ( + writeOnce.storageAddress !== notesStorageAddress(toBigInt(signedNoteId, "managed exit note ID")) || + writeOnce.value.length !== 2 || + writeOnce.value[0] !== OPEN_NOTE_PACKED_VALUE || + writeOnce.value[1] !== token + ) { + throw new Error("managed exit note storage differs from the signed destination"); + } + const invoke = parsed.invokes[0]!; + const expectedInvoke = serializeExit(kind, signedArgs).map((entry, index) => + toBigInt(entry, `managed exit expected invoke[${index}]`), + ); + if ( + invoke.target !== address(contract, "Afterlight contract") || + invoke.calldata.length !== expectedInvoke.length || + invoke.calldata.some((entry, index) => entry !== expectedInvoke[index]) + ) { + throw new Error("managed exit Afterlight invocation differs from the signed authorization"); + } + + let feeTransferTo = 0; + let feeWithdrawal = 0; + for (const action of parsed.actions) { + if (action.variant === 2n || action.variant === 4n || action.variant === 6n || action.variant === 11n) { + throw new Error("managed exit contains a forbidden server action"); + } + const fields = pool.slice(action.start + 1, action.end); + if (action.variant === 3n) { + if (fields.length !== 3 || fields[0] !== lockedForwarder || fields[1] !== token || fields[2] !== poolFee) { + throw new Error("managed exit contains an unexpected public transfer"); + } + feeTransferTo += 1; + } + if (action.variant === 5n) { + if (fields.length !== 6 || fields[3] !== lockedForwarder || fields[4] !== token || fields[5] !== poolFee) { + throw new Error("managed exit contains an unexpected withdrawal"); + } + feeWithdrawal += 1; + } + } + if (feeTransferTo !== 1 || feeWithdrawal !== 1) { + throw new Error("managed exit does not contain exactly one private fee reimbursement"); + } +} + /** Decode enough of the current pool ServerAction ABI to locate top-level Invoke actions. */ function parseServerInvokes( raw: readonly bigint[], diff --git a/client/test/actions.test.ts b/client/test/actions.test.ts index ade3c02..3f34604 100644 --- a/client/test/actions.test.ts +++ b/client/test/actions.test.ts @@ -7,12 +7,17 @@ import { constants, ec, hash, shortString } from "starknet"; import { address, assertExactDappSubmittedPreparedExit, + assertManagedReadyExitEvidence, assertNoSelfWithdraw, bindDappSubmittedPreparedExit, buildCancelRefundActions, buildClaimActions, buildFundActions, + buildManagedCancelRefundActions, + buildManagedClaimActions, CANONICAL_STRK20_POOL, + LOCKED_READY_SPONSOR_FORWARDER, + LOCKED_READY_SPONSOR_SELECTOR, OPEN_NOTE_PLACEHOLDER, PINNED_STRK20_POOL_CLASS_HASH, PREPARE_SIGNATURE, @@ -133,6 +138,143 @@ test("exit builders reject literal note IDs and explicit self-withdraws", () => assert.throws(() => assertNoSelfWithdraw(unsafe, account), /self-withdraw is forbidden/); }); +test("managed Ready exits retain the exact signed note while Ready creates the OPEN note", () => { + const resolved = { ...exit, note_id: "0x777" }; + for (const [kind, actions] of [ + [PrivateAction.CancelRefund, buildManagedCancelRefundActions(contract, account, resolved)], + [PrivateAction.Claim, buildManagedClaimActions(contract, account, resolved)], + ] as const) { + assert.deepEqual(actions[0], { + type: "transfer", + token: address(token), + amount: "OPEN", + recipient: address(account), + }); + assert.equal(actions[1]!.type, "invoke"); + if (actions[1]!.type === "invoke") { + assert.equal(actions[1]!.calldata[0], `0x${kind.toString(16)}`); + assert.equal(actions[1]!.calldata[7], "0x777"); + } + assert.equal(actions.some((action) => action.type === "withdraw"), false); + } +}); + +test("managed Ready exits reject an unresolved destination", () => { + assert.throws( + () => buildManagedClaimActions(contract, account, exit), + /requires the resolved, signed open-note ID/, + ); +}); + +function managedExitEvidence(overrides: Readonly> = {}) { + const signedArgs = { ...exit, note_id: "0x777" }; + const invokeCalldata = serializeExit(PrivateAction.Claim, signedArgs); + const fee = 6n * 10n ** 18n; + const serverActions = [ + ["0x0", noteStorageAddress("0x777"), "0x2", hex(openNotePackedValue), address(token)], + ["0x7", "0xa11d", "0xe11e", "0xec11", address(token), "0x777"], + ["0xa", address(contract), "0xb", ...invokeCalldata], + ["0x3", LOCKED_READY_SPONSOR_FORWARDER, address(token), hex(fee)], + ["0x5", "0x111", "0x222", "0x333", LOCKED_READY_SPONSOR_FORWARDER, address(token), hex(fee)], + ]; + const poolCalldata = [hex(BigInt(serverActions.length)), ...serverActions.flat(), "0x1"]; + const relayCalldata = [ + "0x1", + address(pool), + hash.getSelectorFromName("apply_actions"), + hex(BigInt(poolCalldata.length)), + ...poolCalldata, + address(token), + hex(fee), + "0x0", + "0x1", + "0x1234", + ]; + const transaction = { + transactionHash: "0xabc", + senderAddress: "0x7777", + calldata: [ + "0x2", + address(token), + hash.getSelectorFromName("transfer"), + "0x3", + LOCKED_READY_SPONSOR_FORWARDER, + hex(fee), + "0x0", + LOCKED_READY_SPONSOR_FORWARDER, + LOCKED_READY_SPONSOR_SELECTOR, + hex(BigInt(relayCalldata.length)), + ...relayCalldata, + ], + }; + return { + transaction, + receipt: { + transactionHash: "0xabc", + finalityStatus: "ACCEPTED_ON_L2", + executionStatus: "SUCCEEDED", + poolEventCount: 7, + afterlightEventCount: 1, + openNoteCreatedEventCount: 1, + poolFeeWithdrawalCount: 1, + poolFeeCollectorTransferCount: 1, + }, + readyAccounts: [account, "0x8888"], + contract, + kind: PrivateAction.Claim, + signedArgs, + poolFee: fee, + shieldedBalanceBefore: 12n * 10n ** 18n, + shieldedBalanceAfter: 16n * 10n ** 18n, + lockedLiabilityBefore: 20n * 10n ** 18n, + lockedLiabilityAfter: 10n * 10n ** 18n, + ...overrides, + } as Parameters[0]; +} + +test("managed Ready exit evidence binds sender, sponsor, exact note, receipt, and deltas", () => { + const exact = managedExitEvidence(); + assert.doesNotThrow(() => assertManagedReadyExitEvidence(exact)); + assert.throws( + () => assertManagedReadyExitEvidence(managedExitEvidence({ + transaction: { ...exact.transaction, senderAddress: account }, + })), + /exposed a Ready account/, + ); + assert.throws( + () => assertManagedReadyExitEvidence(managedExitEvidence({ + receipt: { ...exact.receipt, executionStatus: "REVERTED" }, + })), + /not accepted and succeeded/, + ); + assert.throws( + () => assertManagedReadyExitEvidence(managedExitEvidence({ shieldedBalanceAfter: 15n * 10n ** 18n })), + /shielded-balance delta/, + ); + assert.throws( + () => assertManagedReadyExitEvidence(managedExitEvidence({ lockedLiabilityAfter: 11n * 10n ** 18n })), + /liability/, + ); + assert.throws( + () => assertManagedReadyExitEvidence(managedExitEvidence({ + signedArgs: { ...exact.signedArgs, note_id: "0x778" }, + })), + /open note differs from the signed destination/, + ); + assert.throws( + () => assertManagedReadyExitEvidence(managedExitEvidence({ contract: "0x4321" })), + /Afterlight invocation differs/, + ); + const changedEnvelope = [...exact.transaction.calldata]; + changedEnvelope[5] = "0x1"; + assert.throws( + () => assertManagedReadyExitEvidence(managedExitEvidence({ + transaction: { ...exact.transaction, calldata: changedEnvelope }, + })), + /sponsor fee or forwarder/, + ); +}); + function hex(value: bigint): string { return `0x${value.toString(16)}`; } @@ -156,7 +298,7 @@ function canonicalProofFacts(serverActions: readonly string[], classHash: string ...payload, ]); return [ - shortString.encodeShortString("PROOF0"), + shortString.encodeShortString("PROOF1"), shortString.encodeShortString("VIRTUAL_SNOS"), hex(virtualProgramHash), shortString.encodeShortString("VIRTUAL_SNOS0"), @@ -471,6 +613,12 @@ test("final proof pins the pool class and canonical ProofFacts message", () => { const wrongMessage = preparedExit(PrivateAction.Claim, exit, "0xdeadbeef"); wrongMessage.proof.proof_facts[8] = "0x123"; assert.throws(() => validate(wrongMessage), /proof facts differ at field\[8\]/); + + const verifierUpgradeMetadata = preparedExit(PrivateAction.Claim, exit, "0xdeadbeef"); + verifierUpgradeMetadata.proof.proof_facts[0] = "0x50524f4f4631"; + verifierUpgradeMetadata.proof.proof_facts[2] = "0x123456"; + verifierUpgradeMetadata.proof.proof_facts[6] = "0x654321"; + assert.equal(validate(verifierUpgradeMetadata).noteId, "0xdeadbeef"); }); test("dApp-submitted route binds the signed note to the exact PrepareInvoke response", () => { diff --git a/client/test/exit-preflight.test.ts b/client/test/exit-preflight.test.ts index 401f1ec..090aa40 100644 --- a/client/test/exit-preflight.test.ts +++ b/client/test/exit-preflight.test.ts @@ -113,7 +113,7 @@ function canonicalProofFacts(serverActions: readonly string[], classHash: string ...payload, ]); return [ - shortString.encodeShortString("PROOF0"), + shortString.encodeShortString("PROOF1"), shortString.encodeShortString("VIRTUAL_SNOS"), hex(virtualProgramHash), shortString.encodeShortString("VIRTUAL_SNOS0"), diff --git a/client/tools/mainnet-operator.html b/client/tools/mainnet-operator.html index c8be2f4..eb28f43 100644 --- a/client/tools/mainnet-operator.html +++ b/client/tools/mainnet-operator.html @@ -49,6 +49,6 @@

3. Request wallet reviews

No wallet request made.
- + diff --git a/client/tools/mainnet-operator.ts b/client/tools/mainnet-operator.ts index 0da20d8..32bc11f 100644 --- a/client/tools/mainnet-operator.ts +++ b/client/tools/mainnet-operator.ts @@ -80,6 +80,7 @@ let contractDeployed = false; let deploymentVerification: VerifiedDeploymentState | undefined; function normalized(value: string): string { + if (value.trim().toUpperCase() === "SN_MAIN") return MAINNET.toLowerCase(); return num.toHex(BigInt(value)).toLowerCase(); } @@ -118,8 +119,8 @@ async function loadReviewPackage(): Promise { if (normalized(loadedConfig.chainId) !== normalized(MAINNET)) { throw new Error("The operator configuration is not for Starknet Mainnet."); } - if (loadedConfig.constructorCalldata.length !== 11) { - throw new Error("Expected exactly eleven Afterlight constructor fields."); + if (loadedConfig.constructorCalldata.length !== 10) { + throw new Error("Expected exactly ten Afterlight constructor fields."); } const [loadedSierra, loadedCasm] = await Promise.all([ @@ -164,7 +165,7 @@ async function loadReviewPackage(): Promise { constructorCalldata: loadedConfig.constructorCalldata, quotedDeclarationStrk: loadedConfig.quotedDeclarationStrk, quotedDeploymentStrk: loadedConfig.quotedDeploymentStrk, - declarationTransport: "ready_injected_v5_enable_then_explicit_class_hash", + declarationTransport: "ready_injected_v5_exact_sn_main_bigint_adapter", signed: false, submitted: false, }, @@ -252,18 +253,31 @@ async function requestDeclarationReview(): Promise { if (!sierra) throw new Error("The verified Sierra artifact is unavailable."); await refreshNetworkState(); if (classDeclared) throw new Error("The locked Afterlight class is already declared."); - // Ready 5.33.9's standard wallet_addDeclareTransaction path drops class_hash - // and hashes an already-stringified ABI. Initialize its injected v5 account - // explicitly so the locked class hash reaches the review and transaction. + // Ready 5.33.9's standard handler serializes the Sierra class before its + // internal action and cannot complete fee estimation. Its legacy v5 route + // preserves the exact class and hashes, but initializes Starknet.js with the + // textual chain ID `SN_MAIN`, which that bundled version passes to BigInt. + // Adapt only that exact equivalent value during initialization, then restore + // the native global before any wallet review or transaction signing. const legacy = window.starknet_argentX; if (!legacy) throw new Error("Ready X did not expose its injected declaration provider."); - const [enabledAddress] = await legacy.enable({ starknetVersion: "v5" }); + const nativeBigInt = window.BigInt; + const compatibleBigInt = ((value: string | number | bigint | boolean) => + nativeBigInt(typeof value === "string" && value.trim().toUpperCase() === "SN_MAIN" ? locked.chainId : value)) as BigIntConstructor; + compatibleBigInt.asIntN = nativeBigInt.asIntN; + compatibleBigInt.asUintN = nativeBigInt.asUintN; + let enabledAddress: string | undefined; + try { + window.BigInt = compatibleBigInt; + [enabledAddress] = await legacy.enable({ starknetVersion: "v5" }); + } finally { + window.BigInt = nativeBigInt; + } if (!enabledAddress || normalized(enabledAddress) !== normalized(locked.intendedDeployer)) { throw new Error("Ready X initialized the wrong injected declaration account."); } - if (!legacy.account) throw new Error("Ready X did not initialize its declaration account."); - if (normalized(legacy.account.address) !== normalized(locked.intendedDeployer)) { - throw new Error("Ready X's injected declaration account is not the locked deployer."); + if (!legacy.account || normalized(legacy.account.address) !== normalized(locked.intendedDeployer)) { + throw new Error("Ready X did not initialize the locked declaration account."); } if (legacy.chainId && normalized(legacy.chainId) !== normalized(locked.chainId)) { throw new Error("Ready X's injected declaration account is not on Starknet Mainnet."); @@ -271,6 +285,9 @@ async function requestDeclarationReview(): Promise { const result = await legacy.account.declare( buildReadyLegacyDeclarationPayload(sierra, locked.classHash, locked.compiledClassHash), ); + if (normalized(result.class_hash) !== normalized(locked.classHash)) { + throw new Error(`Ready returned an unexpected declared class hash: ${result.class_hash}`); + } output.textContent = JSON.stringify( { operation: "declaration_submitted_by_ready", diff --git a/client/tools/quote-declare-deploy.mjs b/client/tools/quote-declare-deploy.mjs index 61684d4..198c034 100644 --- a/client/tools/quote-declare-deploy.mjs +++ b/client/tools/quote-declare-deploy.mjs @@ -23,6 +23,10 @@ const allowedProfiles = new Set([ "spike-inline-4", "spike-inline-8", "spike-inline-12", + "spike-inline-32", + "spike-inline-40", + "spike-inline-48", + "spike-inline-52", "spike-inline-56", ]); if (!allowedProfiles.has(compilerProfile)) { @@ -82,11 +86,13 @@ const pool = "0x040337b1af3c663e86e333bab5a4b28da8d4652a15a69beee2b677776ffe812a"; const strk = "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d"; +const fixedAmount = BigInt( + process.env.AFTERLIGHT_FIXED_AMOUNT_FRI ?? "1000000000000000000", +); const constructorCalldata = [ pool, strk, - process.env.AFTERLIGHT_SURPLUS_ADMIN ?? simulationSender, - 10_000_000_000_000_000_000n, + fixedAmount, 2_592_000n, 604_800n, 300n, diff --git a/client/tools/verify-locked-artifacts.mjs b/client/tools/verify-locked-artifacts.mjs index e8b8bf0..f348ad1 100644 --- a/client/tools/verify-locked-artifacts.mjs +++ b/client/tools/verify-locked-artifacts.mjs @@ -7,11 +7,11 @@ import { hash } from "starknet"; const profile = "spike-inline-56"; const expected = { classHash: - "0x066654717cccb9875687a1abc8defe640f08f709c01715ea828f14c1ec5c7f25", + "0x05da9866f62cc6dd1e380e8d9206e78a752b460abdb802070e0be1208ec7b1a6", compiledClassHash: - "0x05a3c0719b75e0c4655f707f95c7930b1b72291261138337b5b5ca0f3019e3b7", - sierraWords: 6107, - casmWords: 13437, + "0x055ba10e36aac8e21b3437f1413f009f6b17d3633c307941a4412ce73566251", + sierraWords: 5767, + casmWords: 12725, }; const clientDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url))); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ca81758..b7a2854 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,8 +8,8 @@ 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, and E1 proof-envelope/call binding | Relayer account key | -| Neutral relayer | Submit bounded `HEARTBEAT`, `REQUEST`, `VETO`, and checkpoint transactions from one neutral account | Owner/successor secrets, Ready addresses, or authority over contract state | +| 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 | ## Action routing @@ -17,6 +17,14 @@ 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, +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. + ```text Ready X + STRK20 pool |-- FUND ------------------------------> Afterlight liability + ACTIVE vault @@ -63,19 +71,15 @@ 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 constructor assigns a public `surplus_admin`. That address may transfer only -the amount strictly above total locked liabilities; it cannot use the surplus -path to withdraw a vault reserve, change a vault, or change protocol -configuration. This is nevertheless an administrative trust boundary: the -admin chooses the public recipient of unaccounted donations, and an accidental -donor has no protocol-level right to reclaim them. A compromised admin can take -available surplus, while the contract's liability check is the control that -protects accounted user reserves. +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 +recovery of tokens sent directly to the helper. ## Vault modes -`NORMAL` enforces production-oriented inactivity and grace bounds. `FAST_DEMO` permits short intervals while preserving the same signatures, accounting, caller restrictions, exact-note binding, and replay protection. The mode is immutable per vault and caps the principal at 10 STRK. +`NORMAL` enforces production-oriented inactivity and grace bounds. `FAST_DEMO` permits short intervals while preserving the same signatures, accounting, caller restrictions, exact-note binding, and replay protection. The mode is immutable per vault and caps the principal at 10 STRK. The lean mainnet release fixes the reserve at 1 STRK; changing the denomination requires a different deployment address and an explicit release review. ## Current release boundary -The repository currently proves the design through deterministic local tests. Contract addresses and mainnet transaction evidence will be published only after a complete live lifecycle succeeds; until then they are intentionally absent. +The deployed Mainnet release is `0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25`. Two spike vaults completed the cancellation and recovery branches, and a fresh public Recovery Drill completed the owner and successor journey through the canonical app. All five qualifying STRK20 receipts are listed in `strk20.json`. The E3 claim moved the public vault to `CLAIMED`, reduced liability and allowance to zero, consumed the successor nonce, and increased the successor's Ready X shielded balance from `7 STRK` to `8 STRK`. diff --git a/docs/MAINNET.md b/docs/MAINNET.md index 9a742bb..af8693d 100644 --- a/docs/MAINNET.md +++ b/docs/MAINNET.md @@ -2,10 +2,12 @@ ## Current evidence level -Afterlight is **E1 and pre-deployment**. The entries below separate locked local -artifacts and public dependencies from evidence that does not exist yet. Empty -transaction slots are intentional; they must not be replaced by simulated, -prepared, reverted, or unrelated pool transactions. +Afterlight has a complete deployed Mainnet product and five validator-qualified +STRK20 receipts. It is classified **E3 public completion**. A fresh Recovery +Drill completed through the canonical app, and an approved Ready X +`wallet_strk20Balances` read showed the successor increase from `7 STRK` to +`8 STRK` after its exact-note claim. Prepared, simulated, reverted, and +unrelated pool transactions are not counted as successful evidence. ## Pinned Starknet Mainnet dependencies @@ -26,11 +28,10 @@ artifact-identity proof for the live pool class. | Item | Value | Mainnet status | |---|---|---| | Compiler profile | `spike-inline-56` | Reproduces locally and in CI | -| Sierra class hash | `0x066654717cccb9875687a1abc8defe640f08f709c01715ea828f14c1ec5c7f25` | Locked locally; not declared | -| CASM/compiled class hash | `0x05a3c0719b75e0c4655f707f95c7930b1b72291261138337b5b5ca0f3019e3b7` | Locked locally; not declared | -| Afterlight contract | — | Not deployed | -| Surplus administrator | — | Not selected onchain; constructor value pending deployment | -| Production neutral relayer | — | Not deployed or funded | +| Sierra class hash | `0x05da9866f62cc6dd1e380e8d9206e78a752b460abdb802070e0be1208ec7b1a6` | Declared on Mainnet | +| CASM/compiled class hash | `0x055ba10e36aac8e21b3437f1413f009f6b17d3633c307941a4412ce73566251` | Declaration lock | +| Afterlight contract | [`0x06e8…61c25`](https://starkscan.co/contract/0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25) | Deployed on Mainnet | +| Neutral relayer account | `0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46` | Deployed; spike controls complete | Run `scarb --profile spike-inline-56 build` and `npm --prefix client run verify:locked-artifacts` to recompute the exact hashes. @@ -39,17 +40,23 @@ deployer must be treated as a different release and re-verified. ## Qualifying lifecycle transactions -Each eventual hash must be a successful Starknet Mainnet receipt that touches +Each hash below is a successful Starknet Mainnet receipt that touches the live STRK20 pool and runs through or emits from the declared Afterlight contract. Plain Shield transactions and failed attempts do not fill these slots. | Required branch | Mainnet transaction hash | Validator result | |---|---|---| -| `FUND` Vault A | — | Not run | -| `CANCEL_REFUND` Vault A | — | Not run | -| `FUND` Vault B | — | Not run | -| `CLAIM` Vault B | — | Not run | - -There is therefore no E2 exact-note recovery, shielded-balance increase, -liability reconciliation, deployed demo URL, or submission-ready mainnet -evidence in this release. +| `FUND` Vault A | [`0x030ea1…21722`](https://starkscan.co/tx/0x030ea14ac22e5806e382658971b686692af280bf2f02173a430f572921121722) | PASS | +| `CANCEL_REFUND` Vault A | [`0x69e234…c0fb`](https://starkscan.co/tx/0x69e2345ae8816986a709de84f0dcb571b5d092400d6c53bf90197480102c0fb) | PASS | +| `FUND` Vault B | [`0x036e00…0682a`](https://starkscan.co/tx/0x036e003396fe360ae7fe4766646f493c0eb579d82509652559d40e460770682a) | PASS | +| `CLAIM` Vault B | [`0x11c990…c8098`](https://starkscan.co/tx/0x11c990aea864e755630d41fd1292620c313b3f64407fc0b3a902544c67c8098) | PASS | +| Public E3 `CLAIM` | [`0x722033…f5c1b6`](https://voyager.online/tx/0x722033f7fd0397ff4d3845428c98cad885b6a63824f7c78a2b7e1d7d6f5c1b6) | PASS | + +The official hub validator's exact success, pool-touch, and declared-contract +ownership checks pass for all five. Vault A is `CANCELLED`, Vault B is +`CLAIMED`, total locked liability is zero, and the neutral pool allowance is +zero. Exact-note settlement is proven onchain. The wallet's post-finality +reconciliation is `6 STRK -> 7 STRK`: the exact `+1 STRK` recovery output was +added to the beneficiary while the neutral sponsor paid the separate `6 STRK` +pool fee. The public E3 vault is also `CLAIMED`; its immediate Ready X +reconciliation is `7 STRK -> 8 STRK`. Final video production remains pending. diff --git a/docs/READY_X_ONBOARDING.md b/docs/READY_X_ONBOARDING.md index 668ba8d..c1b3dc2 100644 --- a/docs/READY_X_ONBOARDING.md +++ b/docs/READY_X_ONBOARDING.md @@ -2,10 +2,12 @@ ## Evidence and release status -This is the intended Ready X flow for the **E1, pre-deployment** Afterlight -implementation. It documents prerequisites and the user boundary; it is not a -live-product runbook and is not an instruction to fund or sign today. There is -no deployed Afterlight contract, public product UI, or E2 mainnet lifecycle yet. +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. ## Accounts and STRK20 prerequisites @@ -48,7 +50,7 @@ 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. -## Planned owner journey +## Owner journey 1. Connect the owner Ready X account and verify Starknet Mainnet, deployment, STRK20 registration, and balances. @@ -68,7 +70,7 @@ library boundary explicit. authorization to that exact note, token, amount, vault, epoch, and nonce, and privately execute `CANCEL_REFUND` through STRK20. -## Planned successor journey +## Successor journey 1. Generate and back up a fresh successor application key on the successor's own device; share only its public key with the owner. @@ -82,12 +84,11 @@ library boundary explicit. 5. After grace, ask Ready X to prepare a fresh successor destination open note. Bind `CLAIM` to the resolved note ID, token, amount, vault, state, epoch, and successor nonce. -6. The E1 preflight validates the prepared call, proof envelope, exact note, and - accepted base-block identity without submitting. Ready normally recompiles a - fee-bearing proof for submission rather than consuming that prepared proof. - If an intervening wallet action changes the destination note index, the - recomputed note differs and the exact-note application signature must make - the onchain helper fail closed. +6. Afterlight validates the prepared call, proof envelope, exact note, and + accepted base-block identity, then submits the locked package through the + bounded neutral sponsor. If an intervening wallet action changes the + destination note index, the exact-note application signature makes the + onchain helper fail closed. 7. Treat recovery as complete only after the mainnet receipt succeeds, the Afterlight liability is reduced exactly once, and the successor's shielded balance shows the returned note. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index baef506..83370f6 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -63,7 +63,7 @@ Relayer availability is operationally important but not trusted for correctness. | Double claim or replay | Role nonce consumption and terminal vault state | | 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 | -| Surplus administrator is compromised | `recover_surplus` is public and privileged but can transfer only `held - locked`; it cannot alter vaults or withdraw accounted liabilities | +| 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 | | 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 | @@ -91,13 +91,11 @@ The owner and successor each generate a fresh application key per vault. The suc Loss of an application secret can make its role unavailable. Compromise can authorize only operations within the signed domain and current contract state; it does not reveal a Ready seed or permit changing the exact destination of an already signed claim. -The `surplus_admin` is not a vault owner or recovery authority. It is trusted -only to choose where donated/unaccounted token surplus goes. Its address and -recoveries are public, and users should not intentionally send donations to the -contract expecting a refund. +The contract has no administrative withdrawal path. Users should not send +donations to the helper expecting a refund; unaccounted surplus remains inert. ## Tested failure classes The local suites cover caller, token, amount, mode, interval, key reuse, wrong-key, expiry, nonce, epoch, state, contract, chain, vault, destination, redirect, replay, double settlement, dust, surplus, liability isolation, timing boundaries, veto/claim races, and failed-transfer rollback. -Mainnet privacy and availability claims remain unproven until a live Ready/STRK20 lifecycle produces reconciled receipts and balance changes. +Mainnet receipts now prove the neutral-sender control path, both STRK20 funding actions, exact-note cancellation, exact-note successor recovery, terminal states, and zero remaining liability. A fresh public E3 Recovery Drill completed through the canonical app; its succeeded claim moved the vault to `CLAIMED` and a fresh Ready X read proved the successor balance increased from `7 STRK` to `8 STRK`. The founder-controlled wallets share historical public funding correlation, so this is E3 product evidence, not proof of historically unrelated participants or E4 independent completion. diff --git a/relayer/OPERATIONS.md b/relayer/OPERATIONS.md index 5b35690..79117e1 100644 --- a/relayer/OPERATIONS.md +++ b/relayer/OPERATIONS.md @@ -1,14 +1,14 @@ # Neutral relayer operations -This runbook applies only after Afterlight passes its E2 spike and build gate. The checked-in configuration is intentionally inert and must not be enabled as a shortcut. +This runbook applies to the E2-passed production relayer and its inert staging profile. ## Hosting boundary -- Runtime: direct Cloudflare Worker with the `RelayBudget` SQLite Durable Object and three Rate Limiting bindings. +- Runtime: direct Cloudflare Worker with the `RelayBudget` SQLite Durable Object and four Rate Limiting bindings. - Public product hostname: `afterlight.dolepee.com` on Cloudflare Pages through the existing external DNS zone. -- Relayer exposure: bind the relayer Worker privately to the Pages application and proxy only `/v1/relay`, `/v1/checkpoint`, and `/health` through the product origin. Disable the relayer's public `workers.dev` route after the service binding is verified. +- Relayer exposure: the current Worker remains on its stable provider hostname with strict product-origin CORS. A future same-origin proxy may expose only `/v1/relay`, `/v1/checkpoint`, `/v1/exit`, and `/health`; disable the public route only after that proxy is verified. - Do not migrate the `dolepee.com` authoritative nameservers merely to attach a Worker Custom Domain. Any zone migration is a separate, explicitly approved operation. -- Connect Windscribe before every direct Cloudflare CLI or dashboard operation and disconnect it afterward. +- Use Wrangler on the normal network. Use Windscribe only if a required Cloudflare-owned dashboard or OAuth page will not open, and disconnect it immediately after that browser sequence. ## Required production values @@ -20,7 +20,7 @@ Replace every inert value and verify it against the exact released contract: - production RPC URL; - product origin; - per-call fee cap, daily sponsorship budget, balance alert threshold, and fee margin; -- deployment-specific namespaces for relay, global, and checkpoint rate limits. +- deployment-specific namespaces for relay, global, checkpoint, and exit rate limits. The account's Cloudflare plan rejects custom Worker CPU limits, so the release uses the platform CPU default. Preserve the explicit application-level payload, @@ -32,14 +32,14 @@ Install `RELAYER_ACCOUNT_PRIVATE_KEY` and `STARKNET_RPC_AUTH_TOKEN` only with `w ## Promotion sequence 1. Run `npm ci`, `npm run types`, `npm run check`, and `npm audit` on the exact commit. -2. Keep `SUBMIT_ENABLED=false`; deploy the Worker and Durable Object migration. -3. Verify `/health` reports submission disabled and exposes no address, endpoint, balance, secret state, wallet, vault, or request identifier. -4. Verify the Pages service binding and same-origin proxy with empty or invalid requests only. +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. +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. -7. Enable submission only for the bounded mainnet spike. +7. Enable submission only within the bounded production sponsorship policy. 8. Run one checkpoint and one signed control canary; reconcile their hashes, exact calldata, sender, actual FRI fee, and Durable Object totals. -9. Confirm direct `workers.dev` access is disabled and the public product route remains healthy. +9. Confirm the stable Worker URL and public product route remain healthy, and that strict origin checks reject every unlisted site. Disable direct `workers.dev` access only after a verified same-origin proxy replaces it. ## Nonce and receipt discipline @@ -49,6 +49,8 @@ Install `RELAYER_ACCOUNT_PRIVATE_KEY` and `STARKNET_RPC_AUTH_TOKEN` only with `w - 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. +- Never rotate `DEPLOYMENT_ID` to bypass a `SUBMITTED` or otherwise ambiguous reservation. A new ledger namespace is permitted only after independent transaction, nonce, contract-state, allowance and balance checks prove that no broadcast occurred. Record the abandoned namespace, rebaseline the actual funded balance, use a unique deployment ID, and retain a hard balance floor and one-attempt exit cap in the replacement release. +- E3 namespace `afterlight-mainnet-e3-20260829-v5` is abandoned after a transport-ambiguous broadcast call. More than 70 subsequent Mainnet blocks plus both latest and pre-confirmed nonce reads proved neutral nonce `34` unchanged; vault state, successor nonce, liability, allowance and neutral balance were also byte-for-byte unchanged. Namespace `afterlight-mainnet-e3-20260829-v6` is its one-attempt replacement. This recorded recovery does not permit reuse of v5 or rotation after any accepted or still-ambiguous submission. ## Monitoring and alerts @@ -67,12 +69,13 @@ Monitor through at least 2026-09-04: - RPC error rate and checkpoint/control success rate without payload logging; - Worker and Pages deployment versions matching the recorded release commit. -The bounded lifecycle uses two funding checkpoints plus `HEARTBEAT`, two -`REQUEST` calls, and `VETO`. With the current 0.2 STRK per-call ceiling, the -daily sponsorship budget is 1.2 STRK so all six can fit without silently -contradicting the per-call policy. Replace caps only from fresh quotes; never -enable a daily budget smaller than the quoted lifecycle or larger than the -funded operational bound. +The completed bounded control lifecycle used two funding checkpoints plus +`HEARTBEAT`, two `REQUEST` calls, and `VETO`. The current control-plane ceiling +is `0.4 STRK` per call and `1.6 STRK` per UTC day. The exact-note exit has a +separate `7.5 STRK` full resource-bounds ceiling, a separate exact `6 STRK` +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. Do not log request bodies, signatures, IP addresses, wallet addresses, application keys, vault IDs, transaction fingerprints, RPC authorization, or diff --git a/relayer/README.md b/relayer/README.md index 47b719d..f6d85e8 100644 --- a/relayer/README.md +++ b/relayer/README.md @@ -1,12 +1,12 @@ -# Afterlight neutral relayer (E1/pre-production) +# Afterlight neutral relayer This directory contains the tested neutral control-plane relayer implementation. -It consumes the canonical `afterlight-relay/1` schema from `../client`, accepts -only `HEARTBEAT`, `REQUEST`, and `VETO`, and includes a fail-closed Starknet v3 -signer/RPC adapter. An inert, submission-disabled staging Worker exercises -packaging and health monitoring, but there is no funded or production relayer -and no live recovery service. The signing path remains unreachable until every -production setting and secret is present and `SUBMIT_ENABLED=true`. +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 +funded; a separate submission-disabled configuration remains available for +inert packaging and health tests. ## Proven locally @@ -25,22 +25,24 @@ production setting and secret is present and `SUBMIT_ENABLED=true`. - 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. -## Production blockers (intentional) +## Production configuration -Do not set `SUBMIT_ENABLED=true` on the checked-in pre-production Worker. The -adapter exists for static review and local tests, but the checked-in -configuration is deliberately non-executable. Before funded Phase B: +The checked-in production profile describes the deployed release but contains +no secret values. A fresh deployment is executable only when its two required +Cloudflare secrets already exist and `SUBMIT_ENABLED=true`. Before changing the +release: 1. Replace `DEPLOYMENT_ID`, stage, contract, origin, RPC URL, relayer account, and both rate-limit namespace IDs with deployment-specific values. 2. Install `RELAYER_ACCOUNT_PRIVATE_KEY` and `STARKNET_RPC_AUTH_TOKEN` through `wrangler secret put`; never put their values in source, config, logs, or client variables. 3. Independently review the concrete `StarknetV3RelayAdapter` and exercise its exact nonce, resource-bound, signed calldata, sender, receipt, and fee-unit checks against the deployment account. 4. Exercise the Durable Object migration, RPC provider, account nonce, balance alert, checkpoint route, receipt timeout/reconciliation runbook, and rollback under a non-funded staging profile. 5. Confirm the ordinary owner Ready address never submits the public checkpoint; only the neutral relayer may call it immediately before a private FUND. -6. Only then install the secrets and enable submission in a reviewed promotion commit. A flag without complete configuration still fails closed. +6. Only then install or retain the secrets and enable submission in a reviewed release. A flag without complete configuration still fails closed. Run `npm ci`, then `npm run check`. The tested toolchain is Node.js `22.13.1` -with the committed npm lockfile and Wrangler `4.125.0`. `npm run dry-run` only +with the committed npm lockfile and Wrangler `4.127.1`. `npm run dry-run` only bundles locally; nothing is deployed. `wrangler.staging.jsonc` is a separate inert deployment profile. It deliberately diff --git a/relayer/package-lock.json b/relayer/package-lock.json index c3cf98c..4861dd7 100644 --- a/relayer/package-lock.json +++ b/relayer/package-lock.json @@ -16,7 +16,7 @@ "oxlint": "1.79.0", "typescript": "5.9.3", "vitest": "4.1.11", - "wrangler": "4.125.0" + "wrangler": "^4.127.1" } }, "node_modules/@adraffy/ens-normalize": { @@ -70,6 +70,42 @@ "vitest": "^4.1.0" } }, + "node_modules/@cloudflare/vitest-plugin/node_modules/wrangler": { + "version": "4.125.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.125.0.tgz", + "integrity": "sha512-yFpvggu+xk1Hdm/Uxwaqa19bb7GArME4CrCS3Vov68a2TZq2MPO+wLocKbbnIC9K0oLowcdau7/ycxbbNHKCEg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260820.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260820.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260820.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, "node_modules/@cloudflare/workerd-darwin-64": { "version": "1.20260820.1", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260820.1.tgz", @@ -3653,9 +3689,9 @@ } }, "node_modules/wrangler": { - "version": "4.125.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.125.0.tgz", - "integrity": "sha512-yFpvggu+xk1Hdm/Uxwaqa19bb7GArME4CrCS3Vov68a2TZq2MPO+wLocKbbnIC9K0oLowcdau7/ycxbbNHKCEg==", + "version": "4.127.1", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.127.1.tgz", + "integrity": "sha512-OzsiNgaI8i681L/+KnAKc+uEZ5D57xK5JuNvCOpRKICF4/5Q3Cu1oTGuUiT/f3GDUqQb3gzXNT0tfOHGMEtknw==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { @@ -3663,10 +3699,10 @@ "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", - "miniflare": "5.20260820.0-alpha", + "miniflare": "5.20260828.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260820.1" + "workerd": "1.20260828.1" }, "bin": { "cf-wrangler": "bin/cf-wrangler.js", @@ -3680,7 +3716,7 @@ "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^5.20260820.1" + "@cloudflare/workers-types": "^5.20260828.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { @@ -3688,6 +3724,130 @@ } } }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260828.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260828.1.tgz", + "integrity": "sha512-CVd+xPhqUESg8Xhq09TZx0wl4FSirfJGOzvbPz2yHhBIvmNHFFQkSN3rkd7wEwnhQQk37Xi0/aD6ykPLJbmGiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260828.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260828.1.tgz", + "integrity": "sha512-5HDPXRM152vU5JveByGFk34X57TVyIsfp4cabepAf45DC0MKvm52ucJqAjW1h8bvW4X+zRw9GU35OHF9FEC9Ww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260828.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260828.1.tgz", + "integrity": "sha512-MQ1Ll9P7F72HHUKizbb7BlDfbY8fRoNMpbIpZoU6uKsSkneFICWSKv6UlgU9EQZ+w0i7TMa12iUgJ8l29eRI9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260828.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260828.1.tgz", + "integrity": "sha512-FBTaUQ1xcU9jcp4OyBPcH8x0QiFvc1iuZL2GkD8zp2q1WyTVHYOptRDQUU+cuHjt0rQ2EIKVPBjahPxfa0joBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260828.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260828.1.tgz", + "integrity": "sha512-yvr77hC7dUbvK5K+SCg062kkPq3sx+drV1PcgHslzHDYcJBtT0V3X80qLE49LW1vq2svaeNmsVQS+vHsqWu8cQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/miniflare": { + "version": "5.20260828.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260828.0-alpha.tgz", + "integrity": "sha512-6nbxhZEcz/UET3Y1OnYPsrAUjUmuFoib3ynUqteRdn1YnDxsLg8cwgZJZCk9QmtOmGzXwzXzgE/d/C0dJAPtVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.29.0", + "workerd": "1.20260828.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/wrangler/node_modules/workerd": { + "version": "1.20260828.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260828.1.tgz", + "integrity": "sha512-pB9yvt0kkwZDAGZHmpY59r0o3hM0DzdW6BJERqwZOhunZ3ssOyDSgQxOQer2cSZW4YCFeOTIQYN1qwhK5wv/Cw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260828.1", + "@cloudflare/workerd-darwin-arm64": "1.20260828.1", + "@cloudflare/workerd-linux-64": "1.20260828.1", + "@cloudflare/workerd-linux-arm64": "1.20260828.1", + "@cloudflare/workerd-windows-64": "1.20260828.1" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/relayer/package.json b/relayer/package.json index 22b1c49..e58c6dc 100644 --- a/relayer/package.json +++ b/relayer/package.json @@ -23,6 +23,6 @@ "oxlint": "1.79.0", "typescript": "5.9.3", "vitest": "4.1.11", - "wrangler": "4.125.0" + "wrangler": "^4.127.1" } } diff --git a/relayer/src/budget.ts b/relayer/src/budget.ts index 5982764..a3a45da 100644 --- a/relayer/src/budget.ts +++ b/relayer/src/budget.ts @@ -62,6 +62,16 @@ export type BudgetLookupResult = Readonly< } >; +export type ActiveBudgetLookupResult = Readonly< + | { outcome: "missing" } + | { + outcome: "found"; + semanticKey: string; + state: "reserved" | "submitted"; + transactionHash: string | null; + } +>; + export type BudgetSnapshot = Readonly<{ dayKey: string; reservedTodayFri: string; @@ -164,6 +174,28 @@ export class RelayBudget extends DurableObject { }; } + findActiveByFingerprint(exactFingerprint: string): ActiveBudgetLookupResult { + 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 + FROM reservations + WHERE exact_fingerprint = ? AND status IN ('RESERVED', 'SUBMITTED')`, + exact, + ) + .toArray(); + if (rows.length === 0) return { outcome: "missing" }; + if (rows.length !== 1) throw new BudgetError("idempotency_conflict"); + const row = rows[0]!; + return { + outcome: "found", + semanticKey: row.semantic_key, + state: row.status === "SUBMITTED" ? "submitted" : "reserved", + transactionHash: row.transaction_hash, + }; + } + reserve(input: BudgetReserveInput): BudgetReserveResult { const normalized = validateReserveInput(input); return this.ctx.storage.transactionSync(() => { diff --git a/relayer/src/core.ts b/relayer/src/core.ts index 44c8d88..9f8d064 100644 --- a/relayer/src/core.ts +++ b/relayer/src/core.ts @@ -261,7 +261,7 @@ export async function readUtf8BodyLimited(request: Request, byteLimit: number): export function requireRelayHeaders(request: Request, env: Env): void { const origin = request.headers.get("origin"); - if (origin !== env.ALLOWED_ORIGIN) throw new RelayHttpError(403, "origin_not_allowed"); + if (!isAllowedOrigin(origin, env.ALLOWED_ORIGIN)) throw new RelayHttpError(403, "origin_not_allowed"); const contentType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase(); if (contentType !== "application/json") { throw new RelayHttpError(415, "content_type_required"); @@ -271,17 +271,31 @@ export function requireRelayHeaders(request: Request, env: Env): void { } } -export function requireCheckpointHeaders(request: Request, env: Env): void { - if (request.headers.get("origin") !== env.ALLOWED_ORIGIN) { +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 contentLength = request.headers.get("content-length"); - if (request.body !== null || (contentLength !== null && contentLength !== "0")) { + if (contentLength !== null && (!/^[0-9]+$/.test(contentLength) || contentLength !== "0")) { throw new RelayHttpError(400, "checkpoint_payload_forbidden"); } + if (request.body === null) return; + const reader = request.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return; + if (value.byteLength > 0) { + await reader.cancel("checkpoint payload forbidden"); + throw new RelayHttpError(400, "checkpoint_payload_forbidden"); + } + } + } finally { + reader.releaseLock(); + } } export function corsHeaders(origin: string): HeadersInit { @@ -294,6 +308,11 @@ export function corsHeaders(origin: string): HeadersInit { }; } +export function isAllowedOrigin(origin: string | null, configuredOrigins: string): origin is string { + if (origin === null) return false; + return configuredOrigins.split(",").some((allowed) => allowed === origin); +} + export function jsonResponse( value: Readonly>, status: number, diff --git a/relayer/src/executor.ts b/relayer/src/executor.ts index 4c76573..1d5937d 100644 --- a/relayer/src/executor.ts +++ b/relayer/src/executor.ts @@ -1,4 +1,5 @@ import type { + ActiveBudgetLookupResult, BudgetMutationResult, BudgetReserveInput, BudgetReserveResult, @@ -67,6 +68,7 @@ export interface StarknetRelayAdapter { export interface BudgetCoordinator { lookup(semanticKey: string): Promise; + findActiveByFingerprint?(exactFingerprint: string): Promise; reserve(input: BudgetReserveInput): Promise; markSubmitted( semanticKey: string, @@ -164,6 +166,23 @@ export async function executeRelayPlan( } 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); + if (active?.outcome === "found") { + if (active.state === "submitted" && active.transactionHash !== null) { + return reconcileSubmitted( + active.transactionHash, + Object.freeze({ ...plan, semanticKey: active.semanticKey }), + adapter, + budget, + nowMs, + ); + } + return duplicateResult(active.state, active.transactionHash); + } + const simulation = await adapter.simulateExact(plan); if (!simulation.ok) throw new ExecutorError("simulation_failed"); if (simulation.callFingerprint !== plan.fingerprint) { diff --git a/relayer/src/exit-executor.ts b/relayer/src/exit-executor.ts new file mode 100644 index 0000000..f3db291 --- /dev/null +++ b/relayer/src/exit-executor.ts @@ -0,0 +1,328 @@ +import { Account, RpcProvider, transaction, type Call } from "starknet"; +import type { BudgetCoordinator } from "./executor.js"; +import { + addResourceMargins, + assertOuterSignatureMatchesHash, + assertProofFreshness, + assertSignedExitTransaction, + normalizeHex, + parseResourceBounds, + parseU256Result, + parseVaultResult, + proofFactsForFeeEstimate, + resourceCapFri, + validateAllowanceForAction, + validateAuthorizationInclusionWindow, + validateBalanceForExit, + validateLiveExitState, + validatePolicy, + validatePreparedExitPackage, + type ValidatedExit, +} from "./neutral-exit-policy.mjs"; + +const EXIT_POLICY = Object.freeze({ + schema: "afterlight-neutral-exit-policy/1", + chainId: "0x534e5f4d41494e", + neutralAddress: "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46", + neutralPublicKey: "0x0c041078765f888f2a22a0f68221011641879b222b657cc125014f18c2976ae", + afterlightAddress: "0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25", + afterlightClassHash: "0x05da9866f62cc6dd1e380e8d9206e78a752b460abdb802070e0be1208ec7b1a6", + poolAddress: "0x040337b1af3c663e86e333bab5a4b28da8d4652a15a69beee2b677776ffe812a", + poolFeeCollector: "0x0d79041634625e5288296fbc648088788710ba44903a3a49468a66567749e77", + tokenAddress: "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + neutralClassHash: "0x01d1777db36cdd06dd62cfde77b1b6ae06412af95d57a13dc40ac77b8a702381", + fixedAmountFri: "1000000000000000000", + poolFeeEachFri: "6000000000000000000", + initialPoolAllowanceFri: "12000000000000000000", + postSpendHealthFloorFri: "1000000000000000000", + // The accepted E2 claim consumed about 2.832 STRK, while its full validated + // resource-bounds reservation was 7.436710911292439270 STRK. Keep a narrow, + // explicit ceiling above that proven reservation without weakening the + // separate 1 STRK post-spend health floor. + maxNetworkFeePerExitFri: "7500000000000000000", + // The RPC response already supplies full resource bounds. Reusing those + // exact bounds avoids compounding a 3% amount pad with a second 3% price pad + // after the user-approved 7.5 STRK hard ceiling. + amountMarginBps: "10000", + priceMarginBps: "10000", + maxEstimateAgeBlocks: "300", + minimumRpcSpec: "0.10.1", + starknetJsVersion: "10.7.0", + starknetJsPackageSha256: "3a3b783706f1adde673f29c3afff69f6e7f57c2cfcff774b99110253e962c8e7", + starknetJsModuleSha256: "7aa6f8c6e3df5d7016df991cb61997a76c4f1247e51c722489511712b99cb38a", +}); + +const POOL = EXIT_POLICY.poolAddress; +const TOKEN = EXIT_POLICY.tokenAddress; +const AFTERLIGHT = EXIT_POLICY.afterlightAddress; +const NEUTRAL = EXIT_POLICY.neutralAddress; + +export class ExitExecutorError extends Error { + constructor(readonly code: "invalid_exit" | "exit_unavailable" | "exit_busy" | "exit_uncertain" | "exit_reverted") { + super(code); + this.name = "ExitExecutorError"; + } +} + +export type ExitResult = Readonly<{ + status: "accepted" | "duplicate"; + transactionHash: string | null; + actualFeeFri?: string; +}>; + +export async function executePreparedClaim(payload: string, env: Env, budget: BudgetCoordinator): Promise { + 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"); + } catch { throw new ExitExecutorError("invalid_exit"); } + + // 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 + // public Wrangler configuration used by ordinary relay controls. + nodeUrl: env.EXIT_RPC_URL, + headers: { authorization: `Bearer ${env.STARKNET_RPC_AUTH_TOKEN}` }, + plugins: false, + }); + try { + if (normalizeHex(await provider.getChainId()) !== normalizeHex(EXIT_POLICY.chainId)) throw new Error("wrong_chain"); + } catch { throw exitStage("chain"); } + const account = new Account({ provider, address: NEUTRAL, signer: env.RELAYER_ACCOUNT_PRIVATE_KEY, cairoVersion: "1", transactionVersion: "0x3", plugins: false }); + let policy; + try { policy = validatePolicy(EXIT_POLICY); } catch { throw exitStage("policy"); } + let estimateBlock; + try { estimateBlock = await provider.getBlockWithTxHashes("latest"); } catch { throw exitStage("estimate_block"); } + if (!("block_number" in estimateBlock) || !("block_hash" in estimateBlock)) throw new ExitExecutorError("exit_unavailable"); + const blockNumber = BigInt(estimateBlock.block_number); + const blockTimestamp = BigInt(estimateBlock.timestamp); + let snapshot; + try { snapshot = await readSnapshot(provider, validated, blockNumber, blockTimestamp); } catch { throw exitStage("snapshot"); } + try { + validateAuthorizationInclusionWindow(validated.metadata.validUntil, blockTimestamp, BigInt(Math.floor(Date.now() / 1_000))); + } catch { throw exitStage("inclusion_window"); } + const proofFacts = validated.proof.facts.facts.map(hex); + const call: Call = { contractAddress: validated.call.contractAddress, entrypoint: validated.call.entrypoint, calldata: validated.call.calldata.map(hex) }; + const estimateDetails = (facts: readonly string[]) => ({ + nonce: snapshot.nonce, + blockIdentifier: Number(blockNumber), + skipValidate: false, + proof: validated.proof.data, + proofFacts: [...facts], + tip: 0, + paymasterData: [], + accountDeploymentData: [], + nonceDataAvailabilityMode: "L1" as const, + feeDataAvailabilityMode: "L1" as const, + }); + let estimate; + try { + estimate = await account.estimateInvokeFee(call, estimateDetails(proofFacts)); + } catch (error) { + if (rpcErrorCode(error) !== 41) throw exitStage(`estimate_${classifyEstimateFailure(error)}`); + try { + // Some Starknet estimators execute the SDK proof envelope (PROOF0), even + // though accepted STRK20 transactions carry Ready's real PROOF1 facts. + // Normalize only the estimate copy. The signed and broadcast transaction + // below remains bound to the untouched real proof facts. + const estimateFacts = proofFactsForFeeEstimate(validated.proof.facts.facts).map(hex); + estimate = await account.estimateInvokeFee(call, estimateDetails(estimateFacts)); + } catch (fallbackError) { + throw exitStage(`estimate_fallback_${classifyEstimateFailure(fallbackError)}`); + } + } + let bounds; + let networkCap; + try { + const sourceBounds = parseResourceBounds(estimate.resourceBounds); + bounds = addResourceMargins(sourceBounds, policy.amountMarginBps, policy.priceMarginBps); + networkCap = resourceCapFri(bounds); + if (networkCap > policy.networkCapFri) throw new Error("network_cap"); + validateBalanceForExit(snapshot.balance, networkCap, BigInt(EXIT_POLICY.postSpendHealthFloorFri)); + } catch { throw exitStage("fee_and_balance"); } + + const reservation = await budget.reserve({ + dayKey: new Date().toISOString().slice(0, 10), + semanticKey, + exactFingerprint: validated.bindingSha256, + maxFeeFri: networkCap.toString(), + perCallCapFri: policy.networkCapFri.toString(), + dailyBudgetFri: policy.networkCapFri.toString(), + nowMs: Date.now(), + }); + if (reservation.outcome !== "reserved") { + const duplicate = await budget.lookup(semanticKey); + return { status: "duplicate", transactionHash: duplicate.outcome === "found" ? duplicate.transactionHash : null }; + } + + let signed; + let broadcastStarted = false; + let submissionStage = "fresh_nonce"; + try { + const freshNonce = BigInt(await account.getNonce("pre_confirmed")); + submissionStage = "fresh_block"; + const freshBlock = await provider.getBlockWithTxHashes("latest"); + if (!("block_number" in freshBlock) || freshNonce !== snapshot.nonce || BigInt(freshBlock.block_number) - blockNumber > 300n) throw new Error("snapshot_changed"); + submissionStage = "fresh_snapshot"; + await readSnapshot(provider, validated, BigInt(freshBlock.block_number), BigInt(freshBlock.timestamp)); + submissionStage = "sign"; + signed = await account.getSignedTransaction(call, { + nonce: snapshot.nonce, + resourceBounds: bounds, + tip: 0, + paymasterData: [], + accountDeploymentData: [], + nonceDataAvailabilityMode: "L1", + feeDataAvailabilityMode: "L1", + proof: validated.proof.data, + proofFacts, + }); + submissionStage = "signed_assertions"; + assertSignedExitTransaction(signed, { + nonce: snapshot.nonce, + executeCalldata: transaction.getExecuteCalldata([call], "1"), + proof: validated.proof.data, + proofFacts, + resourceBounds: bounds, + networkCapFri: policy.networkCapFri, + }); + submissionStage = "outer_hash"; + const expectedHash = assertOuterSignatureMatchesHash(signed); + submissionStage = "broadcast"; + broadcastStarted = true; + let response; + try { + response = await provider.invokeSignedTx(signed); + } catch (error) { + const failure = classifyBroadcastFailure(error); + console.error(JSON.stringify({ event: "exit_broadcast_failed", category: failure.category })); + if (failure.definitiveReject) { + // A JSON-RPC error response proves that this node rejected the + // transaction before acceptance. It is safe to release the RESERVED + // slot; transport failures remain ambiguous and serialized. + broadcastStarted = false; + throw new ExitExecutorError("exit_unavailable"); + } + throw new ExitExecutorError("exit_uncertain"); + } + const transactionHash = normalizeHex(response.transaction_hash); + 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 }; + } catch (error) { + if (!broadcastStarted) { + try { + await budget.release(semanticKey, validated.bindingSha256, 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 + // response that hides whether signing began. + console.error(JSON.stringify({ event: "exit_reservation_release_failed", stage: submissionStage })); + } + } + if (error instanceof ExitExecutorError) throw error; + console.error(JSON.stringify({ event: "exit_submission_failed", stage: submissionStage })); + throw broadcastStarted ? new ExitExecutorError("exit_uncertain") : new ExitExecutorError("exit_unavailable"); + } +} + +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([ + provider.getClassHashAt(NEUTRAL, Number(blockNumber)), + provider.getClassHashAt(AFTERLIGHT, Number(blockNumber)), + provider.getNonceForAddress(NEUTRAL, Number(blockNumber)), + call(TOKEN, "balance_of", [NEUTRAL]), + call(TOKEN, "allowance", [NEUTRAL, POOL]), + call(POOL, "get_fee_amount"), + call(POOL, "get_fee_collector"), + 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 (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); + validateLiveExitState(validated, parseVaultResult(vaultRaw), timestamp); + assertProofFreshness(validated.proof.facts.baseBlockNumber, blockNumber, BigInt(validityRaw[0] ?? 0)); + return { nonce: BigInt(nonce), balance: parseU256Result(balanceRaw, "balance") }; +} + +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"; +} + +function exitStage(stage: string): ExitExecutorError { + // Never include request material, proof fields, note IDs, signatures, vaults, + // wallet addresses, IPs, RPC payloads, or raw exception messages. + console.error(JSON.stringify({ event: "exit_preflight_rejected", stage })); + return new ExitExecutorError("exit_unavailable"); +} + +function classifyEstimateFailure(error: unknown): string { + // Emit only a fixed category. Never log the upstream message because RPC + // execution errors can echo calldata, proof material, note IDs or signatures. + const code = rpcErrorCode(error); + if (code === 41) return "rpc_execution"; + if (code === 52) return "rpc_transaction_nonce"; + if (code === 53) return "rpc_validate_resources"; + if (code === 54) return "rpc_account_balance"; + if (code === 55) return "rpc_validation"; + if (code !== undefined) return "rpc_other"; + const message = error instanceof Error ? error.message.toLowerCase() : ""; + if (message.includes("insufficient") && (message.includes("balance") || message.includes("fee"))) return "insufficient_balance"; + if (message.includes("nonce")) return "nonce"; + if (message.includes("proof") || message.includes("validate")) return "proof_or_validation"; + if (message.includes("revert") || message.includes("execution")) return "execution"; + 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 8228510..3d16ccd 100644 --- a/relayer/src/index.ts +++ b/relayer/src/index.ts @@ -7,6 +7,7 @@ import { RelayHttpError, corsHeaders, jsonResponse, + isAllowedOrigin, parsePositiveDecimal, prepareCheckpointPlan, prepareRelayPlan, @@ -27,12 +28,17 @@ import { readBalanceHealth, type BudgetCoordinator, } from "./executor.js"; +import { executePreparedClaim, ExitExecutorError } from "./exit-executor.js"; export { RelayBudget } from "./budget.js"; +const EXIT_PATH = "/v1/exit"; +const EXIT_INTENT_HEADER = "claim-exit"; + export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); + const requestOrigin = request.headers.get("origin") ?? ""; try { if (request.method === "GET" && url.pathname === HEALTH_PATH) { return health(env); @@ -43,7 +49,10 @@ export default { if (request.method === "OPTIONS" && url.pathname === CHECKPOINT_PATH) { return preflight(request, env); } - if (url.pathname !== RELAY_PATH && url.pathname !== CHECKPOINT_PATH) { + if (request.method === "OPTIONS" && url.pathname === EXIT_PATH) { + return preflight(request, env); + } + if (url.pathname !== RELAY_PATH && url.pathname !== CHECKPOINT_PATH && url.pathname !== EXIT_PATH) { return jsonResponse({ status: "error", code: "not_found" }, 404); } if (request.method !== "POST") { @@ -53,12 +62,24 @@ export default { } let plan: RelayPlan; - if (url.pathname === CHECKPOINT_PATH) { - requireCheckpointHeaders(request, env); + let estimateOnly = false; + if (url.pathname === EXIT_PATH) { + requireExitHeaders(request, env); + await rateLimitExit(env); + const payload = await readUtf8BodyLimited(request, Number(parsePositiveDecimal(env.MAX_EXIT_PAYLOAD_BYTES, "exit_payload_limit", 2_097_152n))); + const budget: BudgetCoordinator = env.RELAY_BUDGET.getByName(budgetObjectName(env)); + const result = await executePreparedClaim(payload, env, budget); + return jsonResponse({ status: "relayed", result }, 200, corsHeaders(requestOrigin)); + } else if (url.pathname === CHECKPOINT_PATH) { + await requireCheckpointHeaders(request, env); await rateLimitCheckpoint(env); plan = await prepareCheckpointPlan(env, Date.now()); } else { requireRelayHeaders(request, env); + estimateOnly = url.searchParams.get("mode") === "estimate"; + if (url.searchParams.size !== (estimateOnly ? 1 : 0)) { + throw new RelayHttpError(400, "invalid_query"); + } const bodyLimit = Number( parsePositiveDecimal(env.MAX_RELAY_PAYLOAD_BYTES, "payload_limit", 2_048n), ); @@ -72,6 +93,27 @@ export default { await rateLimitRelay(prepared.request, env); } + if (estimateOnly) { + const readiness = executorReadiness(env); + if (!readiness.executable) throw new RelayHttpError(503, "executor_unavailable"); + const simulation = await createStarknetRelayAdapter(env).simulateExact(plan); + if (!simulation.ok) throw new RelayHttpError(422, "estimate_rejected"); + return jsonResponse( + { + status: "estimated", + submission: "not_attempted", + plan: { + fingerprint: plan.fingerprint, + semanticKey: plan.semanticKey, + call: plan.call, + }, + estimate: simulation, + }, + 200, + corsHeaders(requestOrigin), + ); + } + if (isSubmissionEnabled(env.SUBMIT_ENABLED)) { const readiness = executorReadiness(env); if (!readiness.executable) throw new RelayHttpError(503, "executor_unavailable"); @@ -94,7 +136,7 @@ export default { return jsonResponse( { status: "relayed", result }, 200, - corsHeaders(env.ALLOWED_ORIGIN), + corsHeaders(requestOrigin), ); } @@ -105,14 +147,17 @@ export default { plan, }, 202, - corsHeaders(env.ALLOWED_ORIGIN), + corsHeaders(requestOrigin), ); } catch (error) { + const executorCode = executorErrorCode(error); const handled = error instanceof RelayHttpError ? error - : error instanceof ExecutorError - ? new RelayHttpError(error.code === "relayer_busy" ? 503 : 502, error.code) + : error instanceof ExitExecutorError + ? new RelayHttpError(error.code === "invalid_exit" ? 422 : error.code === "exit_busy" ? 503 : 502, error.code) + : executorCode !== undefined + ? new RelayHttpError(executorCode === "relayer_busy" ? 503 : 502, executorCode) : new RelayHttpError(500, "internal_error"); // Never log payloads, signatures, IPs, vault IDs, wallet addresses, or request headers. console.error( @@ -126,14 +171,30 @@ export default { return jsonResponse( { status: "error", code: handled.code }, handled.status, - request.headers.get("origin") === env.ALLOWED_ORIGIN - ? corsHeaders(env.ALLOWED_ORIGIN) + isAllowedOrigin(request.headers.get("origin"), env.ALLOWED_ORIGIN) + ? corsHeaders(requestOrigin) : undefined, ); } }, } satisfies ExportedHandler; +function executorErrorCode(error: unknown): ExecutorError["code"] | undefined { + if (error instanceof ExecutorError) return error.code; + if (typeof error !== "object" || error === null || !("code" in error)) return undefined; + const code = (error as { code?: unknown }).code; + const allowed: readonly ExecutorError["code"][] = [ + "submission_disabled", "simulation_failed", "simulation_mismatch", + "fee_policy_rejected", "sponsorship_frozen", "sponsorship_invariant_breach", + "submission_not_started", "submission_uncertain", "submission_mismatch", + "receipt_unreconciled", "receipt_reverted", "signer_adapter_unavailable", + "executor_config_incomplete", "relayer_busy", + ]; + return typeof code === "string" && allowed.includes(code as ExecutorError["code"]) + ? code as ExecutorError["code"] + : undefined; +} + async function health(env: Env): Promise { const submitDisabled = !isSubmissionEnabled(env.SUBMIT_ENABLED); const readiness = executorReadiness(env); @@ -171,18 +232,37 @@ function isSubmissionEnabled(value: string): boolean { function preflight(request: Request, env: Env): Response { const origin = request.headers.get("origin"); const intent = request.headers.get("access-control-request-headers")?.toLowerCase() ?? ""; - if (origin !== env.ALLOWED_ORIGIN || !intent.includes("x-afterlight-intent")) { + if (!isAllowedOrigin(origin, env.ALLOWED_ORIGIN) || !intent.includes("x-afterlight-intent")) { throw new RelayHttpError(403, "origin_not_allowed"); } return new Response(null, { status: 204, headers: { - ...corsHeaders(env.ALLOWED_ORIGIN), + ...corsHeaders(origin), "cache-control": "no-store", "x-afterlight-intent": new URL(request.url).pathname === CHECKPOINT_PATH ? CHECKPOINT_INTENT_HEADER - : RELAY_INTENT_HEADER, + : new URL(request.url).pathname === EXIT_PATH + ? EXIT_INTENT_HEADER + : RELAY_INTENT_HEADER, }, }); } + +function requireExitHeaders(request: Request, env: Env): void { + if (!isAllowedOrigin(request.headers.get("origin"), env.ALLOWED_ORIGIN)) throw new RelayHttpError(403, "origin_not_allowed"); + if (request.headers.get("x-afterlight-intent") !== EXIT_INTENT_HEADER) throw new RelayHttpError(400, "invalid_exit_intent"); + const contentType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase(); + 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"); + } +} diff --git a/relayer/src/neutral-exit-policy.d.mts b/relayer/src/neutral-exit-policy.d.mts new file mode 100644 index 0000000..74560ef --- /dev/null +++ b/relayer/src/neutral-exit-policy.d.mts @@ -0,0 +1,50 @@ +export type ExitPolicyResult = Readonly<{ + networkCapFri: bigint; + amountMarginBps: bigint; + priceMarginBps: bigint; +}>; + +export type ValidatedExit = Readonly<{ + action: "CLAIM" | "CANCEL_REFUND"; + actionPolicy: Readonly<{ requiredState: bigint; allowanceBeforeFri: bigint; allowanceAfterFri: bigint }>; + metadata: Readonly<{ + vaultId: string; + expectedState: string; + expectedEpoch: string; + expectedRoleNonce: string; + destinationNoteId: string; + validUntil: string; + preparedAtBlock: bigint; + }>; + call: Readonly<{ contractAddress: string; entrypoint: string; calldata: readonly string[] }>; + proof: Readonly<{ + data: string; + output: readonly bigint[]; + facts: Readonly<{ facts: readonly string[]; baseBlockNumber: bigint; messageHash: string }>; + }>; + bindingSha256: string; +}>; + +export type ResourceBounds = Readonly<{ + l1_gas: Readonly<{ max_amount: bigint; max_price_per_unit: bigint }>; + l1_data_gas: Readonly<{ max_amount: bigint; max_price_per_unit: bigint }>; + l2_gas: Readonly<{ max_amount: bigint; max_price_per_unit: bigint }>; +}>; + +export function validatePolicy(policy: unknown): ExitPolicyResult; +export function validatePreparedExitPackage(input: unknown, policy: unknown): ValidatedExit; +export function proofFactsForFeeEstimate(raw: readonly string[]): readonly string[]; +export function parseResourceBounds(value: unknown): ResourceBounds; +export function addResourceMargins(bounds: ResourceBounds, amountMarginBps: bigint, priceMarginBps: bigint): ResourceBounds; +export function resourceCapFri(bounds: ResourceBounds): bigint; +export function parseU256Result(result: readonly string[], label?: string): bigint; +export function parseVaultResult(result: readonly string[]): Readonly<{ raw: readonly bigint[] }>; +export function validateLiveExitState(validated: ValidatedExit, vault: unknown, timestamp: bigint): unknown; +export function validateAuthorizationInclusionWindow(validUntil: string, blockTimestamp: bigint, wallClockTimestamp: bigint): bigint; +export function validateAllowanceForAction(action: string, allowance: bigint): bigint; +export function validateBalanceForExit(balance: bigint, resourceCap: bigint, healthFloor?: bigint): unknown; +export function assertProofFreshness(baseBlock: bigint, liveBlock: bigint, validityBlocks: bigint): bigint; +export function assertSignedExitTransaction(signed: unknown, expected: unknown): boolean; +export function assertOuterSignatureMatchesHash(signed: unknown, publicKey?: string): string; +export function normalizeHex(value: string, label?: string): string; +export function safePublicError(error: unknown): string; diff --git a/relayer/src/neutral-exit-policy.mjs b/relayer/src/neutral-exit-policy.mjs new file mode 100644 index 0000000..ed0acef --- /dev/null +++ b/relayer/src/neutral-exit-policy.mjs @@ -0,0 +1,986 @@ +import { createHash } from "node:crypto"; +import { ec, hash } from "starknet"; + +export const MAINNET_CHAIN_ID = "0x534e5f4d41494e"; +export const LOCKED_NEUTRAL_ADDRESS = + "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46"; +export const LOCKED_NEUTRAL_PUBLIC_KEY = + "0x0c041078765f888f2a22a0f68221011641879b222b657cc125014f18c2976ae"; +export const LOCKED_AFTERLIGHT_ADDRESS = + "0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25"; +export const LOCKED_POOL_ADDRESS = + "0x040337b1af3c663e86e333bab5a4b28da8d4652a15a69beee2b677776ffe812a"; +export const LOCKED_POOL_FEE_COLLECTOR = + "0x0d79041634625e5288296fbc648088788710ba44903a3a49468a66567749e77"; +export const LOCKED_TOKEN_ADDRESS = + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d"; +export const LOCKED_AFTERLIGHT_CLASS_HASH = + "0x05da9866f62cc6dd1e380e8d9206e78a752b460abdb802070e0be1208ec7b1a6"; +export const LOCKED_NEUTRAL_CLASS_HASH = + "0x01d1777db36cdd06dd62cfde77b1b6ae06412af95d57a13dc40ac77b8a702381"; +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 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; +export const MIN_AUTH_INCLUSION_BUFFER_SECONDS = 180n; +export const MINIMUM_RPC_SPEC = "0.10.1"; +export const STARKNETJS_VERSION = "10.7.0"; +export const STARKNETJS_PACKAGE_SHA256 = + "3a3b783706f1adde673f29c3afff69f6e7f57c2cfcff774b99110253e962c8e7"; +export const STARKNETJS_MODULE_SHA256 = + "7aa6f8c6e3df5d7016df991cb61997a76c4f1247e51c722489511712b99cb38a"; +export const OPEN_NOTE_DEPOSITED_SELECTOR = + "0x25b6da03c4858d11cb0708d5cb6be79b190fb32eb7a7ce83804e07cbbb9bead"; +export const VAULT_CANCELLED_SELECTOR = + "0x4a883802e2fa6195c66706431ffafa819d6cdd4b00df619fe567339accfc0d"; +export const RECOVERY_CLAIMED_SELECTOR = + "0xff3a13b99d0d3f8d2e9810175bcf474778d8a17e3321d43dc845ae218f0a13"; +export const MAX_PROOF_BYTES = 32 * 1024 * 1024; +export const MAX_CALLDATA_FELTS = 16_384; +export const PROOF0_HEADER = "0x50524f4f4630"; +export const PROOF1_HEADER = "0x50524f4f4631"; +export const ESTIMATE_VIRTUAL_PROGRAM_HASH = + "0x03e98c2d7703b03a7edb73ed7f075f97f1dcbaa8f717cdf6e1a57bf058265473"; +export const ESTIMATE_STARKNET_OS_CONFIG_HASH = + "0x070c7b342f93155315d1cb2da7a4e13a3c2430f51fb5696c1b224c3da5508dfb"; + +const FELT_RE = /^(?:0x[0-9a-f]+|0|[1-9][0-9]*)$/i; +const HEX_RE = /^0x[0-9a-f]+$/i; +const DECIMAL_RE = /^(?:0|[1-9][0-9]*)$/; +const SHA256_RE = /^[0-9a-f]{64}$/; +const STARK_FIELD_PRIME = 2n ** 251n + 17n * 2n ** 192n + 1n; +const U64_MAX = 2n ** 64n - 1n; +const ACTION_POLICY = Object.freeze({ + CANCEL_REFUND: Object.freeze({ + discriminant: 1n, + requiredState: 1n, + finalState: 4n, + roleNonceIndex: 13, + eventName: "VaultCancelled", + eventSelector: VAULT_CANCELLED_SELECTOR, + allowanceBeforeFri: LOCKED_INITIAL_ALLOWANCE_FRI, + allowanceAfterFri: LOCKED_POOL_FEE_FRI, + }), + CLAIM: Object.freeze({ + discriminant: 2n, + requiredState: 2n, + finalState: 3n, + roleNonceIndex: 14, + eventName: "RecoveryClaimed", + eventSelector: RECOVERY_CLAIMED_SELECTOR, + allowanceBeforeFri: LOCKED_POOL_FEE_FRI, + allowanceAfterFri: 0n, + }), +}); + +export function sha256Bytes(value) { + return createHash("sha256").update(value).digest("hex"); +} + +export function canonicalJson(value) { + const visit = (item) => { + if (item === null || typeof item === "string" || typeof item === "boolean") return item; + if (typeof item === "number") { + if (!Number.isSafeInteger(item)) throw new TypeError("noncanonical_number"); + return item; + } + if (Array.isArray(item)) return item.map(visit); + if (typeof item === "object") { + const out = {}; + for (const key of Object.keys(item).sort()) { + if (item[key] === undefined) throw new TypeError("undefined_not_canonical"); + out[key] = visit(item[key]); + } + return out; + } + throw new TypeError("unsupported_canonical_json_value"); + }; + return JSON.stringify(visit(value)); +} + +export function hashCanonical(value) { + return sha256Bytes(Buffer.from(canonicalJson(value), "utf8")); +} + +export function normalizeFelt(value, label = "felt") { + if (typeof value !== "string" || !FELT_RE.test(value)) { + throw new TypeError(`invalid_${label}`); + } + const parsed = BigInt(value); + if (parsed < 0n || parsed >= STARK_FIELD_PRIME) throw new RangeError(`${label}_out_of_range`); + return parsed.toString(); +} + +export function normalizeHex(value, label = "hex") { + if (typeof value !== "string" || !HEX_RE.test(value)) { + throw new TypeError(`invalid_${label}`); + } + const parsed = BigInt(value); + if (parsed < 0n || parsed >= STARK_FIELD_PRIME) throw new RangeError(`${label}_out_of_range`); + return `0x${parsed.toString(16)}`; +} + +export function validateAfterlightConfigResult(configRaw) { + if (!Array.isArray(configRaw) || configRaw.length !== 10) { + throw new Error("afterlight_config_shape_drift"); + } + if ( + normalizeHex(configRaw[0]) !== normalizeHex(LOCKED_POOL_ADDRESS) || + normalizeHex(configRaw[1]) !== normalizeHex(LOCKED_TOKEN_ADDRESS) || + BigInt(configRaw[2]) !== LOCKED_AMOUNT_FRI + ) { + throw new Error("afterlight_config_drift"); + } +} + +export function strictDecimal(value, label = "decimal") { + if (typeof value !== "string" || !DECIMAL_RE.test(value)) { + throw new TypeError(`invalid_${label}`); + } + return BigInt(value); +} + +function integer(value, label) { + if (typeof value === "bigint") return value; + if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) return BigInt(value); + if (typeof value === "string" && (DECIMAL_RE.test(value) || HEX_RE.test(value))) { + return BigInt(value); + } + throw new TypeError(`invalid_${label}`); +} + +function feltArray(value, label) { + if (!Array.isArray(value)) throw new TypeError(`invalid_${label}`); + if (value.length > MAX_CALLDATA_FELTS) throw new RangeError(`${label}_too_large`); + return value.map((item, index) => normalizeFelt(item, `${label}_${index}`)); +} + +function sameFelts(left, right) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((item, index) => normalizeFelt(item) === normalizeFelt(right[index])) + ); +} + +export function decodeCanonicalProofData(value) { + if (typeof value !== "string" || value.length === 0 || value.length > MAX_PROOF_BYTES * 2) { + throw new Error("invalid_proof_data"); + } + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + throw new Error("proof_data_not_canonical_base64"); + } + const decoded = Buffer.from(value, "base64"); + if (decoded.length === 0 || decoded.length > MAX_PROOF_BYTES) throw new Error("proof_data_size"); + if (decoded.toString("base64") !== value) throw new Error("proof_data_not_canonical_base64"); + return decoded; +} + +export function parseProofFacts(raw) { + const facts = feltArray(raw, "proof_facts"); + if (facts.length < 9) throw new Error("proof_facts_too_short"); + const messageCount = Number(BigInt(facts[7])); + if (!Number.isSafeInteger(messageCount) || messageCount !== 1 || facts.length !== 8 + messageCount) { + throw new Error("proof_facts_message_shape"); + } + const baseBlockNumber = BigInt(facts[4]); + if (baseBlockNumber === 0n) throw new Error("proof_facts_zero_base_block"); + if (BigInt(facts[8]) === 0n) throw new Error("proof_facts_zero_message_hash"); + return Object.freeze({ facts, baseBlockNumber, messageHash: facts[8] }); +} + +// Starknet's fee-estimation path accepts the current SDK mock-proof envelope, +// while Ready's real mainnet proofs and accepted transactions use a PROOF1 +// envelope. Keep the real facts immutable for signing/broadcast and normalize +// only the three versioned envelope fields in the quote copy. The base block, +// block hash, message count, and action-bound message hash remain byte-for-byte. +export function proofFactsForFeeEstimate(raw) { + const parsed = parseProofFacts(raw); + if (BigInt(parsed.facts[0]) !== BigInt(PROOF1_HEADER)) { + throw new Error("fee_estimate_requires_real_proof1_input"); + } + const facts = [...parsed.facts]; + facts[0] = normalizeFelt(PROOF0_HEADER); + facts[2] = normalizeFelt(ESTIMATE_VIRTUAL_PROGRAM_HASH); + facts[6] = normalizeFelt(ESTIMATE_STARKNET_OS_CONFIG_HASH); + return Object.freeze(facts); +} + +export function parseServerActions(rawCalldata) { + const raw = feltArray(rawCalldata, "pool_calldata").map(BigInt); + let cursor = 0; + const take = (label) => { + if (cursor >= raw.length) throw new Error(`truncated_${label}`); + return raw[cursor++]; + }; + const takeCount = (label) => { + const value = take(label); + if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error(`oversized_${label}`); + return Number(value); + }; + const takeFields = (count, label) => { + if (cursor + count > raw.length) throw new Error(`truncated_${label}`); + const fields = raw.slice(cursor, cursor + count); + cursor += count; + return fields; + }; + + const count = takeCount("server_action_count"); + if (count === 0 || count > 256) throw new Error("invalid_server_action_count"); + const actions = []; + for (let index = 0; index < count; index += 1) { + const start = cursor; + const variant = take(`server_action_${index}_variant`); + let fields; + if (variant === 0n) { + const storageAddress = take(`server_action_${index}_storage_address`); + const length = takeCount(`server_action_${index}_value_length`); + fields = [storageAddress, BigInt(length), ...takeFields(length, `server_action_${index}_value`)]; + } else if (variant === 1n) { + fields = takeFields(4, `server_action_${index}_append`); + } else if (variant === 2n || variant === 3n || variant === 6n) { + fields = takeFields(3, `server_action_${index}_fixed`); + } else if (variant === 4n || variant === 7n) { + fields = takeFields(5, `server_action_${index}_fixed`); + } else if (variant === 5n) { + fields = takeFields(6, `server_action_${index}_withdrawal`); + } else if (variant === 8n) { + fields = takeFields(2, `server_action_${index}_enc_note`); + } else if (variant === 9n) { + fields = takeFields(1, `server_action_${index}_note_used`); + } else if (variant === 10n || variant === 11n) { + const target = take(`server_action_${index}_target`); + const length = takeCount(`server_action_${index}_calldata_length`); + fields = [target, BigInt(length), ...takeFields(length, `server_action_${index}_calldata`)]; + } else { + throw new Error(`unknown_server_action_${variant}`); + } + actions.push(Object.freeze({ index, variant, fields: Object.freeze(fields), start, end: cursor })); + } + const actionsEnd = cursor; + const suffix = raw.slice(cursor); + if (suffix.length !== 1 || suffix[0] !== 1n) { + throw new Error("exit_requires_none_screening_suffix"); + } + return Object.freeze({ + raw: Object.freeze(raw), + actions: Object.freeze(actions), + actionsEnd, + serializedActions: Object.freeze(raw.slice(0, actionsEnd)), + screeningSuffix: Object.freeze(suffix), + }); +} + +function normalizePreparedCall(call) { + if (typeof call !== "object" || call === null) throw new Error("missing_prepared_call"); + const targets = [call.contractAddress, call.contract_address] + .filter((item) => item !== undefined) + .map((item) => normalizeHex(item, "prepared_call_target")); + if (targets.length === 0 || targets.some((item) => item !== targets[0])) { + throw new Error("prepared_call_target_conflict"); + } + const entrypoints = [call.entrypoint, call.entry_point] + .filter((item) => item !== undefined); + if ( + entrypoints.length === 0 || + entrypoints.some((item) => typeof item !== "string" || item !== entrypoints[0]) + ) { + throw new Error("prepared_call_entrypoint_conflict"); + } + return Object.freeze({ + contractAddress: targets[0], + entrypoint: entrypoints[0], + calldata: Object.freeze(feltArray(call.calldata, "prepared_call_calldata")), + }); +} + +export function buildExitLocks(input) { + if (typeof input !== "object" || input === null) throw new Error("missing_exit_package"); + const call = input.prepared?.call; + const proof = input.prepared?.proof; + if (typeof proof !== "object" || proof === null) throw new Error("missing_prepared_proof"); + const proofBytes = decodeCanonicalProofData(proof.data); + const { locks: _ignored, ...bound } = input; + return Object.freeze({ + callSha256: hashCanonical(call), + proofDataSha256: sha256Bytes(proofBytes), + proofOutputSha256: hashCanonical(proof.output), + proofFactsSha256: hashCanonical(proof.proof_facts), + bindingSha256: hashCanonical(bound), + }); +} + +export function validatePolicy(policy) { + if (policy?.schema !== "afterlight-neutral-exit-policy/1") throw new Error("bad_policy_schema"); + const exactHex = (field, expected) => { + if (normalizeHex(policy[field], field) !== normalizeHex(expected)) throw new Error(`wrong_${field}`); + }; + exactHex("chainId", MAINNET_CHAIN_ID); + exactHex("neutralAddress", LOCKED_NEUTRAL_ADDRESS); + exactHex("neutralPublicKey", LOCKED_NEUTRAL_PUBLIC_KEY); + exactHex("afterlightAddress", LOCKED_AFTERLIGHT_ADDRESS); + exactHex("poolAddress", LOCKED_POOL_ADDRESS); + exactHex("poolFeeCollector", LOCKED_POOL_FEE_COLLECTOR); + exactHex("tokenAddress", LOCKED_TOKEN_ADDRESS); + exactHex("afterlightClassHash", LOCKED_AFTERLIGHT_CLASS_HASH); + exactHex("neutralClassHash", LOCKED_NEUTRAL_CLASS_HASH); + if (strictDecimal(policy.fixedAmountFri, "fixed_amount") !== LOCKED_AMOUNT_FRI) { + throw new Error("wrong_fixed_amount"); + } + if (strictDecimal(policy.poolFeeEachFri, "pool_fee") !== LOCKED_POOL_FEE_FRI) { + throw new Error("wrong_pool_fee"); + } + if (strictDecimal(policy.initialPoolAllowanceFri, "initial_allowance") !== LOCKED_INITIAL_ALLOWANCE_FRI) { + throw new Error("wrong_initial_allowance"); + } + if (strictDecimal(policy.postSpendHealthFloorFri, "health_floor") < LOCKED_HEALTH_FLOOR_FRI) { + throw new Error("health_floor_too_low"); + } + const networkCapFri = strictDecimal(policy.maxNetworkFeePerExitFri, "network_cap"); + if (networkCapFri <= 0n || networkCapFri > ABSOLUTE_NETWORK_CAP_PER_EXIT_FRI) { + throw new Error("network_cap_out_of_range"); + } + const amountMarginBps = strictDecimal(policy.amountMarginBps, "amount_margin_bps"); + const priceMarginBps = strictDecimal(policy.priceMarginBps, "price_margin_bps"); + if ( + amountMarginBps < 10_000n || amountMarginBps > 12_500n || + priceMarginBps < 10_000n || priceMarginBps > 12_500n + ) { + throw new Error("margin_out_of_range"); + } + if (strictDecimal(policy.maxEstimateAgeBlocks, "max_estimate_age") !== MAX_ESTIMATE_AGE_BLOCKS) { + throw new Error("wrong_max_estimate_age"); + } + if (policy.minimumRpcSpec !== MINIMUM_RPC_SPEC) throw new Error("wrong_minimum_rpc_spec"); + if (policy.starknetJsVersion !== STARKNETJS_VERSION) throw new Error("wrong_starknetjs_version"); + if (policy.starknetJsPackageSha256 !== STARKNETJS_PACKAGE_SHA256) { + throw new Error("wrong_starknetjs_package_hash"); + } + if (policy.starknetJsModuleSha256 !== STARKNETJS_MODULE_SHA256) { + throw new Error("wrong_starknetjs_module_hash"); + } + return Object.freeze({ policy, networkCapFri, amountMarginBps, priceMarginBps }); +} + +export function validatePreparedExitPackage(input, policy) { + validatePolicy(policy); + if (input?.schema !== "afterlight-prepared-neutral-exit/1") throw new Error("bad_exit_schema"); + if (input.evidence !== "APPLICATION_AUTHORIZED_OUTER_UNSIGNED_NOT_SUBMITTED") { + throw new Error("bad_exit_evidence_class"); + } + const actionPolicy = ACTION_POLICY[input.action]; + if (!actionPolicy) throw new Error("unsupported_exit_action"); + const exactAddress = (field, expected) => { + if (normalizeHex(input[field], field) !== normalizeHex(expected)) throw new Error(`wrong_${field}`); + }; + exactAddress("chainId", MAINNET_CHAIN_ID); + exactAddress("neutralAddress", LOCKED_NEUTRAL_ADDRESS); + exactAddress("afterlightAddress", LOCKED_AFTERLIGHT_ADDRESS); + exactAddress("poolAddress", LOCKED_POOL_ADDRESS); + exactAddress("tokenAddress", LOCKED_TOKEN_ADDRESS); + if (strictDecimal(input.amountFri, "amount") !== LOCKED_AMOUNT_FRI) throw new Error("wrong_amount"); + const metadata = Object.freeze({ + vaultId: normalizeFelt(input.vaultId, "vault_id"), + expectedState: normalizeFelt(input.expectedState, "expected_state"), + expectedEpoch: normalizeFelt(input.expectedEpoch, "expected_epoch"), + expectedRoleNonce: normalizeFelt(input.expectedRoleNonce, "expected_role_nonce"), + destinationNoteId: normalizeFelt(input.destinationNoteId, "destination_note_id"), + validUntil: normalizeFelt(input.validUntil, "valid_until"), + preparedAtBlock: strictDecimal(input.preparedAtBlock, "prepared_at_block"), + }); + if (BigInt(metadata.vaultId) === 0n || BigInt(metadata.destinationNoteId) === 0n) { + throw new Error("zero_vault_or_note"); + } + if (BigInt(metadata.expectedState) !== actionPolicy.requiredState) { + throw new Error("wrong_expected_state_for_action"); + } + if ( + BigInt(metadata.expectedEpoch) > U64_MAX || + BigInt(metadata.expectedRoleNonce) > U64_MAX || + BigInt(metadata.validUntil) > U64_MAX || + metadata.preparedAtBlock > U64_MAX + ) { + throw new Error("exit_u64_out_of_range"); + } + if (metadata.preparedAtBlock === 0n) throw new Error("zero_prepared_block"); + + const call = normalizePreparedCall(input.prepared?.call); + 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 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"); + const note = notes[0]; + if ( + normalizeHex(`0x${note.fields[3].toString(16)}`) !== normalizeHex(LOCKED_TOKEN_ADDRESS) || + note.fields[4].toString() !== metadata.destinationNoteId + ) { + throw new Error("open_note_token_or_id_mismatch"); + } + 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)) { + throw new Error("wrong_afterlight_invoke_target"); + } + const invokeWidth = Number(invoke.fields[1]); + const exitCalldata = invoke.fields.slice(2); + if (invokeWidth !== 11 || exitCalldata.length !== 11) throw new Error("wrong_exit_calldata_width"); + const expected = [ + actionPolicy.discriminant, + BigInt(metadata.vaultId), + BigInt(LOCKED_TOKEN_ADDRESS), + LOCKED_AMOUNT_FRI, + actionPolicy.requiredState, + BigInt(metadata.expectedEpoch), + BigInt(metadata.expectedRoleNonce), + BigInt(metadata.destinationNoteId), + BigInt(metadata.validUntil), + ]; + for (let index = 0; index < expected.length; index += 1) { + if (exitCalldata[index] !== expected[index]) throw new Error(`exit_calldata_mismatch_${index}`); + } + if (exitCalldata[9] === 0n || exitCalldata[10] === 0n) throw new Error("zero_application_signature"); + + const proof = input.prepared?.proof; + 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) { + throw new Error("proof_output_shape"); + } + for (let index = 0; index < parsed.serializedActions.length; index += 1) { + if (proofOutput[index + 1] !== parsed.serializedActions[index]) { + throw new Error(`proof_output_action_mismatch_${index}`); + } + } + const proofFacts = parseProofFacts(proof.proof_facts); + if (typeof input.locks !== "object" || input.locks === null) throw new Error("missing_locks"); + const actualLocks = buildExitLocks(input); + for (const [name, actual] of Object.entries(actualLocks)) { + if (!SHA256_RE.test(input.locks[name] ?? "") || input.locks[name] !== actual) { + throw new Error(`lock_mismatch_${name}`); + } + } + return Object.freeze({ + input, + action: input.action, + actionPolicy, + metadata, + call, + parsed, + proof: Object.freeze({ + data: proof.data, + decodedBytes: proofBytes.length, + output: Object.freeze(proofOutput), + facts: proofFacts, + }), + bindingSha256: actualLocks.bindingSha256, + locks: actualLocks, + }); +} + +export function parseRpcSpec(value) { + if (typeof value !== "string") throw new Error("invalid_rpc_spec"); + const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(value); + if (!match) throw new Error("invalid_rpc_spec"); + const parsed = match.slice(1).map(Number); + const minimum = MINIMUM_RPC_SPEC.split(".").map(Number); + for (let index = 0; index < 3; index += 1) { + if (parsed[index] > minimum[index]) return Object.freeze({ raw: value, compatible: true }); + if (parsed[index] < minimum[index]) throw new Error("rpc_spec_too_old_for_snip36"); + } + return Object.freeze({ raw: value, compatible: true }); +} + +export function parseResourceBounds(raw) { + if (typeof raw !== "object" || raw === null) throw new Error("invalid_resource_bounds"); + const parse = (name) => { + const item = raw[name]; + if (typeof item !== "object" || item === null) throw new Error(`missing_${name}`); + const maxAmount = integer(item.max_amount ?? item.maxAmount, `${name}_max_amount`); + const maxPrice = integer( + item.max_price_per_unit ?? item.maxPricePerUnit, + `${name}_max_price_per_unit`, + ); + if (maxAmount < 0n || maxPrice < 0n) throw new Error(`negative_${name}`); + return Object.freeze({ max_amount: maxAmount, max_price_per_unit: maxPrice }); + }; + return Object.freeze({ l1_gas: parse("l1_gas"), l1_data_gas: parse("l1_data_gas"), l2_gas: parse("l2_gas") }); +} + +export function resourceCapFri(bounds) { + const parsed = parseResourceBounds(bounds); + return [parsed.l1_gas, parsed.l1_data_gas, parsed.l2_gas] + .reduce((sum, item) => sum + item.max_amount * item.max_price_per_unit, 0n); +} + +export function addResourceMargins(bounds, amountMarginBps, priceMarginBps) { + const parsed = parseResourceBounds(bounds); + const amountBps = integer(amountMarginBps, "amount_margin_bps"); + const priceBps = integer(priceMarginBps, "price_margin_bps"); + if ( + amountBps < 10_000n || amountBps > 12_500n || + priceBps < 10_000n || priceBps > 12_500n + ) { + throw new Error("margin_out_of_range"); + } + const ceil = (value, bps) => (value * bps + 9_999n) / 10_000n; + const apply = (item) => Object.freeze({ + max_amount: ceil(item.max_amount, amountBps), + max_price_per_unit: ceil(item.max_price_per_unit, priceBps), + }); + return Object.freeze({ l1_gas: apply(parsed.l1_gas), l1_data_gas: apply(parsed.l1_data_gas), l2_gas: apply(parsed.l2_gas) }); +} + +export function serializeResourceBounds(bounds) { + const parsed = parseResourceBounds(bounds); + const item = (value) => ({ + max_amount: value.max_amount.toString(), + max_price_per_unit: value.max_price_per_unit.toString(), + }); + return { l1_gas: item(parsed.l1_gas), l1_data_gas: item(parsed.l1_data_gas), l2_gas: item(parsed.l2_gas) }; +} + +export function assertFreshEstimate(estimateBlock, liveBlock, maxAge = MAX_ESTIMATE_AGE_BLOCKS) { + const estimated = integer(estimateBlock, "estimate_block"); + const live = integer(liveBlock, "live_block"); + const limit = integer(maxAge, "max_age"); + if (estimated > live || live - estimated > limit) throw new Error("estimate_stale"); + return live - estimated; +} + +export function assertProofFreshness(baseBlock, liveBlock, validityBlocks) { + const base = integer(baseBlock, "proof_base_block"); + const live = integer(liveBlock, "live_block"); + const validity = integer(validityBlocks, "proof_validity_blocks"); + if (validity === 0n || base >= live || live > base + validity) throw new Error("proof_not_live"); + return base + validity - live; +} + +export function parseU256Result(result, label = "u256") { + const values = feltArray(result, label).map(BigInt); + if (values.length !== 2) throw new Error(`wrong_${label}_width`); + if (values[0] >= 2n ** 128n || values[1] >= 2n ** 128n) throw new Error(`${label}_limb_out_of_range`); + return values[0] + (values[1] << 128n); +} + +export function parseVaultResult(result) { + const values = feltArray(result, "vault_result").map(BigInt); + if (values.length !== 15) throw new Error("wrong_vault_width"); + return Object.freeze({ + exists: values[0], state: values[1], mode: values[2], ownerKey: values[3], + successorKey: values[4], token: values[5], amount: values[6], + inactivitySeconds: values[7], graceSeconds: values[8], lastHeartbeat: values[9], + requestedAt: values[10], claimAfter: values[11], epoch: values[12], + ownerNonce: values[13], successorNonce: values[14], raw: Object.freeze(values), + }); +} + +export function validateLiveExitState(validated, vault, blockTimestamp) { + const parsed = vault.raw ? vault : parseVaultResult(vault); + if (parsed.exists !== 1n || parsed.state !== validated.actionPolicy.requiredState) { + throw new Error("vault_wrong_live_state"); + } + if (parsed.token !== BigInt(LOCKED_TOKEN_ADDRESS) || parsed.amount !== LOCKED_AMOUNT_FRI) { + throw new Error("vault_token_or_amount_drift"); + } + if (parsed.epoch !== BigInt(validated.metadata.expectedEpoch)) throw new Error("vault_epoch_drift"); + const liveRoleNonce = validated.action === "CANCEL_REFUND" ? parsed.ownerNonce : parsed.successorNonce; + if (liveRoleNonce !== BigInt(validated.metadata.expectedRoleNonce)) throw new Error("vault_role_nonce_drift"); + const timestamp = integer(blockTimestamp, "block_timestamp"); + if (timestamp > BigInt(validated.metadata.validUntil)) throw new Error("application_authorization_expired"); + if (validated.action === "CLAIM" && timestamp < parsed.claimAfter) throw new Error("claim_too_early"); + return parsed; +} + +export function validateAuthorizationInclusionWindow( + validUntil, + blockTimestamp, + wallClockTimestamp, + minimumBuffer = MIN_AUTH_INCLUSION_BUFFER_SECONDS, +) { + const deadline = integer(validUntil, "valid_until"); + const blockTime = integer(blockTimestamp, "block_timestamp"); + const wallTime = integer(wallClockTimestamp, "wall_clock_timestamp"); + const buffer = integer(minimumBuffer, "minimum_auth_inclusion_buffer"); + const reference = blockTime > wallTime ? blockTime : wallTime; + if (deadline < reference + buffer) throw new Error("application_authorization_inclusion_window_too_short"); + return deadline - reference; +} + +export function validateAllowanceForAction(action, allowance) { + const actionPolicy = ACTION_POLICY[action]; + if (!actionPolicy) throw new Error("unsupported_exit_action"); + const actual = integer(allowance, "allowance"); + if (actual !== actionPolicy.allowanceBeforeFri) throw new Error("wrong_exact_pool_allowance"); + return actionPolicy.allowanceAfterFri; +} + +export function validateBalanceForExit(balance, resourceCap, healthFloor = LOCKED_HEALTH_FLOOR_FRI) { + const actual = integer(balance, "balance"); + const cap = integer(resourceCap, "resource_cap"); + const floor = integer(healthFloor, "health_floor"); + const required = LOCKED_POOL_FEE_FRI + cap + floor; + if (actual < required) throw new Error("neutral_balance_below_exit_reservation"); + return Object.freeze({ balance: actual, required, headroom: actual - required }); +} + +function daMode(value, label) { + if (value === "L1" || value === 0 || value === "0x0") return 0; + if (value === "L2" || value === 1 || value === "0x1") return 1; + throw new Error(`invalid_${label}`); +} + +export function buildSignedInvokeHashInput(signed) { + if (typeof signed !== "object" || signed === null) throw new Error("missing_signed_transaction"); + return { + senderAddress: normalizeHex(signed.sender_address, "signed_sender"), + version: normalizeHex(signed.version, "signed_version"), + compiledCalldata: feltArray(signed.calldata, "signed_calldata"), + chainId: MAINNET_CHAIN_ID, + nonce: integer(signed.nonce, "signed_nonce"), + accountDeploymentData: feltArray(signed.account_deployment_data ?? [], "account_deployment_data"), + nonceDataAvailabilityMode: daMode(signed.nonce_data_availability_mode, "nonce_da_mode"), + feeDataAvailabilityMode: daMode(signed.fee_data_availability_mode, "fee_da_mode"), + resourceBounds: parseResourceBounds(signed.resource_bounds), + tip: integer(signed.tip ?? 0, "tip"), + paymasterData: feltArray(signed.paymaster_data ?? [], "paymaster_data"), + proofFacts: feltArray(signed.proof_facts ?? [], "proof_facts"), + }; +} + +export function assertOuterSignatureMatchesHash(signed, publicKey = LOCKED_NEUTRAL_PUBLIC_KEY) { + if (!Array.isArray(signed?.signature) || signed.signature.length !== 2) { + throw new Error("invalid_outer_signature_shape"); + } + const messageHash = hash.calculateInvokeTransactionHash(buildSignedInvokeHashInput(signed)); + const signature = new ec.starkCurve.Signature( + BigInt(signed.signature[0]), + BigInt(signed.signature[1]), + ); + const x = BigInt(publicKey).toString(16).padStart(64, "0"); + const valid = ["02", "03"].some((prefix) => { + try { + return ec.starkCurve.verify(signature, messageHash, `${prefix}${x}`); + } catch { + return false; + } + }); + if (!valid) throw new Error("outer_signature_hash_mismatch"); + return normalizeHex(messageHash); +} + +export function assertSignedExitTransaction(signed, expected) { + if (normalizeHex(signed.sender_address) !== normalizeHex(LOCKED_NEUTRAL_ADDRESS)) { + throw new Error("signed_sender_mismatch"); + } + if (normalizeHex(signed.version) !== "0x3") throw new Error("signed_not_v3"); + if (integer(signed.nonce, "signed_nonce") !== integer(expected.nonce, "expected_nonce")) { + throw new Error("signed_nonce_mismatch"); + } + if (!sameFelts(signed.calldata, expected.executeCalldata)) throw new Error("signed_calldata_mismatch"); + if (!sameFelts(signed.proof_facts, expected.proofFacts)) throw new Error("signed_proof_facts_mismatch"); + if (signed.proof !== expected.proof) throw new Error("signed_proof_mismatch"); + if ((signed.signature ?? []).length === 0) throw new Error("missing_account_signature"); + if ((signed.account_deployment_data ?? []).length !== 0 || (signed.paymaster_data ?? []).length !== 0) { + throw new Error("unexpected_paymaster_or_deployment_data"); + } + if (integer(signed.tip ?? 0, "tip") !== 0n) throw new Error("nonzero_tip"); + if (daMode(signed.nonce_data_availability_mode, "nonce_da_mode") !== 0 || daMode(signed.fee_data_availability_mode, "fee_da_mode") !== 0) { + throw new Error("non_l1_data_availability"); + } + const actualBounds = serializeResourceBounds(signed.resource_bounds); + const expectedBounds = serializeResourceBounds(expected.resourceBounds); + if (canonicalJson(actualBounds) !== canonicalJson(expectedBounds)) throw new Error("signed_bounds_mismatch"); + if (resourceCapFri(actualBounds) > expected.networkCapFri) throw new Error("signed_network_cap_exceeded"); + return true; +} + +export function makeEmptyLedger() { + return { + schema: "afterlight-neutral-exit-ledger/1", + slots: { + CANCEL_REFUND: { state: "UNUSED" }, + CLAIM: { state: "UNUSED" }, + }, + }; +} + +export function reserveLedgerSlot(ledger, action, bindingSha256, at) { + if (ledger?.schema !== "afterlight-neutral-exit-ledger/1") throw new Error("bad_ledger_schema"); + if (!ACTION_POLICY[action]) throw new Error("unsupported_exit_action"); + const prior = ledger.slots?.[action]; + if (!["UNUSED", "RECONCILED_FAILED"].includes(prior?.state)) { + throw new Error("exit_slot_already_consumed"); + } + if (!SHA256_RE.test(bindingSha256)) throw new Error("bad_binding_hash"); + return { + ...ledger, + slots: { + ...ledger.slots, + [action]: { + state: "RESERVED", + bindingSha256, + reservedAt: at, + ...(prior.state === "RECONCILED_FAILED" + ? { history: [...(prior.history ?? []), { ...prior, history: undefined }] } + : {}), + }, + }, + }; +} + +export function markLedgerSigned(ledger, action, signedArtifactSha256, expectedTransactionHash, at) { + const slot = ledger?.slots?.[action]; + if (slot?.state !== "RESERVED") throw new Error("slot_not_reserved"); + if (!SHA256_RE.test(signedArtifactSha256)) throw new Error("bad_signed_artifact_hash"); + return { + ...ledger, + slots: { + ...ledger.slots, + [action]: { + ...slot, + state: "SIGNED", + signedArtifactSha256, + expectedTransactionHash: normalizeHex(expectedTransactionHash), + signedAt: at, + }, + }, + }; +} + +export function markBroadcastAttempt(ledger, action, expectedTransactionHash, snapshotSha256, at) { + const slot = ledger?.slots?.[action]; + if (slot?.state !== "SIGNED") throw new Error("slot_not_signed_or_already_attempted"); + if (normalizeHex(slot.expectedTransactionHash) !== normalizeHex(expectedTransactionHash)) { + throw new Error("broadcast_hash_mismatch"); + } + if (!SHA256_RE.test(snapshotSha256)) throw new Error("bad_snapshot_hash"); + return { + ...ledger, + slots: { + ...ledger.slots, + [action]: { ...slot, state: "BROADCAST_ATTEMPTED", snapshotSha256, attemptedAt: at }, + }, + }; +} + +export function markBroadcastResult(ledger, action, state, at, detail = {}) { + const allowed = new Set(["SUBMITTED", "BROADCAST_UNKNOWN", "HASH_MISMATCH", "RECONCILED_SUCCEEDED", "RECONCILED_FAILED"]); + if (!allowed.has(state)) throw new Error("invalid_broadcast_state"); + const current = ledger?.slots?.[action]?.state; + const reconciliation = state.startsWith("RECONCILED_"); + if ((!reconciliation && current !== "BROADCAST_ATTEMPTED") || (reconciliation && !["SUBMITTED", "BROADCAST_UNKNOWN", "HASH_MISMATCH"].includes(current))) { + throw new Error("invalid_broadcast_transition"); + } + return { + ...ledger, + slots: { + ...ledger.slots, + [action]: { ...ledger.slots[action], ...detail, state, updatedAt: at }, + }, + }; +} + +export function expectedBroadcastAcknowledgement(action, transactionHash) { + if (!ACTION_POLICY[action]) throw new Error("unsupported_exit_action"); + return `BROADCAST_ONCE_${action}_${normalizeHex(transactionHash)}`; +} + +export function verifyReceiptEvidence(input) { + const { validated, signedArtifact, transaction, receipt, before, after } = input; + const expectedHash = normalizeHex(signedArtifact.expectedTransactionHash); + if (normalizeHex(receipt.transaction_hash) !== expectedHash) throw new Error("receipt_hash_mismatch"); + if (transaction.transaction_hash !== undefined && normalizeHex(transaction.transaction_hash) !== expectedHash) { + throw new Error("transaction_hash_mismatch"); + } + if (normalizeHex(transaction.sender_address) !== normalizeHex(LOCKED_NEUTRAL_ADDRESS)) { + throw new Error("receipt_sender_mismatch"); + } + if (normalizeHex(transaction.version) !== "0x3") throw new Error("receipt_not_v3"); + if (!sameFelts(transaction.calldata, signedArtifact.signedTransaction.calldata)) { + throw new Error("receipt_calldata_mismatch"); + } + if (!sameFelts(transaction.proof_facts, signedArtifact.signedTransaction.proof_facts)) { + throw new Error("receipt_proof_facts_mismatch"); + } + if (receipt.execution_status !== "SUCCEEDED") throw new Error("exit_receipt_not_succeeded"); + if (!String(receipt.finality_status ?? "").startsWith("ACCEPTED_ON_")) { + throw new Error("exit_receipt_not_accepted"); + } + const actualFee = typeof receipt.actual_fee === "string" + ? BigInt(receipt.actual_fee) + : BigInt(receipt.actual_fee?.amount ?? -1); + if (receipt.actual_fee?.unit !== undefined && receipt.actual_fee.unit !== "FRI") { + throw new Error("actual_fee_not_fri"); + } + if (actualFee < 0n || actualFee > BigInt(signedArtifact.networkFeeCapFri)) { + throw new Error("actual_fee_outside_cap"); + } + const coerceVault = (value) => { + if ( + value && + Array.isArray(value.raw) && + typeof value.state === "bigint" && + typeof value.epoch === "bigint" + ) { + return value; + } + const raw = Array.isArray(value?.raw) ? value.raw : value; + if (!Array.isArray(raw)) throw new Error("invalid_reconciliation_vault"); + return parseVaultResult(raw.map((item, index) => integer(item, `reconciliation_vault_${index}`).toString())); + }; + const beforeVault = coerceVault(before.vault); + const afterVault = coerceVault(after.vault); + if (beforeVault.state !== validated.actionPolicy.requiredState || afterVault.state !== validated.actionPolicy.finalState) { + throw new Error("vault_final_state_mismatch"); + } + if (beforeVault.epoch !== afterVault.epoch || beforeVault.epoch !== BigInt(validated.metadata.expectedEpoch)) { + throw new Error("vault_epoch_changed"); + } + const roleIndex = validated.actionPolicy.roleNonceIndex; + if (afterVault.raw[roleIndex] !== beforeVault.raw[roleIndex] + 1n) { + throw new Error("role_nonce_not_incremented_once"); + } + const otherIndex = roleIndex === 13 ? 14 : 13; + if (afterVault.raw[otherIndex] !== beforeVault.raw[otherIndex]) throw new Error("other_role_nonce_changed"); + if (BigInt(before.lockedLiabilityFri) - BigInt(after.lockedLiabilityFri) !== LOCKED_AMOUNT_FRI) { + throw new Error("liability_delta_mismatch"); + } + if (BigInt(before.allowanceFri) !== validated.actionPolicy.allowanceBeforeFri || BigInt(after.allowanceFri) !== validated.actionPolicy.allowanceAfterFri) { + throw new Error("allowance_delta_mismatch"); + } + if ( + BigInt(before.poolFeeFri) !== LOCKED_POOL_FEE_FRI || + BigInt(after.poolFeeFri) !== LOCKED_POOL_FEE_FRI + ) { + throw new Error("pool_fee_reconciliation_mismatch"); + } + if ( + normalizeHex(before.poolFeeCollector) !== normalizeHex(LOCKED_POOL_FEE_COLLECTOR) || + normalizeHex(after.poolFeeCollector) !== normalizeHex(LOCKED_POOL_FEE_COLLECTOR) + ) { + throw new Error("pool_fee_collector_reconciliation_mismatch"); + } + const expectedBalanceDelta = LOCKED_POOL_FEE_FRI + actualFee; + if (BigInt(before.neutralBalanceFri) - BigInt(after.neutralBalanceFri) !== expectedBalanceDelta) { + throw new Error("neutral_balance_delta_mismatch"); + } + if (BigInt(after.neutralNonce) !== BigInt(before.neutralNonce) + 1n) throw new Error("neutral_nonce_delta_mismatch"); + + const events = Array.isArray(receipt.events) ? receipt.events : []; + const exactEvent = (event, emitter, keys, data) => + normalizeHex(event.from_address) === normalizeHex(emitter) && + sameFelts(event.keys, keys) && + sameFelts(event.data, data); + if (!events.some((event) => exactEvent( + event, + LOCKED_POOL_ADDRESS, + [OPEN_NOTE_DEPOSITED_SELECTOR, LOCKED_AFTERLIGHT_ADDRESS, LOCKED_TOKEN_ADDRESS, validated.metadata.destinationNoteId], + [LOCKED_AMOUNT_FRI.toString()], + ))) { + throw new Error("pool_event_missing_exact_note_deposit"); + } + if (!events.some((event) => exactEvent( + event, + LOCKED_AFTERLIGHT_ADDRESS, + [validated.actionPolicy.eventSelector, validated.metadata.vaultId], + [validated.metadata.expectedEpoch, validated.metadata.destinationNoteId, LOCKED_AMOUNT_FRI.toString()], + ))) { + throw new Error("afterlight_event_missing_exact_exit"); + } + return Object.freeze({ + action: validated.action, + transactionHash: expectedHash, + sender: normalizeHex(LOCKED_NEUTRAL_ADDRESS), + destinationNoteId: validated.metadata.destinationNoteId, + actualNetworkFeeFri: actualFee.toString(), + poolFeeFri: LOCKED_POOL_FEE_FRI.toString(), + poolFeeCollector: normalizeHex(LOCKED_POOL_FEE_COLLECTOR), + finalState: validated.actionPolicy.finalState.toString(), + liabilityDeltaFri: LOCKED_AMOUNT_FRI.toString(), + }); +} + +export function verifyEmptyProofFactsTransportRevert(input) { + const { validated, signedArtifact, transaction, receipt, before, after } = input; + const expectedHash = normalizeHex(signedArtifact.expectedTransactionHash); + if (normalizeHex(receipt.transaction_hash) !== expectedHash) throw new Error("receipt_hash_mismatch"); + if (transaction.transaction_hash !== undefined && normalizeHex(transaction.transaction_hash) !== expectedHash) { + throw new Error("transaction_hash_mismatch"); + } + if (normalizeHex(transaction.sender_address) !== normalizeHex(LOCKED_NEUTRAL_ADDRESS)) throw new Error("receipt_sender_mismatch"); + if (normalizeHex(transaction.version) !== "0x3") throw new Error("receipt_not_v3"); + if (!sameFelts(transaction.calldata, signedArtifact.signedTransaction.calldata)) throw new Error("receipt_calldata_mismatch"); + if (Array.isArray(transaction.proof_facts) && transaction.proof_facts.length !== 0) { + throw new Error("transport_revert_transaction_contains_proof_facts"); + } + if (receipt.execution_status !== "REVERTED") throw new Error("transport_revert_not_reverted"); + if (!String(receipt.finality_status ?? "").startsWith("ACCEPTED_ON_")) throw new Error("transport_revert_not_accepted"); + if (!/EMPTY_PROOF_FACTS/.test(String(receipt.revert_reason ?? ""))) throw new Error("transport_revert_wrong_reason"); + const actualFee = typeof receipt.actual_fee === "string" ? BigInt(receipt.actual_fee) : BigInt(receipt.actual_fee?.amount ?? -1); + if (receipt.actual_fee?.unit !== undefined && receipt.actual_fee.unit !== "FRI") throw new Error("actual_fee_not_fri"); + if (actualFee < 0n || actualFee > BigInt(signedArtifact.networkFeeCapFri)) throw new Error("actual_fee_outside_cap"); + const beforeVault = Array.isArray(before.vault?.raw) ? before.vault.raw : before.vault; + const afterVault = Array.isArray(after.vault?.raw) ? after.vault.raw : after.vault; + if ( + !Array.isArray(beforeVault) || + !Array.isArray(afterVault) || + !sameFelts(beforeVault.map(String), afterVault.map(String)) + ) { + throw new Error("transport_revert_vault_changed"); + } + if (BigInt(before.lockedLiabilityFri) !== BigInt(after.lockedLiabilityFri)) throw new Error("transport_revert_liability_changed"); + if (BigInt(before.allowanceFri) !== BigInt(after.allowanceFri)) throw new Error("transport_revert_allowance_changed"); + if (BigInt(before.poolFeeFri) !== BigInt(after.poolFeeFri)) throw new Error("transport_revert_pool_fee_changed"); + if (normalizeHex(before.poolFeeCollector) !== normalizeHex(after.poolFeeCollector)) throw new Error("transport_revert_fee_collector_changed"); + if (BigInt(after.neutralNonce) !== BigInt(before.neutralNonce) + 1n) throw new Error("transport_revert_nonce_delta_mismatch"); + if (BigInt(before.neutralBalanceFri) - BigInt(after.neutralBalanceFri) !== actualFee) { + throw new Error("transport_revert_balance_delta_mismatch"); + } + return { + evidence: "ACCEPTED_REVERT_EMPTY_PROOF_FACTS", + action: validated.action, + transactionHash: expectedHash, + actualNetworkFeeFri: actualFee.toString(), + vaultStateUnchanged: true, + liabilityUnchanged: true, + allowanceUnchanged: true, + neutralNonceConsumed: true, + replacementArtifactPermitted: true, + }; +} + +export function safePublicError(error) { + const message = error instanceof Error ? error.message : String(error); + return message.replace(/https?:\/\/[^\s]+/g, "[RPC_URL_REDACTED]"); +} + +export function validateExactSimulationResult(simulations, requireValidation = true) { + if (!Array.isArray(simulations) || simulations.length !== 1) { + throw new Error("unexpected_simulation_result_count"); + } + const simulation = simulations[0]; + const trace = simulation?.transaction_trace; + if (!trace || trace.type !== "INVOKE" || (requireValidation && !trace.validate_invocation) || !trace.execute_invocation) { + throw new Error("simulation_missing_validation_or_execution_trace"); + } + if (trace.revert_reason || trace.execute_invocation?.revert_reason) { + throw new Error("simulation_reverted"); + } + return Object.freeze({ validationExecuted: requireValidation, executionExecuted: true }); +} + +export function actionPolicy(action) { + const value = ACTION_POLICY[action]; + if (!value) throw new Error("unsupported_exit_action"); + return value; +} diff --git a/relayer/src/starknet-adapter.ts b/relayer/src/starknet-adapter.ts index a3134d1..6d353b8 100644 --- a/relayer/src/starknet-adapter.ts +++ b/relayer/src/starknet-adapter.ts @@ -113,10 +113,22 @@ export class StarknetV3RelayAdapter implements StarknetRelayAdapter { async reconcileReceipt(transactionHash: string, plan: RelayPlan): Promise { await this.assertChain(plan); const normalizedHash = normalizeHex(transactionHash); - const [receipt, submitted] = await Promise.all([ - this.provider.waitForTransaction(normalizedHash, { retries: 30, retryInterval: 2_000 }), - this.provider.getTransactionByHash(normalizedHash), - ]); + let receipt: Awaited> | undefined; + for (let attempt = 0; attempt < 30; attempt += 1) { + try { + const candidate = await this.provider.getTransactionReceipt(normalizedHash); + if (!candidate.isError()) { + receipt = candidate; + break; + } + } catch { + // RPC propagation can lag the accepted broadcast. Keep the single + // submitted reservation serialized while waiting for its receipt. + } + if (attempt < 29) await new Promise((resolve) => setTimeout(resolve, 2_000)); + } + if (receipt === undefined) return { status: "pending" }; + const submitted = await this.provider.getTransactionByHash(normalizedHash); const candidate = submitted as unknown as Record; if ( candidate.type !== "INVOKE" || @@ -130,7 +142,6 @@ export class StarknetV3RelayAdapter implements StarknetRelayAdapter { ) { return { status: "rejected" }; } - if (receipt.isError()) return { status: "pending" }; const raw = receipt.value as unknown as Record; const actualFeeFri = readActualFeeFri(raw.actual_fee); if (actualFeeFri === undefined) return { status: "pending" }; diff --git a/relayer/test/executor.spec.ts b/relayer/test/executor.spec.ts index 16fff79..5d8bbd6 100644 --- a/relayer/test/executor.spec.ts +++ b/relayer/test/executor.spec.ts @@ -126,6 +126,27 @@ describe("fail-closed exact-call executor", () => { expect(adapter.reconcileReceipt).toHaveBeenCalledTimes(2); }); + it("recovers a submitted checkpoint after its time-bucket semantic key changes", async () => { + const adapter = fakeAdapter(); + adapter.reconcileReceipt = vi + .fn() + .mockResolvedValueOnce({ status: "pending" }) + .mockResolvedValueOnce(successfulReceipt()); + const budget = freshBudget(); + await expect(executeRelayPlan(plan, policy, adapter, budget, nowMs)).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({ + status: "accepted", + transactionHash: "0xabc", + actualFeeFri: "70", + }); + expect(adapter.simulateExact).toHaveBeenCalledTimes(1); + expect(adapter.signAndSubmitExact).toHaveBeenCalledTimes(1); + expect(adapter.reconcileReceipt).toHaveBeenCalledTimes(2); + }); + it("commits gas from a definitive reverted receipt and reports failure", async () => { const adapter = fakeAdapter({ receipt: { diff --git a/relayer/test/neutral-exit-signing.spec.ts b/relayer/test/neutral-exit-signing.spec.ts new file mode 100644 index 0000000..a9bdab8 --- /dev/null +++ b/relayer/test/neutral-exit-signing.spec.ts @@ -0,0 +1,75 @@ +import { Account, RpcProvider, ec, transaction, type Call } from "starknet"; +import { describe, expect, it, vi } from "vitest"; + +import { + assertOuterSignatureMatchesHash, + assertSignedExitTransaction, +} from "../src/neutral-exit-policy.mjs"; +import { classifyBroadcastFailure } from "../src/exit-executor.js"; + +const MAINNET_CHAIN_ID = "0x534e5f4d41494e"; +const LOCKED_NEUTRAL_ADDRESS = "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46"; +const TEST_SIGNER = "0x12345"; + +describe("neutral exact-exit signing boundary", () => { + it("distinguishes definitive RPC rejection from ambiguous transport failure", () => { + expect(classifyBroadcastFailure({ baseError: { code: 55 } })).toEqual({ + category: "rpc_validation", + definitiveReject: true, + }); + expect(classifyBroadcastFailure(new Error("network unavailable"))).toEqual({ + category: "transport_or_unknown", + definitiveReject: false, + }); + }); + + it("signs the real proof facts and reconstructs the exact outer hash offline", 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 call: Call = { + contractAddress: "0x987654321", + entrypoint: "privacy_invoke", + calldata: ["0x1", "0x2", "0x3"], + }; + const proof = "AQIDBA=="; + const proofFacts = ["0xabc", "0xdef"]; + 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, + proofFacts, + }); + + expect(assertSignedExitTransaction(signed, { + nonce: 7n, + executeCalldata: transaction.getExecuteCalldata([call], "1"), + proof, + proofFacts, + resourceBounds, + networkCapFri: 184n, + })).toBe(true); + + const publicKey = ec.starkCurve.getStarkKey(TEST_SIGNER); + expect(assertOuterSignatureMatchesHash(signed, publicKey)).toMatch(/^0x[0-9a-f]+$/); + }); +}); diff --git a/relayer/test/relayer.spec.ts b/relayer/test/relayer.spec.ts index c7cccef..1bcc5b8 100644 --- a/relayer/test/relayer.spec.ts +++ b/relayer/test/relayer.spec.ts @@ -7,6 +7,7 @@ import { encodeRelayRequest, type RelayRequest, } from "../src/schema.js"; +import { isAllowedOrigin, rateLimitRelay, RelayHttpError } from "../src/core.js"; const origin = "https://afterlight.invalid"; const contract = "0x1234"; @@ -14,6 +15,14 @@ const token = "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938 const amount = 10_000_000_000_000_000_000n; describe("Afterlight Phase A relay Worker", () => { + it("matches only exact origins from the configured transition allowlist", () => { + const configured = "https://afterlight-app.pages.dev,https://afterlight.dolepee.com"; + expect(isAllowedOrigin("https://afterlight-app.pages.dev", configured)).toBe(true); + expect(isAllowedOrigin("https://afterlight.dolepee.com", configured)).toBe(true); + expect(isAllowedOrigin("https://afterlight.dolepee.com.evil.invalid", configured)).toBe(false); + expect(isAllowedOrigin(null, configured)).toBe(false); + }); + it("reports structured health without secret, wallet, IP or account material", async () => { const response = await exports.default.fetch("https://relay.invalid/health"); expect(response.status).toBe(200); @@ -67,6 +76,31 @@ describe("Afterlight Phase A relay Worker", () => { expect(body.plan.maxSponsoredFeeFri).toBe("200000000000000000"); }); + it("supports a bounded estimate-only relay route without signing or budget mutation", async () => { + const payload = validPayload(RelayOperation.Request, 1n); + const response = await exports.default.fetch( + new Request("https://relay.invalid/v1/relay?mode=estimate", { + method: "POST", + headers: relayHeaders(), + body: payload, + }), + ); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ status: "error", code: "executor_unavailable" }); + }); + + it("rejects unknown relay query parameters", async () => { + const response = await exports.default.fetch( + new Request("https://relay.invalid/v1/relay?mode=estimate&broadcast=true", { + method: "POST", + headers: relayHeaders(), + body: validPayload(RelayOperation.Request, 1n), + }), + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ status: "error", code: "invalid_query" }); + }); + it("builds a payload-free neutral funding checkpoint without wallet or vault material", async () => { const response = await exports.default.fetch( new Request("https://relay.invalid/v1/checkpoint", { @@ -94,6 +128,21 @@ describe("Afterlight Phase A relay Worker", () => { expect(JSON.stringify(body)).not.toMatch(/wallet|vault|signature|ready/i); }); + 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", { + method: "POST", + headers: { + origin, + "content-length": "0", + "x-afterlight-intent": "funding-checkpoint", + }, + body: "", + }), + ); + expect(response.status).toBe(202); + }); + it("rejects checkpoint payloads and the control intent on the checkpoint route", async () => { const withPayload = await exports.default.fetch( new Request("https://relay.invalid/v1/checkpoint", { @@ -151,6 +200,47 @@ describe("Afterlight Phase A relay Worker", () => { expect(deniedIntent.status).toBe(400); }); + it("exposes the exact-note exit only behind the configured origin, intent and strict package validator", async () => { + const preflight = await exports.default.fetch( + new Request("https://relay.invalid/v1/exit", { + method: "OPTIONS", + headers: { + origin, + "access-control-request-headers": "content-type, x-afterlight-intent", + }, + }), + ); + expect(preflight.status).toBe(204); + expect(preflight.headers.get("x-afterlight-intent")).toBe("claim-exit"); + + const invalid = await exports.default.fetch( + new Request("https://relay.invalid/v1/exit", { + method: "POST", + headers: { + origin, + "content-type": "application/json", + "x-afterlight-intent": "claim-exit", + }, + body: "{}", + }), + ); + expect(invalid.status).toBe(422); + expect(await invalid.json()).toEqual({ status: "error", code: "invalid_exit" }); + + const wrongOrigin = await exports.default.fetch( + new Request("https://relay.invalid/v1/exit", { + method: "POST", + headers: { + origin: "https://attacker.invalid", + "content-type": "application/json", + "x-afterlight-intent": "claim-exit", + }, + body: "{}", + }), + ); + expect(wrongOrigin.status).toBe(403); + }); + it("derives exact fingerprints from normalized fields, not attacker-controlled JSON ordering", async () => { const canonical = buildRelayRequest(RelayOperation.Heartbeat, contract, { ...validArgs(1n), @@ -271,22 +361,24 @@ describe("Afterlight Phase A relay Worker", () => { }); it("enforces the local per-vault/operation abuse limit", async () => { - const payload = encodeRelayRequest( - buildRelayRequest(RelayOperation.Request, contract, { - ...validArgs(1n), - vault_id: "0xdead", - }), - ); + const request = buildRelayRequest(RelayOperation.Request, contract, { + ...validArgs(1n), + vault_id: "0xdead", + }); + let vaultCalls = 0; + const rateLimitEnv = { + RELAY_GLOBAL_LIMITER: { limit: async () => ({ success: true }) }, + RELAY_RATE_LIMITER: { limit: async () => ({ success: ++vaultCalls <= 4 }) }, + } as unknown as Env; const statuses: number[] = []; for (let attempt = 0; attempt < 5; attempt += 1) { - const response = await exports.default.fetch( - new Request("https://relay.invalid/v1/relay", { - method: "POST", - headers: relayHeaders(), - body: payload, - }), - ); - statuses.push(response.status); + try { + await rateLimitRelay(request, rateLimitEnv); + statuses.push(202); + } catch (error) { + expect(error).toBeInstanceOf(RelayHttpError); + statuses.push((error as RelayHttpError).status); + } } expect(statuses).toEqual([202, 202, 202, 202, 429]); }); diff --git a/relayer/vitest.config.ts b/relayer/vitest.config.ts index 3ae94c7..16f561c 100644 --- a/relayer/vitest.config.ts +++ b/relayer/vitest.config.ts @@ -4,7 +4,9 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ plugins: [ cloudflareTest({ - wrangler: { configPath: "./wrangler.jsonc" }, + // Unit tests intentionally exercise the inert preflight surface. The + // production config is validated separately by Wrangler dry-run. + wrangler: { configPath: "./wrangler.staging.jsonc" }, }), ], }); diff --git a/relayer/worker-configuration.d.ts b/relayer/worker-configuration.d.ts index 936fcab..fd285d4 100644 --- a/relayer/worker-configuration.d.ts +++ b/relayer/worker-configuration.d.ts @@ -1,29 +1,32 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 3c1d9a9dac6f1eb578016fdc508f6866) -// Runtime types generated with workerd@1.20260820.1 2026-08-24 nodejs_compat +// Generated by Wrangler by running `wrangler types` (hash: 4c0ae7137bd79a78774459e014d5b55b) +// Runtime types generated with workerd@1.20260828.1 2026-08-28 nodejs_compat interface __BaseEnv_Env { RELAY_RATE_LIMITER: RateLimit; RELAY_GLOBAL_LIMITER: RateLimit; CHECKPOINT_RATE_LIMITER: RateLimit; - DEPLOYMENT_STAGE: "phase-a-local"; - ALLOWED_ORIGIN: "https://afterlight.invalid"; - AFTERLIGHT_CONTRACT: "0x1234"; + EXIT_RATE_LIMITER: RateLimit; + DEPLOYMENT_STAGE: "phase-b-live-spike"; + ALLOWED_ORIGIN: "https://afterlight-app.pages.dev,https://afterlight.dolepee.com"; + AFTERLIGHT_CONTRACT: "0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25"; STARKNET_CHAIN_ID: "0x534e5f4d41494e"; STRK_TOKEN: "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d"; - RESERVE_AMOUNT_FRI: "10000000000000000000"; + RESERVE_AMOUNT_FRI: "1000000000000000000"; MAX_RELAY_TTL_SECONDS: "900"; MAX_RELAY_PAYLOAD_BYTES: "2048"; - MAX_SPONSORED_FEE_FRI: "200000000000000000"; - DAILY_SPONSOR_BUDGET_FRI: "1200000000000000000"; + MAX_EXIT_PAYLOAD_BYTES: "2097152"; + MAX_SPONSORED_FEE_FRI: "400000000000000000"; + DAILY_SPONSOR_BUDGET_FRI: "1600000000000000000"; SPONSOR_FEE_MARGIN_BPS: "11000"; MIN_RELAYER_BALANCE_FRI: "1000000000000000000"; - DEPLOYMENT_ID: "phase-a-local"; - RELAYER_ACCOUNT_ADDRESS: "0x0"; + DEPLOYMENT_ID: "afterlight-mainnet-e3-20260829-v6"; + RELAYER_ACCOUNT_ADDRESS: "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46"; RELAYER_ACCOUNT_CAIRO_VERSION: "1"; - STARKNET_RPC_URL: "https://rpc.invalid"; - SUBMIT_ENABLED: "false"; + STARKNET_RPC_URL: "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_10"; + SUBMIT_ENABLED: "true"; RELAYER_ACCOUNT_PRIVATE_KEY: string; STARKNET_RPC_AUTH_TOKEN: string; + EXIT_RPC_URL: string; RELAY_BUDGET: DurableObjectNamespace; } declare namespace Cloudflare { @@ -38,7 +41,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 @@ -586,7 +589,7 @@ interface DurableObjectState { setHibernatableWebSocketEventTimeout(timeoutMs?: number): void; getHibernatableWebSocketEventTimeout(): number | null; getTags(ws: WebSocket): string[]; - abort(reason?: string): void; + abort(reason?: string, options?: DurableObjectAbortOptions): void; } interface DurableObjectTransaction { get(key: string, options?: DurableObjectGetOptions): Promise; @@ -622,6 +625,9 @@ interface DurableObjectStorage { getBookmarkForTime(timestamp: number | Date): Promise; onNextSessionRestoreBookmark(bookmark: string): Promise; } +interface DurableObjectAbortOptions { + retryAlarm?: boolean; +} interface DurableObjectListOptions { start?: string; startAfter?: string; @@ -1304,10 +1310,13 @@ interface CryptoKeyArbitraryKeyAlgorithm { length?: number; } declare class DigestStream extends WritableStream { - constructor(algorithm: string | SubtleCryptoHashAlgorithm); + constructor(algorithm: string | SubtleCryptoHashAlgorithm, options?: DigestStreamOptions); readonly digest: Promise; get bytesWritten(): number | bigint; } +interface DigestStreamOptions { + toWellFormed?: boolean; +} /** * The **`TextDecoder`** interface represents a decoder for a specific text encoding, such as UTF-8, ISO-8859-2, or GBK. A decoder takes an array of bytes as input and returns a JavaScript string. * @@ -1396,6 +1405,9 @@ declare class ErrorEvent extends Event { get error(): any; } interface ErrorEventErrorEventInit { + bubbles?: boolean; + cancelable?: boolean; + composed?: boolean; message?: string; filename?: string; lineno?: number; @@ -1408,7 +1420,7 @@ interface ErrorEventErrorEventInit { * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent) */ declare class MessageEvent extends Event { - constructor(type: string, initializer: MessageEventInit); + constructor(type: string, initializer?: MessageEventInit); /** * The **`data`** read-only property of the MessageEvent interface represents the data sent by the message emitter. * @@ -1441,7 +1453,14 @@ declare class MessageEvent extends Event { readonly ports: MessagePort[]; } interface MessageEventInit { - data: ArrayBuffer | string; + bubbles?: boolean; + cancelable?: boolean; + composed?: boolean; + data?: any; + origin?: string; + lastEventId?: string; + source?: MessagePort; + ports?: MessagePort[]; } /** * The **`PromiseRejectionEvent`** interface represents events which are sent to the global script context when JavaScript Promises are rejected. These events are particularly useful for telemetry and debugging purposes. @@ -3197,6 +3216,9 @@ declare class CloseEvent extends Event { readonly wasClean: boolean; } interface CloseEventInit { + bubbles?: boolean; + cancelable?: boolean; + composed?: boolean; code?: number; reason?: string; wasClean?: boolean; @@ -3450,10 +3472,13 @@ interface ContainerDirectorySnapshotOptions { dir: string; name?: string; } -interface ContainerDirectorySnapshotRestoreParams { +type ContainerDirectorySnapshotRestoreParams = { snapshot: ContainerDirectorySnapshot; mountPoint?: string; -} +} | { + snapshot?: undefined; + mountPoint: string; +}; interface ContainerSnapshot { id: string; size: number; @@ -3585,7 +3610,7 @@ interface WorkerLoaderModule { data?: ArrayBuffer; json?: any; py?: string; - wasm?: ArrayBuffer; + wasm?: ArrayBuffer | ArrayBufferView | WebAssembly.Module; } interface WorkerLoaderWorkerCode { compatibilityDate: string; @@ -3593,7 +3618,7 @@ interface WorkerLoaderWorkerCode { allowExperimental?: boolean; limits?: workerdResourceLimits; mainModule: string; - modules: Record; + modules: Record; env?: any; globalOutbound?: (Fetcher | null); tails?: Fetcher[]; @@ -10174,6 +10199,163 @@ declare abstract class Base_Ai_Cf_Google_Gemma_4_26B_A4B_IT { inputs: ChatCompletionsInput; postProcessedOutputs: ChatCompletionsOutput; } +declare abstract class Base_Ai_Cf_Moonshotai_Kimi_K2_7_Code { + inputs: ChatCompletionsInput; + postProcessedOutputs: ChatCompletionsOutput; +} +declare abstract class Base_Ai_Cf_Zai_Org_Glm_5_2 { + inputs: ChatCompletionsInput; + postProcessedOutputs: ChatCompletionsOutput; +} +interface Ai_Cf_Moondream_Moondream3_1_9B_A2B_Input { + /** + * Which Moondream skill to run. + */ + task?: "query" | "caption" | "point" | "detect"; + /** + * Input image as a public HTTPS URL or base64 data URI. Optional for `query`; required for `caption`, `point`, and `detect`. + */ + image?: string; + /** + * Question for the `query` task. + */ + question?: string; + /** + * Caption length for the `caption` task. + */ + caption_length?: "short" | "normal" | "long"; + /** + * Object phrase to locate for `point` and `detect` tasks (e.g. 'person wearing a red shirt'). + */ + target?: string; + /** + * Enable reasoning trace for the `query` task. + */ + reasoning?: boolean; + /** + * Sampling temperature. + */ + temperature?: number; + /** + * Top-p (nucleus) sampling. + */ + top_p?: number; + /** + * Max tokens to generate for `query` and `caption`. + */ + max_tokens?: number; + /** + * Max objects to return for `point` and `detect`. + */ + max_objects?: number; + /** + * Return incremental tokens for `query` and `caption`. `point` and `detect` do not support streaming. + */ + stream?: boolean; +} +interface Ai_Cf_Moondream_Moondream3_1_9B_A2B_Output { + /** + * Reason the generation finished. + */ + finish_reason: string; + metrics: { + /** + * Number of input tokens consumed. + */ + input_tokens: number; + /** + * Number of output tokens generated. + */ + output_tokens: number; + /** + * Prefill time in milliseconds. + */ + prefill_time_ms: number; + /** + * Decode time in milliseconds. + */ + decode_time_ms: number; + /** + * Time to first token in milliseconds. + */ + ttft_ms: number; + }; + /** + * Answer text for the `query` task. Null for other tasks. + */ + answer?: string; + /** + * Caption text for the `caption` task. Null for other tasks. + */ + caption?: string; + /** + * Located points for the `point` task. Null for other tasks. + */ + points?: { + /** + * X coordinate. + */ + x: number; + /** + * Y coordinate. + */ + y: number; + }[]; + /** + * Detected bounding boxes for the `detect` task. Null for other tasks. + */ + objects?: { + /** + * Minimum X coordinate. + */ + x_min: number; + /** + * Minimum Y coordinate. + */ + y_min: number; + /** + * Maximum X coordinate. + */ + x_max: number; + /** + * Maximum Y coordinate. + */ + y_max: number; + }[]; + /** + * Reasoning trace for the `query` task when reasoning=true. Null otherwise. + */ + reasoning?: { + /** + * Reasoning text. + */ + text: string; + /** + * Grounding information. + */ + grounding?: {}[]; + }; +} +declare abstract class Base_Ai_Cf_Moondream_Moondream3_1_9B_A2B { + inputs: Ai_Cf_Moondream_Moondream3_1_9B_A2B_Input; + postProcessedOutputs: Ai_Cf_Moondream_Moondream3_1_9B_A2B_Output; +} +declare abstract class Base_Ai_Cf_Deepseek_Ai_Deepseek_V4_Flash_0731 { + inputs: ChatCompletionsInput; + postProcessedOutputs: ChatCompletionsOutput; +} +declare abstract class Base_Ai_Cf_Deepseek_Ai_Deepseek_V4_Pro_0813 { + inputs: ChatCompletionsInput; + postProcessedOutputs: ChatCompletionsOutput; +} +declare abstract class Base_Ai_Cf_Qwen_Qwen3_8_27B { + inputs: ChatCompletionsInput; + postProcessedOutputs: ChatCompletionsOutput; +} +declare abstract class Base_Ai_Cf_Zai_Org_Glm_5_3_Flash { + inputs: ChatCompletionsInput; + postProcessedOutputs: ChatCompletionsOutput; +} interface AiModels { "@cf/huggingface/distilbert-sst-2-int8": BaseAiTextClassification; "@cf/stabilityai/stable-diffusion-xl-base-1.0": BaseAiTextToImage; @@ -10266,6 +10448,13 @@ interface AiModels { "@cf/moonshotai/kimi-k2.6": Base_Ai_Cf_Moonshotai_Kimi_K2_6; "@cf/nvidia/nemotron-3-120b-a12b": Base_Ai_Cf_Nvidia_Nemotron_3_120B_A12B; "@cf/google/gemma-4-26b-a4b-it": Base_Ai_Cf_Google_Gemma_4_26B_A4B_IT; + "@cf/moonshotai/kimi-k2.7-code": Base_Ai_Cf_Moonshotai_Kimi_K2_7_Code; + "@cf/zai-org/glm-5.2": Base_Ai_Cf_Zai_Org_Glm_5_2; + "@cf/moondream/moondream3.1-9B-A2B": Base_Ai_Cf_Moondream_Moondream3_1_9B_A2B; + "@cf/deepseek-ai/deepseek-v4-flash-0731": Base_Ai_Cf_Deepseek_Ai_Deepseek_V4_Flash_0731; + "@cf/deepseek-ai/deepseek-v4-pro-0813": Base_Ai_Cf_Deepseek_Ai_Deepseek_V4_Pro_0813; + "@cf/qwen/qwen3.8-27b": Base_Ai_Cf_Qwen_Qwen3_8_27B; + "@cf/zai-org/glm-5.3-flash": Base_Ai_Cf_Zai_Org_Glm_5_3_Flash; } type AiOptions = { /** @@ -11064,11 +11253,25 @@ type BrowserRunJsonOptions = BrowserRunCommonOptions & BrowserRunJsonBaseOptions }); type BrowserRunContentOptions = BrowserRunCommonOptions; type BrowserRunMarkdownOptions = BrowserRunCommonOptions; +type BrowserRunRedirectHop = { + /** URL that returned the redirect. */ + url: string; + /** HTTP status of the redirect. */ + status: number; + /** Redirect response headers, including `location`. */ + headers: Record; +}; type BrowserRunResponseMeta = { /** HTTP status code of the rendered page */ status: number; /** Page title */ title: string; + /** Origin response headers, lowercased. Repeated headers are joined with a newline. Credential and transport-only headers that do not survive rendering are omitted. */ + headers?: Record; + /** URL that served the response, after any redirects the browser followed. */ + finalUrl?: string; + /** HTTP redirects followed to reach `finalUrl`, oldest first. Omitted for direct navigation and for client-side redirects such as meta refresh. An empty array means redirects occurred but their intermediate responses could not be read. */ + redirectChain?: BrowserRunRedirectHop[]; }; /** * A node in the page's accessibility tree, as exposed to assistive technology. @@ -11134,6 +11337,7 @@ type BrowserRunLinksSuccessResponse = { success: true; /** Extracted links */ result: string[]; + meta: BrowserRunResponseMeta; }; /** Success response for `scrape` action. */ type BrowserRunScrapeSuccessResponse = { @@ -11164,6 +11368,7 @@ type BrowserRunScrapeSuccessResponse = { }>; }>; }>; + meta: BrowserRunResponseMeta; }; /** Success response for `snapshot` action. Each field is present only when the * corresponding entry was requested in `formats`. @@ -11198,12 +11403,14 @@ type BrowserRunJsonSuccessResponse = { success: true; /** JSON data extracted from the page using an AI model */ result: Record; + meta: BrowserRunResponseMeta; }; /** Success response for `markdown` action. */ type BrowserRunMarkdownSuccessResponse = { success: true; /** Extracted markdown content */ result: string; + meta: BrowserRunResponseMeta; }; /** Error response for BrowserRun actions. */ type BrowserRunErrorResponse = { @@ -14429,7 +14636,7 @@ declare namespace TailStream { interface ConnectEventInfo { readonly type: "connect"; } - type EventOutcome = "ok" | "canceled" | "exception" | "unknown" | "killSwitch" | "daemonDown" | "exceededCpu" | "exceededMemory" | "loadShed" | "responseStreamDisconnected" | "scriptNotFound" | "internalError" | "exceededWallTime"; + type EventOutcome = "ok" | "canceled" | "exception" | "unknown" | "killSwitch" | "daemonDown" | "exceededCpu" | "exceededMemory" | "loadShed" | "responseStreamDisconnected" | "scriptNotFound" | "internalError" | "exceededWallTime" | "aborted"; interface ScriptVersion { readonly id: string; readonly tag?: string; diff --git a/relayer/wrangler.jsonc b/relayer/wrangler.jsonc index fd0c543..c9b7e47 100644 --- a/relayer/wrangler.jsonc +++ b/relayer/wrangler.jsonc @@ -2,35 +2,34 @@ "$schema": "./node_modules/wrangler/config-schema.json", "name": "afterlight-relayer-phase-a", "main": "src/index.ts", - "compatibility_date": "2026-08-24", + "compatibility_date": "2026-08-28", "compatibility_flags": ["nodejs_compat"], "vars": { - // Local-only Phase A values. Replace the contract and origin after promotion. - "DEPLOYMENT_STAGE": "phase-a-local", - "ALLOWED_ORIGIN": "https://afterlight.invalid", - "AFTERLIGHT_CONTRACT": "0x1234", + "DEPLOYMENT_STAGE": "phase-b-live-spike", + "ALLOWED_ORIGIN": "https://afterlight-app.pages.dev,https://afterlight.dolepee.com", + "AFTERLIGHT_CONTRACT": "0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25", "STARKNET_CHAIN_ID": "0x534e5f4d41494e", "STRK_TOKEN": "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", - "RESERVE_AMOUNT_FRI": "10000000000000000000", + "RESERVE_AMOUNT_FRI": "1000000000000000000", "MAX_RELAY_TTL_SECONDS": "900", "MAX_RELAY_PAYLOAD_BYTES": "2048", - "MAX_SPONSORED_FEE_FRI": "200000000000000000", - "DAILY_SPONSOR_BUDGET_FRI": "1200000000000000000", + "MAX_EXIT_PAYLOAD_BYTES": "2097152", + "MAX_SPONSORED_FEE_FRI": "400000000000000000", + "DAILY_SPONSOR_BUDGET_FRI": "1600000000000000000", "SPONSOR_FEE_MARGIN_BPS": "11000", "MIN_RELAYER_BALANCE_FRI": "1000000000000000000", - // Deliberately inert placeholders. A promoted deployment must replace every one. - "DEPLOYMENT_ID": "phase-a-local", - "RELAYER_ACCOUNT_ADDRESS": "0x0", + "DEPLOYMENT_ID": "afterlight-mainnet-e3-20260829-v6", + "RELAYER_ACCOUNT_ADDRESS": "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46", "RELAYER_ACCOUNT_CAIRO_VERSION": "1", - "STARKNET_RPC_URL": "https://rpc.invalid", - // The signer path remains unreachable under these inert values and must fail closed. - "SUBMIT_ENABLED": "false" + "STARKNET_RPC_URL": "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_10", + "SUBMIT_ENABLED": "true" }, // Names only: values must be installed with `wrangler secret put` after promotion. "secrets": { "required": [ "RELAYER_ACCOUNT_PRIVATE_KEY", - "STARKNET_RPC_AUTH_TOKEN" + "STARKNET_RPC_AUTH_TOKEN", + "EXIT_RPC_URL" ] }, "durable_objects": { @@ -56,6 +55,11 @@ "name": "CHECKPOINT_RATE_LIMITER", "namespace_id": "73003", "simple": { "limit": 4, "period": 60 } + }, + { + "name": "EXIT_RATE_LIMITER", + "namespace_id": "73004", + "simple": { "limit": 2, "period": 60 } } ], "observability": { diff --git a/relayer/wrangler.staging.jsonc b/relayer/wrangler.staging.jsonc index ada9b5f..a2035e1 100644 --- a/relayer/wrangler.staging.jsonc +++ b/relayer/wrangler.staging.jsonc @@ -4,7 +4,7 @@ // both signer and authenticated-RPC secrets before Wrangler may deploy it. "name": "afterlight-relayer-phase-a", "main": "src/index.ts", - "compatibility_date": "2026-08-24", + "compatibility_date": "2026-08-27", "compatibility_flags": ["nodejs_compat"], "vars": { "DEPLOYMENT_STAGE": "phase-a-staging", @@ -15,6 +15,7 @@ "RESERVE_AMOUNT_FRI": "10000000000000000000", "MAX_RELAY_TTL_SECONDS": "900", "MAX_RELAY_PAYLOAD_BYTES": "2048", + "MAX_EXIT_PAYLOAD_BYTES": "2097152", "MAX_SPONSORED_FEE_FRI": "200000000000000000", "DAILY_SPONSOR_BUDGET_FRI": "1200000000000000000", "SPONSOR_FEE_MARGIN_BPS": "11000", @@ -48,6 +49,11 @@ "name": "CHECKPOINT_RATE_LIMITER", "namespace_id": "73003", "simple": { "limit": 4, "period": 60 } + }, + { + "name": "EXIT_RATE_LIMITER", + "namespace_id": "73004", + "simple": { "limit": 2, "period": 60 } } ], "observability": { diff --git a/src/afterlight.cairo b/src/afterlight.cairo index 99ff789..c7aa902 100644 --- a/src/afterlight.cairo +++ b/src/afterlight.cairo @@ -8,7 +8,6 @@ pub trait IAfterlight { fn heartbeat(ref self: T, auth: ControlArgs); fn request_recovery(ref self: T, auth: ControlArgs); fn veto(ref self: T, auth: ControlArgs); - fn recover_surplus(ref self: T, recipient: ContractAddress, amount: u256); fn get_vault(self: @T, vault_id: felt252) -> Vault; fn get_locked_by_token(self: @T, token: ContractAddress) -> u256; fn get_config(self: @T) -> Config; @@ -17,7 +16,6 @@ pub trait IAfterlight { pub mod errors { pub const INVALID_CONFIG: felt252 = 'AL_INVALID_CONFIG'; pub const NOT_POOL: felt252 = 'AL_NOT_POOL'; - pub const NOT_ADMIN: felt252 = 'AL_NOT_ADMIN'; pub const ZERO_VAULT: felt252 = 'AL_ZERO_VAULT'; pub const VAULT_EXISTS: felt252 = 'AL_VAULT_EXISTS'; pub const VAULT_NOT_FOUND: felt252 = 'AL_VAULT_NOT_FOUND'; @@ -44,8 +42,6 @@ pub mod errors { pub const LIABILITY_UNDERFLOW: felt252 = 'AL_LIABILITY_UNDERFLOW'; pub const APPROVE_FAILED: felt252 = 'AL_APPROVE_FAILED'; pub const TRANSFER_FAILED: felt252 = 'AL_TRANSFER_FAILED'; - pub const ZERO_RECIPIENT: felt252 = 'AL_ZERO_RECIPIENT'; - pub const INSUFFICIENT_SURPLUS: felt252 = 'AL_INSUFFICIENT_SURPLUS'; } #[starknet::contract] @@ -71,7 +67,6 @@ pub mod Afterlight { trait IERC20 { fn balance_of(self: @T, account: ContractAddress) -> u256; fn approve(ref self: T, spender: ContractAddress, amount: u256) -> bool; - fn transfer(ref self: T, recipient: ContractAddress, amount: u256) -> bool; } /// Vault fields use separate maps deliberately. Hot transitions read and @@ -81,7 +76,6 @@ pub mod Afterlight { struct Storage { pool: ContractAddress, token: ContractAddress, - surplus_admin: ContractAddress, fixed_amount: u128, normal_min_inactivity: u64, normal_min_grace: u64, @@ -117,7 +111,6 @@ pub mod Afterlight { RecoveryVetoed: RecoveryVetoed, VaultCancelled: VaultCancelled, RecoveryClaimed: RecoveryClaimed, - SurplusRecovered: SurplusRecovered, FundingCheckpointSynced: FundingCheckpointSynced, } @@ -178,12 +171,6 @@ pub mod Afterlight { pub amount: u128, } - #[derive(Drop, PartialEq, starknet::Event)] - pub struct SurplusRecovered { - pub recipient: ContractAddress, - pub amount: u256, - } - #[derive(Drop, PartialEq, starknet::Event)] pub struct FundingCheckpointSynced { pub balance: u256, @@ -195,7 +182,6 @@ pub mod Afterlight { ref self: ContractState, pool: ContractAddress, token: ContractAddress, - surplus_admin: ContractAddress, fixed_amount: u128, normal_min_inactivity: u64, normal_min_grace: u64, @@ -207,7 +193,6 @@ pub mod Afterlight { ) { assert(pool.is_non_zero(), errors::INVALID_CONFIG); assert(token.is_non_zero(), errors::INVALID_CONFIG); - assert(surplus_admin.is_non_zero(), errors::INVALID_CONFIG); assert(fixed_amount > 0, errors::INVALID_CONFIG); assert(normal_min_inactivity > 0, errors::INVALID_CONFIG); assert(normal_min_grace > 0, errors::INVALID_CONFIG); @@ -224,7 +209,6 @@ pub mod Afterlight { ); self.pool.write(pool); self.token.write(token); - self.surplus_admin.write(surplus_admin); self.fixed_amount.write(fixed_amount); self.normal_min_inactivity.write(normal_min_inactivity); self.normal_min_grace.write(normal_min_grace); @@ -341,18 +325,6 @@ pub mod Afterlight { ); } - fn recover_surplus(ref self: ContractState, recipient: ContractAddress, amount: u256) { - assert(get_caller_address() == self.surplus_admin.read(), errors::NOT_ADMIN); - assert(recipient.is_non_zero(), errors::ZERO_RECIPIENT); - let token = self.token.read(); - let erc20 = IERC20Dispatcher { contract_address: token }; - let held = erc20.balance_of(get_contract_address()); - let locked = self.locked.read(); - assert(held >= locked + amount, errors::INSUFFICIENT_SURPLUS); - assert(erc20.transfer(recipient, amount), errors::TRANSFER_FAILED); - self.emit(SurplusRecovered { recipient, amount }); - } - fn get_vault(self: @ContractState, vault_id: felt252) -> Vault { let state = self.state.entry(vault_id).read(); Vault { @@ -386,7 +358,6 @@ pub mod Afterlight { Config { pool: self.pool.read(), token: self.token.read(), - surplus_admin: self.surplus_admin.read(), fixed_amount: self.fixed_amount.read(), normal_min_inactivity: self.normal_min_inactivity.read(), normal_min_grace: self.normal_min_grace.read(), diff --git a/src/common.cairo b/src/common.cairo index ddef0ca..7abf0b3 100644 --- a/src/common.cairo +++ b/src/common.cairo @@ -92,7 +92,6 @@ pub struct Vault { pub struct Config { pub pool: ContractAddress, pub token: ContractAddress, - pub surplus_admin: ContractAddress, pub fixed_amount: u128, pub normal_min_inactivity: u64, pub normal_min_grace: u64, diff --git a/strk20.json b/strk20.json new file mode 100644 index 0000000..cb3b68a --- /dev/null +++ b/strk20.json @@ -0,0 +1,13 @@ +{ + "transactions": [ + "0x030ea14ac22e5806e382658971b686692af280bf2f02173a430f572921121722", + "0x69e2345ae8816986a709de84f0dcb571b5d092400d6c53bf90197480102c0fb", + "0x036e003396fe360ae7fe4766646f493c0eb579d82509652559d40e460770682a", + "0x11c990aea864e755630d41fd1292620c313b3f64407fc0b3a902544c67c8098", + "0x722033f7fd0397ff4d3845428c98cad885b6a63824f7c78a2b7e1d7d6f5c1b6" + ], + "contracts": [ + "0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25" + ], + "demo_url": "https://afterlight.dolepee.com" +} diff --git a/tests/test_afterlight.cairo b/tests/test_afterlight.cairo index 1c3e669..ee7c1d2 100644 --- a/tests/test_afterlight.cairo +++ b/tests/test_afterlight.cairo @@ -38,7 +38,6 @@ struct Env { pool: IMockPrivacyPoolDispatcher, token: IMockERC20Dispatcher, app: IAfterlightDispatcher, - admin: ContractAddress, } fn deploy_env() -> Env { @@ -48,16 +47,14 @@ fn deploy_env() -> Env { .deploy(@array![]) .unwrap(); let (token_addr, _) = declare("MockERC20").unwrap().contract_class().deploy(@array![]).unwrap(); - let admin: ContractAddress = 'surplus-admin'.try_into().unwrap(); let (app_addr, _) = declare("Afterlight") .unwrap() .contract_class() .deploy( @array![ - pool_addr.into(), token_addr.into(), admin.into(), AMOUNT.into(), - NORMAL_MIN_INACTIVITY.into(), NORMAL_MIN_GRACE.into(), FAST_INACTIVITY.into(), - FAST_GRACE.into(), MAX_INTERVAL.into(), MAX_AUTH_WINDOW.into(), - MAX_FUNDING_CHECKPOINT_AGE.into(), + pool_addr.into(), token_addr.into(), AMOUNT.into(), NORMAL_MIN_INACTIVITY.into(), + NORMAL_MIN_GRACE.into(), FAST_INACTIVITY.into(), FAST_GRACE.into(), + MAX_INTERVAL.into(), MAX_AUTH_WINDOW.into(), MAX_FUNDING_CHECKPOINT_AGE.into(), ], ) .unwrap(); @@ -65,7 +62,6 @@ fn deploy_env() -> Env { pool: IMockPrivacyPoolDispatcher { contract_address: pool_addr }, token: IMockERC20Dispatcher { contract_address: token_addr }, app: IAfterlightDispatcher { contract_address: app_addr }, - admin, }; start_cheat_chain_id(env.app.contract_address, TEST_CHAIN_ID); set_time(env, START); @@ -78,10 +74,10 @@ fn deploy_second_app(env: Env) -> IAfterlightDispatcher { .contract_class() .deploy( @array![ - env.pool.contract_address.into(), env.token.contract_address.into(), - env.admin.into(), AMOUNT.into(), NORMAL_MIN_INACTIVITY.into(), - NORMAL_MIN_GRACE.into(), FAST_INACTIVITY.into(), FAST_GRACE.into(), - MAX_INTERVAL.into(), MAX_AUTH_WINDOW.into(), MAX_FUNDING_CHECKPOINT_AGE.into(), + env.pool.contract_address.into(), env.token.contract_address.into(), AMOUNT.into(), + NORMAL_MIN_INACTIVITY.into(), NORMAL_MIN_GRACE.into(), FAST_INACTIVITY.into(), + FAST_GRACE.into(), MAX_INTERVAL.into(), MAX_AUTH_WINDOW.into(), + MAX_FUNDING_CHECKPOINT_AGE.into(), ], ) .unwrap(); @@ -972,34 +968,12 @@ fn failed_pool_pull_rolls_back_helper_and_pool_state() { } #[test] -fn surplus_admin_can_recover_only_assets_above_locked_liabilities() { - let env = deploy_env(); - let (owner, successor) = fresh_keys(); - fund_vault(env, 'vault', owner, successor); - env.token.mint(env.app.contract_address, 77_u256); - let recipient: ContractAddress = 'surplus-recipient'.try_into().unwrap(); - start_cheat_caller_address(env.app.contract_address, env.admin); - env.app.recover_surplus(recipient, 77_u256); - stop_cheat_caller_address(env.app.contract_address); - assert(env.token.balance_of(recipient) == 77, 'surplus not sent'); - assert(env.token.balance_of(env.app.contract_address) == AMOUNT.into(), 'liability touched'); - assert( - env.app.get_locked_by_token(env.token.contract_address) == AMOUNT.into(), 'lock changed', - ); -} - -#[test] -fn open_checkpoint_neither_counts_as_liability_nor_weakens_private_exit() { +fn donated_surplus_and_open_checkpoint_neither_count_as_liability_nor_weaken_private_exit() { let env = deploy_env(); let (owner, successor) = fresh_keys(); fund_vault(env, 'vault', owner, successor); env.token.mint(env.app.contract_address, 77_u256); env.app.sync_funding_checkpoint(); - let recipient: ContractAddress = 'surplus-recipient'.try_into().unwrap(); - start_cheat_caller_address(env.app.contract_address, env.admin); - env.app.recover_surplus(recipient, 77_u256); - stop_cheat_caller_address(env.app.contract_address); - assert(env.token.balance_of(env.app.contract_address) == AMOUNT.into(), 'liability touched'); assert( env.app.get_locked_by_token(env.token.contract_address) == AMOUNT.into(), 'lock changed', ); @@ -1010,46 +984,10 @@ fn open_checkpoint_neither_counts_as_liability_nor_weakens_private_exit() { ); invoke_private(env, PrivateAction::CancelRefund(cancel), 1); assert(env.pool.note_amount('owner-refund') == AMOUNT, 'exit underfunded'); - assert(env.token.balance_of(env.app.contract_address) == 0, 'assets remain'); + assert(env.token.balance_of(env.app.contract_address) == 77, 'donated surplus changed'); assert(env.app.get_locked_by_token(env.token.contract_address) == 0, 'liability remains'); } -#[test] -#[feature("safe_dispatcher")] -fn failed_surplus_transfer_rolls_back_balances() { - let env = deploy_env(); - env.token.mint(env.app.contract_address, 77_u256); - env.token.set_fail_transfer(true); - let recipient: ContractAddress = 'surplus-recipient'.try_into().unwrap(); - start_cheat_caller_address(env.app.contract_address, env.admin); - let safe = IAfterlightSafeDispatcher { contract_address: env.app.contract_address }; - match safe.recover_surplus(recipient, 77_u256) { - Result::Ok(_) => assert(false, 'transfer should fail'), - Result::Err(_) => {}, - } - stop_cheat_caller_address(env.app.contract_address); - assert(env.token.balance_of(recipient) == 0, 'recipient credited'); - assert(env.token.balance_of(env.app.contract_address) == 77, 'source debited'); -} - -#[test] -#[should_panic(expected: 'AL_NOT_ADMIN')] -fn non_admin_cannot_recover_surplus() { - let env = deploy_env(); - env.token.mint(env.app.contract_address, 77_u256); - env.app.recover_surplus('recipient'.try_into().unwrap(), 1_u256); -} - -#[test] -#[should_panic(expected: 'AL_INSUFFICIENT_SURPLUS')] -fn surplus_recovery_cannot_touch_locked_assets() { - let env = deploy_env(); - let (owner, successor) = fresh_keys(); - fund_vault(env, 'vault', owner, successor); - start_cheat_caller_address(env.app.contract_address, env.admin); - env.app.recover_surplus('recipient'.try_into().unwrap(), 1_u256); -} - #[test] #[should_panic(expected: 'AL_BAD_NONCE')] fn consumed_heartbeat_nonce_cannot_replay() { diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..e4c3f9b --- /dev/null +++ b/web/index.html @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + Afterlight — Private recovery reserve + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..c5f0f72 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2151 @@ +{ + "name": "afterlight-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "afterlight-web", + "version": "0.1.0", + "dependencies": { + "@starknet-io/get-starknet-discovery": "^6.0.4", + "@starknet-io/get-starknet-wallet-standard": "^6.0.4", + "@starknet-io/types-js": "0.10.4-beta.2", + "starknet": "10.7.0" + }, + "devDependencies": { + "typescript": "5.9.3", + "vite": "7.3.6" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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", + "integrity": "sha512-DEXQjopcBuGzp/NA9OVtASO0uZ6grVK5TIe0PjrbDRyZDxVaYQXKrISxBLOE+3nSIELE98tYpfxptm8WC9A8zA==", + "license": "MIT" + }, + "node_modules/@module-federation/runtime": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.12.0.tgz", + "integrity": "sha512-Cz9/7+gSvrdencwA8LXUMKnZdu0/flyN+yk6t3pkxfhvPJi3W65ZcalAKyOgyk2x8rEYrRSyEXu+/2DIFgrzmA==", + "license": "MIT", + "dependencies": { + "@module-federation/error-codes": "0.12.0", + "@module-federation/runtime-core": "0.12.0", + "@module-federation/sdk": "0.12.0" + } + }, + "node_modules/@module-federation/runtime-core": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.12.0.tgz", + "integrity": "sha512-373zBM54196KHURs/O8lry9trCAM3PPidvsF4YdrtahNc8YaQynml0mE3zdZeBnqP6H0/4OpPqMMjACI80Ht8w==", + "license": "MIT", + "dependencies": { + "@module-federation/error-codes": "0.12.0", + "@module-federation/sdk": "0.12.0" + } + }, + "node_modules/@module-federation/sdk": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.12.0.tgz", + "integrity": "sha512-vh3GcG90fxjbkMghK7iSWcMayi/y8U5DxI6mhEFuz11St3y1UgQO2TZYephL8nISFBld7DdiqAkimx+6Hb3hjQ==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@noble/ciphers": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", + "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.6.1.tgz", + "integrity": "sha512-+pormrDZwjRw05U8ADK4JpHejo87+gBd+muRBB/ozztH5yhDLMDF4jHQWN3NQQAsu1zBNPWTG0ZwVI0CR29H0A==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "~2.2.0", + "@noble/curves": "~2.2.0", + "@noble/hashes": "~2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum/node_modules/@noble/ciphers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum/node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@scure/base": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.4.0.tgz", + "integrity": "sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.4.0.tgz", + "integrity": "sha512-i3DS0CptAocyvqE4n3SUkpzeQK4vJMFwWLofTwRiiKo2aWojBOfyMCgfKw9HVpO6fSY5AK86sHS/Uzn8kK9Few==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.4.0", + "@noble/hashes": "2.4.0", + "@scure/base": "2.4.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.4.0.tgz", + "integrity": "sha512-82dxFbZUYboyOf0AXiydsQrFQ5Q4h9mX+O2UkE91ROYmsc0BKMGZLwDmy96Jpa2+vrtoxomjUhy1RPIgH/r2nA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/starknet": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@scure/starknet/-/starknet-1.1.0.tgz", + "integrity": "sha512-83g3M6Ix2qRsPN4wqLDqiRZ2GBNbjVWfboJE/9UjfG+MHr6oDSu/CWgy8hsBSJejr09DkkL+l0Ze4KVrlCIdtQ==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.7.0", + "@noble/hashes": "~1.6.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/starknet/node_modules/@noble/curves": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.7.0.tgz", + "integrity": "sha512-UTMhXK9SeDhFJVrHeUJ5uZlI6ajXg10O6Ddocf9S6GjbSBVZsJo88HzKwXznNfGpMTRDyJkqMjNDPYgf0qFWnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.6.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/starknet/node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.6.0.tgz", + "integrity": "sha512-YUULf0Uk4/mAA89w+k3+yUYh6NrEvxZa5T6SY3wlMvE2chHkxFUUIDI8/XW1QSC357iA5pSnqt7XEhvFOqmDyQ==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/starknet/node_modules/@noble/hashes": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.6.1.tgz", + "integrity": "sha512-pq5D8h10hHBjyqX+cfBm0i8JUXJ0UhczFc4r74zbuT9XgewFo2E3J1cOaGtdZynILNmQ685YWGzGE1Zv6io50w==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "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", + "integrity": "sha512-lKmO/osLLPQKcTtkvu7dcE6/6Man//EH9mvMANPieLRTgJX5F7RKU4Kt+YdXhI4wCHnmZpctbXig5ANRzr+jag==", + "license": "MIT", + "dependencies": { + "@starknet-io/get-starknet-virtual-wallet": "^6.0.4", + "@starknet-io/get-starknet-wallet-standard": "^6.0.4", + "@starknet-io/types-js": "0.10.4-beta.2", + "@wallet-standard/base": "^1.1.1" + } + }, + "node_modules/@starknet-io/get-starknet-virtual-wallet": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@starknet-io/get-starknet-virtual-wallet/-/get-starknet-virtual-wallet-6.0.5.tgz", + "integrity": "sha512-pxLPYuysrCOy32UYu1jQHCJtQbwAdeXE0hpE5TCCi8TSAi7id2zTQjHoP4bkvKq8nLB7iqI70rLr3QuG0seBog==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime": "^0.12.0", + "@starknet-io/get-starknet-wallet-standard": "6.0.5", + "@starknet-io/types-js": "0.10.4-beta.2", + "@wallet-standard/base": "^1.1.1", + "@wallet-standard/features": "^1.1.1", + "async-mutex": "^0.5.0" + } + }, + "node_modules/@starknet-io/get-starknet-wallet-standard": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@starknet-io/get-starknet-wallet-standard/-/get-starknet-wallet-standard-6.0.5.tgz", + "integrity": "sha512-lmoC2A/CyvZEMNtpRwXzRn3ugNkqGxTk+OCuLSFACBZ1joe/ixaAfO8749UV+1WxPK7Uzpngno8FaM+KHVnN0g==", + "license": "MIT", + "dependencies": { + "@starknet-io/types-js": "0.10.4-beta.2", + "@wallet-standard/base": "^1.1.1", + "@wallet-standard/features": "^1.1.1", + "ox": "^1.6.2" + } + }, + "node_modules/@starknet-io/get-starknet-wallet-standard-v6": { + "name": "@starknet-io/get-starknet-wallet-standard", + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@starknet-io/get-starknet-wallet-standard/-/get-starknet-wallet-standard-6.0.4.tgz", + "integrity": "sha512-HhJlC7lSqaiFZPcn+nY4j3hNyzprt7KC7UZY6TFi8w1TNRYccGV9dwY2NtJ+bfLl5mv5ZSxt9OigRn9t6WzNLg==", + "license": "MIT", + "dependencies": { + "@starknet-io/types-js": "0.10.4-beta.2", + "@wallet-standard/base": "^1.1.1", + "@wallet-standard/features": "^1.1.1", + "ox": "^0.4.4" + } + }, + "node_modules/@starknet-io/get-starknet-wallet-standard-v6/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@starknet-io/get-starknet-wallet-standard-v6/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@starknet-io/get-starknet-wallet-standard-v6/node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@starknet-io/get-starknet-wallet-standard-v6/node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@starknet-io/get-starknet-wallet-standard-v6/node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@starknet-io/get-starknet-wallet-standard-v6/node_modules/ox": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.4.4.tgz", + "integrity": "sha512-oJPEeCDs9iNiPs6J0rTx+Y0KGeCGyCAA3zo94yZhm8G5WpOxrwUtn2Ie/Y8IyARSqqY/j9JTKA3Fc1xs1DvFnw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.10.1", + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@scure/bip32": "^1.5.0", + "@scure/bip39": "^1.4.0", + "abitype": "^1.0.6", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@starknet-io/starknet-types-0101": { + "name": "@starknet-io/types-js", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@starknet-io/types-js/-/types-js-0.10.2.tgz", + "integrity": "sha512-AtUFPYdmo9DqVus++aBSoY9W13/2PZmillPr8/mXZjc+V0iYJ/QTmkTsbw+es2mnLeLhYWSymW9ivQzyyyKdog==", + "license": "MIT" + }, + "node_modules/@starknet-io/starknet-types-0103": { + "name": "@starknet-io/types-js", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@starknet-io/types-js/-/types-js-0.10.3.tgz", + "integrity": "sha512-WtTGjqgyjqYSaSks/CQrpERGiLlwhr1TTD4llsr8IKEZHb78OJEmEhzrb/LxJV1SIz+MEsB1pioG62BOmFKYLA==", + "license": "MIT" + }, + "node_modules/@starknet-io/starknet-types-0104": { + "name": "@starknet-io/types-js", + "version": "0.10.4-beta.2", + "resolved": "https://registry.npmjs.org/@starknet-io/types-js/-/types-js-0.10.4-beta.2.tgz", + "integrity": "sha512-DHfzg/d6s1NYeQpbBpLwXxYBVgCMhMIW2RjbSS2ukwz8XbPZQCLwj8ArAH4Tx4dmkJpn4J6YVRhZGFgjO26TVQ==", + "license": "MIT" + }, + "node_modules/@starknet-io/starknet-types-09": { + "name": "@starknet-io/types-js", + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@starknet-io/types-js/-/types-js-0.9.2.tgz", + "integrity": "sha512-vWOc0FVSn+RmabozIEWcEny1I73nDGTvOrLYJsR1x7LGA3AZmqt4i/aW69o/3i2NN5CVP8Ok6G1ayRQJKye3Wg==", + "license": "MIT" + }, + "node_modules/@starknet-io/types-js": { + "version": "0.10.4-beta.2", + "resolved": "https://registry.npmjs.org/@starknet-io/types-js/-/types-js-0.10.4-beta.2.tgz", + "integrity": "sha512-DHfzg/d6s1NYeQpbBpLwXxYBVgCMhMIW2RjbSS2ukwz8XbPZQCLwj8ArAH4Tx4dmkJpn4J6YVRhZGFgjO26TVQ==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@wallet-standard/base": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@wallet-standard/base/-/base-1.1.1.tgz", + "integrity": "sha512-gggIHTtxicF9XFMQ12DkfS6NAG92Ak795JeSA7f2whAQ6Y3AkMWWuCMxSZXG2NIPN42kEaZSNVjqMsJRaJRxMQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=22" + } + }, + "node_modules/@wallet-standard/features": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@wallet-standard/features/-/features-1.1.1.tgz", + "integrity": "sha512-aCWYmVeSCGViyEU5k7GMoW8zxE4Gs+C1s1Pp2XLesvSNlnZ4PMES9HUnTB3hl0b3RVj7C61yze3IWyrncqg4MA==", + "license": "Apache-2.0", + "dependencies": { + "@wallet-standard/base": "^1.1.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/abi-wan-kanabi": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/abi-wan-kanabi/-/abi-wan-kanabi-2.2.4.tgz", + "integrity": "sha512-0aA81FScmJCPX+8UvkXLki3X1+yPQuWxEkqXBVKltgPAK79J+NB+Lp5DouMXa7L6f+zcRlIA/6XO7BN/q9fnvg==", + "license": "ISC", + "dependencies": { + "ansicolors": "^0.3.2", + "cardinal": "^2.1.1", + "fs-extra": "^10.0.0", + "yargs": "^17.7.2" + }, + "bin": { + "generate": "dist/generate.js" + } + }, + "node_modules/abitype": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.3.0.tgz", + "integrity": "sha512-fk6Te+bojIFrMvMZrnOO+SxCB+RUksTGOzq/60ZRvs1L+BVzvi2bqt9L3W/17ZLdZsyM1FuYf65P5nlmoiH1Bg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansicolors": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/ansicolors/-/ansicolors-0.3.2.tgz", + "integrity": "sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==", + "license": "MIT" + }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/cardinal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/cardinal/-/cardinal-2.1.1.tgz", + "integrity": "sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==", + "license": "MIT", + "dependencies": { + "ansicolors": "~0.3.2", + "redeyed": "~2.1.0" + }, + "bin": { + "cdl": "bin/cdl.js" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "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/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "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/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/lossless-json": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/lossless-json/-/lossless-json-4.3.1.tgz", + "integrity": "sha512-SqD/Bg3ZfltBJ2Z14hJ/BihnvtV553WO4g9/ePtlp4lrnl9jF3AdIJt53A/Wkg/0Li+LMfxaBqgx1MiFZdQlpQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/ox": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/ox/-/ox-1.7.2.tgz", + "integrity": "sha512-vLIGXURTJqcb79zP3sOKNFclkPxxPoe2+4LmejRl8NNISeupRJjLBYx4iCq/a4Qx6I85oxLYcZzLBU6SftqLaw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^2.2.0", + "@noble/curves": "^2.2.0", + "@noble/hashes": "^2.2.0", + "@noble/post-quantum": "^0.6.1", + "@scure/bip32": "^2.0.0", + "@scure/bip39": "^2.0.0", + "abitype": "^1.3.0", + "eventemitter3": "5.0.1", + "zod": "^4.4.3" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/redeyed": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/redeyed/-/redeyed-2.1.1.tgz", + "integrity": "sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ==", + "license": "MIT", + "dependencies": { + "esprima": "~4.0.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/starknet": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/starknet/-/starknet-10.7.0.tgz", + "integrity": "sha512-tHNQTqxCnmeR8slyQ9VfLxbBJmGj7eK0uxb13uVMjG6HNdPxxfDX2v0F41053VgX/3R8BHGd5gBxH+CP8LYT+w==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.7.0", + "@noble/hashes": "~1.6.0", + "@scure/base": "~1.2.1", + "@scure/starknet": "1.1.0", + "@starknet-io/get-starknet-wallet-standard": "^5.0.0", + "@starknet-io/get-starknet-wallet-standard-v6": "npm:@starknet-io/get-starknet-wallet-standard@6.0.4", + "@starknet-io/starknet-types-0101": "npm:@starknet-io/types-js@0.10.2", + "@starknet-io/starknet-types-0103": "npm:@starknet-io/types-js@0.10.3", + "@starknet-io/starknet-types-0104": "npm:@starknet-io/types-js@0.10.4-beta.2", + "@starknet-io/starknet-types-09": "npm:@starknet-io/types-js@~0.9.2", + "abi-wan-kanabi": "2.2.4", + "lossless-json": "^4.2.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/starknet/node_modules/@noble/curves": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.7.0.tgz", + "integrity": "sha512-UTMhXK9SeDhFJVrHeUJ5uZlI6ajXg10O6Ddocf9S6GjbSBVZsJo88HzKwXznNfGpMTRDyJkqMjNDPYgf0qFWnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.6.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/starknet/node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.6.0.tgz", + "integrity": "sha512-YUULf0Uk4/mAA89w+k3+yUYh6NrEvxZa5T6SY3wlMvE2chHkxFUUIDI8/XW1QSC357iA5pSnqt7XEhvFOqmDyQ==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/starknet/node_modules/@noble/hashes": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.6.1.tgz", + "integrity": "sha512-pq5D8h10hHBjyqX+cfBm0i8JUXJ0UhczFc4r74zbuT9XgewFo2E3J1cOaGtdZynILNmQ685YWGzGE1Zv6io50w==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/starknet/node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/starknet/node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/starknet/node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/starknet/node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/starknet/node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/starknet/node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/starknet/node_modules/@starknet-io/get-starknet-wallet-standard": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@starknet-io/get-starknet-wallet-standard/-/get-starknet-wallet-standard-5.0.0.tgz", + "integrity": "sha512-isDNGDlp16W24HE4IuweYXLDRZN0JbsDnazAieeKXE87Mn+jqhsjgTsMxcwWTjX7v906Bjz39FiDjGUddnr36g==", + "license": "MIT", + "dependencies": { + "@starknet-io/types-js": "^0.7.10", + "@wallet-standard/base": "^1.1.0", + "@wallet-standard/features": "^1.1.0", + "ox": "^0.4.4" + } + }, + "node_modules/starknet/node_modules/@starknet-io/types-js": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/@starknet-io/types-js/-/types-js-0.7.10.tgz", + "integrity": "sha512-1VtCqX4AHWJlRRSYGSn+4X1mqolI1Tdq62IwzoU2vUuEE72S1OlEeGhpvd6XsdqXcfHmVzYfj8k1XtKBQqwo9w==", + "license": "MIT" + }, + "node_modules/starknet/node_modules/ox": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.4.4.tgz", + "integrity": "sha512-oJPEeCDs9iNiPs6J0rTx+Y0KGeCGyCAA3zo94yZhm8G5WpOxrwUtn2Ie/Y8IyARSqqY/j9JTKA3Fc1xs1DvFnw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.10.1", + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@scure/bip32": "^1.5.0", + "@scure/bip39": "^1.4.0", + "abitype": "^1.0.6", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/zod": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.1.tgz", + "integrity": "sha512-P3GqeAEOEDqKvafVGLezbg+39YW/ze4xrD4qdS0adOY8eoI3zfjv1PQM4UwQzgT8mZKXEbqtVt8K+ZKGqkMFKg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..73e8a14 --- /dev/null +++ b/web/package.json @@ -0,0 +1,21 @@ +{ + "name": "afterlight-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "tsc --noEmit && vite build", + "preview": "vite preview --host 127.0.0.1" + }, + "dependencies": { + "@starknet-io/get-starknet-discovery": "^6.0.4", + "@starknet-io/get-starknet-wallet-standard": "^6.0.4", + "@starknet-io/types-js": "0.10.4-beta.2", + "starknet": "10.7.0" + }, + "devDependencies": { + "typescript": "5.9.3", + "vite": "7.3.6" + } +} diff --git a/web/public/afterlight-card.png b/web/public/afterlight-card.png new file mode 100644 index 0000000..4523184 Binary files /dev/null and b/web/public/afterlight-card.png differ diff --git a/web/public/afterlight-card.svg b/web/public/afterlight-card.svg new file mode 100644 index 0000000..30ebea4 --- /dev/null +++ b/web/public/afterlight-card.svg @@ -0,0 +1,31 @@ + + Afterlight — private control over time + Private Starknet recovery reserves with heartbeat, grace, veto, and exact-note recovery. + + + + + + + + Afterlight + + PRIVATE STARKNET RECOVERY + Private control + over time. + Fund privately. Heartbeat while active. Veto during grace. + + PROTECTED + + GRACE + + PRIVATE RECOVERY + + + + + + + + Powered by STRK20 + diff --git a/web/public/apple-touch-icon.png b/web/public/apple-touch-icon.png new file mode 100644 index 0000000..9cfea65 Binary files /dev/null and b/web/public/apple-touch-icon.png differ diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..5a72a33 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/src/chain.ts b/web/src/chain.ts new file mode 100644 index 0000000..edf7ad6 --- /dev/null +++ b/web/src/chain.ts @@ -0,0 +1,47 @@ +import { RpcProvider, num } from "starknet"; +import { CHAIN_ID, CONTRACT, RPC_URL, STRK } from "./config.ts"; +import type { VaultSnapshot } from "./model.ts"; + +export const provider = new RpcProvider({ nodeUrl: RPC_URL }); + +function hex(value: unknown): string { + return num.toHex(BigInt(String(value ?? 0))); +} + +export async function assertMainnet(): Promise { + if (num.toHex(BigInt(await provider.getChainId())) !== CHAIN_ID) throw new Error("The public RPC is not Starknet Mainnet."); +} + +export async function readVault(vaultId: string): Promise { + const raw = await provider.callContract({ contractAddress: CONTRACT, entrypoint: "get_vault", calldata: [vaultId] }) as string[]; + if (raw.length !== 15) throw new Error("Afterlight returned an unexpected vault shape."); + const decimal = raw.map((value) => BigInt(value).toString()); + return { + exists: BigInt(raw[0]!) !== 0n, + state: decimal[1]!, + mode: decimal[2]!, + ownerKey: hex(raw[3]), + successorKey: hex(raw[4]), + token: hex(raw[5]), + amount: decimal[6]!, + inactivitySeconds: decimal[7]!, + graceSeconds: decimal[8]!, + lastHeartbeat: decimal[9]!, + requestedAt: decimal[10]!, + claimAfter: decimal[11]!, + epoch: decimal[12]!, + ownerNonce: decimal[13]!, + successorNonce: decimal[14]!, + }; +} + +export async function readLiability(): Promise { + const raw = await provider.callContract({ contractAddress: CONTRACT, entrypoint: "get_locked_by_token", calldata: [STRK] }); + return BigInt(raw[0] ?? 0) + (BigInt(raw[1] ?? 0) << 128n); +} + +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."); +} diff --git a/web/src/config.ts b/web/src/config.ts new file mode 100644 index 0000000..a153c45 --- /dev/null +++ b/web/src/config.ts @@ -0,0 +1,12 @@ +export const CHAIN_ID = "0x534e5f4d41494e"; +export const CONTRACT = "0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25"; +export const POOL = "0x040337b1af3c663e86e333bab5a4b28da8d4652a15a69beee2b677776ffe812a"; +export const STRK = "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d"; +export const RPC_URL = "https://rpc.starknet.lava.build"; +export const RELAYER_URL = "https://afterlight-relayer-phase-a.qdworld001.workers.dev"; +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 AUTH_TTL_SECONDS = 600; +export const READY_VERSION = "5.33.9"; diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..bd32940 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,295 @@ +import "./style.css"; +import { num } from "starknet"; +import { 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 type { LocalStarkKey } from "../../client/src/keys.ts"; + +let role: Role = (new URL(location.href).searchParams.get("role") === "successor") ? "successor" : "owner"; +let walletStatus: WalletStatus = "checking"; +let ready: ReadySession | undefined; +let privateBalance: bigint | undefined; +let applicationKey: LocalStarkKey | undefined; +let successorPublicKey = ""; +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; + +const appRoot = document.querySelector("#app"); +if (!appRoot) throw new Error("Afterlight app root is missing."); +const app: HTMLDivElement = appRoot; + +function escapeHtml(value: string): string { + return value.replace(/[&<>'"]/g, (character) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[character] ?? character); +} + +function short(value: string): string { + return value.length > 18 ? `${value.slice(0, 10)}…${value.slice(-6)}` : value; +} + +function strk(value: bigint): string { + const whole = value / 10n ** 18n; + const fraction = (value % 10n ** 18n).toString().padStart(18, "0").replace(/0+$/, ""); + return `${whole}${fraction ? `.${fraction.slice(0, 4)}` : ""} STRK`; +} + +function download(filename: string, contents: string): void { + const blob = new Blob([`${contents}\n`], { type: "application/json" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = filename; + link.click(); + window.setTimeout(() => URL.revokeObjectURL(link.href), 0); +} + +function walletCopy(): string { + if (walletStatus === "checking") return "Checking for Ready X…"; + if (walletStatus === "missing") return "Ready X was not found in this browser profile."; + if (walletStatus === "available") return "Ready X is available. Connecting spends nothing."; + if (walletStatus === "connecting") return "Waiting for Ready X authorization…"; + if (walletStatus === "wrong-network") return "Switch Ready X to Starknet Mainnet."; + return `${short(ready?.address ?? "")} · ${privateBalance === undefined ? "balance unread" : strk(privateBalance)} private`; +} + +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)}
` : ``} + +
`; +} + +function walletRow(): string { + return `
Ready X${escapeHtml(walletCopy())}
`; +} + +function ownerView(): string { + const invitation = parseInvitation(invitationText); + const canFund = walletStatus === "connected" && privateBalance !== undefined && privateBalance >= 7n * 10n ** 18n && applicationKey && /^0x[0-9a-f]{1,64}$/i.test(successorPublicKey); + const liveControls = invitation.valid && vault?.exists; + return `
+

Owner · live Mainnet journey

+

Create a recovery reserve

+

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

+ ${walletRow()} + ${keyPanel("owner")} +
+
Recovery Drill terms
1 STRK reserveFixed by the deployed contract
5 min + 5 minInactivity, then grace
+ + + + + ${privateBalance !== undefined && privateBalance < 7n * 10n ** 18n ? `

At least 7 private STRK is required for this action.

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

Successor · live Mainnet journey

+

Recover a reserve privately

+

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

+ ${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)}` : ""} +
`; +} + +function controlPanel(invitation: RecoveryInvitation, snapshot?: VaultSnapshot): string { + if (!snapshot) return `
Live controls

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

`; + const current = stateName(snapshot.state); + const now = Math.floor(Date.now() / 1000); + const inactiveAt = Number(snapshot.lastHeartbeat) + Number(snapshot.inactivitySeconds); + const requestReady = current === "ACTIVE" && now >= inactiveAt; + const claimReady = current === "GRACE" && now >= Number(snapshot.claimAfter); + const timingLabel = current === "GRACE" ? "Claim after" : current === "ACTIVE" ? "Inactive after" : "Settlement"; + const timingValue = current === "GRACE" + ? new Date(Number(snapshot.claimAfter) * 1000).toLocaleTimeString() + : current === "ACTIVE" + ? new Date(inactiveAt * 1000).toLocaleTimeString() + : current; + return `
${current}

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

+
Reserve1 STRK
${timingLabel}${timingValue}
+ ${role === "owner" && current === "ACTIVE" ? `` : ""} + ${role === "owner" && current === "GRACE" ? `` : ""} + ${role === "successor" && current === "ACTIVE" ? `` : ""} + ${role === "successor" && current === "GRACE" ? `` : ""} +

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

+
`; +} + +function statusPanel(): string { + const parsed = parseInvitation(invitationText); + const current = vault?.exists ? stateName(vault.state) : "Not loaded"; + return ``; +} + +function render(): void { + app.innerHTML = ` +

Private recovery, under your control

A reserve for the person you trust—without publishing the relationship.

Heartbeat while active. Veto during grace. Only the designated successor key can authorize private recovery.

+ +
${role === "owner" ? ownerView() : successorView()}${statusPanel()}
+
${busy ? "Working… " : ""}${escapeHtml(notice)}
+ `; + bindEvents(); +} + +function fail(error: unknown): void { + notice = error instanceof Error ? error.message : String(error); + busy = false; + render(); +} + +async function run(action: () => Promise): Promise { + if (busy) return; + busy = true; + render(); + try { await action(); } catch (error) { fail(error); return; } + busy = false; + render(); +} + +function bindEvents(): void { + document.querySelectorAll("[data-role]").forEach((button) => button.addEventListener("click", () => { + role = button.dataset.role === "successor" ? "successor" : "owner"; + applicationKey?.destroy(); + applicationKey = undefined; + vault = undefined; + history.replaceState(null, "", `/?role=${role}`); + render(); + })); + document.querySelector("[data-action=connect]")?.addEventListener("click", () => void run(async () => { + if (!ready) { + walletStatus = "connecting"; + render(); + ready = await connectReady((await import("./chain.ts")).provider, () => { + ready?.disconnect(); ready = undefined; privateBalance = undefined; walletStatus = "available"; notice = "Ready account or network changed. Reconnect to continue."; render(); + }); + } + privateBalance = await ready.balance(STRK); + walletStatus = "connected"; + notice = `Ready X connected. Private balance: ${strk(privateBalance)}.`; + })); + document.querySelector("[data-action=generate-key]")?.addEventListener("click", () => { + applicationKey?.destroy(); + applicationKey = generateKey(); + if (role === "successor") successorPublicKey = applicationKey.publicKey; + notice = `${role === "owner" ? "Owner" : "Successor"} key generated locally. Download its secret backup before leaving this page.`; + render(); + }); + document.querySelector("[data-action=copy-key]")?.addEventListener("click", () => void run(async () => { + if (!applicationKey) throw new Error("Generate or restore a key first."); + await navigator.clipboard.writeText(applicationKey.publicKey); + notice = "Public key copied. Sharing the public key is safe; never share the backup file."; + })); + document.querySelector("[data-action=download-key]")?.addEventListener("click", () => { + if (!applicationKey) return; + download(`afterlight-${role}-key-${applicationKey.publicKey.slice(2, 10)}.json`, exportKey(applicationKey)); + notice = "Secret backup downloaded. Store it safely and never send it to anyone."; + render(); + }); + document.querySelector("[data-key-file]")?.addEventListener("change", (event) => void run(async () => { + const file = (event.currentTarget as HTMLInputElement).files?.[0]; + if (!file) throw new Error("Choose an Afterlight key backup."); + applicationKey?.destroy(); + applicationKey = restoreKey(await file.text()); + if (role === "successor") successorPublicKey = applicationKey.publicKey; + notice = `${role === "owner" ? "Owner" : "Successor"} key restored locally.`; + })); + document.querySelector("[name=successor-key]")?.addEventListener("input", (event) => { + successorPublicKey = (event.currentTarget as HTMLInputElement).value.trim(); + const submit = document.querySelector("#reserve-form button[type=submit]"); + if (submit) submit.disabled = !(walletStatus === "connected" && privateBalance !== undefined && privateBalance >= 7n * 10n ** 18n && applicationKey && /^0x[0-9a-f]{1,64}$/i.test(successorPublicKey)); + }); + document.querySelector("[name=cost-ack]")?.addEventListener("change", (event) => { + costAcknowledged = (event.currentTarget as HTMLInputElement).checked; + }); + document.querySelector("[name=invitation]")?.addEventListener("input", (event) => { + invitationText = (event.currentTarget as HTMLTextAreaElement).value; + localStorage.setItem("afterlight:invitation:v1", invitationText); + vault = undefined; + render(); + }); + document.querySelector("#reserve-form")?.addEventListener("submit", (event) => { + event.preventDefault(); + const form = new FormData(event.currentTarget as HTMLFormElement); + void run(async () => { + if (!form.has("cost-ack") || !costAcknowledged) throw new Error("Confirm the exact 7 STRK private-wallet consequence first."); + if (!ready || !applicationKey) throw new Error("Connect Ready X and generate the owner key first."); + if (privateBalance === undefined || privateBalance < 7n * 10n ** 18n) throw new Error("At least 7 private STRK is required."); + notice = "Requesting a fresh neutral funding checkpoint…"; render(); + const result = await fundRecoveryDrill({ ready, ownerKey: applicationKey, successorKey: successorPublicKey, + onCheckpoint: (hash) => { transactionHash = hash; notice = "Checkpoint succeeded. Confirm the single private FUND transaction in Ready X."; render(); }, + onSubmitted: (hash) => { transactionHash = hash; notice = "Private FUND submitted. Waiting for Mainnet success…"; render(); }, + }); + invitationText = JSON.stringify(result.invitation, null, 2); + localStorage.setItem("afterlight:invitation:v1", invitationText); + download(`afterlight-invitation-${result.invitation.vaultId.slice(2, 10)}.json`, invitationText); + vault = await readVault(result.invitation.vaultId); + privateBalance = await ready.balance(STRK); + costAcknowledged = false; + notice = "Recovery reserve is ACTIVE. Invitation downloaded; share it with the designated successor."; + }); + }); + document.querySelectorAll("[data-action=load-vault]").forEach((button) => button.addEventListener("click", () => void run(async () => { + const parsed = parseInvitation(invitationText); + if (!parsed.valid) throw new Error(parsed.reason); + vault = await readVault(parsed.invitation.vaultId); + if (!vault.exists) throw new Error("This vault does not exist on the deployed Afterlight contract."); + if (num.toHex(BigInt(vault.successorKey)) !== num.toHex(BigInt(parsed.invitation.successorKey))) throw new Error("Invitation successor key does not match Mainnet state."); + notice = `Live vault loaded: ${stateName(vault.state)}.`; + }))); + document.querySelectorAll("[data-control]").forEach((button) => button.addEventListener("click", () => void run(async () => { + const parsed = parseInvitation(invitationText); + if (!parsed.valid || !vault || !applicationKey) throw new Error("Import the invitation, live vault and correct key first."); + const operation = button.dataset.control as "HEARTBEAT" | "REQUEST" | "VETO"; + transactionHash = await relayControl(operation, parsed.invitation, vault, applicationKey); + vault = await readVault(parsed.invitation.vaultId); + notice = `${operation === "HEARTBEAT" ? "Heartbeat recorded" : operation === "REQUEST" ? "Recovery grace opened" : "Recovery vetoed"}. Mainnet state is now ${stateName(vault.state)}.`; + }))); + document.querySelector("[data-action=claim]")?.addEventListener("click", () => void run(async () => { + const parsed = parseInvitation(invitationText); + if (!parsed.valid || !vault || !ready || !applicationKey) throw new Error("Connect Ready X and restore the designated successor key first."); + const balanceBefore = await ready.balance(STRK); + notice = "Ready X will prepare the exact private destination twice, then the neutral sponsor will submit it. The sponsor pays the pool and network fees."; render(); + const claimPackage = await prepareClaimPackage({ ready, invitation: parsed.invitation, vault, successorKey: applicationKey }); + notice = "Exact destination and designated-key authorization verified. Submitting through the neutral sponsor…"; render(); + let result; + try { + result = await submitClaimPackage(claimPackage); + } catch (error) { + if (error instanceof Error && error.message.includes("needs receipt reconciliation")) claimRetryBlocked = true; + throw error; + } + transactionHash = result.transactionHash; + vault = await readVault(parsed.invitation.vaultId); + privateBalance = await ready.balance(STRK); + if (vault.state !== "3") throw new Error("The transaction succeeded but the vault is not CLAIMED. Do not retry."); + if (privateBalance !== balanceBefore + 1n * 10n ** 18n) throw new Error("The vault is CLAIMED, but the expected 1 STRK shielded-balance increase is not visible yet. Refresh Ready X; do not retry the claim."); + notice = `Recovery complete. Your shielded balance increased from ${strk(balanceBefore)} to ${strk(privateBalance)}.`; + })); +} + +render(); +void run(async () => { + await assertMainnet(); + await readLiability(); + const detection = detectReady(); + walletStatus = detection.found ? "available" : "missing"; + notice = detection.found ? `Ready X ${detection.version ?? ""} detected. No wallet request was made.` : "Ready X was not detected. You can still inspect and import a recovery invitation."; +}); + +window.addEventListener("beforeunload", () => applicationKey?.destroy()); diff --git a/web/src/model.ts b/web/src/model.ts new file mode 100644 index 0000000..5c7b4b7 --- /dev/null +++ b/web/src/model.ts @@ -0,0 +1,65 @@ +import { CONTRACT } from "./config.ts"; + +export type Role = "owner" | "successor"; +export type WalletStatus = "checking" | "missing" | "available" | "connecting" | "connected" | "wrong-network"; +export type VaultStateName = "NOT FOUND" | "ACTIVE" | "GRACE" | "CLAIMED" | "CANCELLED"; + +export interface VaultSnapshot { + exists: boolean; + state: string; + mode: string; + ownerKey: string; + successorKey: string; + token: string; + amount: string; + inactivitySeconds: string; + graceSeconds: string; + lastHeartbeat: string; + requestedAt: string; + claimAfter: string; + epoch: string; + ownerNonce: string; + successorNonce: string; +} + +export interface RecoveryInvitation { + version: 1; + chain: "SN_MAIN"; + contract: string; + vaultId: string; + ownerKey: string; + successorKey: string; + token: "STRK"; + amount: "1"; + mode: "FAST_DEMO"; + inactivitySeconds: "300"; + graceSeconds: "300"; +} + +export type InvitationResult = + | { valid: true; invitation: RecoveryInvitation } + | { valid: false; reason: string }; + +export function parseInvitation(value: string): InvitationResult { + if (!value.trim()) return { valid: false, reason: "Paste a recovery invitation first." }; + try { + const parsed = JSON.parse(value) as Record; + const fields = ["version", "chain", "contract", "vaultId", "ownerKey", "successorKey", "token", "amount", "mode", "inactivitySeconds", "graceSeconds"]; + if (Object.keys(parsed).some((key) => !fields.includes(key))) return { valid: false, reason: "Invitation contains an unsupported field." }; + if (parsed.version !== 1 || parsed.chain !== "SN_MAIN") return { valid: false, reason: "Invitation is not Afterlight Mainnet v1." }; + if (String(parsed.contract).toLowerCase() !== CONTRACT) return { valid: false, reason: "Invitation points to a different contract." }; + for (const key of ["vaultId", "ownerKey", "successorKey"] as const) { + if (!/^0x[0-9a-f]{1,64}$/i.test(String(parsed[key]))) return { valid: false, reason: `${key} is invalid.` }; + } + if (parsed.token !== "STRK" || parsed.amount !== "1" || parsed.mode !== "FAST_DEMO" || parsed.inactivitySeconds !== "300" || parsed.graceSeconds !== "300") { + return { valid: false, reason: "This release supports the 1 STRK, five-minute Recovery Drill only." }; + } + return { valid: true, invitation: parsed as unknown as RecoveryInvitation }; + } catch { + return { valid: false, reason: "Invitation is not valid Afterlight JSON." }; + } +} + +export function stateName(state: string): VaultStateName { + return ({ "1": "ACTIVE", "2": "GRACE", "3": "CLAIMED", "4": "CANCELLED" } as Record)[state] ?? "NOT FOUND"; +} diff --git a/web/src/operations.ts b/web/src/operations.ts new file mode 100644 index 0000000..cfd3ec7 --- /dev/null +++ b/web/src/operations.ts @@ -0,0 +1,344 @@ +import { num } from "starknet"; +import { + OPEN_NOTE_PLACEHOLDER, + PREPARE_SIGNATURE, + PrivateAction, + bindDappSubmittedPreparedExit, + buildClaimActions, + buildFundActions, + resolveSimulatedPreparedExitNoteId, + serializeControl, + type ControlArgs, +} from "../../client/src/actions.ts"; +import { LocalStarkKey, BACKUP_CONFIRMATION } from "../../client/src/keys.ts"; +import { authorizationHash, type Authorization } from "../../client/src/messages.ts"; +import { buildRelayRequest, encodeRelayRequest, RelayOperation } from "../../client/src/relay.ts"; +import { + AMOUNT_FRI, + AUTH_TTL_SECONDS, + CHAIN_ID, + CONTRACT, + FAST_GRACE_SECONDS, + FAST_INACTIVITY_SECONDS, + POOL, + RELAYER_URL, + STRK, +} from "./config.ts"; +import type { RecoveryInvitation, VaultSnapshot } from "./model.ts"; +import type { ReadySession } from "./wallet.ts"; +import { waitForSuccess } from "./chain.ts"; +import { provider } from "./chain.ts"; + +const CHECKPOINT_SELECTOR = "0x680f3f85ab85ad72c372d5d99610c8c5ffd7ebf3a1f26fbd21fa999346525d"; + +export function generateKey(): LocalStarkKey { + return LocalStarkKey.generate(); +} + +export function restoreKey(serialized: string): LocalStarkKey { + return LocalStarkKey.restore(serialized); +} + +export function exportKey(key: LocalStarkKey): string { + return key.serializeBackup(BACKUP_CONFIRMATION); +} + +export function freshVaultId(): string { + const bytes = crypto.getRandomValues(new Uint8Array(31)); + bytes[0] = (bytes[0] ?? 0) & 0x03; + const value = BigInt(`0x${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`); + return num.toHex(value === 0n ? 1n : value); +} + +function validUntil(): string { + return String(Math.floor(Date.now() / 1000) + AUTH_TTL_SECONDS); +} + +function base(vaultId: string, signerKey: string, state: string, epoch: string, nonce: string, expiry: string) { + return { + chain_id: CHAIN_ID, + contract: CONTRACT, + vault_id: vaultId, + token: STRK, + amount: AMOUNT_FRI, + expected_state: state, + epoch, + nonce, + signer_key: signerKey, + note_id: "0", + valid_until: expiry, + }; +} + +async function checkpoint(): Promise { + const startedBlock = await provider.getBlockNumber(); + const startedAt = Math.floor(Date.now() / 1_000); + let returnedHash: string | undefined; + try { + const response = await fetch(`${RELAYER_URL}/v1/checkpoint`, { + method: "POST", + headers: { "x-afterlight-intent": "funding-checkpoint" }, + cache: "no-store", + credentials: "omit", + referrerPolicy: "no-referrer", + }); + const body = await response.json() as { status?: string; result?: { status?: string; transactionHash?: string } }; + if (response.ok && body.status === "relayed" && body.result?.transactionHash && ["accepted", "duplicate"].includes(String(body.result.status))) { + returnedHash = num.toHex(BigInt(body.result.transactionHash)); + } + } catch { + // A Worker response may be lost after its single broadcast. Reconcile the + // public, payload-free checkpoint event before declaring the attempt dead. + } + for (let attempt = 0; attempt < 4; attempt += 1) { + const result = await provider.getEvents({ + from_block: { block_number: startedBlock }, + to_block: "latest", + address: CONTRACT, + keys: [[CHECKPOINT_SELECTOR]], + chunk_size: 10, + }); + const event = result.events + .filter((candidate) => { + if (!candidate.transaction_hash || candidate.data.length < 3) return false; + const hash = num.toHex(BigInt(candidate.transaction_hash)); + const syncedAt = Number(BigInt(candidate.data[2] ?? "0")); + return (!returnedHash || hash === returnedHash) && syncedAt >= startedAt - 30; + }) + .at(-1); + if (event?.transaction_hash) { + const transactionHash = num.toHex(BigInt(event.transaction_hash)); + await waitForSuccess(transactionHash); + return transactionHash; + } + if (attempt < 3) await new Promise((resolve) => setTimeout(resolve, 2_000)); + } + throw new Error("The neutral funding checkpoint is unavailable. No private funds moved."); +} + +export async function fundRecoveryDrill(input: { + ready: ReadySession; + ownerKey: LocalStarkKey; + successorKey: string; + onCheckpoint?: (hash: string) => void; + onSubmitted?: (hash: string) => void; +}): Promise<{ invitation: RecoveryInvitation; transactionHash: string }> { + const vaultId = freshVaultId(); + const expiry = validUntil(); + const auth: Authorization = { + operation: "FUND", + base: base(vaultId, input.ownerKey.publicKey, "0", "0", "0", expiry), + mode: "1", + successor_key: input.successorKey, + inactivity_seconds: FAST_INACTIVITY_SECONDS, + grace_seconds: FAST_GRACE_SECONDS, + }; + const signature = input.ownerKey.sign(authorizationHash(auth)); + const actions = buildFundActions(CONTRACT, { + vault_id: vaultId, + token: STRK, + amount: AMOUNT_FRI, + mode: "1", + owner_key: input.ownerKey.publicKey, + successor_key: input.successorKey, + inactivity_seconds: FAST_INACTIVITY_SECONDS, + grace_seconds: FAST_GRACE_SECONDS, + valid_until: expiry, + ...signature, + }); + const checkpointHash = await checkpoint(); + input.onCheckpoint?.(checkpointHash); + const transactionHash = await input.ready.invoke(actions); + input.onSubmitted?.(transactionHash); + await waitForSuccess(transactionHash); + return { + transactionHash, + invitation: { + version: 1, + chain: "SN_MAIN", + contract: CONTRACT, + vaultId, + ownerKey: input.ownerKey.publicKey, + successorKey: num.toHex(BigInt(input.successorKey)), + token: "STRK", + amount: "1", + mode: "FAST_DEMO", + inactivitySeconds: "300", + graceSeconds: "300", + }, + }; +} + +export async function relayControl(operation: "HEARTBEAT" | "REQUEST" | "VETO", invitation: RecoveryInvitation, vault: VaultSnapshot, key: LocalStarkKey): Promise { + const expiry = validUntil(); + const owner = operation !== "REQUEST"; + const nonce = owner ? vault.ownerNonce : vault.successorNonce; + const signerKey = owner ? invitation.ownerKey : invitation.successorKey; + if (num.toHex(BigInt(key.publicKey)) !== num.toHex(BigInt(signerKey))) throw new Error(`This is not the designated ${owner ? "owner" : "successor"} key.`); + const authBase = base(invitation.vaultId, signerKey, vault.state, vault.epoch, nonce, expiry); + const auth: Authorization = operation === "HEARTBEAT" + ? { operation, base: authBase, last_heartbeat: vault.lastHeartbeat } + : operation === "REQUEST" + ? { operation, base: authBase, last_heartbeat: vault.lastHeartbeat } + : { operation, base: authBase, requested_at: vault.requestedAt, claim_after: vault.claimAfter }; + const signature = key.sign(authorizationHash(auth)); + const args: ControlArgs = { + vault_id: invitation.vaultId, + token: STRK, + amount: AMOUNT_FRI, + expected_state: vault.state, + expected_epoch: vault.epoch, + expected_nonce: nonce, + valid_until: expiry, + ...signature, + }; + // Keep this explicit serialization check beside the user action so frontend + // and Cairo calldata cannot silently drift. + serializeControl(args); + const relayOperation = operation === "HEARTBEAT" ? RelayOperation.Heartbeat : operation === "REQUEST" ? RelayOperation.Request : RelayOperation.Veto; + const payload = encodeRelayRequest(buildRelayRequest(relayOperation, CONTRACT, args)); + const response = await fetch(`${RELAYER_URL}/v1/relay`, { + method: "POST", + headers: { "content-type": "application/json", "x-afterlight-intent": "relay-control" }, + body: payload, + cache: "no-store", + credentials: "omit", + referrerPolicy: "no-referrer", + }); + const body = await response.json() as { status?: string; result?: { transactionHash?: string } }; + if (!response.ok || body.status !== "relayed" || !body.result?.transactionHash) throw new Error("The neutral relay did not accept this authorization."); + const transactionHash = num.toHex(BigInt(body.result.transactionHash)); + await waitForSuccess(transactionHash); + return transactionHash; +} + +async function sha256Canonical(value: unknown): Promise { + const canonical = (item: unknown): unknown => { + if (item === null || typeof item === "string" || typeof item === "boolean") return item; + if (typeof item === "number" && Number.isSafeInteger(item)) return item; + if (Array.isArray(item)) return item.map(canonical); + if (typeof item === "object" && item !== null) return Object.fromEntries(Object.keys(item as object).sort().map((key) => [key, canonical((item as Record)[key])])); + throw new Error("Prepared package contains a noncanonical value."); + }; + const bytes = new TextEncoder().encode(JSON.stringify(canonical(value))); + return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function sha256ProofData(base64: string): Promise { + const binary = atob(base64); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export async function prepareClaimPackage(input: { + ready: ReadySession; + invitation: RecoveryInvitation; + vault: VaultSnapshot; + successorKey: LocalStarkKey; +}): Promise>> { + const { ready, invitation, vault, successorKey } = input; + if (!vault.exists || vault.state !== "2") throw new Error("The vault is not in GRACE."); + if (Math.floor(Date.now() / 1000) < Number(vault.claimAfter)) throw new Error("The grace period has not finished."); + if (num.toHex(BigInt(successorKey.publicKey)) !== num.toHex(BigInt(invitation.successorKey))) throw new Error("This is not the designated successor key."); + const expiry = validUntil(); + const sentinelArgs = { + vault_id: invitation.vaultId, token: STRK, amount: AMOUNT_FRI, expected_state: "2", + expected_epoch: vault.epoch, expected_nonce: vault.successorNonce, note_id: OPEN_NOTE_PLACEHOLDER, + valid_until: expiry, ...PREPARE_SIGNATURE, + }; + const sentinelActions = buildClaimActions(CONTRACT, ready.address, sentinelArgs); + const sentinelPrepared = await ready.prepare(sentinelActions, true); + const noteId = resolveSimulatedPreparedExitNoteId(sentinelPrepared, POOL, CONTRACT, PrivateAction.Claim, sentinelArgs); + const auth: Authorization = { + operation: "CLAIM", + base: { ...base(invitation.vaultId, invitation.successorKey, "2", vault.epoch, vault.successorNonce, expiry), note_id: noteId }, + requested_at: vault.requestedAt, + claim_after: vault.claimAfter, + }; + const signature = successorKey.sign(authorizationHash(auth)); + const signedArgs = { ...sentinelArgs, ...signature }; + const signedPrepared = await ready.prepare(buildClaimActions(CONTRACT, ready.address, signedArgs), false); + const facts = signedPrepared.proof.proof_facts; + if (!Array.isArray(facts) || facts.length < 6) throw new Error("Ready returned incomplete proof facts."); + const proofBlockNumber = BigInt(facts[4]!); + if (proofBlockNumber > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("Proof block is not safely addressable."); + const proofBlock = await provider.getBlockWithTxHashes(Number(proofBlockNumber)); + if (!("block_hash" in proofBlock) || proofBlock.block_hash === undefined || num.toHex(BigInt(proofBlock.block_hash)) !== num.toHex(BigInt(facts[5]!))) throw new Error("Ready proof facts do not match the canonical Mainnet block."); + const bound = bindDappSubmittedPreparedExit({ + pool: POOL, + contract: CONTRACT, + proofBaseBlock: { number: facts[4]!, hash: facts[5]! }, + kind: PrivateAction.Claim, + sentinelArgs, + sentinelPrepared, + signedNoteId: noteId, + signedArgs, + signedPrepared, + }); + const preparedAtBlock = String(await provider.getBlockNumber()); + const body: Record = { + schema: "afterlight-prepared-neutral-exit/1", + evidence: "APPLICATION_AUTHORIZED_OUTER_UNSIGNED_NOT_SUBMITTED", + action: "CLAIM", + chainId: CHAIN_ID, + neutralAddress: "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46", + afterlightAddress: CONTRACT, + poolAddress: POOL, + tokenAddress: STRK, + amountFri: AMOUNT_FRI, + vaultId: invitation.vaultId, + expectedState: "2", + expectedEpoch: vault.epoch, + expectedRoleNonce: vault.successorNonce, + destinationNoteId: noteId, + validUntil: expiry, + preparedAtBlock, + prepared: bound.prepared, + }; + const prepared = bound.prepared as unknown as { call: unknown; proof: { data: string; output: unknown; proof_facts: unknown } }; + const locks = { + callSha256: await sha256Canonical(prepared.call), + proofDataSha256: await sha256ProofData(prepared.proof.data), + proofOutputSha256: await sha256Canonical(prepared.proof.output), + proofFactsSha256: await sha256Canonical(prepared.proof.proof_facts), + bindingSha256: await sha256Canonical(body), + }; + return Object.freeze({ ...body, locks: Object.freeze(locks) }); +} + +export async function submitClaimPackage( + claimPackage: Readonly>, +): Promise<{ transactionHash: string; actualFeeFri?: string }> { + const response = await fetch(`${RELAYER_URL}/v1/exit`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-afterlight-intent": "claim-exit", + }, + body: JSON.stringify(claimPackage), + cache: "no-store", + credentials: "omit", + referrerPolicy: "no-referrer", + }); + const body = await response.json() as { + status?: string; + code?: string; + result?: { transactionHash?: string | null; actualFeeFri?: string }; + }; + if (!response.ok || body.status !== "relayed" || !body.result?.transactionHash) { + const reason = body.code === "exit_unavailable" + ? "The neutral claim sponsor is temporarily unavailable. No recovery was submitted." + : body.code === "exit_busy" + ? "A claim is already being processed. Refresh the vault before trying again." + : body.code === "exit_reverted" + ? "The recovery was refused by the Afterlight contract. Refresh the vault state." + : body.code === "exit_uncertain" + ? "The claim submission needs receipt reconciliation. Do not retry yet." + : "The exact-note claim package was rejected. No recovery was submitted."; + throw new Error(reason); + } + return { + transactionHash: num.toHex(BigInt(body.result.transactionHash)), + actualFeeFri: body.result.actualFeeFri, + }; +} diff --git a/web/src/style.css b/web/src/style.css new file mode 100644 index 0000000..9842456 --- /dev/null +++ b/web/src/style.css @@ -0,0 +1,7 @@ +:root{color:#191915;background:#f4f1e9;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-synthesis:none;text-rendering:optimizeLegibility;--ink:#191915;--muted:#68685f;--paper:#fffdf8;--line:#d8d3c8;--accent:#d86f31;--accent-dark:#8e3514;--soft:#ebe6db;--success:#2e7d55;--error:#a33a2b}*{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh}button,input,select,textarea{font:inherit;min-height:44px}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.48}a{color:inherit}.skip-link{position:fixed;left:1rem;top:-5rem;z-index:20;background:var(--ink);color:#fff;padding:.75rem 1rem}.skip-link:focus{top:1rem}:focus-visible{outline:3px solid #1e63d5;outline-offset:3px}.site-header{max-width:1180px;margin:0 auto;min-height:72px;padding:0 24px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{display:inline-flex;gap:.55rem;align-items:center;text-decoration:none;font-weight:780;letter-spacing:-.02em}.brand span{color:var(--accent);font-size:1.4rem}.network{display:flex;gap:.5rem;align-items:center;color:var(--muted);font-size:.875rem}.network span{width:8px;height:8px;border-radius:50%;background:#2e8b57}main{max-width:1180px;margin:0 auto;padding:72px 24px 56px}.intro{max-width:820px}.kicker,.eyebrow{margin:0 0 12px;color:var(--accent-dark);font-weight:760;font-size:.78rem;letter-spacing:.11em;text-transform:uppercase}h1{margin:0;max-width:820px;font-family:Georgia,"Times New Roman",serif;font-weight:500;font-size:clamp(2.55rem,6vw,5.5rem);line-height:.98;letter-spacing:-.045em}.intro>p:last-child{max-width:680px;color:var(--muted);font-size:1.1rem;line-height:1.65}.role-tabs{display:inline-flex;gap:4px;margin:48px 0 24px;padding:4px;background:#e2ddd2;border-radius:10px}.role-tabs button{border:0;border-radius:7px;padding:0 18px;color:var(--muted);background:transparent}.role-tabs button[aria-current=page]{color:var(--ink);background:var(--paper);box-shadow:0 1px 4px rgb(40 35 25/12%);font-weight:700}.content-grid{display:grid;grid-template-columns:minmax(0,760px) minmax(240px,1fr);gap:24px;align-items:start}.journey,.status-panel{background:var(--paper);border:1px solid var(--line);border-radius:16px}.journey{padding:clamp(24px,4vw,48px)}.journey h2{margin:0;font-size:clamp(1.75rem,3vw,2.5rem);letter-spacing:-.035em}.lede{margin:12px 0 32px;max-width:600px;color:var(--muted);line-height:1.6}.status-panel{position:sticky;top:20px;padding:24px}.status-label{color:var(--muted);font-size:.82rem;margin:0 0 8px}.status-panel>strong{font-size:1.3rem}.status-panel p,details{color:var(--muted);line-height:1.5}details{border-top:1px solid var(--line);padding-top:16px;margin-top:20px}summary{color:var(--ink);font-weight:650;cursor:pointer;min-height:44px;display:flex;align-items:center}.wallet-row{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:18px;margin-bottom:28px;background:var(--soft);border-radius:10px}.wallet-row div{display:grid;gap:4px}.wallet-row span{color:var(--muted);font-size:.875rem}.key-panel,.control-panel{display:grid;gap:14px;margin:0 0 28px;padding:20px;border:1px solid var(--line);border-radius:12px}.key-panel p,.control-panel p{margin:4px 0 0;color:var(--muted);line-height:1.5}.key-panel code{display:block;overflow-wrap:anywhere;padding:12px;background:var(--soft);border-radius:8px;font-size:.78rem}.button-row{display:flex;flex-wrap:wrap;gap:10px}fieldset{display:grid;grid-template-columns:1fr 1fr;gap:12px;border:0;padding:0;margin:0 0 28px}legend,label>span{display:block;width:100%;margin:0 0 9px;font-weight:700}.choice{display:flex;align-items:center;gap:12px;min-height:76px;padding:14px;border:1px solid var(--accent);border-radius:10px;box-shadow:inset 0 0 0 1px var(--accent)}.choice span{margin:0}.choice small,label small{display:block;color:var(--muted);margin-top:3px;font-weight:400;line-height:1.45}.full-field{display:block;margin:24px 0}input,textarea{width:100%;border:1px solid #bbb5a9;border-radius:8px;color:var(--ink);background:#fff;padding:11px 12px}textarea{resize:vertical}.cost-note{margin:28px 0 16px;padding:18px;border-left:3px solid var(--accent);background:#fff4e9}.cost-note p{margin:6px 0 0;color:#5e4b3e;line-height:1.5}.ack{display:flex;gap:10px;align-items:flex-start;margin:0 0 20px}.ack input{width:auto;min-height:auto;margin-top:4px;accent-color:var(--accent-dark)}.ack span{margin:0;font-weight:600}.invitation-result{margin:-10px 0 28px;padding:16px;border:1px solid #c8a7a0;border-radius:10px;background:#fff5f2}.invitation-result[data-valid=true]{border-color:#9bb9a3;background:#f1f8f2}.invitation-result p{margin:6px 0 0;color:var(--muted);line-height:1.5}.control-heading{display:flex;justify-content:space-between;gap:16px;align-items:start}.metrics{display:grid;grid-template-columns:1fr 1fr;gap:10px}.metrics div{display:grid;gap:3px;padding:12px;background:var(--soft);border-radius:8px}.metrics span{color:var(--muted);font-size:.75rem}.text-button{min-height:44px;padding:0;border:0;color:var(--accent-dark);background:transparent;font-weight:700;text-decoration:underline;text-underline-offset:3px}.trace{display:grid;gap:12px;margin:22px 0;padding-top:20px;border-top:1px solid var(--line)}.trace>div{display:flex;align-items:flex-start;gap:10px}.trace>div>span{display:grid;flex:0 0 22px;width:22px;height:22px;place-items:center;border-radius:50%;color:#fff;background:var(--success);font-size:.75rem;font-weight:800}.trace>div.public>span{color:#695541;background:#eadbc9}.trace p{display:grid;gap:2px;margin:0}.trace strong{color:var(--ink);font-size:.82rem}.trace small{color:var(--muted);font-size:.74rem;line-height:1.35}.button{min-height:46px;border-radius:8px;padding:0 18px;font-weight:730}.button.primary{border:1px solid var(--ink);color:#fff;background:var(--ink)}.button.primary:hover:not(:disabled){background:#34342e}.button.secondary{border:1px solid #aaa397;color:var(--ink);background:var(--paper)}.action-help{color:var(--muted);font-size:.82rem;line-height:1.5}.error{color:var(--error);font-weight:650}.receipt{display:block;margin:16px 0;color:var(--accent-dark);font-weight:700}.notice{position:fixed;right:20px;bottom:20px;z-index:5;max-width:min(440px,calc(100vw - 40px));padding:14px 16px;border:1px solid #bcb4a7;border-radius:10px;background:var(--paper);box-shadow:0 12px 30px rgb(20 18 12/14%);font-size:.875rem}.notice[data-busy=true]{border-color:var(--accent)}footer{max-width:1180px;margin:0 auto;padding:24px;display:flex;justify-content:space-between;gap:24px;border-top:1px solid var(--line);color:var(--muted);font-size:.82rem}@media(max-width:800px){main{padding-top:48px}.content-grid{grid-template-columns:1fr}.status-panel{position:static;order:-1}fieldset{grid-template-columns:1fr}}@media(max-width:520px){.site-header{padding:0 16px}.network{font-size:0}main{padding:36px 16px 120px}.role-tabs{display:grid;grid-template-columns:1fr 1fr;width:100%}.journey{padding:22px 18px}.wallet-row{align-items:flex-start;flex-direction:column}.wallet-row .button,.button-row .button{width:100%}.metrics{grid-template-columns:1fr}.notice{position:static;margin:0 16px 24px;max-width:none}footer{padding:24px 16px;flex-direction:column}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;transition:none!important;animation:none!important}} +/* The labelled checkbox is the touch target, not the 13px native glyph. */ +.ack{min-height:44px;align-items:center} + +/* Keep every navigational link at the same minimum touch target as actions. */ +.brand,footer a{min-height:44px} +footer a{display:inline-flex;align-items:center} diff --git a/web/src/wallet.ts b/web/src/wallet.ts new file mode 100644 index 0000000..e67d02c --- /dev/null +++ b/web/src/wallet.ts @@ -0,0 +1,98 @@ +import { createStore } from "@starknet-io/get-starknet-discovery"; +import { WalletAccountV6, walletV6, num, type RpcProvider } from "starknet"; +import type { STRK20_ACTION } from "@starknet-io/types-js"; +import type { PreparedCallAndProof } from "../../client/src/actions.ts"; +import { CHAIN_ID, READY_VERSION } from "./config.ts"; + +type ReadyWallet = ReturnType["getWallets"]>[number]; + +export interface ReadySession { + name: string; + version: string; + address: string; + chainId: string; + balance(token: string): Promise; + invoke(actions: readonly STRK20_ACTION[]): Promise; + prepare(actions: readonly STRK20_ACTION[], simulate: boolean): Promise; + disconnect(): void; +} + +const store = createStore({ eip1193Adapters: [] }); + +function walletFeature(wallet: ReadyWallet): { walletVersion?: unknown; request?: unknown } | undefined { + return wallet.features["starknet:walletApi"] as { walletVersion?: unknown; request?: unknown } | undefined; +} + +export function detectReady(): { found: boolean; version?: string } { + const wallet = findReady(); + const feature = wallet ? walletFeature(wallet) : undefined; + return wallet && typeof feature?.request === "function" + ? { found: true, version: String(feature.walletVersion ?? "") } + : { found: false }; +} + +function findReady(): ReadyWallet | undefined { + return store.getWallets().find((wallet) => /^(?:ready|readyx)$/i.test(wallet.name.replace(/[^a-z0-9]/gi, ""))); +} + +export async function connectReady(provider: RpcProvider, onChanged: () => void): Promise { + const wallet = findReady(); + if (!wallet) throw new Error("Ready X was not detected in this browser profile."); + const feature = walletFeature(wallet); + if (typeof feature?.request !== "function") throw new Error("Ready X does not expose its Wallet API."); + if (feature.walletVersion !== READY_VERSION) throw new Error(`Ready X ${READY_VERSION} is required; detected ${String(feature.walletVersion)}.`); + // This function runs only from the explicit Connect Ready X button. Use the + // interactive Wallet Standard flow so a profile that has not authorized + // Afterlight yet can actually approve the connection. Silent mode is only + // suitable for restoring an authorization that already exists. + const account = await WalletAccountV6.connect(provider, wallet as never); + const chainId = num.toHex(BigInt(await walletV6.requestChainId(wallet as never))); + const accounts = await walletV6.requestAccounts(wallet as never, true); + if (chainId !== CHAIN_ID) throw new Error("Switch Ready X to Starknet Mainnet."); + if (accounts.length !== 1) throw new Error("Ready X must expose exactly one selected account."); + const address = num.toHex(BigInt(accounts[0]!)); + if (num.toHex(BigInt(account.address)) !== address) throw new Error("Ready X returned conflicting account identities."); + const unsubscribe = walletV6.subscribeWalletEvent(wallet as never, onChanged); + + async function assertFresh(): Promise { + const [freshAccounts, freshChain] = await Promise.all([ + walletV6.requestAccounts(wallet as never, true), + walletV6.requestChainId(wallet as never), + ]); + if (freshAccounts.length !== 1 || num.toHex(BigInt(freshAccounts[0]!)) !== address || num.toHex(BigInt(freshChain)) !== CHAIN_ID) { + throw new Error("Ready account or network changed. Reconnect before continuing."); + } + } + + return { + name: wallet.name, + version: READY_VERSION, + address, + chainId, + balance: async (token) => { + await assertFresh(); + const raw = await account.strk20Balances([token] as never) as unknown; + if (!Array.isArray(raw)) throw new Error("Ready returned an invalid private-balance response."); + const match = raw.find((entry) => { + if (!entry || typeof entry !== "object") return false; + const record = entry as Record; + const candidate = record.token ?? record.token_address ?? record[0]; + try { return num.toHex(BigInt(String(candidate))) === num.toHex(BigInt(token)); } catch { return false; } + }) as Record | undefined; + if (!match) throw new Error("Ready did not return the requested STRK balance."); + const amount = match.amount ?? match.balance ?? match[1]; + return BigInt(String(amount)); + }, + invoke: async (actions) => { + await assertFresh(); + const response = await account.strk20InvokeTransaction([...actions] as never); + if (!response.transaction_hash) throw new Error("Ready did not return a transaction hash."); + return num.toHex(BigInt(response.transaction_hash)); + }, + prepare: async (actions, simulate) => { + await assertFresh(); + return account.strk20PrepareInvoke([...actions] as never, simulate); + }, + disconnect: unsubscribe, + }; +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..0f006d9 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUncheckedIndexedAccess": true + }, + "include": ["src", "../client/src"] +}