diff --git a/.github/workflows/relayer-health.yml b/.github/workflows/relayer-health.yml index 7f10780..ea92a6e 100644 --- a/.github/workflows/relayer-health.yml +++ b/.github/workflows/relayer-health.yml @@ -1,4 +1,4 @@ -name: Post-spike relayer health +name: Production judged-path health on: schedule: @@ -14,13 +14,14 @@ concurrency: jobs: health: - name: Verify production submission and privacy boundary + name: Verify judged path, submission, and privacy boundary runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Check HTTPS health and security boundary env: HEALTH_URL: https://afterlight-relayer-phase-a.qdworld001.workers.dev/health + APP_URL: https://afterlight.dolepee.com run: | set -euo pipefail evidence_dir="$(mktemp -d)" @@ -46,6 +47,8 @@ jobs: .executor.executable == true and .balance.status == "ok" and .balance.alert == false and + .claimCapacity.status == "ready" and + .claimCapacity.fundingStatus == "ready" and .privacy.payloadLogging == false and .privacy.appKeysHeld == false and .privacy.walletAddressesRequired == false @@ -54,3 +57,15 @@ jobs: grep -Eiq '^cache-control: no-store' "$evidence_dir/headers" grep -Eiq '^content-security-policy:.*default-src .none.' "$evidence_dir/headers" grep -Eiq '^x-content-type-options: nosniff' "$evidence_dir/headers" + + curl \ + --fail-with-body \ + --silent \ + --show-error \ + --retry 3 \ + --retry-all-errors \ + --connect-timeout 10 \ + --max-time 20 \ + --output "$evidence_dir/app.html" \ + "$APP_URL" + grep -Fq 'Afterlight' "$evidence_dir/app.html" diff --git a/README.md b/README.md index 9716dd1..1f86ef0 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,13 @@ An owner privately funds a fixed reserve through STRK20 and remains in control t [![CI](https://github.com/dolepee/afterlight/actions/workflows/ci.yml/badge.svg)](https://github.com/dolepee/afterlight/actions/workflows/ci.yml) +| Rubric | Present evidence | +|---|---| +| STRK20 integration depth | Private funding, cancellation/refund, and exact-note recovery through the canonical pool and custom Afterlight helper | +| Working Mainnet product | Public Ready X app, deployed contract, five qualifying receipts, and a founder-operated E3 lifecycle | +| Innovation | Authenticated inactivity, heartbeat, veto, designated-successor authorization, and one exact private destination | +| Open source | Reproducible artifacts, CI, read-only Mainnet verifier, threat model, operations guide, and MIT license | + ## Current status **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. @@ -75,6 +82,7 @@ scarb --profile spike-inline-56 build npm --prefix client ci npm --prefix client run verify:locked-artifacts +npm --prefix client run verify:mainnet npm --prefix client test npm --prefix relayer ci diff --git a/client/README.md b/client/README.md index 80f06ee..73f760b 100644 --- a/client/README.md +++ b/client/README.md @@ -6,7 +6,7 @@ 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. The deployed Mainnet release and its -four qualifying receipts are recorded in [`../docs/MAINNET.md`](../docs/MAINNET.md). +five qualifying receipts are recorded in [`../docs/MAINNET.md`](../docs/MAINNET.md). ```sh npm ci @@ -20,8 +20,12 @@ 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. 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. +pool allowance, exact destination notes, and five qualifying receipts reconcile; +fresh Ready reads proved the E2 and public E3 shielded-balance increases. + +Run `npm run verify:mainnet` for a read-only check of the five qualifying +receipts, deployed class and configuration, neutral control senders, terminal +vault states, and zero locked liability. 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`. diff --git a/client/package.json b/client/package.json index 9ffdb92..0aa6362 100644 --- a/client/package.json +++ b/client/package.json @@ -10,6 +10,7 @@ "operator:build": "esbuild tools/mainnet-operator.ts --bundle --format=esm --platform=browser --outfile=tools/mainnet-operator.js", "preflight:build": "esbuild tools/private-exit-preflight.ts --bundle --format=esm --platform=browser --minify --alias:starknet=./tools/starknet-narrow.ts --metafile=tools/private-exit-preflight.meta.json --outfile=tools/private-exit-preflight.js", "verify:locked-artifacts": "node tools/verify-locked-artifacts.mjs", + "verify:mainnet": "node tools/verify-mainnet.mjs", "quote:mainnet": "node tools/quote-declare-deploy.mjs", "quote:ready-deploy": "node tools/quote-ready-deployment.mjs", "quote:ready-build": "esbuild tools/ready-deployment-data.ts --bundle --format=esm --platform=browser --outfile=tools/ready-quote.js" diff --git a/client/tools/verify-mainnet.mjs b/client/tools/verify-mainnet.mjs new file mode 100644 index 0000000..0ad5c69 --- /dev/null +++ b/client/tools/verify-mainnet.mjs @@ -0,0 +1,144 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { RpcProvider } from "starknet"; + +const ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url)))); +const RPC_URL = process.env.STARKNET_RPC_URL ?? "https://rpc.starknet.lava.build"; +const provider = new RpcProvider({ nodeUrl: RPC_URL }); +const RECEIPT_RPC_URLS = [ + RPC_URL, + process.env.STARKNET_FALLBACK_RPC_URL ?? "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_10", +]; +const receiptRpcUrls = [...new Set(RECEIPT_RPC_URLS)]; +const receiptProviders = receiptRpcUrls.map( + (nodeUrl) => new RpcProvider({ nodeUrl }), +); + +const expected = { + chainId: "0x534e5f4d41494e", + contract: "0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25", + classHash: "0x05da9866f62cc6dd1e380e8d9206e78a752b460abdb802070e0be1208ec7b1a6", + pool: "0x040337b1af3c663e86e333bab5a4b28da8d4652a15a69beee2b677776ffe812a", + token: "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + neutral: "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46", + qualifyingSenders: { + "0x030ea14ac22e5806e382658971b686692af280bf2f02173a430f572921121722": + "0x073c97f6049ecfaac9284686454550e6d74769eb6ebd8fc014c12eba4ca0dd7", + "0x69e2345ae8816986a709de84f0dcb571b5d092400d6c53bf90197480102c0fb": + "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46", + "0x036e003396fe360ae7fe4766646f493c0eb579d82509652559d40e460770682a": + "0x03dc021b06b8f0a5038b238b71ef5dd6d94b3efdbb7c5dafa1d914c5e89fa55a", + "0x11c990aea864e755630d41fd1292620c313b3f64407fc0b3a902544c67c8098": + "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46", + "0x722033f7fd0397ff4d3845428c98cad885b6a63824f7c78a2b7e1d7d6f5c1b6": + "0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46", + }, + config: [ + "0x040337b1af3c663e86e333bab5a4b28da8d4652a15a69beee2b677776ffe812a", + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "1000000000000000000", "2592000", "604800", "300", "300", "31536000", "900", "300", + ], + vaults: [ + { name: "Vault A", id: "0x06d6c75f3625806e4e186e94e98c36074cb6a53b990cf8b5d98b91f1b9931d7", state: 4n }, + { name: "Vault B", id: "0x00df091b09941850386624da955ea4b9a7bedea70b1fe03734a5ddfa291fa5b2", state: 3n }, + { name: "Public E3 vault", id: "0x03ace87fc2d2fc6b1849ce77de6460900a34168541e4cea04e09e34e04d2b0", state: 3n }, + ], + controls: [ + "0x0500ea24d721ce9c051ee7ef44bb4f786f26b0363fbfbe7c1b71facf62f9f7ab", + "0x02a5d7cd844e5dbae9b6c6a3f1ab53a2d2cedce33c5b20a53f04db247d69b437", + "0x06cb079f8415e74f609e05df8ce5357c5706f1f266b00539e236a76624a0b513", + "0x041a44e82131392092b54edc305bb6dc20708301623ceb519ed0006ee044fc90", + ], +}; + +const sameFelt = (left, right) => BigInt(left) === BigInt(right); +const assert = (condition, message) => { if (!condition) throw new Error(message); }; +const unwrapRpcValue = (response) => response?.value ?? response; +const succeeded = (receipt) => + receipt.execution_status === "SUCCEEDED" && receipt.finality_status === "ACCEPTED_ON_L1"; +const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); +const getL1Receipt = async (transactionHash) => { + let receipt; + let lastError; + for (let attempt = 0; attempt < 3; attempt += 1) { + for (const receiptProvider of receiptProviders) { + try { + receipt = unwrapRpcValue(await receiptProvider.getTransactionReceipt(transactionHash)); + if (succeeded(receipt)) return receipt; + } catch (error) { + lastError = error; + } + } + if (attempt < 2) await wait(250 * (attempt + 1)); + } + throw new Error( + `${transactionHash} is not SUCCEEDED / ACCEPTED_ON_L1 ` + + `(observed ${receipt?.execution_status ?? "unknown"} / ${receipt?.finality_status ?? "unknown"}; ` + + `last RPC error: ${lastError instanceof Error ? lastError.message : "none"}).`, + ); +}; +const hasEventFrom = (receipt, address) => + receipt.events.some((event) => sameFelt(event.from_address, address)); +const calldataContains = (transaction, address) => + Array.isArray(transaction.calldata) && transaction.calldata.some((felt) => sameFelt(felt, address)); + +assert(sameFelt(await provider.getChainId(), expected.chainId), "RPC is not Starknet Mainnet."); +assert(sameFelt(await provider.getClassHashAt(expected.contract), expected.classHash), "Deployed class hash mismatch."); + +const config = await provider.callContract({ contractAddress: expected.contract, entrypoint: "get_config", calldata: [] }); +assert(config.length === expected.config.length, "Unexpected deployed configuration length."); +expected.config.forEach((felt, index) => + assert(sameFelt(config[index], felt), `Deployed configuration mismatch at index ${index}.`), +); + +const locked = await provider.callContract({ + contractAddress: expected.contract, + entrypoint: "get_locked_by_token", + calldata: [expected.token], +}); +assert(locked.length === 2 && locked.every((felt) => BigInt(felt) === 0n), "Afterlight liability is not zero."); + +const declaration = JSON.parse(fs.readFileSync(path.join(ROOT, "strk20.json"), "utf8")); +assert(declaration.contracts.length === 1 && sameFelt(declaration.contracts[0], expected.contract), "strk20.json contract mismatch."); +assert(declaration.demo_url === "https://afterlight.dolepee.com", "strk20.json demo URL mismatch."); +assert(declaration.transactions.length === 5, "Expected exactly five qualifying transactions."); + +for (const transactionHash of declaration.transactions) { + const [receipt, transaction] = await Promise.all([ + getL1Receipt(transactionHash), provider.getTransactionByHash(transactionHash), + ]); + assert(hasEventFrom(receipt, expected.pool), `${transactionHash} does not touch the canonical pool.`); + assert(hasEventFrom(receipt, expected.contract) || calldataContains(transaction, expected.contract), `${transactionHash} is not owned by Afterlight.`); + assert( + sameFelt(transaction.sender_address, expected.qualifyingSenders[transactionHash]), + `${transactionHash} outer sender does not match the audited release.`, + ); +} + +for (const transactionHash of expected.controls) { + const [receipt, transaction] = await Promise.all([ + getL1Receipt(transactionHash), provider.getTransactionByHash(transactionHash), + ]); + assert(sameFelt(transaction.sender_address, expected.neutral), `${transactionHash} was not submitted by the neutral relayer.`); + assert(hasEventFrom(receipt, expected.contract), `${transactionHash} has no Afterlight event.`); +} + +for (const vault of expected.vaults) { + const state = await provider.callContract({ contractAddress: expected.contract, entrypoint: "get_vault", calldata: [vault.id] }); + assert(BigInt(state[0]) === 1n, `${vault.name} does not exist.`); + assert(BigInt(state[1]) === vault.state, `${vault.name} is not terminal in the expected state.`); +} + +process.stdout.write(`${JSON.stringify({ + evidence: "AFTERLIGHT_MAINNET_VERIFIED", + chainId: expected.chainId, + contract: expected.contract, + classHash: expected.classHash, + qualifyingTransactions: declaration.transactions.length, + neutralControls: expected.controls.length, + terminalVaults: expected.vaults.length, + lockedLiability: "0", + rpcOrigins: receiptRpcUrls.map((url) => new URL(url).origin), +})}\n`); diff --git a/docs/MAINNET.md b/docs/MAINNET.md index af8693d..7ef127e 100644 --- a/docs/MAINNET.md +++ b/docs/MAINNET.md @@ -31,10 +31,12 @@ artifact-identity proof for the live pool class. | Sierra class hash | `0x05da9866f62cc6dd1e380e8d9206e78a752b460abdb802070e0be1208ec7b1a6` | Declared on Mainnet | | CASM/compiled class hash | `0x055ba10e36aac8e21b3437f1413f009f6b17d3633c307941a4412ce73566251` | Declaration lock | | Afterlight contract | [`0x06e8…61c25`](https://starkscan.co/contract/0x06e8b6e49b4366e0dc6a35eee722b417c718988eca3f4a0c298bdf8785261c25) | Deployed on Mainnet | -| Neutral relayer account | `0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46` | Deployed; spike controls complete | +| Neutral relayer account | `0x05b0b8cbda8eca89b88ae6975c80a880b0164a853c6ed881a56e39e4622edd46` | Deployed; public E3 controls complete | Run `scarb --profile spike-inline-56 build` and `npm --prefix client run verify:locked-artifacts` to recompute the exact hashes. +A read-only live release verification is available as +`npm --prefix client run verify:mainnet`. A different source, dependency resolution, compiler, profile, constructor, or deployer must be treated as a different release and re-verified. @@ -60,3 +62,11 @@ reconciliation is `6 STRK -> 7 STRK`: the exact `+1 STRK` recovery output was added to the beneficiary while the neutral sponsor paid the separate `6 STRK` pool fee. The public E3 vault is also `CLAIMED`; its immediate Ready X reconciliation is `7 STRK -> 8 STRK`. Final video production remains pending. + +The public E3 lifecycle was founder-operated. An unrelated owner-successor E4 +completion is not claimed. + +Historical identifiers such as the `spike-inline-56` compiler profile and the +relayer's `phase-a` hostname are pinned release identifiers. They do not mean +the deployed public release is still an inert preparation environment; changing +them would break reproducibility or the stable public endpoint.