From 7c7acd52b21cc5cf1c01bd1fd5ab62b2607c42e6 Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 15:54:57 +0100 Subject: [PATCH 01/38] Publish Afterlight mainnet evidence --- strk20.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 strk20.json diff --git a/strk20.json b/strk20.json new file mode 100644 index 0000000..fcc36a6 --- /dev/null +++ b/strk20.json @@ -0,0 +1,11 @@ +{ + "transactions": [ + "0x030ea14ac22e5806e382658971b686692af280bf2f02173a430f572921121722", + "0x69e2345ae8816986a709de84f0dcb571b5d092400d6c53bf90197480102c0fb", + "0x036e003396fe360ae7fe4766646f493c0eb579d82509652559d40e460770682a", + "0x11c990aea864e755630d41fd1292620c313b3f64407fc0b3a902544c67c8098" + ], + "contracts": [ + "0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25" + ] +} From a4269430566768b633dfb7b4278da7cee9311098 Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 16:07:58 +0100 Subject: [PATCH 02/38] Publish deployed Afterlight mechanism --- .gitignore | 5 + README.md | 8 +- client/README.md | 15 +- client/src/actions.ts | 315 +++++++++++++++++++++-- client/test/actions.test.ts | 150 ++++++++++- client/test/exit-preflight.test.ts | 2 +- client/tools/mainnet-operator.html | 2 +- client/tools/mainnet-operator.ts | 37 ++- client/tools/quote-declare-deploy.mjs | 10 +- client/tools/verify-locked-artifacts.mjs | 8 +- docs/ARCHITECTURE.md | 27 +- docs/MAINNET.md | 39 +-- docs/READY_X_ONBOARDING.md | 9 +- docs/THREAT_MODEL.md | 10 +- relayer/src/budget.ts | 32 +++ relayer/src/core.ts | 18 +- relayer/src/executor.ts | 19 ++ relayer/src/index.ts | 49 +++- relayer/test/executor.spec.ts | 21 ++ relayer/test/relayer.spec.ts | 40 +++ relayer/vitest.config.ts | 4 +- relayer/worker-configuration.d.ts | 16 +- relayer/wrangler.jsonc | 17 +- src/afterlight.cairo | 29 --- src/common.cairo | 1 - tests/test_afterlight.cairo | 80 +----- 26 files changed, 752 insertions(+), 211 deletions(-) diff --git a/.gitignore b/.gitignore index 6d180e7..8952a75 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,14 @@ client/node_modules/ client/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/README.md b/README.md index ec42391..f5cc0e2 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ An owner privately funds a fixed reserve through STRK20 and remains in control t ## 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: E1 pending one balance-visibility check.** The complete two-branch recovery mechanism has run on Starknet Mainnet. Four successful transactions touch both the canonical STRK20 pool and Afterlight, including exact-note private cancellation and successor recovery. The contract, client, and neutral-relayer controls also have deterministic local coverage. Promotion to E2 remains intentionally blocked until Ready X exposes the successor's post-claim shielded balance after finality. -**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 mechanism, public product pending.** Afterlight is deployed at `0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25`. Vault A completed `FUND -> CANCEL_REFUND`; Vault B completed early refusal, heartbeat, request, veto, second request, and `CLAIM`. The bounded neutral relayer executed the control path without using either Ready role as the outer sender and is submission-disabled after the spike. A public user-ready interface and E3 completion do not exist yet. ## Recovery flow @@ -41,8 +41,8 @@ Afterlight does not claim legal inheritance, invisible authorization keys, proof - `relayer/` — fail-closed neutral-relayer implementation and operational controls - `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 diff --git a/client/README.md b/client/README.md index c54c97d..66bb8c4 100644 --- a/client/README.md +++ b/client/README.md @@ -33,6 +33,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 +52,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 +63,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 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..da1174a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,7 +8,7 @@ Afterlight separates private value movement, application authorization, and tran |---|---|---| | Ready X and STRK20 | Shielded balances, private invocation, proof preparation, and exact open-note settlement | Owner or successor application secrets outside the user's device | | Afterlight Cairo contract | Vault state, signed authorization, liabilities, timing, exact-note binding, and terminal settlement | Ready wallet identity assumptions | -| Client library | Per-vault key generation, typed authorization hashes, STRK20 action assembly, and E1 proof-envelope/call binding | Relayer account key | +| 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`, and checkpoint transactions from one neutral account | Owner/successor secrets, Ready addresses, or authority over contract state | ## Action routing @@ -17,6 +17,15 @@ 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. +Ready-managed private exits retain the literal role-signed destination note in +the helper invocation while Ready creates the corresponding OPEN note and pays +its fee through the observed private sponsor envelope. After inclusion, the +client independently parses the actual outer transaction and rejects a Ready +role address as sender, sponsor-envelope drift, extra public transfers, a +different note or helper call, a failed receipt, or incorrect shielded-balance +and liability deltas. This is a post-receipt check; it does not turn preparation +into execution evidence. + ```text Ready X + STRK20 pool |-- FUND ------------------------------> Afterlight liability + ACTIVE vault @@ -63,19 +72,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 vaults completed the cancellation and recovery branches, and all four STRK20 receipts are listed in `strk20.json`. The public product UI remains outside this release, and E2 promotion still requires Ready X to expose the successor's post-claim shielded balance after finality. diff --git a/docs/MAINNET.md b/docs/MAINNET.md index 9a742bb..b36012a 100644 --- a/docs/MAINNET.md +++ b/docs/MAINNET.md @@ -2,10 +2,11 @@ ## 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 mechanism and four validator-qualified +STRK20 receipts. It remains classified **E1 pending one visibility check**: +Ready X has not yet exposed the successor's post-claim shielded balance through +`wallet_strk20Balances`. Prepared, simulated, reverted, and unrelated pool +transactions are not counted as successful evidence. ## Pinned Starknet Mainnet dependencies @@ -26,11 +27,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 | `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 +39,20 @@ 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 | `0x030ea14ac22e5806e382658971b686692af280bf2f02173a430f572921121722` | PASS | +| `CANCEL_REFUND` Vault A | `0x69e2345ae8816986a709de84f0dcb571b5d092400d6c53bf90197480102c0fb` | PASS | +| `FUND` Vault B | `0x036e003396fe360ae7fe4766646f493c0eb579d82509652559d40e460770682a` | PASS | +| `CLAIM` Vault B | `0x11c990aea864e755630d41fd1292620c313b3f64407fc0b3a902544c67c8098` | PASS | + +The official hub validator's exact success, pool-touch, and declared-contract +ownership checks pass for all four. 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 remaining promotion gap is +the wallet's post-finality display of the successor's expected `1 STRK` +shielded balance. A public demo URL and final video remain pending. diff --git a/docs/READY_X_ONBOARDING.md b/docs/READY_X_ONBOARDING.md index 668ba8d..c977cdf 100644 --- a/docs/READY_X_ONBOARDING.md +++ b/docs/READY_X_ONBOARDING.md @@ -2,10 +2,11 @@ ## 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 exercised by the deployed Afterlight Mainnet mechanism. +It documents prerequisites and the user boundary; it is not yet a public-product +runbook or a standing instruction to fund or sign. The contract and a complete +founder-operated lifecycle exist, while the public UI and unrelated-user E3/E4 +completion remain pending. ## Accounts and STRK20 prerequisites diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index baef506..b44989c 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. The founder-controlled wallets share historical public funding correlation, so this run does not prove historically unrelated participants. Ready X's post-claim balance view remains pending after finality; no stronger E2/E4 claim is made until that evidence exists. 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..0109d51 100644 --- a/relayer/src/core.ts +++ b/relayer/src/core.ts @@ -271,7 +271,7 @@ export function requireRelayHeaders(request: Request, env: Env): void { } } -export function requireCheckpointHeaders(request: Request, env: Env): void { +export async function requireCheckpointHeaders(request: Request, env: Env): Promise { if (request.headers.get("origin") !== env.ALLOWED_ORIGIN) { throw new RelayHttpError(403, "origin_not_allowed"); } @@ -279,9 +279,23 @@ export function requireCheckpointHeaders(request: Request, env: Env): void { 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 { 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/index.ts b/relayer/src/index.ts index 8228510..536727a 100644 --- a/relayer/src/index.ts +++ b/relayer/src/index.ts @@ -53,12 +53,17 @@ export default { } let plan: RelayPlan; + let estimateOnly = false; if (url.pathname === CHECKPOINT_PATH) { - requireCheckpointHeaders(request, env); + 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 +77,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(env.ALLOWED_ORIGIN), + ); + } + if (isSubmissionEnabled(env.SUBMIT_ENABLED)) { const readiness = executorReadiness(env); if (!readiness.executable) throw new RelayHttpError(503, "executor_unavailable"); @@ -108,11 +134,12 @@ export default { corsHeaders(env.ALLOWED_ORIGIN), ); } 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) + : 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( @@ -134,6 +161,22 @@ export default { }, } 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); 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/relayer.spec.ts b/relayer/test/relayer.spec.ts index c7cccef..ce20f88 100644 --- a/relayer/test/relayer.spec.ts +++ b/relayer/test/relayer.spec.ts @@ -67,6 +67,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 +119,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", { 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..ede494f 100644 --- a/relayer/worker-configuration.d.ts +++ b/relayer/worker-configuration.d.ts @@ -1,26 +1,26 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 3c1d9a9dac6f1eb578016fdc508f6866) +// Generated by Wrangler by running `wrangler types` (hash: 4120a3412f216003af8ef0eb6b90036e) // Runtime types generated with workerd@1.20260820.1 2026-08-24 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"; + DEPLOYMENT_STAGE: "phase-b-live-spike"; + ALLOWED_ORIGIN: "http://127.0.0.1:43117"; + 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"; SPONSOR_FEE_MARGIN_BPS: "11000"; MIN_RELAYER_BALANCE_FRI: "1000000000000000000"; - DEPLOYMENT_ID: "phase-a-local"; - RELAYER_ACCOUNT_ADDRESS: "0x0"; + DEPLOYMENT_ID: "afterlight-mainnet-spike-20260827-v2"; + RELAYER_ACCOUNT_ADDRESS: "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46"; RELAYER_ACCOUNT_CAIRO_VERSION: "1"; - STARKNET_RPC_URL: "https://rpc.invalid"; + STARKNET_RPC_URL: "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_10"; SUBMIT_ENABLED: "false"; RELAYER_ACCOUNT_PRIVATE_KEY: string; STARKNET_RPC_AUTH_TOKEN: string; diff --git a/relayer/wrangler.jsonc b/relayer/wrangler.jsonc index fd0c543..28012db 100644 --- a/relayer/wrangler.jsonc +++ b/relayer/wrangler.jsonc @@ -5,25 +5,22 @@ "compatibility_date": "2026-08-24", "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": "http://127.0.0.1:43117", + "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", "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-spike-20260827-v2", + "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. + "STARKNET_RPC_URL": "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_10", "SUBMIT_ENABLED": "false" }, // Names only: values must be installed with `wrangler secret put` after promotion. 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/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() { From 0c23470156abb3bfac958fb179394b10c8462ed2 Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 16:08:42 +0100 Subject: [PATCH 03/38] Monitor the post-spike relayer state --- .github/workflows/relayer-health.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/relayer-health.yml b/.github/workflows/relayer-health.yml index 70b6a9a..f50a39b 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 disabled submission and privacy boundary runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -41,8 +41,9 @@ jobs: .service == "afterlight-neutral-relayer" and .schema == "afterlight-relay/1" and .submission == "disabled" and - .executor.configurationReady == false and - .executor.executable == false and + .executor.configurationReady == true and + .executor.signerAdapterAvailable == true and + .executor.executable == true and .balance.status == "disabled" and .privacy.payloadLogging == false and .privacy.appKeysHeld == false and From 94d9e42c91ea9964edef3055384eb9bfb71b91af Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 16:10:43 +0100 Subject: [PATCH 04/38] Define the user-first recovery interface --- DESIGN.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 DESIGN.md diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..5dfea57 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,46 @@ +--- +version: alpha +name: Afterlight +description: User-first interaction guidance for a private Starknet recovery reserve. +omitted: + - section: colors + reason: No public product palette has been selected. + - section: typography + reason: No public product type system has been selected. + - section: spacing + reason: No shared product spacing tokens exist yet. + - section: rounded + reason: No shared product shape tokens exist yet. + - section: components + reason: Product components have not been implemented yet. +--- + +## 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. + +## 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. From e7bd2e8ee6e41b5637992ac640302d4b79657706 Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 16:21:28 +0100 Subject: [PATCH 05/38] Align client docs with mainnet evidence --- client/README.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/client/README.md b/client/README.md index 66bb8c4..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`. @@ -81,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, From 5655e4251f0dc2b0568e81e9faf0fa46717001f8 Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 16:28:49 +0100 Subject: [PATCH 06/38] Link the verified mainnet release evidence --- README.md | 2 +- docs/MAINNET.md | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f5cc0e2..5c6b095 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ An owner privately funds a fixed reserve through STRK20 and remains in control t **Evidence level: E1 pending one balance-visibility check.** The complete two-branch recovery mechanism has run on Starknet Mainnet. Four successful transactions touch both the canonical STRK20 pool and Afterlight, including exact-note private cancellation and successor recovery. The contract, client, and neutral-relayer controls also have deterministic local coverage. Promotion to E2 remains intentionally blocked until Ready X exposes the successor's post-claim shielded balance after finality. -**Release status: deployed mechanism, public product pending.** Afterlight is deployed at `0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25`. Vault A completed `FUND -> CANCEL_REFUND`; Vault B completed early refusal, heartbeat, request, veto, second request, and `CLAIM`. The bounded neutral relayer executed the control path without using either Ready role as the outer sender and is submission-disabled after the spike. A public user-ready interface and E3 completion do not exist yet. +**Release status: deployed mechanism, public product pending.** Afterlight is [deployed on Mainnet](https://voyager.online/contract/0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25). Vault A completed `FUND -> CANCEL_REFUND`; Vault B completed early refusal, heartbeat, request, veto, second request, and `CLAIM`. The bounded neutral relayer executed the control path without using either Ready role as the outer sender and is submission-disabled after the spike. A public user-ready interface and E3 completion do not exist yet. ## Recovery flow diff --git a/docs/MAINNET.md b/docs/MAINNET.md index b36012a..72029ca 100644 --- a/docs/MAINNET.md +++ b/docs/MAINNET.md @@ -29,7 +29,7 @@ artifact-identity proof for the live pool class. | Compiler profile | `spike-inline-56` | Reproduces locally and in CI | | Sierra class hash | `0x05da9866f62cc6dd1e380e8d9206e78a752b460abdb802070e0be1208ec7b1a6` | Declared on Mainnet | | CASM/compiled class hash | `0x055ba10e36aac8e21b3437f1413f009f6b17d3633c307941a4412ce73566251` | Declaration lock | -| Afterlight contract | `0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25` | Deployed on Mainnet | +| Afterlight contract | [`0x06e8…61c25`](https://voyager.online/contract/0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25) | Deployed on Mainnet | | Neutral relayer account | `0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46` | Deployed; spike controls complete | Run `scarb --profile spike-inline-56 build` and @@ -45,14 +45,15 @@ contract. Plain Shield transactions and failed attempts do not fill these slots. | Required branch | Mainnet transaction hash | Validator result | |---|---|---| -| `FUND` Vault A | `0x030ea14ac22e5806e382658971b686692af280bf2f02173a430f572921121722` | PASS | -| `CANCEL_REFUND` Vault A | `0x69e2345ae8816986a709de84f0dcb571b5d092400d6c53bf90197480102c0fb` | PASS | -| `FUND` Vault B | `0x036e003396fe360ae7fe4766646f493c0eb579d82509652559d40e460770682a` | PASS | -| `CLAIM` Vault B | `0x11c990aea864e755630d41fd1292620c313b3f64407fc0b3a902544c67c8098` | PASS | +| `FUND` Vault A | [`0x030ea1…21722`](https://voyager.online/tx/0x030ea14ac22e5806e382658971b686692af280bf2f02173a430f572921121722) | PASS | +| `CANCEL_REFUND` Vault A | [`0x69e234…c0fb`](https://voyager.online/tx/0x69e2345ae8816986a709de84f0dcb571b5d092400d6c53bf90197480102c0fb) | PASS | +| `FUND` Vault B | [`0x036e00…0682a`](https://voyager.online/tx/0x036e003396fe360ae7fe4766646f493c0eb579d82509652559d40e460770682a) | PASS | +| `CLAIM` Vault B | [`0x11c990…c8098`](https://voyager.online/tx/0x11c990aea864e755630d41fd1292620c313b3f64407fc0b3a902544c67c8098) | PASS | The official hub validator's exact success, pool-touch, and declared-contract ownership checks pass for all four. 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 remaining promotion gap is -the wallet's post-finality display of the successor's expected `1 STRK` -shielded balance. A public demo URL and final video remain pending. +the wallet's post-finality reconciliation from `6 STRK` to `1 STRK`: the exact +`+1 STRK` recovery output minus the exact `6 STRK` private action fee. A public +demo URL and final video remain pending. From 46cd481540513d3971833181e5e5766ba4a5e6c4 Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 16:29:26 +0100 Subject: [PATCH 07/38] Use accessible mainnet evidence links --- README.md | 2 +- docs/MAINNET.md | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 5c6b095..716a14e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ An owner privately funds a fixed reserve through STRK20 and remains in control t **Evidence level: E1 pending one balance-visibility check.** The complete two-branch recovery mechanism has run on Starknet Mainnet. Four successful transactions touch both the canonical STRK20 pool and Afterlight, including exact-note private cancellation and successor recovery. The contract, client, and neutral-relayer controls also have deterministic local coverage. Promotion to E2 remains intentionally blocked until Ready X exposes the successor's post-claim shielded balance after finality. -**Release status: deployed mechanism, public product pending.** Afterlight is [deployed on Mainnet](https://voyager.online/contract/0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25). Vault A completed `FUND -> CANCEL_REFUND`; Vault B completed early refusal, heartbeat, request, veto, second request, and `CLAIM`. The bounded neutral relayer executed the control path without using either Ready role as the outer sender and is submission-disabled after the spike. A public user-ready interface and E3 completion do not exist yet. +**Release status: deployed mechanism, public product pending.** Afterlight is [deployed on Mainnet](https://starkscan.co/contract/0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25). Vault A completed `FUND -> CANCEL_REFUND`; Vault B completed early refusal, heartbeat, request, veto, second request, and `CLAIM`. The bounded neutral relayer executed the control path without using either Ready role as the outer sender and is submission-disabled after the spike. A public user-ready interface and E3 completion do not exist yet. ## Recovery flow diff --git a/docs/MAINNET.md b/docs/MAINNET.md index 72029ca..746a68d 100644 --- a/docs/MAINNET.md +++ b/docs/MAINNET.md @@ -29,7 +29,7 @@ artifact-identity proof for the live pool class. | Compiler profile | `spike-inline-56` | Reproduces locally and in CI | | Sierra class hash | `0x05da9866f62cc6dd1e380e8d9206e78a752b460abdb802070e0be1208ec7b1a6` | Declared on Mainnet | | CASM/compiled class hash | `0x055ba10e36aac8e21b3437f1413f009f6b17d3633c307941a4412ce73566251` | Declaration lock | -| Afterlight contract | [`0x06e8…61c25`](https://voyager.online/contract/0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25) | Deployed on Mainnet | +| 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 @@ -45,10 +45,10 @@ contract. Plain Shield transactions and failed attempts do not fill these slots. | Required branch | Mainnet transaction hash | Validator result | |---|---|---| -| `FUND` Vault A | [`0x030ea1…21722`](https://voyager.online/tx/0x030ea14ac22e5806e382658971b686692af280bf2f02173a430f572921121722) | PASS | -| `CANCEL_REFUND` Vault A | [`0x69e234…c0fb`](https://voyager.online/tx/0x69e2345ae8816986a709de84f0dcb571b5d092400d6c53bf90197480102c0fb) | PASS | -| `FUND` Vault B | [`0x036e00…0682a`](https://voyager.online/tx/0x036e003396fe360ae7fe4766646f493c0eb579d82509652559d40e460770682a) | PASS | -| `CLAIM` Vault B | [`0x11c990…c8098`](https://voyager.online/tx/0x11c990aea864e755630d41fd1292620c313b3f64407fc0b3a902544c67c8098) | PASS | +| `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 | The official hub validator's exact success, pool-touch, and declared-contract ownership checks pass for all four. Vault A is `CANCELLED`, Vault B is From 0cb762c4cf93c07f7a8b7351f6e248b666ae6191 Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 19:41:35 +0100 Subject: [PATCH 08/38] Ship public Afterlight recovery product --- .github/workflows/ci.yml | 22 + .gitignore | 2 + DESIGN.md | 34 +- README.md | 14 +- docs/ARCHITECTURE.md | 19 +- docs/MAINNET.md | 17 +- docs/READY_X_ONBOARDING.md | 13 +- docs/THREAT_MODEL.md | 2 +- relayer/OPERATIONS.md | 18 +- relayer/README.md | 28 +- relayer/package-lock.json | 174 +- relayer/package.json | 2 +- relayer/src/exit-executor.ts | 207 +++ relayer/src/index.ts | 41 +- relayer/src/neutral-exit-policy.d.mts | 49 + relayer/src/neutral-exit-policy.mjs | 986 ++++++++++++ relayer/test/relayer.spec.ts | 41 + relayer/worker-configuration.d.ts | 234 ++- relayer/wrangler.jsonc | 12 +- relayer/wrangler.staging.jsonc | 8 +- web/index.html | 17 + web/package-lock.json | 2151 +++++++++++++++++++++++++ web/package.json | 21 + web/src/chain.ts | 47 + web/src/config.ts | 12 + web/src/main.ts | 279 ++++ web/src/model.ts | 65 + web/src/operations.ts | 311 ++++ web/src/style.css | 3 + web/src/wallet.ts | 94 ++ web/tsconfig.json | 17 + 31 files changed, 4851 insertions(+), 89 deletions(-) create mode 100644 relayer/src/exit-executor.ts create mode 100644 relayer/src/neutral-exit-policy.d.mts create mode 100644 relayer/src/neutral-exit-policy.mjs create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/chain.ts create mode 100644 web/src/config.ts create mode 100644 web/src/main.ts create mode 100644 web/src/model.ts create mode 100644 web/src/operations.ts create mode 100644 web/src/style.css create mode 100644 web/src/wallet.ts create mode 100644 web/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db5478e..90ae906 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,3 +121,25 @@ 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 + - run: npm run build + - run: npm audit --audit-level=high diff --git a/.gitignore b/.gitignore index 8952a75..6ba0662 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ 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.* diff --git a/DESIGN.md b/DESIGN.md index 5dfea57..189ebd5 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -2,23 +2,43 @@ 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: colors - reason: No public product palette has been selected. - - section: typography - reason: No public product type system has been selected. - section: spacing - reason: No shared product spacing tokens exist yet. + reason: Layout spacing is evidenced, but no named shared spacing scale exists. - section: rounded - reason: No shared product shape tokens exist yet. + reason: Repeated radii exist, but no named shared radius scale exists. - section: components - reason: Product components have not been implemented yet. + 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. diff --git a/README.md b/README.md index 716a14e..2b26ece 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ An owner privately funds a fixed reserve through STRK20 and remains in control t ## Current status -**Evidence level: E1 pending one balance-visibility check.** The complete two-branch recovery mechanism has run on Starknet Mainnet. Four successful transactions touch both the canonical STRK20 pool and Afterlight, including exact-note private cancellation and successor recovery. The contract, client, and neutral-relayer controls also have deterministic local coverage. Promotion to E2 remains intentionally blocked until Ready X exposes the successor's post-claim shielded balance after finality. +**Evidence level: E2 observable replay.** The complete two-branch recovery mechanism has run on Starknet Mainnet. Four successful transactions touch both the canonical STRK20 pool and Afterlight, including exact-note private cancellation and successor recovery. After L1 finality, a fresh Ready X read showed the successor's shielded balance increase from `6 STRK` to `7 STRK`, reconciling the exact `1 STRK` recovery output while the neutral sponsor paid the separate pool fee. -**Release status: deployed mechanism, public product pending.** Afterlight is [deployed on Mainnet](https://starkscan.co/contract/0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25). Vault A completed `FUND -> CANCEL_REFUND`; Vault B completed early refusal, heartbeat, request, veto, second request, and `CLAIM`. The bounded neutral relayer executed the control path without using either Ready role as the outer sender and is submission-disabled after the spike. A public user-ready interface and E3 completion do not exist yet. +**Release status: deployed mechanism and public-product candidate; fresh E3 pending.** Afterlight is [deployed on Mainnet](https://starkscan.co/contract/0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25). Vault A completed `FUND -> CANCEL_REFUND`; Vault B completed early refusal, heartbeat, request, veto, second request, and `CLAIM`. The bounded neutral relayer executed the control path without using either Ready role as the outer sender. The public app now supports real Ready X connection, local per-vault keys, private funding, live state, relayed controls, exact-note recovery, and post-claim balance reconciliation. It is not classified E3 until a fresh user completes that path through the deployed interface. ## Recovery flow @@ -39,6 +39,7 @@ 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` — Ready X prerequisites and owner/successor flow @@ -74,13 +75,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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index da1174a..128d0ed 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -9,7 +9,7 @@ Afterlight separates private value movement, application authorization, and tran | Ready X and STRK20 | Shielded balances, private invocation, proof preparation, and exact open-note settlement | Owner or successor application secrets outside the user's device | | Afterlight Cairo contract | Vault state, signed authorization, liabilities, timing, exact-note binding, and terminal settlement | Ready wallet identity assumptions | | Client library | Per-vault key generation, typed authorization hashes, STRK20 action assembly, proof-envelope/call binding, and independent managed-exit receipt reconciliation | Relayer account key | -| Neutral relayer | Submit bounded `HEARTBEAT`, `REQUEST`, `VETO`, and checkpoint transactions from one neutral account | Owner/successor secrets, Ready addresses, or authority over contract state | +| Neutral relayer | Submit bounded `HEARTBEAT`, `REQUEST`, `VETO`, checkpoints, and strictly validated exact-note claim packages from one neutral account | Owner/successor secrets, Ready addresses, or authority over contract state | ## Action routing @@ -17,14 +17,13 @@ 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. -Ready-managed private exits retain the literal role-signed destination note in -the helper invocation while Ready creates the corresponding OPEN note and pays -its fee through the observed private sponsor envelope. After inclusion, the -client independently parses the actual outer transaction and rejects a Ready -role address as sender, sponsor-envelope drift, extra public transfers, a -different note or helper call, a failed receipt, or incorrect shielded-balance -and liability deltas. This is a post-receipt check; it does not turn preparation -into execution evidence. +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 @@ -83,4 +82,4 @@ recovery of tokens sent directly to the helper. ## Current release boundary -The deployed Mainnet release is `0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25`. Two vaults completed the cancellation and recovery branches, and all four STRK20 receipts are listed in `strk20.json`. The public product UI remains outside this release, and E2 promotion still requires Ready X to expose the successor's post-claim shielded balance after finality. +The deployed Mainnet release is `0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25`. Two vaults completed the cancellation and recovery branches, and all four STRK20 receipts are listed in `strk20.json`. A fresh Ready X read reconciled the successor's shielded balance from `6 STRK` to `7 STRK`, so the mechanism is E2. The public UI and neutral claim route are deployed candidates; E3 requires one fresh end-to-end completion through that interface. diff --git a/docs/MAINNET.md b/docs/MAINNET.md index 746a68d..ddd2cd4 100644 --- a/docs/MAINNET.md +++ b/docs/MAINNET.md @@ -3,10 +3,11 @@ ## Current evidence level Afterlight has a complete deployed Mainnet mechanism and four validator-qualified -STRK20 receipts. It remains classified **E1 pending one visibility check**: -Ready X has not yet exposed the successor's post-claim shielded balance through -`wallet_strk20Balances`. Prepared, simulated, reverted, and unrelated pool -transactions are not counted as successful evidence. +STRK20 receipts. It is classified **E2 observable replay**. After L1 finality, +a fresh approved Ready X `wallet_strk20Balances` read showed the successor at +`7 STRK`, compared with the verified `6 STRK` pre-claim balance. Prepared, +simulated, reverted, and unrelated pool transactions are not counted as +successful evidence. ## Pinned Starknet Mainnet dependencies @@ -53,7 +54,7 @@ contract. Plain Shield transactions and failed attempts do not fill these slots. The official hub validator's exact success, pool-touch, and declared-contract ownership checks pass for all four. 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 remaining promotion gap is -the wallet's post-finality reconciliation from `6 STRK` to `1 STRK`: the exact -`+1 STRK` recovery output minus the exact `6 STRK` private action fee. A public -demo URL and final video remain pending. +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. A fresh E3 public-interface lifecycle and final video remain pending. diff --git a/docs/READY_X_ONBOARDING.md b/docs/READY_X_ONBOARDING.md index c977cdf..6602215 100644 --- a/docs/READY_X_ONBOARDING.md +++ b/docs/READY_X_ONBOARDING.md @@ -69,7 +69,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. @@ -83,12 +83,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 b44989c..04c1ee2 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -98,4 +98,4 @@ donations to the helper expecting a refund; unaccounted surplus remains inert. 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 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. The founder-controlled wallets share historical public funding correlation, so this run does not prove historically unrelated participants. Ready X's post-claim balance view remains pending after finality; no stronger E2/E4 claim is made until that evidence exists. +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 post-finality Ready X read proved the successor balance increased from `6 STRK` to `7 STRK`. The founder-controlled wallets share historical public funding correlation, so this is E2 engineering evidence, not proof of historically unrelated participants or E4 independent completion. diff --git a/relayer/OPERATIONS.md b/relayer/OPERATIONS.md index 5b35690..f4d0c59 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,12 +32,12 @@ 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. 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/exit-executor.ts b/relayer/src/exit-executor.ts new file mode 100644 index 0000000..98b0d56 --- /dev/null +++ b/relayer/src/exit-executor.ts @@ -0,0 +1,207 @@ +import { Account, RpcProvider, transaction, type Call } from "starknet"; +import type { BudgetCoordinator } from "./executor.js"; +import { + addResourceMargins, + assertOuterSignatureMatchesHash, + assertProofFreshness, + assertSignedExitTransaction, + normalizeHex, + parseResourceBounds, + parseU256Result, + parseVaultResult, + 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", + maxNetworkFeePerExitFri: "9027538581262736234", + amountMarginBps: "10300", + priceMarginBps: "10300", + 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"); } + + const semanticKey = `exit:${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({ + nodeUrl: env.STARKNET_RPC_URL, + headers: { authorization: `Bearer ${env.STARKNET_RPC_AUTH_TOKEN}` }, + plugins: false, + }); + if (normalizeHex(await provider.getChainId()) !== normalizeHex(EXIT_POLICY.chainId)) throw new ExitExecutorError("exit_unavailable"); + const account = new Account({ provider, address: NEUTRAL, signer: env.RELAYER_ACCOUNT_PRIVATE_KEY, cairoVersion: "1", transactionVersion: "0x3", plugins: false }); + const policy = validatePolicy(EXIT_POLICY); + const estimateBlock = await provider.getBlockWithTxHashes("latest"); + 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); + const snapshot = await readSnapshot(provider, validated, blockNumber, blockTimestamp); + validateAuthorizationInclusionWindow(validated.metadata.validUntil, blockTimestamp, BigInt(Math.floor(Date.now() / 1_000))); + 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) }; + let estimate; + try { + estimate = await account.estimateInvokeFee(call, { + nonce: snapshot.nonce, + blockIdentifier: Number(blockNumber), + skipValidate: false, + proof: validated.proof.data, + proofFacts, + tip: 0, + paymasterData: [], + accountDeploymentData: [], + nonceDataAvailabilityMode: "L1", + feeDataAvailabilityMode: "L1", + }); + } catch { throw new ExitExecutorError("exit_unavailable"); } + const sourceBounds = parseResourceBounds(estimate.resourceBounds); + const bounds = addResourceMargins(sourceBounds, policy.amountMarginBps, policy.priceMarginBps); + const networkCap = resourceCapFri(bounds); + if (networkCap > policy.networkCapFri) throw new ExitExecutorError("exit_unavailable"); + validateBalanceForExit(snapshot.balance, networkCap, BigInt(EXIT_POLICY.postSpendHealthFloorFri)); + + 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; + try { + const freshNonce = BigInt(await account.getNonce("pre_confirmed")); + 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"); + await readSnapshot(provider, validated, BigInt(freshBlock.block_number), BigInt(freshBlock.timestamp)); + signed = await account.getSignedTransaction(call, { + nonce: snapshot.nonce, + resourceBounds: bounds, + tip: 0, + paymasterData: [], + accountDeploymentData: [], + nonceDataAvailabilityMode: "L1", + feeDataAvailabilityMode: "L1", + proof: validated.proof.data, + proofFacts, + }); + assertSignedExitTransaction(signed, { + nonce: snapshot.nonce, + executeCalldata: transaction.getExecuteCalldata([call], "1"), + proof: validated.proof.data, + proofFacts, + resourceBounds: bounds, + networkCapFri: policy.networkCapFri, + }); + const expectedHash = assertOuterSignatureMatchesHash(signed); + const response = await provider.invokeSignedTx(signed); + const transactionHash = normalizeHex(response.transaction_hash); + if (transactionHash !== expectedHash) throw new ExitExecutorError("exit_uncertain"); + await budget.markSubmitted(semanticKey, validated.bindingSha256, transactionHash, Date.now()); + 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"); + } + await budget.finalize(semanticKey, validated.bindingSha256, transactionHash, fee, "succeeded", Date.now()); + return { status: "accepted", transactionHash, actualFeeFri: fee }; + } catch (error) { + if (signed === undefined) await budget.release(semanticKey, validated.bindingSha256, Date.now()); + if (error instanceof ExitExecutorError) throw error; + throw signed === undefined ? new ExitExecutorError("exit_unavailable") : new ExitExecutorError("exit_uncertain"); + } +} + +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"; +} diff --git a/relayer/src/index.ts b/relayer/src/index.ts index 536727a..4d6ed41 100644 --- a/relayer/src/index.ts +++ b/relayer/src/index.ts @@ -27,9 +27,13 @@ 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); @@ -43,7 +47,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") { @@ -54,7 +61,14 @@ export default { let plan: RelayPlan; let estimateOnly = false; - if (url.pathname === CHECKPOINT_PATH) { + 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(env.ALLOWED_ORIGIN)); + } else if (url.pathname === CHECKPOINT_PATH) { await requireCheckpointHeaders(request, env); await rateLimitCheckpoint(env); plan = await prepareCheckpointPlan(env, Date.now()); @@ -138,6 +152,8 @@ export default { const handled = error instanceof RelayHttpError ? error + : 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"); @@ -225,7 +241,26 @@ function preflight(request: Request, env: Env): Response { "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 (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..1fe85ea --- /dev/null +++ b/relayer/src/neutral-exit-policy.d.mts @@ -0,0 +1,49 @@ +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 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): 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/test/relayer.spec.ts b/relayer/test/relayer.spec.ts index ce20f88..8a112c5 100644 --- a/relayer/test/relayer.spec.ts +++ b/relayer/test/relayer.spec.ts @@ -191,6 +191,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), diff --git a/relayer/worker-configuration.d.ts b/relayer/worker-configuration.d.ts index ede494f..c8cc431 100644 --- a/relayer/worker-configuration.d.ts +++ b/relayer/worker-configuration.d.ts @@ -1,18 +1,20 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 4120a3412f216003af8ef0eb6b90036e) -// Runtime types generated with workerd@1.20260820.1 2026-08-24 nodejs_compat +// Generated by Wrangler by running `wrangler types` (hash: ec11667ed5d0dd6d2d9f5ae060b2aedb) +// 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; + EXIT_RATE_LIMITER: RateLimit; DEPLOYMENT_STAGE: "phase-b-live-spike"; - ALLOWED_ORIGIN: "http://127.0.0.1:43117"; + ALLOWED_ORIGIN: "https://afterlight-app.pages.dev"; AFTERLIGHT_CONTRACT: "0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25"; STARKNET_CHAIN_ID: "0x534e5f4d41494e"; STRK_TOKEN: "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d"; RESERVE_AMOUNT_FRI: "1000000000000000000"; 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"; @@ -21,7 +23,7 @@ interface __BaseEnv_Env { RELAYER_ACCOUNT_ADDRESS: "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46"; RELAYER_ACCOUNT_CAIRO_VERSION: "1"; STARKNET_RPC_URL: "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_10"; - SUBMIT_ENABLED: "false"; + SUBMIT_ENABLED: "true"; RELAYER_ACCOUNT_PRIVATE_KEY: string; STARKNET_RPC_AUTH_TOKEN: string; RELAY_BUDGET: DurableObjectNamespace; @@ -38,7 +40,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 +588,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 +624,9 @@ interface DurableObjectStorage { getBookmarkForTime(timestamp: number | Date): Promise; onNextSessionRestoreBookmark(bookmark: string): Promise; } +interface DurableObjectAbortOptions { + retryAlarm?: boolean; +} interface DurableObjectListOptions { start?: string; startAfter?: string; @@ -1304,10 +1309,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 +1404,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 +1419,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 +1452,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 +3215,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 +3471,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 +3609,7 @@ interface WorkerLoaderModule { data?: ArrayBuffer; json?: any; py?: string; - wasm?: ArrayBuffer; + wasm?: ArrayBuffer | ArrayBufferView | WebAssembly.Module; } interface WorkerLoaderWorkerCode { compatibilityDate: string; @@ -3593,7 +3617,7 @@ interface WorkerLoaderWorkerCode { allowExperimental?: boolean; limits?: workerdResourceLimits; mainModule: string; - modules: Record; + modules: Record; env?: any; globalOutbound?: (Fetcher | null); tails?: Fetcher[]; @@ -10174,6 +10198,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 +10447,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 +11252,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 +11336,7 @@ type BrowserRunLinksSuccessResponse = { success: true; /** Extracted links */ result: string[]; + meta: BrowserRunResponseMeta; }; /** Success response for `scrape` action. */ type BrowserRunScrapeSuccessResponse = { @@ -11164,6 +11367,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 +11402,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 +14635,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 28012db..3d42861 100644 --- a/relayer/wrangler.jsonc +++ b/relayer/wrangler.jsonc @@ -2,17 +2,18 @@ "$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": { "DEPLOYMENT_STAGE": "phase-b-live-spike", - "ALLOWED_ORIGIN": "http://127.0.0.1:43117", + "ALLOWED_ORIGIN": "https://afterlight-app.pages.dev", "AFTERLIGHT_CONTRACT": "0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25", "STARKNET_CHAIN_ID": "0x534e5f4d41494e", "STRK_TOKEN": "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", "RESERVE_AMOUNT_FRI": "1000000000000000000", "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", @@ -21,7 +22,7 @@ "RELAYER_ACCOUNT_ADDRESS": "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46", "RELAYER_ACCOUNT_CAIRO_VERSION": "1", "STARKNET_RPC_URL": "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_10", - "SUBMIT_ENABLED": "false" + "SUBMIT_ENABLED": "true" }, // Names only: values must be installed with `wrangler secret put` after promotion. "secrets": { @@ -53,6 +54,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/web/index.html b/web/index.html new file mode 100644 index 0000000..7b8f6da --- /dev/null +++ b/web/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + 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/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..4fc259a --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,279 @@ +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 = ""; + +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(); }); + 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")) 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); + 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(); + const result = await submitClaimPackage(claimPackage); + 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..e96789f --- /dev/null +++ b/web/src/operations.ts @@ -0,0 +1,311 @@ +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"; + +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 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))) { + throw new Error("The neutral funding checkpoint is unavailable. No private funds moved."); + } + return num.toHex(BigInt(body.result.transactionHash)); +} + +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..a303c3e --- /dev/null +++ b/web/src/style.css @@ -0,0 +1,3 @@ +: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} diff --git a/web/src/wallet.ts b/web/src/wallet.ts new file mode 100644 index 0000000..a860ac4 --- /dev/null +++ b/web/src/wallet.ts @@ -0,0 +1,94 @@ +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)}.`); + const account = await WalletAccountV6.connectSilent(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"] +} From af281a623d7073148d62323dd32609123d9865d6 Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 19:46:06 +0100 Subject: [PATCH 09/38] Install shared client dependencies in web CI --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90ae906..23b5888 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,5 +141,7 @@ jobs: 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 From 4fedd70310461a7960e617af6bd25eb14286c466 Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 19:51:23 +0100 Subject: [PATCH 10/38] Bound public claim network sponsorship --- relayer/src/exit-executor.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/relayer/src/exit-executor.ts b/relayer/src/exit-executor.ts index 98b0d56..364aba5 100644 --- a/relayer/src/exit-executor.ts +++ b/relayer/src/exit-executor.ts @@ -34,7 +34,9 @@ const EXIT_POLICY = Object.freeze({ poolFeeEachFri: "6000000000000000000", initialPoolAllowanceFri: "12000000000000000000", postSpendHealthFloorFri: "1000000000000000000", - maxNetworkFeePerExitFri: "9027538581262736234", + // The proven E2 CLAIM used 2.832236 STRK of network fee. A 4 STRK hard cap + // leaves 41% headroom while keeping the public E3 sponsor budget bounded. + maxNetworkFeePerExitFri: "4000000000000000000", amountMarginBps: "10300", priceMarginBps: "10300", maxEstimateAgeBlocks: "300", From bae3d2fba12a81c8042751c1a508075a552407e6 Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 20:38:22 +0100 Subject: [PATCH 11/38] Use interactive Ready connection from connect button --- web/src/wallet.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/src/wallet.ts b/web/src/wallet.ts index a860ac4..e67d02c 100644 --- a/web/src/wallet.ts +++ b/web/src/wallet.ts @@ -41,7 +41,11 @@ export async function connectReady(provider: RpcProvider, onChanged: () => void) 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)}.`); - const account = await WalletAccountV6.connectSilent(provider, wallet as never); + // 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."); From 93b8ecf1509835db8b95039334b21aceb460feac Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 20:43:27 +0100 Subject: [PATCH 12/38] Make relayer rate-limit test deterministic --- relayer/test/relayer.spec.ts | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/relayer/test/relayer.spec.ts b/relayer/test/relayer.spec.ts index 8a112c5..43deef0 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 { rateLimitRelay, RelayHttpError } from "../src/core.js"; const origin = "https://afterlight.invalid"; const contract = "0x1234"; @@ -352,22 +353,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]); }); From 312ffa88ecfbef2be0ea51ef2b81439bf8d943bf Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 20:49:30 +0100 Subject: [PATCH 13/38] Reconcile ambiguous funding checkpoints onchain --- web/src/operations.ts | 46 ++++++++++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/web/src/operations.ts b/web/src/operations.ts index e96789f..7a9c2d7 100644 --- a/web/src/operations.ts +++ b/web/src/operations.ts @@ -29,6 +29,8 @@ 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(); } @@ -69,18 +71,40 @@ function base(vaultId: string, signerKey: string, state: string, epoch: string, } async function checkpoint(): Promise { - 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))) { - throw new Error("The neutral funding checkpoint is unavailable. No private funds moved."); + const startedBlock = await provider.getBlockNumber(); + 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))) { + return 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.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)); } - return num.toHex(BigInt(body.result.transactionHash)); + throw new Error("The neutral funding checkpoint is unavailable. No private funds moved."); } export async function fundRecoveryDrill(input: { From 2e5b9b0468e274d6dfa3a367c12076e565ea6fed Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 20:57:55 +0100 Subject: [PATCH 14/38] Require a fresh funding checkpoint --- web/src/main.ts | 15 ++++++++++++--- web/src/operations.ts | 13 +++++++++++-- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/web/src/main.ts b/web/src/main.ts index 4fc259a..63d9ebd 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -18,6 +18,7 @@ let vault: VaultSnapshot | undefined; let notice = "Loading the live Starknet Mainnet product…"; let busy = false; let transactionHash = ""; +let costAcknowledged = false; const appRoot = document.querySelector("#app"); if (!appRoot) throw new Error("Afterlight app root is missing."); @@ -82,7 +83,7 @@ function ownerView(): string {
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.

` : ""} @@ -207,7 +208,14 @@ function bindEvents(): void { 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(); }); + 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); @@ -218,7 +226,7 @@ function bindEvents(): void { event.preventDefault(); const form = new FormData(event.currentTarget as HTMLFormElement); void run(async () => { - if (!form.has("cost-ack")) throw new Error("Confirm the exact 7 STRK private-wallet consequence first."); + 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(); @@ -231,6 +239,7 @@ function bindEvents(): void { 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."; }); }); diff --git a/web/src/operations.ts b/web/src/operations.ts index 7a9c2d7..cfd3ec7 100644 --- a/web/src/operations.ts +++ b/web/src/operations.ts @@ -72,6 +72,8 @@ function base(vaultId: string, signerKey: string, state: string, epoch: string, 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", @@ -82,7 +84,7 @@ async function checkpoint(): Promise { }); 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))) { - return num.toHex(BigInt(body.result.transactionHash)); + returnedHash = num.toHex(BigInt(body.result.transactionHash)); } } catch { // A Worker response may be lost after its single broadcast. Reconcile the @@ -96,7 +98,14 @@ async function checkpoint(): Promise { keys: [[CHECKPOINT_SELECTOR]], chunk_size: 10, }); - const event = result.events.at(-1); + 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); From 4f48f4ce5011e47686e7b4d64599b5212582731c Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 21:10:09 +0100 Subject: [PATCH 15/38] Rotate the bounded E3 relay ledger --- relayer/worker-configuration.d.ts | 6 +++--- relayer/wrangler.jsonc | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/relayer/worker-configuration.d.ts b/relayer/worker-configuration.d.ts index c8cc431..1247188 100644 --- a/relayer/worker-configuration.d.ts +++ b/relayer/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: ec11667ed5d0dd6d2d9f5ae060b2aedb) +// Generated by Wrangler by running `wrangler types` (hash: 7556d295c2e99a09543ebd44f60dc904) // Runtime types generated with workerd@1.20260828.1 2026-08-28 nodejs_compat interface __BaseEnv_Env { RELAY_RATE_LIMITER: RateLimit; @@ -16,10 +16,10 @@ interface __BaseEnv_Env { MAX_RELAY_PAYLOAD_BYTES: "2048"; MAX_EXIT_PAYLOAD_BYTES: "2097152"; MAX_SPONSORED_FEE_FRI: "200000000000000000"; - DAILY_SPONSOR_BUDGET_FRI: "1200000000000000000"; + DAILY_SPONSOR_BUDGET_FRI: "800000000000000000"; SPONSOR_FEE_MARGIN_BPS: "11000"; MIN_RELAYER_BALANCE_FRI: "1000000000000000000"; - DEPLOYMENT_ID: "afterlight-mainnet-spike-20260827-v2"; + DEPLOYMENT_ID: "afterlight-mainnet-e3-20260828-v3"; RELAYER_ACCOUNT_ADDRESS: "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46"; RELAYER_ACCOUNT_CAIRO_VERSION: "1"; STARKNET_RPC_URL: "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_10"; diff --git a/relayer/wrangler.jsonc b/relayer/wrangler.jsonc index 3d42861..e0c3255 100644 --- a/relayer/wrangler.jsonc +++ b/relayer/wrangler.jsonc @@ -15,10 +15,10 @@ "MAX_RELAY_PAYLOAD_BYTES": "2048", "MAX_EXIT_PAYLOAD_BYTES": "2097152", "MAX_SPONSORED_FEE_FRI": "200000000000000000", - "DAILY_SPONSOR_BUDGET_FRI": "1200000000000000000", + "DAILY_SPONSOR_BUDGET_FRI": "800000000000000000", "SPONSOR_FEE_MARGIN_BPS": "11000", "MIN_RELAYER_BALANCE_FRI": "1000000000000000000", - "DEPLOYMENT_ID": "afterlight-mainnet-spike-20260827-v2", + "DEPLOYMENT_ID": "afterlight-mainnet-e3-20260828-v3", "RELAYER_ACCOUNT_ADDRESS": "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46", "RELAYER_ACCOUNT_CAIRO_VERSION": "1", "STARKNET_RPC_URL": "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_10", From fe1406744a3416f392bad720e7e1cc6b5ce7517e Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 21:13:58 +0100 Subject: [PATCH 16/38] Wait for propagated relay receipts --- relayer/src/starknet-adapter.ts | 21 ++++++++++++++++----- relayer/worker-configuration.d.ts | 4 ++-- relayer/wrangler.jsonc | 2 +- 3 files changed, 19 insertions(+), 8 deletions(-) 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/worker-configuration.d.ts b/relayer/worker-configuration.d.ts index 1247188..4f3cf90 100644 --- a/relayer/worker-configuration.d.ts +++ b/relayer/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 7556d295c2e99a09543ebd44f60dc904) +// Generated by Wrangler by running `wrangler types` (hash: e996908736cbc08c73e08a2cc16037ce) // Runtime types generated with workerd@1.20260828.1 2026-08-28 nodejs_compat interface __BaseEnv_Env { RELAY_RATE_LIMITER: RateLimit; @@ -19,7 +19,7 @@ interface __BaseEnv_Env { DAILY_SPONSOR_BUDGET_FRI: "800000000000000000"; SPONSOR_FEE_MARGIN_BPS: "11000"; MIN_RELAYER_BALANCE_FRI: "1000000000000000000"; - DEPLOYMENT_ID: "afterlight-mainnet-e3-20260828-v3"; + DEPLOYMENT_ID: "afterlight-mainnet-e3-20260828-v4"; RELAYER_ACCOUNT_ADDRESS: "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46"; RELAYER_ACCOUNT_CAIRO_VERSION: "1"; STARKNET_RPC_URL: "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_10"; diff --git a/relayer/wrangler.jsonc b/relayer/wrangler.jsonc index e0c3255..cb7dd1b 100644 --- a/relayer/wrangler.jsonc +++ b/relayer/wrangler.jsonc @@ -18,7 +18,7 @@ "DAILY_SPONSOR_BUDGET_FRI": "800000000000000000", "SPONSOR_FEE_MARGIN_BPS": "11000", "MIN_RELAYER_BALANCE_FRI": "1000000000000000000", - "DEPLOYMENT_ID": "afterlight-mainnet-e3-20260828-v3", + "DEPLOYMENT_ID": "afterlight-mainnet-e3-20260828-v4", "RELAYER_ACCOUNT_ADDRESS": "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46", "RELAYER_ACCOUNT_CAIRO_VERSION": "1", "STARKNET_RPC_URL": "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_10", From 7f0d680de375ed30a842a8252cdb996f99332a3c Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 21:23:53 +0100 Subject: [PATCH 17/38] Authorize the canonical Afterlight origin --- relayer/src/core.ts | 9 +++++++-- relayer/src/index.ts | 20 +++++++++++--------- relayer/test/relayer.spec.ts | 10 +++++++++- relayer/worker-configuration.d.ts | 4 ++-- relayer/wrangler.jsonc | 2 +- 5 files changed, 30 insertions(+), 15 deletions(-) diff --git a/relayer/src/core.ts b/relayer/src/core.ts index 0109d51..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"); @@ -272,7 +272,7 @@ export function requireRelayHeaders(request: Request, env: Env): void { } export async function requireCheckpointHeaders(request: Request, env: Env): Promise { - if (request.headers.get("origin") !== env.ALLOWED_ORIGIN) { + 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) { @@ -308,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/index.ts b/relayer/src/index.ts index 4d6ed41..3d16ccd 100644 --- a/relayer/src/index.ts +++ b/relayer/src/index.ts @@ -7,6 +7,7 @@ import { RelayHttpError, corsHeaders, jsonResponse, + isAllowedOrigin, parsePositiveDecimal, prepareCheckpointPlan, prepareRelayPlan, @@ -37,6 +38,7 @@ 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); @@ -67,7 +69,7 @@ export default { 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(env.ALLOWED_ORIGIN)); + return jsonResponse({ status: "relayed", result }, 200, corsHeaders(requestOrigin)); } else if (url.pathname === CHECKPOINT_PATH) { await requireCheckpointHeaders(request, env); await rateLimitCheckpoint(env); @@ -108,7 +110,7 @@ export default { estimate: simulation, }, 200, - corsHeaders(env.ALLOWED_ORIGIN), + corsHeaders(requestOrigin), ); } @@ -134,7 +136,7 @@ export default { return jsonResponse( { status: "relayed", result }, 200, - corsHeaders(env.ALLOWED_ORIGIN), + corsHeaders(requestOrigin), ); } @@ -145,7 +147,7 @@ export default { plan, }, 202, - corsHeaders(env.ALLOWED_ORIGIN), + corsHeaders(requestOrigin), ); } catch (error) { const executorCode = executorErrorCode(error); @@ -169,8 +171,8 @@ 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, ); } @@ -230,13 +232,13 @@ 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 @@ -249,7 +251,7 @@ function preflight(request: Request, env: Env): Response { } function requireExitHeaders(request: Request, env: Env): void { - if (request.headers.get("origin") !== env.ALLOWED_ORIGIN) throw new RelayHttpError(403, "origin_not_allowed"); + 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"); diff --git a/relayer/test/relayer.spec.ts b/relayer/test/relayer.spec.ts index 43deef0..1bcc5b8 100644 --- a/relayer/test/relayer.spec.ts +++ b/relayer/test/relayer.spec.ts @@ -7,7 +7,7 @@ import { encodeRelayRequest, type RelayRequest, } from "../src/schema.js"; -import { rateLimitRelay, RelayHttpError } from "../src/core.js"; +import { isAllowedOrigin, rateLimitRelay, RelayHttpError } from "../src/core.js"; const origin = "https://afterlight.invalid"; const contract = "0x1234"; @@ -15,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); diff --git a/relayer/worker-configuration.d.ts b/relayer/worker-configuration.d.ts index 4f3cf90..3d984ca 100644 --- a/relayer/worker-configuration.d.ts +++ b/relayer/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: e996908736cbc08c73e08a2cc16037ce) +// Generated by Wrangler by running `wrangler types` (hash: 62db36ece2d57a29e675937d72b6d30c) // Runtime types generated with workerd@1.20260828.1 2026-08-28 nodejs_compat interface __BaseEnv_Env { RELAY_RATE_LIMITER: RateLimit; @@ -7,7 +7,7 @@ interface __BaseEnv_Env { CHECKPOINT_RATE_LIMITER: RateLimit; EXIT_RATE_LIMITER: RateLimit; DEPLOYMENT_STAGE: "phase-b-live-spike"; - ALLOWED_ORIGIN: "https://afterlight-app.pages.dev"; + ALLOWED_ORIGIN: "https://afterlight-app.pages.dev,https://afterlight.dolepee.com"; AFTERLIGHT_CONTRACT: "0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25"; STARKNET_CHAIN_ID: "0x534e5f4d41494e"; STRK_TOKEN: "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d"; diff --git a/relayer/wrangler.jsonc b/relayer/wrangler.jsonc index cb7dd1b..c89813b 100644 --- a/relayer/wrangler.jsonc +++ b/relayer/wrangler.jsonc @@ -6,7 +6,7 @@ "compatibility_flags": ["nodejs_compat"], "vars": { "DEPLOYMENT_STAGE": "phase-b-live-spike", - "ALLOWED_ORIGIN": "https://afterlight-app.pages.dev", + "ALLOWED_ORIGIN": "https://afterlight-app.pages.dev,https://afterlight.dolepee.com", "AFTERLIGHT_CONTRACT": "0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25", "STARKNET_CHAIN_ID": "0x534e5f4d41494e", "STRK_TOKEN": "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", From fb918a72e37d7a7031bd37810ee3a5bda38c6bbf Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 21:35:34 +0100 Subject: [PATCH 18/38] Bound E3 sponsorship to the funded budget --- relayer/worker-configuration.d.ts | 6 +++--- relayer/wrangler.jsonc | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/relayer/worker-configuration.d.ts b/relayer/worker-configuration.d.ts index 3d984ca..b2213dd 100644 --- a/relayer/worker-configuration.d.ts +++ b/relayer/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 62db36ece2d57a29e675937d72b6d30c) +// Generated by Wrangler by running `wrangler types` (hash: c0b4c259cd5833ac30ccf80ef767dac2) // Runtime types generated with workerd@1.20260828.1 2026-08-28 nodejs_compat interface __BaseEnv_Env { RELAY_RATE_LIMITER: RateLimit; @@ -15,8 +15,8 @@ interface __BaseEnv_Env { MAX_RELAY_TTL_SECONDS: "900"; MAX_RELAY_PAYLOAD_BYTES: "2048"; MAX_EXIT_PAYLOAD_BYTES: "2097152"; - MAX_SPONSORED_FEE_FRI: "200000000000000000"; - DAILY_SPONSOR_BUDGET_FRI: "800000000000000000"; + MAX_SPONSORED_FEE_FRI: "400000000000000000"; + DAILY_SPONSOR_BUDGET_FRI: "1600000000000000000"; SPONSOR_FEE_MARGIN_BPS: "11000"; MIN_RELAYER_BALANCE_FRI: "1000000000000000000"; DEPLOYMENT_ID: "afterlight-mainnet-e3-20260828-v4"; diff --git a/relayer/wrangler.jsonc b/relayer/wrangler.jsonc index c89813b..6908424 100644 --- a/relayer/wrangler.jsonc +++ b/relayer/wrangler.jsonc @@ -14,8 +14,8 @@ "MAX_RELAY_TTL_SECONDS": "900", "MAX_RELAY_PAYLOAD_BYTES": "2048", "MAX_EXIT_PAYLOAD_BYTES": "2097152", - "MAX_SPONSORED_FEE_FRI": "200000000000000000", - "DAILY_SPONSOR_BUDGET_FRI": "800000000000000000", + "MAX_SPONSORED_FEE_FRI": "400000000000000000", + "DAILY_SPONSOR_BUDGET_FRI": "1600000000000000000", "SPONSOR_FEE_MARGIN_BPS": "11000", "MIN_RELAYER_BALANCE_FRI": "1000000000000000000", "DEPLOYMENT_ID": "afterlight-mainnet-e3-20260828-v4", From 1dd4a9023e5c8f68c68f8b9307d4af68b61b8cf0 Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 22:05:23 +0100 Subject: [PATCH 19/38] Classify private claim preflight stages --- relayer/src/exit-executor.ts | 40 ++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/relayer/src/exit-executor.ts b/relayer/src/exit-executor.ts index 364aba5..88fcb06 100644 --- a/relayer/src/exit-executor.ts +++ b/relayer/src/exit-executor.ts @@ -84,15 +84,22 @@ export async function executePreparedClaim(payload: string, env: Env, budget: Bu headers: { authorization: `Bearer ${env.STARKNET_RPC_AUTH_TOKEN}` }, plugins: false, }); - if (normalizeHex(await provider.getChainId()) !== normalizeHex(EXIT_POLICY.chainId)) throw new ExitExecutorError("exit_unavailable"); + 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 }); - const policy = validatePolicy(EXIT_POLICY); - const estimateBlock = await provider.getBlockWithTxHashes("latest"); + 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); - const snapshot = await readSnapshot(provider, validated, blockNumber, blockTimestamp); - validateAuthorizationInclusionWindow(validated.metadata.validUntil, blockTimestamp, BigInt(Math.floor(Date.now() / 1_000))); + 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) }; let estimate; @@ -109,12 +116,16 @@ export async function executePreparedClaim(payload: string, env: Env, budget: Bu nonceDataAvailabilityMode: "L1", feeDataAvailabilityMode: "L1", }); - } catch { throw new ExitExecutorError("exit_unavailable"); } - const sourceBounds = parseResourceBounds(estimate.resourceBounds); - const bounds = addResourceMargins(sourceBounds, policy.amountMarginBps, policy.priceMarginBps); - const networkCap = resourceCapFri(bounds); - if (networkCap > policy.networkCapFri) throw new ExitExecutorError("exit_unavailable"); - validateBalanceForExit(snapshot.balance, networkCap, BigInt(EXIT_POLICY.postSpendHealthFloorFri)); + } catch { throw exitStage("estimate"); } + 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), @@ -207,3 +218,10 @@ function readFee(value: unknown): string { 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"); +} From 603b0d295606deaf1ebf7bab3854434b9f970aac Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 22:13:32 +0100 Subject: [PATCH 20/38] Use the canonical exit binding as budget key --- relayer/src/exit-executor.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/relayer/src/exit-executor.ts b/relayer/src/exit-executor.ts index 88fcb06..5f0661a 100644 --- a/relayer/src/exit-executor.ts +++ b/relayer/src/exit-executor.ts @@ -73,7 +73,9 @@ export async function executePreparedClaim(payload: string, env: Env, budget: Bu if (validated.action !== "CLAIM") throw new Error("only_claim_is_public"); } catch { throw new ExitExecutorError("invalid_exit"); } - const semanticKey = `exit:${validated.bindingSha256}`; + // 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 }; From af0b81d5ff07089a74c06e75552533530b2b30c0 Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 22:27:01 +0100 Subject: [PATCH 21/38] Classify private claim estimate failures --- relayer/src/exit-executor.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/relayer/src/exit-executor.ts b/relayer/src/exit-executor.ts index 5f0661a..dbc8925 100644 --- a/relayer/src/exit-executor.ts +++ b/relayer/src/exit-executor.ts @@ -118,7 +118,9 @@ export async function executePreparedClaim(payload: string, env: Env, budget: Bu nonceDataAvailabilityMode: "L1", feeDataAvailabilityMode: "L1", }); - } catch { throw exitStage("estimate"); } + } catch (error) { + throw exitStage(`estimate_${classifyEstimateFailure(error)}`); + } let bounds; let networkCap; try { @@ -227,3 +229,15 @@ function exitStage(stage: string): ExitExecutorError { 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 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"; +} From ca90dc2429c83fa852981ff4a8fe9e96f56a674f Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 22:36:17 +0100 Subject: [PATCH 22/38] Use Starknet RPC codes for claim diagnostics --- relayer/src/exit-executor.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/relayer/src/exit-executor.ts b/relayer/src/exit-executor.ts index dbc8925..59f82ac 100644 --- a/relayer/src/exit-executor.ts +++ b/relayer/src/exit-executor.ts @@ -233,6 +233,17 @@ function exitStage(stage: string): ExitExecutorError { 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 record = typeof error === "object" && error !== null ? error as Record : undefined; + const base = typeof record?.baseError === "object" && record.baseError !== null + ? record.baseError as Record + : undefined; + const code = typeof record?.code === "number" ? record.code : typeof base?.code === "number" ? base.code : undefined; + 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"; From b5ab821bc93788f8ed3745c240934c9f5b5c43e7 Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 22:46:07 +0100 Subject: [PATCH 23/38] Use the audited STRK20 estimate envelope fallback --- relayer/src/exit-executor.ts | 52 +++++++++++++++++---------- relayer/src/neutral-exit-policy.d.mts | 1 + 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/relayer/src/exit-executor.ts b/relayer/src/exit-executor.ts index 59f82ac..b2c5f01 100644 --- a/relayer/src/exit-executor.ts +++ b/relayer/src/exit-executor.ts @@ -9,6 +9,7 @@ import { parseResourceBounds, parseU256Result, parseVaultResult, + proofFactsForFeeEstimate, resourceCapFri, validateAllowanceForAction, validateAuthorizationInclusionWindow, @@ -104,22 +105,33 @@ export async function executePreparedClaim(payload: string, env: Env, budget: Bu } 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, { - nonce: snapshot.nonce, - blockIdentifier: Number(blockNumber), - skipValidate: false, - proof: validated.proof.data, - proofFacts, - tip: 0, - paymasterData: [], - accountDeploymentData: [], - nonceDataAvailabilityMode: "L1", - feeDataAvailabilityMode: "L1", - }); + estimate = await account.estimateInvokeFee(call, estimateDetails(proofFacts)); } catch (error) { - throw exitStage(`estimate_${classifyEstimateFailure(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; @@ -233,11 +245,7 @@ function exitStage(stage: string): ExitExecutorError { 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 record = typeof error === "object" && error !== null ? error as Record : undefined; - const base = typeof record?.baseError === "object" && record.baseError !== null - ? record.baseError as Record - : undefined; - const code = typeof record?.code === "number" ? record.code : typeof base?.code === "number" ? base.code : undefined; + const code = rpcErrorCode(error); if (code === 41) return "rpc_execution"; if (code === 52) return "rpc_transaction_nonce"; if (code === 53) return "rpc_validate_resources"; @@ -252,3 +260,11 @@ function classifyEstimateFailure(error: unknown): string { if (message.includes("timeout") || message.includes("network") || message.includes("fetch")) return "transport"; return "unknown"; } + +function rpcErrorCode(error: unknown): number | undefined { + const record = typeof error === "object" && error !== null ? error as Record : undefined; + const base = typeof record?.baseError === "object" && record.baseError !== null + ? record.baseError as Record + : undefined; + return typeof record?.code === "number" ? record.code : typeof base?.code === "number" ? base.code : undefined; +} diff --git a/relayer/src/neutral-exit-policy.d.mts b/relayer/src/neutral-exit-policy.d.mts index 1fe85ea..14731c5 100644 --- a/relayer/src/neutral-exit-policy.d.mts +++ b/relayer/src/neutral-exit-policy.d.mts @@ -33,6 +33,7 @@ export type ResourceBounds = Readonly<{ 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; From c60b78c5e9fce67355d0b12c6e7a50eee95b7942 Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 22:57:51 +0100 Subject: [PATCH 24/38] Align the claim cap with the proven resource reservation --- relayer/src/exit-executor.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/relayer/src/exit-executor.ts b/relayer/src/exit-executor.ts index b2c5f01..e03c939 100644 --- a/relayer/src/exit-executor.ts +++ b/relayer/src/exit-executor.ts @@ -35,9 +35,11 @@ const EXIT_POLICY = Object.freeze({ poolFeeEachFri: "6000000000000000000", initialPoolAllowanceFri: "12000000000000000000", postSpendHealthFloorFri: "1000000000000000000", - // The proven E2 CLAIM used 2.832236 STRK of network fee. A 4 STRK hard cap - // leaves 41% headroom while keeping the public E3 sponsor budget bounded. - maxNetworkFeePerExitFri: "4000000000000000000", + // 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", amountMarginBps: "10300", priceMarginBps: "10300", maxEstimateAgeBlocks: "300", From d10aeecbca5ccae558068edfbef1dd0f2511ee96 Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 23:02:43 +0100 Subject: [PATCH 25/38] Use the exact Starknet claim resource bounds --- relayer/src/exit-executor.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/relayer/src/exit-executor.ts b/relayer/src/exit-executor.ts index e03c939..1e15b86 100644 --- a/relayer/src/exit-executor.ts +++ b/relayer/src/exit-executor.ts @@ -40,8 +40,11 @@ const EXIT_POLICY = Object.freeze({ // explicit ceiling above that proven reservation without weakening the // separate 1 STRK post-spend health floor. maxNetworkFeePerExitFri: "7500000000000000000", - amountMarginBps: "10300", - priceMarginBps: "10300", + // 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", From 52463b3c850c00809f67b06f601155bd825f9094 Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 23:08:03 +0100 Subject: [PATCH 26/38] Preserve private claim failure classification --- relayer/src/exit-executor.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/relayer/src/exit-executor.ts b/relayer/src/exit-executor.ts index 1e15b86..b3a2489 100644 --- a/relayer/src/exit-executor.ts +++ b/relayer/src/exit-executor.ts @@ -203,8 +203,18 @@ export async function executePreparedClaim(payload: string, env: Env, budget: Bu await budget.finalize(semanticKey, validated.bindingSha256, transactionHash, fee, "succeeded", Date.now()); return { status: "accepted", transactionHash, actualFeeFri: fee }; } catch (error) { - if (signed === undefined) await budget.release(semanticKey, validated.bindingSha256, Date.now()); + if (signed === undefined) { + 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: "before_signing" })); + } + } if (error instanceof ExitExecutorError) throw error; + console.error(JSON.stringify({ event: "exit_submission_failed", stage: signed === undefined ? "before_signing" : "after_signing" })); throw signed === undefined ? new ExitExecutorError("exit_unavailable") : new ExitExecutorError("exit_uncertain"); } } From 628afddc8709035ccb8ea224331719ceaf56837c Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 23:16:05 +0100 Subject: [PATCH 27/38] Publish canonical demo and production health checks --- .github/workflows/relayer-health.yml | 7 ++++--- strk20.json | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/relayer-health.yml b/.github/workflows/relayer-health.yml index f50a39b..7f10780 100644 --- a/.github/workflows/relayer-health.yml +++ b/.github/workflows/relayer-health.yml @@ -14,7 +14,7 @@ concurrency: jobs: health: - name: Verify disabled submission and privacy boundary + name: Verify production submission and privacy boundary runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -40,11 +40,12 @@ jobs: .status == "ok" and .service == "afterlight-neutral-relayer" and .schema == "afterlight-relay/1" and - .submission == "disabled" and + .submission == "enabled" and .executor.configurationReady == true and .executor.signerAdapterAvailable == true and .executor.executable == true and - .balance.status == "disabled" and + .balance.status == "ok" and + .balance.alert == false and .privacy.payloadLogging == false and .privacy.appKeysHeld == false and .privacy.walletAddressesRequired == false diff --git a/strk20.json b/strk20.json index fcc36a6..e56559e 100644 --- a/strk20.json +++ b/strk20.json @@ -7,5 +7,6 @@ ], "contracts": [ "0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25" - ] + ], + "demo_url": "https://afterlight.dolepee.com" } From 6cb2a15a2412c019abb667643bfbdacc433b4290 Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 23:18:15 +0100 Subject: [PATCH 28/38] Align Ready onboarding with the live public interface --- docs/READY_X_ONBOARDING.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/READY_X_ONBOARDING.md b/docs/READY_X_ONBOARDING.md index 6602215..c1b3dc2 100644 --- a/docs/READY_X_ONBOARDING.md +++ b/docs/READY_X_ONBOARDING.md @@ -2,11 +2,12 @@ ## Evidence and release status -This is the Ready X flow exercised by the deployed Afterlight Mainnet mechanism. -It documents prerequisites and the user boundary; it is not yet a public-product -runbook or a standing instruction to fund or sign. The contract and a complete -founder-operated lifecycle exist, while the public UI and unrelated-user E3/E4 -completion remain pending. +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 @@ -49,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. From d4be7ae31e1063b1f0e60b14673078d1cfc172a8 Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 23:19:41 +0100 Subject: [PATCH 29/38] Separate claim signing from broadcast state --- relayer/src/exit-executor.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/relayer/src/exit-executor.ts b/relayer/src/exit-executor.ts index b3a2489..051719a 100644 --- a/relayer/src/exit-executor.ts +++ b/relayer/src/exit-executor.ts @@ -163,11 +163,16 @@ export async function executePreparedClaim(payload: string, env: Env, budget: Bu } 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, @@ -179,6 +184,7 @@ export async function executePreparedClaim(payload: string, env: Env, budget: Bu proof: validated.proof.data, proofFacts, }); + submissionStage = "signed_assertions"; assertSignedExitTransaction(signed, { nonce: snapshot.nonce, executeCalldata: transaction.getExecuteCalldata([call], "1"), @@ -187,11 +193,16 @@ export async function executePreparedClaim(payload: string, env: Env, budget: Bu resourceBounds: bounds, networkCapFri: policy.networkCapFri, }); + submissionStage = "outer_hash"; const expectedHash = assertOuterSignatureMatchesHash(signed); + submissionStage = "broadcast"; + broadcastStarted = true; const response = await provider.invokeSignedTx(signed); 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; @@ -200,22 +211,23 @@ export async function executePreparedClaim(payload: string, env: Env, budget: Bu 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 (signed === undefined) { + 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: "before_signing" })); + 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: signed === undefined ? "before_signing" : "after_signing" })); - throw signed === undefined ? new ExitExecutorError("exit_unavailable") : new ExitExecutorError("exit_uncertain"); + console.error(JSON.stringify({ event: "exit_submission_failed", stage: submissionStage })); + throw broadcastStarted ? new ExitExecutorError("exit_uncertain") : new ExitExecutorError("exit_unavailable"); } } From def653531a9660ebf01bcce7b98917107a4d5ee9 Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 23:22:28 +0100 Subject: [PATCH 30/38] Add launch-ready metadata and social assets --- web/index.html | 14 ++++++++++++++ web/public/afterlight-card.png | Bin 0 -> 59157 bytes web/public/afterlight-card.svg | 31 +++++++++++++++++++++++++++++++ web/public/apple-touch-icon.png | Bin 0 -> 5508 bytes web/public/favicon.svg | 6 ++++++ 5 files changed, 51 insertions(+) create mode 100644 web/public/afterlight-card.png create mode 100644 web/public/afterlight-card.svg create mode 100644 web/public/apple-touch-icon.png create mode 100644 web/public/favicon.svg diff --git a/web/index.html b/web/index.html index 7b8f6da..e4c3f9b 100644 --- a/web/index.html +++ b/web/index.html @@ -5,8 +5,22 @@ + + + + + + + + + + + + + + Afterlight — Private recovery reserve diff --git a/web/public/afterlight-card.png b/web/public/afterlight-card.png new file mode 100644 index 0000000000000000000000000000000000000000..4523184a29ab98efd1e49d5aef95df35786fc1f6 GIT binary patch literal 59157 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
  • K9gJfap3IAAxnnni zHG5+OH`tpOs?<~qfivX6SLx=a!!lzykTlYwL;U24cX(!(PJ_GFikfqogO!$EZ|3YO z7^Nl7N)K4@X0d|-wEM?vVyW&tCgRF()<74d(qYk4vIM}{g9v#8wgWO^ii(OnREqD; zTi9qh+I(LUaTr!!Z2o{WgU^FryIj)QP~428R4ZKJShLD`uY8@RLnF_!(X@kE4F`dU z*WPveBpxG}Un+&weHZuN!|^hEE&)&dRO;%G0(wS%Pa6>l@1JWFJySszT0nJr``!Rw z$-JE)|7Xoo=)G8}r{SwoHV`Sip9rQ0vVhW;pcuDnRPn5j6rx=w3h(;rx_(==3`Xpe z)!E|7yi1=Q0X9hk=3((O*aGL53t4e*$zKwf*_%wviuI_u;-H96j-Xb+h_*19>T!8>@XQYu|u$u3TG>jjA2?_Rh|~lzvJr0O>9RdfKR#oUn<+gv-)L?qB)6(8dEpCX@9<^opA0eELPfS=W z6!-|Of=#D%v6hOr!f1lJe6r%7wb3@J7+4#l*{vW!*x|k?+bru7r8==S$ruYjdf=Hi zP;L}OCZH}%p!hWCV((nr(5G$>y0?u}Ozy~t%gL$R0W3zh??$F;WEeOTyG+LFeS}_g z3=U{i{|!?;yo+`JfRNeB_{}$Q;*^q)6w&edY=7Eb7u+j92&=5}(qSpb;CLeDKO)|d zc>nd_r?K;g7CsEnV)yuv(J?TQZIc^9%Q4WHAkJY4wDEd59i7FtHbG!c>@~nZG}_$? z$MFA26oNZgk%{D&m`J0L5{Y@wdo_7eBY#sbWdcf_MRyjjbE;XD!9i*37?3~TP+VI!fjW9O;QZ2=I_pxCHN-FXv#9>u@)$m$+xYnZMUaC z)`yFS;4o;#7@IGapja($frJn($MP~d80EH}lYd7#qQJz)W;eD$hY@%ZTVML7)E5Q+ z6G!vWsX;)x%6N$dfxpPCODuiT?Og*Rs9K9uYR-P8d!z0zlJW~-Q~GNNZHNLPr~?5d zBKx(Db&i%?jr87pEM1oZKl01F-`vO5gb^c6!pDY3EEHXxoe4Gig!uR%-U&8ZtdDP^ zr~vbKHD_RSF92&&h!UO6ynBpPj`bktr>Ey~*hiks#NFvSZcc1+L)#6p!~aG#pnRbyGVji6Nn_hPL0f!0>- zdO3-*5%1n8D$)g9EG)_eTnr3qcRxXCpQH8oFOCX3eyGI%>K2mcKbXPvtwV@1& zGQe&YVM)7_W*mY6!lG*y=f}sf`_UZw2WdcQ7H5^`WLk1dEBYqo0K2qJ&jgW#zzuDT zAuxw0aAifM#dw-Sk_q+V>`DF0R~{Tzhl;+rQmH0pW^Gu%ubW#?h2A^`{}QDV+H(V> z_<=+RG=plE_0^3!qp8U0+->C6Bq5`=!c!9**YggJth6uCfu*mS`f1cRVCX zhs(V4Dw;?-#KM%yUKa(m%}Z+19cthG)l=R8utvOqppzw>v59U^l)kZX z`y7~@!EaBwUJpjkMHy$UUV~B)j201YeE4$oRqxF|xSc`dI&10`0YTrzL8o-bgyPZ$ zlI)L_m5ar5aLb>YRj+6)04}G8ui^(PuMd=%R>9;n+=2N&i{=)=Br%k&DWf629*rK@ zbe#kVT1v}Txyo>F_?JssU``gh)iqeLcv0GFYB$8={;OReuNeODN^sB9`|>1XT&M01 z&q$+XCfDujxtL*U9BCdNe>+zx{2o-0a;LuBqtBrD1_(tfQr$0K{>-Js#kHrMFf%c& zTg2K)Q9Zm;9uS4%&CMO1Cxt~sn8}4jkBvS-z`!)%D=1NDh_StHzSZgV1x3Bv)h-?f zUG9Bu(Py;un-`uK?bC5BnbwZ)Ar23(bMe?y_8qqdH?;FVuZ-vLs61r-ryx9Pl;wPt zk(H(<4@Z;DRBj@syu~ljzbK#7Gc@g=Bl`PVLg4n;-7Qv(A*xVnN4XK<1l+3hy;2Ip{az9;tNWr77vxhNyV+V>uUMdpGE(k=vhc54 zh6VfKwPZEGmlrg$bn2O?sKB7%x1LW2&NDtyd*-e zvfUXK9&`rxtOBQ_AY`Zg8)0SV9(-}p;_(tSh5EzY%NIad`FoN5@Z3{2=l`^ifPi5g zUDwsgKHb6v{nBl4Vhy{+OegP+VLKxxrrzaFzJlMTb^Dmnpl=N1i=4PPwSvpUg(W&V zA)(vhsKMW&;LQ(^E-x;Ynkb0Qo|8%8YQu_&iz9P~IfhqTsBhL6Ra7W|4>oJ=!Bx&G zJMjC_t&Nrpn3B=Bca2K)MI6lhygIGn6b&WJr6#B8%nRxp*ZzNaOS}kQJUpI)H)y>q zj&Fv#=@m5;ZcxgNBtwMUEhvv#{zTV?tk z*T5N{*(IH)mh0}_Ibgg3@@Idarug%x0!||%VFfdcZfo&HL-%ZfGN&JwMWekp^x-H*l5KhrJVKVq!ff{u%!0q@tzT4IzKOp<>@ZP==P2>;tb% zq~&6jODIWOjI39NX^;@Zs9oabMt1V13+%&|S{seP^1e z%EOIw&o zwbIM9t*u$J2ow}9X`24JFw{@~FBgX65by4Avl|%XCo}|UcH3-WI+sH5q-G4X{jI-iuTwr{cFGj;p=I;k$Ep@Jj7RxR12U{L3G#po>z8=sC`RwN4HE=;$alW9}xMP^eC&O<^WGt~vo|VJ{K29(bE7lS=Qv5;zzW9d&uVVYP z?om`~jUu61a-~4-m71GY zsN3Djv&grstUvy)xw72wNTv2CC)fX@iG11puWyY%r=WkB@eD|kF`q;g3*%W}p>3jM z-mP15i_(eA@d50TVl`HacxMBYhp#~Mn%zRRFN%!zLbBnj+=dncP_FRVOohKiaxW%GtusKgL=u_!cKLeMFlkJfKx$UPfy7ia^T6(zWv+Hv4U=_XmWx8fD^A%2{a7v9Vvh;sbqYxf(HaRMgOPLHPQHdN6-J zH#>Ww<^J=|8R6*2R&GyZXDDIY5g9jkgH(g`$JW-27-5gwkqS|VoJJr$QjHk~#JXXr z0A>fc=*nthRRQHEpx0P@VJM7w6Evl%nBNzy+z%0QI~>vrLv4Ym`**X{r+nv$rX_+c{Ly&oj1D+|qK!eV zmdFirC|E)v;#!%L)4K;|Z4wmui<$CoZYg7LFzq!6x%9y}xxQC)swtrSD^aa>oQY`3 zpUfTiiej>dO}|Tl@9_(L+Y4E^1JeU56|qyj!PsAe+pANoq!|cgaAqc6yg6W#2IK9n zL4hNP-|uIle{oar_V$ieXBRi$0F6zmsw#j17!3y3m78HtUeFSXX9jas+`isxtKBAn zfX1IWX*}zLw0T(A-}+YgrKc>SudfJzbJTv_S9ItoS+udXO*)<{itGwyH5vW6E_1%G z<4(FxY-7U`qXGoqWNy30uV>?H#1vHW`2_5?2kS44@>cY}e4#yzN>6{Q0J33x3RFn_ zi5x|QAkBPPA(yjIW;YfcxCwp_y^Q`^zV=TS;scUWN58PTe*gYX)h281PD;97JvsgJ z-YuQA)&nL@9Y!6I3e~GNnmW_su6C;JD}pq<=eAO0xE0F=G~@#oHnV8kqs`z|I1enfn{fw?(&0P>v>$QT&cLk*_KA7SW7(9EiwK(ihA zK^BNM33B;WPY=_DAZOU6!(Vgu8EVs#ToW+HDNP_Z`vE^0>~0 z5bElt&kN3~tn>7u5V8K`PHr;1tp|Hluli(J(Q0VqyJGDcW#=DG>N0A62BIt}~X;qWgILRiD*V5-cgPiaQbi(o7r?3kKfEsuIfBtIVP@0A3%x}X3q_oJuD z4Z8~xZ1)p1>VLN5U-s3cEu}>lD$5apHEK<}gNBa14ujoa4eSVeFo0}xYJb0EaxYg- zcFs2zQBhHHatZ}mtc4p8FjG6=|3kRHyC!sOv&Y2rt(DlzWL+YL;d;3NR_CH@0}FIC zfOYiYBvzZJ`wxPaRP0BP|2X@TEx{@${hpo3U<3@e>FnsLF&k5^eKb-yRgzv@tk7Vu zu8!aF2aF4mx`k3Wm*Rm*Yi}Pm!6*#H)r>HZU}qC7cnxWG$<>;xR&@CC#ug0D?NuS1 zbub>=9y75;GQWQLy4K+yQas0zuRm3%KrzxETcXKjcO{wWi(=vqRkDN5cP#8lh_(QH zOYxjTqwDF>_NkdSJaSaZeOc&Gh!(fWmk%xOc01$Qh`o+c@TVuX9x3eJE};T~fw# zxa}WUe9$STZVqJXhf6M8w!#;AlI)KOOC?shTUEMaoeu%AseT0bXL9{6yT@@WVbw8b z4d1_mVd|ExU|{mdNfW|(ouBTq?g=eN5(G@hQMsY1)gI~Y9-f%+dAGGpv#2tkM9k-2 zYj^!>f-FA|`{wQR^z^5@8(>)3ov8^O{S4F)mWpx^ho`&8mXqYN-i=Es(dG=bRur2n zx1hS}Q1q!9=4pYp}93|dEDH-YSx7}K5)s|{f<aH`95_$3n-`IZN2*U_cYb+!d2bnvCPh6F6nLt_$;Iie zM$n{o@+JN&78oD)9qGSy&cswU&-O!2Eq-KV1l(KFOvDY&&g`R@Ky>T_DyR}iCWIM> z>tQ=nAm%vjaqwYXJQ|4dBA0s;g|SkJtWpvZnUumT!!4S40R)3tp!NZSToGRG=RyJB z3_uugU$g`9{C*N_{~q_dI)-w-%j}uDxA4664w*j;KYxB>oEdQ#)Ov4aNR4~TM?t|2TDNUop&(>l zN&L`;;hT##o3dQ28~E|0^F1e5uUh(NRAMg5`R-SexZmSHKRqqojuH9eN0nM?{loQN zm-oB;m${HV3_7q%+kBD7#>bJ6kg^I04T$JbA_A}}5u#m=&n&)v{R)UsGRd5`E9*2! zKki^R@k=7&uSQ0g9zA*lbS;lFNwnuQDg_u=uO-ntzy=}V(7rG*&^gOR!Wvoofh@g+IGb*wU?D0{eFyr@AvCKr{F%VoD!t2afz$YBMp0p4ET#vj#Xe*Ahuot>c8a!ai) z2AGVPC!d2>9>4V=8EN{tJ|wdz`ITxlHt`(vbpv{X8bOo0F1GF4S4s)buyXQee?(C5 z<#~>qJwhL+gQSPsf@7aI2}p{LqBcRmc)1il7i(y5^zXDZy1DAFQ)1M{U=s!tjPam& z;7hC7?Chi7nBXV|`UNHjU9gti#v1eW0B(pwVOVc8`9?ZggW-aD!I*r=t>uc4Gs4Vf z1Czplvg;tohrO$%#^v_)LT|NU`4 zA@#yDu=T>&D6s-3WxYQijcc*3$(t2*IfQ#%Mmc=gho@rWy6x@`zJAn<`t+TP>nLQX z%7+QsC%DIeO0^)j@4UMz_;znl>!Zhr^B-Lv^7i)j`9`-s$Y2P;T4Zm8K_Az!OdC9k zht5IneS-r)3)btXfPo~Hl`AD%netg>z}FE;F=;i+=(f%3FA0crl>37}EYz#&zd}dB zz0f`RvZCxT_L~mKGpXD(Ff&sJ7$!2>H|B}=ER2zWG`Xuq&mZ@!+Kx&ffjkC#>j$Wj z1$ix#vygt2BdJQO4OU3M+JXYwbKo$?B_vd6(RM44l~W1}Fbs8jI#F$!k|OCys`IYV z*J8YeRG0=a^d|^{R9BbB++`naZtf3K;P?0<`oU*kr6=V@hQ+l%nY!hoPj~_DRXJH%)I`ra*sLaZd&VeW zPo8j?W9bVVrkNRDLA8DMW~(#RzX<8CZ4_&?q+SqC#l=O{ns*>wO~Z4cp?_ zhcv_<<>n=8zY}I=d881dB8Y&R?)C{imYD*Ed;(g*B8sag(cH8y0h=&y|A`Znmmuqj}1 zB5UM#aiLCN(pAn^EF|GdBGDfd(3-8h2oirNw1u zInr2X?7KZtpztOuuXDjVY}+M@_lI7jGR}ZtAn^92aC?M!y~Y%2&XG!}HksJ&i!pYu zB0t)+S}0c%7e~T||Nq!~%de>0wrvF$v3j-iL$Q53>mf!Zvg_G(N@>gxS{V`BsJ zspPFeb)`029QbaywmR@f(hNm)#b39e|HT4!c6uqCasiiC-S^rG3@$+F@f?RhzP!;P zqg5j^_NT7o-)vP$$s*%S$6y7-g}IRtp(i~ZJ(61F-wlq_I@6rY2R*y|qUtzutwe4E zrSgqe$XRWuGzB+@_%CBP6dgBJi2bjhSzp}FL}tL{dGHUF|FTY=JNLUfyPEu4N1>px zF){|PhNhR5B_)yev+BtyDn9F^z{U4en%?HQXJWww9#GaPBRy618#DpB9R=j>95YX0k7@sgW+{MLX+ z$1)I+%pQRHzxATbEVr8?}dxUOJ)?2LGpUR;>d0wVv+orWR(r1xs&lLuI}i z{Ku~sCC(PmwAQ-A%~1N2rP>jRu`A0fgc6v=pP-{Q5mwS)4lC^K`W(X8M9Z;^M*U+tiy z+8BDk)V{eFO?Gtp!mO>lshnrm>bwB~$YqORl6AUfQXAHG0BuY{g6lz-OLBpBei;lV z*k4~zP!JqNJt`GuKIr1?3`TTcJtb+@NvKJD!AW&>+Ry|eGN*aw{*XSGgNhRfsAA#a zp+$zpS`rkn4`sqmhqkh^J%2|{;`Cm>dKKh#065$lle)-{pfsuaRcSJ0-_l8PH#hAMkaAiJ0XD>KJb&-hA(tJ ze>gQW1e~6)^DJp{M0`&s3O$@Qt59R(;yUc$#nr{~sGnplnd;lVos<91bqol3@AUO$ zjExhK%##cRE)}4`0PO{EsN_=py`(@4K)fd6fCVC|7p>OkzRw6%pCRKjGaqT*g7S!p zl9GUo7zp-tUFkD*T|d0DiXg|DrbXTu<5zoDoyCY60IA?VeGJ1onN|BqlCF z0lf*As*aWxE-O_PSGBI1%}ldLg!Sav*}2YfS5rCGnN99@hKOWU8dHD&#TI+RM)~0% z2?+pc{`gIvh?R_-l(kMPL6-|YdT4LI7MMl|Oi%xPbhDB)f#q@R`U#H+#S`9@n6jLd zYr-HKVE4zy#G0YK%U~3nnek_utC3UJnk6M88^iqsJ|&m}6`;9DX9WY6UgN{EM6I=^jaIn_n9e=8Ay!vc2L=X2<`)1bmgS4qJpg?Rx$~Fc zc;HpQswg)s&hzs*WUPL?2p9L<|5;clx3t&-4)Mb+MBwOFS2H^zP zZ9LF3Kfb|RPKXE%6>4JX%F3Eruy&LAj$^n>Iva7w3l={NwP!o!D~BUm3O09U zip&(Wek?Y7I9=^d;!*qW&;Uzcz(+4M3C0~uJ6=Z+zoQNSsk%D4y1e!ifauT;AS(N^ zvN-C7`gHS#FKgFfB%Iri|23Ve(esRoOczjgkN|pFNz!0FUK_sClbDx(1FXd?hSH#S zn|4xC0-|i{UFnOfTI~M>f6RR8c(qFes2mj)uRZ_mrVy0cr{gsd!UQHNe-koy%iC;5nSpce7t=a$B06wLbbS!M215t1&pB)s=|P#vV|@$g9Lx0&lN6CD8M3>=%P;as9w1cVAmF5}+j+aCcfD-xq_7IV=HS zXWRIiXjNNqvB6_)C0mAC^4(6hrJb4PP_{vSBqg)bm*Fy_CI&W<@RU3U9%xBdBvp1k zBh!kxnHjK-JzvT0KAXv=v@x+e*xX!%smsu}&p&)0Wz8LCk7u<~-x;RYWJ}?*r+X9S z?+fvO8^pN-{Wwr5%5wuwRk9Jp)AKsn#l{Kpx5oi~e0-dlNC8Z2pJ=ls#$SHtS_vA_ z2BhpgXkzQ=*MWHH8*n(_n}|Kf&|E&4<9B`{7u<$_ za7Jw+l=lVOX@hFaw-YpSz$%o}s8Q-6dXsFY=j(l}V)r00S0h2=6IwNZ?HZ4IZa2h2 zlDB(wA}=r9uZa|&H!dLh)qu!*7|vRH?#^YP`{4>GDUpXu-I|OIrMVy9ih~Ja9=-YED0XdattAd9 zuK zVS;lXBw$K4g)P!tXU3vtosU?U?}Tn-i4xz^{`3JJ#Q@22kn&n<502X7D_L7KP{Y0x z@w{~hAYWG(g8@CI_ju-OyBJHpYtwHSqTaWTQ^VX=4{$Fe2Y?5>v;-_PyuDM;#4-h8 zBxAKs7Ip_JzB-5E4Z|%zEI8A{P-$C~;_@ut#fA0)Q(osrsk0wmYCKyNwCxSUZ)Ihf zN?92Ak~a|0mE0bm<=LHu7jUT`tpYWglS zsx;9Ufp6Ztsi{x;Wno2cUwhU&(_m%tQT2yqx(f9Iqgb{~B;>_?X*T-{(f*cx(j?L| z%pCYT0MioP-O<*lYzNnyeb{QdB&nJ*a=k^RyN z0Plp(E{8wE3Opl3%De5)o&!ANm_(X!sI9$Ss0Kk}ox^s0GO1vnya*K+hdF zi2h{SVR3t2O%)+8udLT%&rT3&!TrnFSerz05#6lTCrODUT)L!k5;rU zpNUZRIaTIc$Fl&8*onzRMdme&3^$5}2Kr88%v5TZ2sc(AmA|;%Q)wrXE`!$y=D_>JRuFJ61bqn`3m&2D0d3 zKoh){zEI=Bo%mGPW&bVFPWXT#!fO$LeA%(sz^)500|n~S0nQ4c?g1{`+GRSue>>JE z0VfqPpZEUy?>Mbh#4hhs9E!91JFi2#RV*mqRNa_xD6Y=VuUMb+Cbvf${l2?VGU(g$ z9sP;rQq;7EpZTuc$6N8)hvFrfwc>}?1Yd0UWS3d z%P5^;o2Jz`!^e4Eu_>+Sjt38_UZhc&)zkXn$R@tN{2K)!oEd%0m|TeYZH6e8bXNYh z$`gJG-^7NH?Cwa2mEW$3hGw4K5%fR-jrE$f#_BW=IH2SXJdERsgJ}Y?&XI}i+I1YR z@fANWn%#gbCyIjz>3n_$Xh+H!A3VeOfq(W~i80{dx!36Pv zXUzAr9*>Q(*ZEoEA0q zGgT(Rk@L=icu`lrw-Vil05SluowO9kjZ(5eG;0yqrYbj9eYfHds<+xeuNy@!`b~Id zsacm>$jNteUzep{l_B~ifUkf7zt*}T=ADtyI+XgQEaOgRlz{`blQxj7YqgP0=dvay z9lbW|s92eI$hiQQ{k@0{G38V<9wCM6_VzYFa!EsddIE`Q(+HOJLp`@(la)3KHLq`tx(3P+KNA8Us)Rvs>6nUw2?Pz+^m(ufBr&GgVXt` z9wVib{crpjfHlM*+O(Cp(#E0h+wS=bS67XS+#OImU+j%DPD68-r&%0~ajt-Gy}V%X z#naAxP{@YNA}}7)9=IM#LvJsmi#!ws<}H^NGla@NMAlZ-=v5o3y5=<~``&Gr6KHQR zWNFL42=L%Ti}qXmTB& z9$&+6rSQ$N`J?~sG=YPg&#*9?36}f#!k{(rF)a5 z-Go!MPK?Ld2j)sG4OICO62=?P0cT&=>c_a0`#CP9o%iLDpG_t`cc==_Xu~IyvY-#> zl&jCk1djIByl~2v)U?dMrJlpj37kiYwCl^;dCboxMyWZz zf-~J?$lm@$6L*7!k;0SS-S;1JY)qULA8ct)4j0FQud217%Pd@>^9Es6O}S>L#8|GV zIrSezWJf+w1{uz7_wedm@~N%FVFm%461hPk!MVhv5^MKb+bvLN4JQWxohqN;d8V!m#2Eg?wxE3yczNeD6irSStqy zlljO+A%to0*oIjH9wA&L^Tq}3&d6bL+El)_0}C9Flt)%uhEW^=qDv-jckP4o$AHb8Bw#Lhj44FE0Aq{R;S*%xmp&YZQ!uc4bInV+H~xhs z?d!wKlV~sL89^?D4v5DX9t1L%>hIq%j`L$;#5>oHjvN<-Gx@FmZtvFGC_2W^O0tB# zk?>WMnC6uq@@Q}pqFlw>U&=rh!V}pr34~Ab4ptZn?7dH?l#T`4+n$)AdrF@*f@ymE z0y}6572lj`LAveeGzKqgKxQ9+eP2ZTr;$=qKP)9rHrrs3M54V5o^=ja7H>0(RteI7 zDWAg9h!t5>rbrqm4o$z5lId2J=jY>F`KFzj#Y6_YAQt9EQI4?IiJNGM6ijC zre*r&Q@?+4`C@jJy`0$JwOKr~9fipsoyLBN0(?w*L~E*R2{Iz?8^`kbc{Gf~hTE&~ z=M*MeVP7X=5a4aIGL>3gLjDr$I4+wrpkvUu+#H86YwNS(R(AT)OS zxr7!qtohCBkwnYUv0I3390SdzO4XUF1M#N3lJs2oLR`iA z`H{#LFFb0|?a6F(%C3cT;_&x6gVo8~+9T0>pfyBzE)kjOp-I1o3N7cY`5X#-(2EN% z`|Rwko~KAEWDR#h;-bBqwHY?XhAwojNlxHa^N!8w}x6l=+eDvM(7sor=-y!ePH?C{@hP5SIL6iH%?`jl~ zrpI#R*G#DY2q+Q~O2=!Cvc{hmccOn?>`aNomt2_ zKOfDfNJ`ShXU}>RJTSnv4c#i+aAMp0TP5wZ$m^ILKDOo5S>y{VB8ev~DB53L-PM`; zGdKx7ynVprOi(X-Crgc2q;h?ABkp^gx=XsR9<|LC&!U<2R#v7(Bi@sFKZ3!~MH|wi zC>ILn93z~^%TgEY?ZrC(9ItXe&5~h^BUoUqggxdZkRBhBsfUDwWK22y7B2`7svwaJo!HhTCB0o3B6^4|UQSfF5B|}g zo8%2$CJ=Ko&|U$1!~5aS9cY3f<7XE0?F&HIQ3=Kiz-M2;)8O?bPCfUtz#Be*g7zk4 zAV~oD_v*?ikOoVyG_OW^MiS22=vAjBc-wZTd7)-5n!R zR9yu5sD%KOrCb;FK2udtIP!bL$A{n4$Pf`G3TBjwFNN>-QrkMNlZn@f=sq)9VHWzM}hz*?k!1Nyxh(5TF)BeVoq(k&$|@ zr>i$Q>h0sIP?jU?90WalZDqsCbfu@K6NrrvWvbLpI`V(>7z!xf zQ`ivAKiERnSYiXZeGaBzWY7G()%!eo55sV%Jxxo0Xl>j~d5bfBtCdhQp5iH=HdZTV zb3aQ1wN#LV8dP(AEM5WR83_mrhJ4%ZiN7t^6oZqU@Y>A>4#_|9#ZXpMZ16&i%ktHI zZh2*8(du)WY}90-sdD^BHmPDv`gi~^o2UAK=-YDe>_xm7BQOI#U`nHo*2(`~9JU8O zW_EV=KMC=RD@Av)#WY?`DlMW`jNg}LxPWM0!-@5~nIK7^$?ynf7!-fV z+fgDnx6;ekdSOwe(iSfhOLgq6&8NzgQf`|8Vx!9PNtKl7^$1C7T>MM^ldFCbHWdQ{ zgP$KwBNN}WVRUEG=h*Odl{9$B>z^+3mtHY7qE`k9>Hn#t$( zu4n5FKkcmSXl#`=FWqDi0)x_Kv4MFzv1g$y?E*!6XqDq8XDm0m$ib&{`KGO@1eC$) zt4}AJT|hL5R>I%%o>m}Fl9dH$0GqnGaqpGh#%VDvb(-ADQ!bbnA{}_-DY^4nv4{l} z#NNi+7%!Z2Ffe%bRso7`nL#T=aGGw`Fe?hn&F>Xd3PwOj=NuZ^n(T^J%%9%Ej&`Y6ToFi@Z`&Gbeymm9S-whi_y za~Q9@zg*YR)APQMa8nmY5`ev^S9fA;uXpR1pzOf?DTGZxK!8Kp(d7EjunBWZ&bY;u zefk}|6AV7=38kRWJL~ilc{%4N!-4cXV-kV~HM^6o1I<=I(7ZRDCg}C8Fb<^u-Ff;a zs<>1D(KrO%DMT&=v``_D#82G+Wv>-wPuNFG>+3t7EF#ZSdJ1}S*2~)lz73`X1hr$1 zMX$~{S-`{!farG1U#ojKn+w>mgpeD?nrDZI-dub+Ffy9|d7r^|0;bCb9)7NCcj!of znYPvD=lSfCGdH&UiOpBBa_HZ|R(kVI_~VB1Ta8<9%}??2no_4nkz;Wx|JmlP)W8po z&if~T#P|*=!$U)7Yc24Q3%07pBZj)i&{tbFopD#zBb-@LLU)0zQz2hZ1o?0Gcewo> z(4k-c3H@>Q^b7UJ}j24_nbkQVRTjr3qQv#0jzO)wpBSmN+9_Qx+8Uy7#^rN&{(0 zIn&a@q6lWp-ieeZn01l_$iY>_`(LMuzLkMGm84b=WdE$WkcQ~j2a zbDIG2w!x{OLUAjc(AQVj?~WE+yPwev-)sKz;4bfO`;3})M%=hPKZ>_Hr9%@Y()bm&y)IrcFzj5dtX%CX| z0@9vr?qe1UH5{4MP9Nb_@~ePOuLiG|nB7^X{@H)^R!#UEH@=_M)Zn)1a}}s2DyQv~ zh)@P&9`-?z9Cp>)Kf_%i8AdH}3+z>Yb>>FMEQfDvEu({jyD#6ENUMCg`C;Ux2fAj` zZ2nxkJ-+#Bo`n`0Z6UfGQOQ>lLUEvH#)lFLyX|+hywEJtm=yC2pWT`N({h6Zhje>; zJ1lM3&Q!K1zmh|$# z%oUy#J}Z`@w#ia;na^LT^-h013MeR%hadjV4A6+37@w58_U=Zjf&CW?*!aiJDSKFw zKcws1LZuDKov_Ck9?;NG3VdAtrbKQvqoiLqB1oeLv*ZM^9;srzRdu1O8*Xcix97;E znqlp7eb)QqgkQ7bu+Y8v$~AO}718ggukUU%T5eEmtA-u~?)$0i#w8eoh*kNxo=7Lq zp!x^j%tJU#g&h|>+;)|aM((?X1zYJo#v0!Up3f*eiF=hDd&r=~eGgy9q>Zb~c|Myn zd}CIqGR~-S5}q^6xU_V7GkH~-H>yw-wb9x(J>qucFCDh7)$)*uzL801k`8h2XD?~4 zT;JHLwHkka*woM2w4bz<9_!TCLSuY$LLajHiTikVPg?z=Tp-;c2j%OwL{Np#pO;yf zYbTx_yW$()nOY;-q{J+=(5StIK{WHD6OlE zPQK$DVs9KJ6pP#SO+03O`Lk5c0oG@_olW8GHBE6cCq(}GQvHYqoW@->J13Kj)5>LW z^Zi^)eJ@LF6~l5Xhuzwm9VCaZO5RwciP`?-Vvl<7A<`3+%4tijx^GGb_7_ zfP^D)|6JN46(Q^VTYDo+{}Q9SX=SOq2!7s|rIm86tJn&c8H?b0K-aeL0e z=owgTyxXFEIVW+c(ua+WO<;>zHLvHeBI-Z4yKO}Ff_5orJ@|#$1`$_RJ$P%RY;3V^ zvoCyp$c1bJ#FO<^#m^n|S}m0o+7Xyya2_MWCojLhh=%str5dF9`@e%>?^38i&Jovp z#%3ik)U^|&yP|YRoL9y}CwDoGjV!?Q@;hR;@4`*UhMQ|_@0kTP;m|!A3Wa!++tquN z-C6n*?QtGVrT6r`n~Zc)>F?0QM?{6H%G^6Lx9FwQ9eJFG)ZB(H13mkJU%85i8c&dj zs2;4(4LVwpDJfMQ%XrP~6EQ$=Z*C-R)p0|iWcd5``M9J-QJ&AWVZS{Sb!AMKAmohg zTWcz!wBC{_2Hw^^M|@$4Y+5WwFhN-NT5INdzbVLmXjpvhh9aLAn$SUf@ z9p`U|)(7QuhQu?Yo5E$GYtaH!tq{)aC0>%)Keb*jw&(Onb}{wG9T zBlJJSEEZ{+e+BO-@pS(~Fa0Y2;{W~o|4%<%E(wa`*X{$?n|tP^d82aeU!TY#-sg{7 zxjfP^cpWrwM~-XwpGJL1<_}XhA`)O@_;Vb=VR>iROGYj0EXv ziDlVC6iIP$OwVa?y=oVX*x_CxR0of^4lHZP{JwdJa&dWyqW73u^7Us=TG#2I(RHXB zetPhbv%7b7lMcy;z?+)?8tU%5OkvPKc;3fyv_=d~y(iDeQjp~{b*^zHFR5j~Kv8}4 z-*ee!9qB4aDLpV-Z84m9O47&F@Ogv9;{%i9ZEpkD$F~oo7LgR9)~0|#_FJv+ivSC2 zpD7^^S;W%-378k$o=H>5=Kob_xoq? zyQ!2W=N-D|d`UX;3Spx5^}VJsf~r%fXmm0OIA&bV0@i1q(9e+2fmzf~#q57|`5w%= zH_cGL<8GEcCXg|t@BfC1LKph><@0PmM+6`qi5{P&s%$}`HF1lw;RxT~E0!xt!9oM(rwhKYy_hgge9N!xpURn7d0(DY zy=EFQFfFW@(oFgJ)0RvB37Kl`XQQ3wt(IB?itpRGZ+^xMrIStjmY{rjeQpKnCE~g>*51yABt(TDdD-xq-kA7mm z=bh4Mytu+v6=*ZId@$?_5+aO1$HSQqNAo;mG@oV9yAassg9U?g94NmuX`X-Ei`O=8 zx~yXLy|Jr*J23~0*%6$u)di&3^CMPfjtLA03eE8ZVv;RZ<~TwU%u7v~kZ-nkB& zK?^Yv8*%mY9!H)slhmL?iagv~*WKM%TJdCRD$e^_ac~F+73DL9-%-!W$q9o>ngm(6 zAOy&5zMQr~i#WtI;^I9;bN+18xdl(Jad2`Hv21d%&OgB6(NAB@iIlC@-|uj&ol>Gy zM!a=@0$kn+7?oI=HO~w6_gR>klSB^QPhOzg)PrCW?XeF$SiNZ<3^jKu=7Lx}C^V%X znz{`Q8NR;UD&%1GJVh+(c~YW3Z^sCfdA)ZG<#f{w_Us4tQpKDM~af zuV!sG%wAE|lPQ=IEwxGpQ&{p^Bk%O?CsV-JAoih(`({NsFI*W0>6{-{Rip5{vWRvx|O+8}!vhDb<)E;lxDwF7c%8`mWJ# z4%l2`)X*(K;W~#}nW#iWW<10ZwC2@?Rqu%I?52nhi)Jc~P)g(065~&3NKl^JU<970 zz&~cbEhgA#I`2-TG8WpCSUC?hS*y@irwXTqSj-e>d)|h8r%V#(BzjxiC4oyr`~qXI z{^K7yKP|>&x4pjUbp6u{g*|cigMKduXJMO;(HN_dkFPCx$oyW?x+VPij9xU3~n|)5`N$1 zyBgfhK6l5IZ!Y?jW;bh45P7+1p2eXXa!l79fWh!>XKD^^Ze%fX6`7FTY*Z}h#rL%3`nd$uU&J6)X+ zX&bo2ixlH7rpJtJ4)PoAEw(hR{C@5Pu`1RoO?EuzH8X4N|1l)GmAYjiI^Cn5EVlMO zX%E*|OTuYuD2>Ogh|8|Ft3~W4-tRHGb*~X)d4R-ewkj2ma@T3FssF)$lVcoJo=VGdGp1g7xe)WUX?RHe^I~Sy#}0f z;;DUg>b%ElQluZ>J9iIo3iQ_f7YktJXws>9lC;9g!YZv1IuUL4aD_g~M!_-MfQOud zjb@QN<{B8kVKD2sy5AKvOs~dGk&&;qRO#hWIZU(UzQToPStC20ki8+?PHZHukr}NO zs>)jo+s2fMq@LwO9v8x`=Vo7N?FJvnl35s8($by8aM>POGwq(%;y7P<4X zjr?L8hqjN|8)#Hy7W~0eZEeh1Sm9r6X7V*uTZk5k37@rR{zmS}=qiup{f_WHrheZ| ztSRpw=cF}ZUB>jdpO!-Fny|dz$Gcr8wL$2KGpv&Wxo)3_HI{e>lzN_qi-*aEW>pXS4>f5KEO+rwnlBLQpOk$p*(mxrZN{KcR zKFqEb@-ZZxb_hrL`WL_kR4G2!x>B+&>`2pw-GwIPZaizF>%ne@j6Q-=iD7&sQ&Q`; zfz5r5IX|_^a|7aGEZOsQ4Py29=`)=s&&lOK0a7n(^bFSyFazN6Dh_wEngLI7u<|5U z#GTSS`q>f9=Lz5EO@cVY-J1-$SK3vUD(nvm^V3=AEC!QZ==9QFf8K9EH&Vso+PLw# zvAi}5O5x|R!lbI#_n~YRO^H=JdGJ||zM=Bw;lYs)OXNs#Jjiiqy)(K}^ZD`R5?Wk* z0DP0(FRn-&TGobLd}^WHxUX>J5zP;o3r5J2#)<+Rjt6iv;^s-n5i>e8o)3{2VTI#u z+SQDV`cL8{`9N>VTJ7+0BJX6*DvMk4#HwIr6gE)_A#FP4J7SozB5rR70ftHG|%5^y%J0WUdz;-)|pl zmt~xlT`J7MA5U{G^g$ zUW7QZd0C{%^}y5R!lupF^>3;EH1?3Var#U@)*N6rgQ#Cdn*i|q^6=5jtd1vVs`(uj z$1){@ZOUOn^z@P(b{DMWXCj42JV3)+%Xs)e>^^k4biOyOqFiDOU&)RV*RnTgHa))D zN+Y>0*%g^I6TdG|Y`*H+BMw6hdzhJRv`L5g+}-wYgwMVLQXawBRudzA9PEpw>_B6b#kmz+4Lq(O@JL2 zJG6>=?bBxb5kERwOYvExW4u1!5w+jn9%6LWYjM3(9~R+s@GzeW1J)XQV^hkmu}4w zKVSY;ufnbSsQn9uSl)k&Xw9$}eu=uVwy6o@x{L3%@thqJf8%(xRGp(EMxFHO{!K#! zL}4uRydFKcwlFq6@@E>=%Y>rw%g?#vYuLG`Zr6}ebp2vwdG27CsV=#<4bEzt76#% zD|vrY-b-}zq~U?ezOU>SHxP?+LBcis4ejXZSyaSA`9WJ+X%Y57_%B=_)<6AFp zGr0_&*vVgpHN~DD=?HohSa$jLl%f)2@`Eb+R}(3k5}8jc#~6W4=?}xcaXVo3J=_>S z5VjJG;AR5R-?_dcRiUA4liy=#XJvI)c#PkXif?nf?Zus1;<4gW__D2v=-pYeCaal! z9ODdf$!*E5eu>IJHW(KsFFRN9#bi5X531#&2yWq$61Ek*j6cC%8SWK~%|+{Y}o zcoHKxF>wN6f(Phnt`!)m#;TWX3guW!el^T5F>I_;9pFg6)NK$X7IVGmfovan+u+?< zbahV!&SH@b@nO-z`M(dQOOLL``m51#L^L`0c%E-=)?hSwpdPfHgz-dN#z zpk^u$y$;qD6?RW_j+G#3P=GsU<0tqGR0qS?EvBEij!U+G@S^-4{g_LsO62F~(Y&9| zu8DIc2{A&u9~98VH#+88ZYRGWy-XT7o#RNFM3#4sVylYA_jd6 z9{+wr_k1e5V*AqQ#bTj4qghr>&7VKrVD9X7zFVjWTt+l~sQGvtDJQ}>Cn>Kldh^q@ z@Xg_^P$_j&3kzeH4Wi8Y27|KtB@b=oXO#Hv4G%3>=G)J-?^dimHpx77*DA%eT12RZ z8J)yhPD1-PoQJ6vqys5Bi9%Q{#95OKhkmb=?e2#9x3uluU5tA5xgsds+e>AdwZrO3 z?lzm=hK=l%r-7J(pj4lWUYSUDWM5wqbppF~-kdol+nRU1`&(By@y6K{Yf1M+?czPb3o~B7%?7TF?HV#|s2#sqgy)W5Ubx-b?$z2)6K-d@ss0K^ zh3{2lQ|z7Ec(?W{H1-jFnCp!m!l-EN!cYHMYA#Ui%2e?wY^-Wxe`(Tore3M;Ww|yr zARt+!NwT?+c!2GUId@#)8iT{G``#=!*Rsjou>42X=PWtoB24#BL0wbZD}uzM>5lDKQPEnd_4>E83E zJpDz>a*Sw0!rJA6>vYAml20ERU8>*H4?nB@6G@k*yj5In(T|xjm^WYL-PtFEZQM%d zh+8^w*5=lRnMUP#^hb!Fg|^hB8@Deih2H`5uC!Z)$B;g1@`tf6j)8Mr%AACh?5Ae42eaRkj&WOW|V6&=fdJ ziBo@^Se-)CEE%jn4y~2s*W4QRZ+YSvj{Vfhs{Gyx7M{7RL5}Hi%j=R zHkJn3Lt{%~F zJ!`Y?y2Lp0C(=}2Vb5p8$bdp%yh>-+!PA*ACI)qSGA^O^@7DF7b9p&A{GuGp9T|z@ zagXfU@#tS<7Qg7RuIN56@ZHu}?S?01p3V!{jp0li6WppL+{bDClGsXfx)?~wN5{Jq z1@ac7Rq{QLf$eBM{kGrg*ZKc2A~>0^j@AVEl`~A=+(cgaxBd3W87m>{o}-n?<13+8 zH+k*3P@6^CE3}x+isx}twcT0Ug1ZA^3P?RDM} ziyv?xZAPgWqPTQWM{OywYeOQ+)lGu2(UEGWse}_K^GSb(c8|_vH)k=#V)NTgLoWJm zLBe}|Z6Q2w$4{jWe8semwsBYEvmw1*^H1)EuoCBon)+;F6=!qrtI|tqU1dd*S^@v*qSSde-ZnQ3uG*vQD9Y=3 zRNq|?-C+7Xo`4dsWkl^Vptyc#VTJcZ2dB3e(>WkLgSzt1grQ1JYwYB;a9&mMCLriw32pr@cEXk^?kg|N>Pxof%M$qNd$ zQ{2Y{^mA5Cw=mJ1xx~5t!m;?|B07yrsDZxtm%G$&P@pXP{K}=3%5r_PFC2Sb8*|{3 zSS3SgwpMo1EWgz*>QbO>VlJL&*q0p2SMb@2S420!HNCUGGh+gnUA|!Fl-&wb(7kV-?cZSKn;%RnpuC z&(+Ne9f9#`{o?(WMY^|?oJ=dW($d}AB&S0Qd2B zZw_#M@8Kncu+2b$Q+E_j3qG$XJ8up4IH<%7Vjq9nr$~y?N`FOiKs;FBR#nmB^fI&Emv-1N1Y z*6+rgOR>&aqKc27c5}}+Tn;a$rOn4D@fEiDHZ+IKVxUCidq#=Ur}B+LxxwUycRDJN=vn?>rIEZ>LwKUczThZN5O zPf6GaU!2S|ztuHA+g1a9E`2q(f$rBe%vA|=)vGgWT_J0736kC6n9lT9S0tyPo{CB% z*L@{i=i@z2^h($#dR!>mY95|}{gA3Z4U&g-h`dh^knsxWW~JpO-3C{=c4s0gLl+~^ z2aP;425;nDRNPK(e?o91t4??0IeEOy&K%>&i^F~X#kdR@)A&j4#r`R0UlaU%$g4Qp z{Z=k${>v=G(>8NgHkrJmv{(7xH(yEq3g=;H(Q?ZPr(K$1NQ?Wq2X|CXxKG!a(NPbR zuzNhS7DhSgcaeq8ArM#31L?-A(-K-3JU*dV6tnlv|E=EWFmt~<>qxC(A542+8@rcl zfLVC?DZ|7`u9PiH3|^*TFQ08R$2eFpaM6_~2|;-2v&8RRH~zC{hyH%F=leMF)VLAW zGqkELR%3T&o1@WdQ_)5z=X z7g!YhseMm+sS(Ze{QTPG(+`*JW1t9@NjJ)HJB0lyrg*qQ_=e?Ku2=etyCN11hfH|L zxW~Ak;!))oNMG!g&w=b*?iaM|txn|iZU-lvh@_R0lUgBe#=P(*2Q9s6250EZs^9B3 zqpp)giLJ+*!jS9L{_=FOjc@6qP4RJYK6eM7bD6E^Ax?8k9&_w?qPxQ15Wd@$+}|?N zlalU7yE9ne(4DJ%uMB5Lb?>9&RCH<|gUiN*B(Lc-ZZBtq=iz1b*1#Q`IQ*yzb6P3g zz4Ugn+v%Ru-DMTgl}2=}BW^c)yYnzvs%>Fz8mL8*k_3PEvc;jN+uan)Sh%sE6<=qw zOB_cL$nbsYyDQzJpt7!aiKhBEv(-X6lGRHX5AJ7N`_MCmUj%C^L;YMqXUx|KvzrYr zGjF}d;Wr}tte7PGC1X@mvsX22)q~FKOuR6+F=P|;Ko9!(wJlS|6esh*E0x`}f^f{^ ziQTGOYqt!Z@$0PQC|}H)fR^QsC$Vr_foz8{oKqDO0+r1Qt$fu z@2>HF5KJ$<-pR&Wc$sR}_65JE4ZNg@d-E%c(HiIf8h2#EBiw9usY9uQP80YqA)qd)|tN)@F% z8_x3|JkP89bHClaW_EXWW-~L_H@~0RLf64T05nhkm7L$&E5rGBq_Ev^=5uNbe&0TD z`K*jyO3(e-?SvCZQ>(~OK&ro(Y`TmF#6cJS-ADErx9xE{KG{W1o63XY>?Ggc_75qi zI_b+6OjSPl!-k?J3{|V#U1*TmVdV)x-`OJ`3`W_ySrRFC`yOw^bsryePb4B)0cHY_ zRp#?V|D+q3B_Hmu1%<(OzcC#py$bj!I^J2) z7H5SQIP8^u)?g}V>WzxB>K=V*b6W;#@@FmScEIIai-z+ ztW94)xxsd2dOTS7F=b5;l}`96d&_PG9$+dpX;lK~U$Da}3XS%&kW1fAclr``%R@*( z2cK?)4L-gnu1Q5FA<8r@ylpa;MGqW?$a=iR)onDkXP%R^#Y;bpjV->0`kUbM?aR` zse9YowGG&yePGS1qpj?F$`DvIJKWm(g(A>DtY1m}JLY9eudVVL#z^2bkc25XXUNf~ z`(hMw};?6_AO4$*(?vD&N1GvYx$x$}^L=+HFK>@nqi* z8Soo>Ic%VAtvS4sderzqLdCmYTIybI{}7r|foZ_1R{;XTfrr1{Mk_r5Kk(UK%fL=9 zurcNQrCeMj!XiAYuflLWVLE6}k|W8y?RPKvhCL~gEwo=@t0MMCw5DQo^*b!ay!p0* zlUtFXWoC(V?g_kp);0hiZmdk#VFK63 z2^Bb`98cLy5mJb76GFD${lI1Q{Co9?;rm7;lamgz=vZEMvK+lHkK5#*fRON7=-5_c~5@az7Y$eCVnP zqdLl`_cjW%6b>|#LM=fmrI#QS_w6Fti%+82_tAV!_cYEc;b-+Ym^p(=kO@nR;)*x} zd3pKoIM-{%5kO98?Rt^RkvJWWIIju=2~s>*?;jd04lfOxFami@uW<5|n5p1&@}kEu z_&t~hJVzizuK%>x>XF7PXajl7o2}H7Vh}K&+*WeNX>{C#8xd6$O0uOa;Uy>PSURr^ zWgED^Y|$$UUF|Jl_^dOAz=(qc899Ig*~cWpr<-@O&0e-_&IcZbIbDG^yeTi|jPM8G zku`}N-hxSNvRff@HqhbfMGM10!Q4P*wpa4T40oMS=%oO!tDjm?m>q0r2Tm@H{{`pT z9qyO}B`pdgD1}&GV|A=BQz~O*kkz{6lFwMFL#B?F7DO!BS~pQ%q-6>Qk)L1t?6l+c z1$SZ5Pf9CeSu(9!w zT}+}LH}t^KC?!5!*t9=&3hHIya}~2-!N*W@f#yvRCkXNf z9H#H#)gE~9$%b3ui|s3%Bh5bR!afPb2!0mLjNwOwF=KPYMOn#$($Zd+D)K*NDqs_X z*lkfm9^;9#f(Y(vgO^N_1U5RHJW$aPOzBVP-ZcE-6?%%MXp{4_-xj_p<20L14HOD( z5WQAm9nfhne8bnzuStTX_@KGt*}nU;?JQ@YJit0o%B-$I_zeIXO!7QmSG2N8Hd3IW#5SmE2xU`;jzJLK{xmf) zv1yoy5q{{UQ|$D(g*EGN;i6cZUr&=m4JbEOx<$8hDFxNc4m|p$79ZD?nU>u}arF6+ zjZ}$0o=wqD0XXmiPBE0}^>tTrUI@M61@JxBvKjzUSoKOf0B|Igd0!&7a7grQs6X+N zbW==+H3WRGZx>IUX*1heb~>fbWK!4$^whn^#}KMp^^z-Dl>Jdsz<^gU@Xu?Ua!aFkc6P?V zQpO&{7*?j~v6Ofu0#wGXzV1p!3ZO(P*xFi=JLXthFqxU`7Ai3nz8KFVd+x2LsvrxF zAw;UEw$`PiQv=8o!9+yF0N+Uw5eX5Ayq04dhboIfz(1IJduDGKkcl9RXvJ1=%zKQk zj+n`_wM5H})7y?2_27D9HhphZb1y2Yl#G!6##Nz5lx7b+zrHE2JZ{aq9@Ns3``)B! z27_HGm)M0npN1&%612Hy5Dw0S7{dYR6+Rg_x|x-NHoFxq8Emft+?0L@Bmyrp(l`a% zH~f|_Gb92qu7O9#OHDd}q+HlF9l9JW=Fq2Kd18#&mu20LIfP+Uc_NFUy_^D{6$QuB#EroD6dir@G|EUvCk#v%%(l_ZxbQ zxVhHFqa%|kn;}NeV;h)fyx(xriheF0JM7S8R+WSSYHPU%YeoOwGLD+4V-cX18$pNU z0J9p zC|7m(bmzP&U{K-J1WZ;=(AQdcQWDjz;T~gFo-N&deqMbvBKaW555+uZNGpu9YZWl` zL%n&W)q@kyMGT)-yY!a}$v$di&ik%u;)z25PD6~`b5kIeWjGmcq|qt~PNnYnCwisG!<28y-cwI2IvZx(vdh2CNRTLAyC!y? z76Yky9(Bg~wLxe{>QoycLV182TnB#wM9K5UOkvH;J3ofe!X6kqE(L}Y(S(8eo~68z zAL`qYT`D4Jy~*`sOAjc599!MOBg`+@ zo+b*KkrH_5-O(8xpKA?_(nf$ z!N6a` z)P;n`B-THob?a=jKto|`Yi)I|CAgwDWYr74w8O|T^*|7E2CzMR?6Y3F9Xnq%f^&`3 zmf%S>ZIY|HUSyNXqEN%p(QM~zZa_u5pg75? zl;k3vt_LqOFBw{{Z9C3w?|{+TQDot#FF>z$?-?E~#G4p|+p>QW6w=ahLg-}FT9hI` z*b16{w6MUSP-bRG6so)y_}2tb>id1U^x0WgU^G&Gg{3xHR$`_92HzQ>z)Qq>Udzk7 zr9`j5NRmxXJOcti_}i+N_YDhgEIxu0u@yu6!qLJe0j>J(IMzF}b@Bh=;W#n)gOj%S xFCPy2KeO?Nw}D1KKK71UpgHz2(ta)ILCnj-lb7uo1A!wTZ4CqUay9FJ{s&C08Grx) literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9cfea651dc7a7dc7f0533d094d023995e51f6ca8 GIT binary patch literal 5508 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 rXOW0F4nUBCxHh<_r0VI8Qrmz + + + + + From 9fc9288604b2be9db1bec56dd94af08a3c02c621 Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 23:23:01 +0100 Subject: [PATCH 31/38] Lead the repository with the live recovery product --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 2b26ece..2e42500 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ 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: E2 observable replay.** The complete two-branch recovery mechanism has run on Starknet Mainnet. Four successful transactions touch both the canonical STRK20 pool and Afterlight, including exact-note private cancellation and successor recovery. After L1 finality, a fresh Ready X read showed the successor's shielded balance increase from `6 STRK` to `7 STRK`, reconciling the exact `1 STRK` recovery output while the neutral sponsor paid the separate pool fee. From 01446bf2a98f80d96294b59ddd35afac97a1181e Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 23:30:48 +0100 Subject: [PATCH 32/38] Meet touch targets across public navigation --- web/src/style.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/src/style.css b/web/src/style.css index a303c3e..9842456 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -1,3 +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} From 17959fb375a3d5a9323e7e8c39fdbce870e30b50 Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 23:36:28 +0100 Subject: [PATCH 33/38] Test exact neutral exit signing boundary --- relayer/src/neutral-exit-policy.d.mts | 2 +- relayer/test/neutral-exit-signing.spec.ts | 63 +++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 relayer/test/neutral-exit-signing.spec.ts diff --git a/relayer/src/neutral-exit-policy.d.mts b/relayer/src/neutral-exit-policy.d.mts index 14731c5..74560ef 100644 --- a/relayer/src/neutral-exit-policy.d.mts +++ b/relayer/src/neutral-exit-policy.d.mts @@ -45,6 +45,6 @@ export function validateAllowanceForAction(action: string, allowance: bigint): b 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): string; +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/test/neutral-exit-signing.spec.ts b/relayer/test/neutral-exit-signing.spec.ts new file mode 100644 index 0000000..8e0a3a8 --- /dev/null +++ b/relayer/test/neutral-exit-signing.spec.ts @@ -0,0 +1,63 @@ +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"; + +const MAINNET_CHAIN_ID = "0x534e5f4d41494e"; +const LOCKED_NEUTRAL_ADDRESS = "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46"; +const TEST_SIGNER = "0x12345"; + +describe("neutral exact-exit signing boundary", () => { + 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]+$/); + }); +}); From 8410b4de866b44d3ed8d2890d75168c5b40b6e98 Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 23:40:17 +0100 Subject: [PATCH 34/38] Rotate stale claim sponsorship ledger --- relayer/worker-configuration.d.ts | 4 ++-- relayer/wrangler.jsonc | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/relayer/worker-configuration.d.ts b/relayer/worker-configuration.d.ts index b2213dd..42f7c59 100644 --- a/relayer/worker-configuration.d.ts +++ b/relayer/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: c0b4c259cd5833ac30ccf80ef767dac2) +// Generated by Wrangler by running `wrangler types` (hash: d493680fb5b101fccb8ff8c3531337fb) // Runtime types generated with workerd@1.20260828.1 2026-08-28 nodejs_compat interface __BaseEnv_Env { RELAY_RATE_LIMITER: RateLimit; @@ -19,7 +19,7 @@ interface __BaseEnv_Env { DAILY_SPONSOR_BUDGET_FRI: "1600000000000000000"; SPONSOR_FEE_MARGIN_BPS: "11000"; MIN_RELAYER_BALANCE_FRI: "1000000000000000000"; - DEPLOYMENT_ID: "afterlight-mainnet-e3-20260828-v4"; + DEPLOYMENT_ID: "afterlight-mainnet-e3-20260829-v5"; RELAYER_ACCOUNT_ADDRESS: "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46"; RELAYER_ACCOUNT_CAIRO_VERSION: "1"; STARKNET_RPC_URL: "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_10"; diff --git a/relayer/wrangler.jsonc b/relayer/wrangler.jsonc index 6908424..a0a7d51 100644 --- a/relayer/wrangler.jsonc +++ b/relayer/wrangler.jsonc @@ -18,7 +18,7 @@ "DAILY_SPONSOR_BUDGET_FRI": "1600000000000000000", "SPONSOR_FEE_MARGIN_BPS": "11000", "MIN_RELAYER_BALANCE_FRI": "1000000000000000000", - "DEPLOYMENT_ID": "afterlight-mainnet-e3-20260828-v4", + "DEPLOYMENT_ID": "afterlight-mainnet-e3-20260829-v5", "RELAYER_ACCOUNT_ADDRESS": "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46", "RELAYER_ACCOUNT_CAIRO_VERSION": "1", "STARKNET_RPC_URL": "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_10", From a2cdb277c9fb89e5958f1b28bd902b5dd0c22607 Mon Sep 17 00:00:00 2001 From: qdee Date: Fri, 28 Aug 2026 23:40:56 +0100 Subject: [PATCH 35/38] Document safe sponsorship ledger recovery --- relayer/OPERATIONS.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/relayer/OPERATIONS.md b/relayer/OPERATIONS.md index f4d0c59..0bb1b88 100644 --- a/relayer/OPERATIONS.md +++ b/relayer/OPERATIONS.md @@ -39,7 +39,7 @@ Install `RELAYER_ACCOUNT_PRIVATE_KEY` and `STARKNET_RPC_AUTH_TOKEN` only with `w 6. Install secrets, verify configuration readiness, and obtain fresh no-submit quotes. 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,7 @@ 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. ## Monitoring and alerts @@ -67,12 +68,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 From d232afe63ffa2df4e74fe3c48e56eb1e16294fa8 Mon Sep 17 00:00:00 2001 From: qdee Date: Sat, 29 Aug 2026 07:14:13 +0100 Subject: [PATCH 36/38] Classify exact claim broadcast failures --- relayer/OPERATIONS.md | 1 + relayer/src/exit-executor.ts | 30 ++++++++++++++++++++++- relayer/test/neutral-exit-signing.spec.ts | 12 +++++++++ relayer/worker-configuration.d.ts | 4 +-- relayer/wrangler.jsonc | 2 +- web/src/main.ts | 11 +++++++-- 6 files changed, 54 insertions(+), 6 deletions(-) diff --git a/relayer/OPERATIONS.md b/relayer/OPERATIONS.md index 0bb1b88..79117e1 100644 --- a/relayer/OPERATIONS.md +++ b/relayer/OPERATIONS.md @@ -50,6 +50,7 @@ Install `RELAYER_ACCOUNT_PRIVATE_KEY` and `STARKNET_RPC_AUTH_TOKEN` only with `w - 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 diff --git a/relayer/src/exit-executor.ts b/relayer/src/exit-executor.ts index 051719a..136de7e 100644 --- a/relayer/src/exit-executor.ts +++ b/relayer/src/exit-executor.ts @@ -197,7 +197,21 @@ export async function executePreparedClaim(payload: string, env: Env, budget: Bu const expectedHash = assertOuterSignatureMatchesHash(signed); submissionStage = "broadcast"; broadcastStarted = true; - const response = await provider.invokeSignedTx(signed); + 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"; @@ -295,3 +309,17 @@ function rpcErrorCode(error: unknown): number | undefined { : 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/test/neutral-exit-signing.spec.ts b/relayer/test/neutral-exit-signing.spec.ts index 8e0a3a8..a9bdab8 100644 --- a/relayer/test/neutral-exit-signing.spec.ts +++ b/relayer/test/neutral-exit-signing.spec.ts @@ -5,12 +5,24 @@ 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); diff --git a/relayer/worker-configuration.d.ts b/relayer/worker-configuration.d.ts index 42f7c59..bc9036a 100644 --- a/relayer/worker-configuration.d.ts +++ b/relayer/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: d493680fb5b101fccb8ff8c3531337fb) +// Generated by Wrangler by running `wrangler types` (hash: 3775ed3c59fefc44ce6617e85497a542) // Runtime types generated with workerd@1.20260828.1 2026-08-28 nodejs_compat interface __BaseEnv_Env { RELAY_RATE_LIMITER: RateLimit; @@ -19,7 +19,7 @@ interface __BaseEnv_Env { DAILY_SPONSOR_BUDGET_FRI: "1600000000000000000"; SPONSOR_FEE_MARGIN_BPS: "11000"; MIN_RELAYER_BALANCE_FRI: "1000000000000000000"; - DEPLOYMENT_ID: "afterlight-mainnet-e3-20260829-v5"; + DEPLOYMENT_ID: "afterlight-mainnet-e3-20260829-v6"; RELAYER_ACCOUNT_ADDRESS: "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46"; RELAYER_ACCOUNT_CAIRO_VERSION: "1"; STARKNET_RPC_URL: "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_10"; diff --git a/relayer/wrangler.jsonc b/relayer/wrangler.jsonc index a0a7d51..b911636 100644 --- a/relayer/wrangler.jsonc +++ b/relayer/wrangler.jsonc @@ -18,7 +18,7 @@ "DAILY_SPONSOR_BUDGET_FRI": "1600000000000000000", "SPONSOR_FEE_MARGIN_BPS": "11000", "MIN_RELAYER_BALANCE_FRI": "1000000000000000000", - "DEPLOYMENT_ID": "afterlight-mainnet-e3-20260829-v5", + "DEPLOYMENT_ID": "afterlight-mainnet-e3-20260829-v6", "RELAYER_ACCOUNT_ADDRESS": "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46", "RELAYER_ACCOUNT_CAIRO_VERSION": "1", "STARKNET_RPC_URL": "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_10", diff --git a/web/src/main.ts b/web/src/main.ts index 63d9ebd..bd32940 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -19,6 +19,7 @@ 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."); @@ -122,7 +123,7 @@ function controlPanel(invitation: RecoveryInvitation, snapshot?: VaultSnapshot): ${role === "owner" && current === "ACTIVE" ? `` : ""} ${role === "owner" && current === "GRACE" ? `` : ""} ${role === "successor" && current === "ACTIVE" ? `` : ""} - ${role === "successor" && current === "GRACE" ? `` : ""} + ${role === "successor" && current === "GRACE" ? `` : ""}

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

    `; } @@ -266,7 +267,13 @@ function bindEvents(): void { 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(); - const result = await submitClaimPackage(claimPackage); + 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); From 9517d67e7df1b26bd182a94f033bddba56487dc2 Mon Sep 17 00:00:00 2001 From: qdee Date: Sat, 29 Aug 2026 07:23:55 +0100 Subject: [PATCH 37/38] Route private exits through the proven RPC --- relayer/src/exit-executor.ts | 5 ++++- relayer/worker-configuration.d.ts | 5 +++-- relayer/wrangler.jsonc | 3 ++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/relayer/src/exit-executor.ts b/relayer/src/exit-executor.ts index 136de7e..f3db291 100644 --- a/relayer/src/exit-executor.ts +++ b/relayer/src/exit-executor.ts @@ -88,7 +88,10 @@ export async function executePreparedClaim(payload: string, env: Env, budget: Bu } const provider = new RpcProvider({ - nodeUrl: env.STARKNET_RPC_URL, + // 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, }); diff --git a/relayer/worker-configuration.d.ts b/relayer/worker-configuration.d.ts index bc9036a..fd285d4 100644 --- a/relayer/worker-configuration.d.ts +++ b/relayer/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 3775ed3c59fefc44ce6617e85497a542) +// 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; @@ -26,6 +26,7 @@ interface __BaseEnv_Env { SUBMIT_ENABLED: "true"; RELAYER_ACCOUNT_PRIVATE_KEY: string; STARKNET_RPC_AUTH_TOKEN: string; + EXIT_RPC_URL: string; RELAY_BUDGET: DurableObjectNamespace; } declare namespace Cloudflare { @@ -40,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 diff --git a/relayer/wrangler.jsonc b/relayer/wrangler.jsonc index b911636..c9b7e47 100644 --- a/relayer/wrangler.jsonc +++ b/relayer/wrangler.jsonc @@ -28,7 +28,8 @@ "secrets": { "required": [ "RELAYER_ACCOUNT_PRIVATE_KEY", - "STARKNET_RPC_AUTH_TOKEN" + "STARKNET_RPC_AUTH_TOKEN", + "EXIT_RPC_URL" ] }, "durable_objects": { From cf6510bfe84a59ee9931daa0867c53348eba508b Mon Sep 17 00:00:00 2001 From: qdee Date: Sat, 29 Aug 2026 07:30:07 +0100 Subject: [PATCH 38/38] Publish public E3 recovery evidence --- README.md | 4 ++-- docs/ARCHITECTURE.md | 2 +- docs/MAINNET.md | 18 ++++++++++-------- docs/THREAT_MODEL.md | 2 +- strk20.json | 3 ++- 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 2e42500..9716dd1 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ An owner privately funds a fixed reserve through STRK20 and remains in control t ## Current status -**Evidence level: E2 observable replay.** The complete two-branch recovery mechanism has run on Starknet Mainnet. Four successful transactions touch both the canonical STRK20 pool and Afterlight, including exact-note private cancellation and successor recovery. After L1 finality, a fresh Ready X read showed the successor's shielded balance increase from `6 STRK` to `7 STRK`, reconciling the exact `1 STRK` recovery output while the neutral sponsor paid the separate pool fee. +**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: deployed mechanism and public-product candidate; fresh E3 pending.** Afterlight is [deployed on Mainnet](https://starkscan.co/contract/0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25). Vault A completed `FUND -> CANCEL_REFUND`; Vault B completed early refusal, heartbeat, request, veto, second request, and `CLAIM`. The bounded neutral relayer executed the control path without using either Ready role as the outer sender. The public app now supports real Ready X connection, local per-vault keys, private funding, live state, relayed controls, exact-note recovery, and post-claim balance reconciliation. It is not classified E3 until a fresh user completes that path through the deployed interface. +**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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 128d0ed..b7a2854 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -82,4 +82,4 @@ recovery of tokens sent directly to the helper. ## Current release boundary -The deployed Mainnet release is `0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25`. Two vaults completed the cancellation and recovery branches, and all four STRK20 receipts are listed in `strk20.json`. A fresh Ready X read reconciled the successor's shielded balance from `6 STRK` to `7 STRK`, so the mechanism is E2. The public UI and neutral claim route are deployed candidates; E3 requires one fresh end-to-end completion through that interface. +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 ddd2cd4..af8693d 100644 --- a/docs/MAINNET.md +++ b/docs/MAINNET.md @@ -2,12 +2,12 @@ ## Current evidence level -Afterlight has a complete deployed Mainnet mechanism and four validator-qualified -STRK20 receipts. It is classified **E2 observable replay**. After L1 finality, -a fresh approved Ready X `wallet_strk20Balances` read showed the successor at -`7 STRK`, compared with the verified `6 STRK` pre-claim balance. Prepared, -simulated, reverted, and unrelated pool transactions are not counted as -successful evidence. +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 @@ -50,11 +50,13 @@ contract. Plain Shield transactions and failed attempts do not fill these slots. | `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 four. Vault A is `CANCELLED`, Vault B is +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. A fresh E3 public-interface lifecycle and final video remain pending. +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/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 04c1ee2..83370f6 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -98,4 +98,4 @@ donations to the helper expecting a refund; unaccounted surplus remains inert. 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 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 post-finality Ready X read proved the successor balance increased from `6 STRK` to `7 STRK`. The founder-controlled wallets share historical public funding correlation, so this is E2 engineering evidence, not proof of historically unrelated participants or E4 independent completion. +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/strk20.json b/strk20.json index e56559e..cb3b68a 100644 --- a/strk20.json +++ b/strk20.json @@ -3,7 +3,8 @@ "0x030ea14ac22e5806e382658971b686692af280bf2f02173a430f572921121722", "0x69e2345ae8816986a709de84f0dcb571b5d092400d6c53bf90197480102c0fb", "0x036e003396fe360ae7fe4766646f493c0eb579d82509652559d40e460770682a", - "0x11c990aea864e755630d41fd1292620c313b3f64407fc0b3a902544c67c8098" + "0x11c990aea864e755630d41fd1292620c313b3f64407fc0b3a902544c67c8098", + "0x722033f7fd0397ff4d3845428c98cad885b6a63824f7c78a2b7e1d7d6f5c1b6" ], "contracts": [ "0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25"