diff --git a/PUBLICATION_GUIDE.md b/PUBLICATION_GUIDE.md index 67e1294..4eaec2e 100644 --- a/PUBLICATION_GUIDE.md +++ b/PUBLICATION_GUIDE.md @@ -443,6 +443,52 @@ chain and wallet state. Do not reset an uncertain attempt simply because no receipt appears immediately. If `.lock` remains after a crash, inspect its PID and confirm that process is dead before removing **only the lock**. +**MetaMask terminal Smart Transaction cancellation:** a wallet detail that +explicitly says `cancelled` / `FAILED_TIMEOUT` is distinct from a pending or +unknown send. Preserve that wallet evidence and stop the signing server. Use +`review-cancelled` only after reviewing that exact terminal outcome: + +```sh +npm run publication -- review-cancelled --config=publication/batch-N.json --directory=PROD_RUN --submission=ENTITY_ID --transaction=0xRECORDED_HASH --nonce=REVIEWED_UNUSED_NONCE --wallet-outcome=smart_transaction_cancelled_failed_timeout +``` + +The command checks two independent RPCs: the original hash must have neither a +transaction nor a receipt, and both latest and pending account nonces must equal +the reviewed unused nonce. It preserves the original journal and failed hash, +then prepares a retry pinned to that nonce. The signing server checks the nonce +again before accepting a new intent, and verifies it after inclusion. This does +not resend anything. A known transaction, consumed nonce, pending replacement, +RPC disagreement or unknown wallet outcome requires further reconciliation. + +For a standard wallet attempt visibly marked **Failed**, with no detailed error, +use `review-failed` only if its original nonce is already pinned in the journal: + +```sh +npm run publication -- review-failed --config=publication/batch-N.json --directory=PROD_RUN --submission=ENTITY_ID --transaction=0xRECORDED_HASH --nonce=ORIGINAL_PINNED_NONCE --wallet-outcome=wallet_failed_not_broadcast +``` + +This requires the same independent RPC checks, preserves the original attempt +in a `failed-HASH.json` archive, and retries at the exact original nonce. Do not +claim a Smart Transaction timeout when the wallet only shows Failed. A pending +attempt or a failure with an unknown original nonce remains blocked. + +MetaMask Smart Transactions may keep a transaction outside the public mempool +before inclusion; RPC absence alone therefore never proves cancellation. For +the reviewed retry, the owner can use standard transaction submission by +turning Smart Transactions off under Settings > Transactions, then review and +sign from the publication page. This temporarily disables Smart Transactions' +relay protections; restore the setting after the publication if desired. See +[MetaMask's Smart Transactions guide](https://support.metamask.io/manage-crypto/transactions/smart-transactions/). + +**Transport gas limits:** the signing page estimates execution gas, supplies an +explicit 10% reserve, and bounds the request by the Base per-transaction ceiling +of 16,777,216 gas. This does not change receipt bytes or sealed calldata. +MetaMask can otherwise apply its default 1.5 multiplier: an executable estimate +of 12,532,654 would become 18,798,981, above that ceiling. The actual gas limit +of a previously pending wallet attempt must be inspected before attributing its +failure to this risk. See [MetaMask's gas implementation](https://github.com/MetaMask/core/blob/main/packages/transaction-controller/src/utils/gas.ts) +and [EIP-7825](https://eips.ethereum.org/EIPS/eip-7825). + **Catalog failure:** the existing active generation remains available while a new generation builds. A failed `building` generation is retained inactive and must not be overwritten. Archive its `catalog-library.json` or @@ -450,6 +496,13 @@ must not be overwritten. Archive its `catalog-library.json` or candidate. A concurrent pointer change stops activation; review the new active state rather than automatically overwriting another publisher's work. +**Staging QA scope:** Library verification and publication always cover the +complete sealed proof cohort. If iPulse staging's active QA catalog excludes an +asset, pass the explicitly reviewed available entity IDs to `sync-ipulse` with +`--ipulse-entities=ID,ID`. This narrows only staging ledger sidecars and records +excluded IDs in its report; production refuses partial scope. Do not modify a +sealed plan or expand an F2 asset release to resolve a missing staging route. + For a reviewed rollback to a retained ready generation: ```sh diff --git a/package-lock.json b/package-lock.json index a73dc24..35803b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "firebase": "^12.17.1", "json-canonicalize": "^2.0.0", "nanoid": "3.3.18", - "next": "16.3.4", + "next": "16.3.8", "react": "19.2.8", "react-dom": "19.2.8", "server-only": "^0.0.1", @@ -1005,16 +1005,34 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.9.16", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.16.tgz", - "integrity": "sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ==", + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", + "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==", "license": "Apache-2.0", "dependencies": { - "@grpc/proto-loader": "^0.7.8", - "@types/node": ">=12.12.47" + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" }, "engines": { - "node": "^8.13.0 || >=10.10.0" + "node": ">=6" } }, "node_modules/@grpc/proto-loader": { @@ -1693,15 +1711,15 @@ } }, "node_modules/@next/env": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz", - "integrity": "sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz", + "integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.4.tgz", - "integrity": "sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz", + "integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==", "cpu": [ "arm64" ], @@ -1715,9 +1733,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.4.tgz", - "integrity": "sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz", + "integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==", "cpu": [ "x64" ], @@ -1731,9 +1749,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.4.tgz", - "integrity": "sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz", + "integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==", "cpu": [ "arm64" ], @@ -1747,9 +1765,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.4.tgz", - "integrity": "sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz", + "integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==", "cpu": [ "arm64" ], @@ -1763,9 +1781,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.4.tgz", - "integrity": "sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz", + "integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==", "cpu": [ "x64" ], @@ -1779,9 +1797,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.4.tgz", - "integrity": "sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz", + "integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==", "cpu": [ "x64" ], @@ -1795,9 +1813,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.4.tgz", - "integrity": "sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz", + "integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==", "cpu": [ "arm64" ], @@ -1811,9 +1829,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.4.tgz", - "integrity": "sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz", + "integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==", "cpu": [ "x64" ], @@ -3350,9 +3368,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -3836,9 +3854,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", @@ -4139,19 +4157,6 @@ "node": ">=18" } }, - "node_modules/google-gax/node_modules/@grpc/grpc-js": { - "version": "1.14.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", - "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", - "license": "Apache-2.0", - "dependencies": { - "@grpc/proto-loader": "^0.8.0", - "@js-sdsl/ordered-map": "^4.4.2" - }, - "engines": { - "node": ">=12.10.0" - } - }, "node_modules/google-gax/node_modules/@grpc/proto-loader": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", @@ -4798,12 +4803,12 @@ } }, "node_modules/next": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.4.tgz", - "integrity": "sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz", + "integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==", "license": "MIT", "dependencies": { - "@next/env": "16.3.4", + "@next/env": "16.3.8", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -4817,14 +4822,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.4", - "@next/swc-darwin-x64": "16.3.4", - "@next/swc-linux-arm64-gnu": "16.3.4", - "@next/swc-linux-arm64-musl": "16.3.4", - "@next/swc-linux-x64-gnu": "16.3.4", - "@next/swc-linux-x64-musl": "16.3.4", - "@next/swc-win32-arm64-msvc": "16.3.4", - "@next/swc-win32-x64-msvc": "16.3.4", + "@next/swc-darwin-arm64": "16.3.8", + "@next/swc-darwin-x64": "16.3.8", + "@next/swc-linux-arm64-gnu": "16.3.8", + "@next/swc-linux-arm64-musl": "16.3.8", + "@next/swc-linux-x64-gnu": "16.3.8", + "@next/swc-linux-x64-musl": "16.3.8", + "@next/swc-win32-arm64-msvc": "16.3.8", + "@next/swc-win32-x64-msvc": "16.3.8", "sharp": "^0.35.4" }, "peerDependencies": { @@ -5768,9 +5773,9 @@ } }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", "dev": true, "license": "MIT", "engines": { @@ -5903,6 +5908,7 @@ "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", diff --git a/package.json b/package.json index 1a8c5a2..4cc18c6 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "firebase": "^12.17.1", "json-canonicalize": "^2.0.0", "nanoid": "3.3.18", - "next": "16.3.4", + "next": "16.3.8", "react": "19.2.8", "react-dom": "19.2.8", "server-only": "^0.0.1", @@ -68,5 +68,8 @@ "postcss": "^8.5.6", "typescript": "~5.9.3", "vitest": "4.1.11" + }, + "overrides": { + "@grpc/grpc-js": "1.14.5" } } diff --git a/scripts/__tests__/publication-gas.test.mjs b/scripts/__tests__/publication-gas.test.mjs new file mode 100644 index 0000000..78f53f6 --- /dev/null +++ b/scripts/__tests__/publication-gas.test.mjs @@ -0,0 +1,18 @@ +import {it,expect} from 'vitest'; +import {publicationGasLimit,BASE_TRANSACTION_GAS_CAP} from '../lib/publication-gas.mjs'; + +it('keeps the complete Silver cohort below the cap despite a wallet default 50% buffer',()=>{ + const estimate=12_532_654n; + expect(estimate*150n/100n).toBeGreaterThan(BASE_TRANSACTION_GAS_CAP); + expect(publicationGasLimit(estimate)).toBe(13_785_920n); + expect(publicationGasLimit(estimate)).toBeGreaterThan(estimate); +}); +it('never lowers the limit below an executable estimate near the cap',()=>{ + expect(publicationGasLimit(16_000_000n)).toBe(BASE_TRANSACTION_GAS_CAP); + expect(publicationGasLimit(BASE_TRANSACTION_GAS_CAP)).toBe(BASE_TRANSACTION_GAS_CAP); +}); +it.each([0n,-1n,BASE_TRANSACTION_GAS_CAP+1n])('rejects an invalid or oversized estimate (%s)',estimate=>expect(()=>publicationGasLimit(estimate)).toThrow()); +it('rounds reserves upward and accepts the wallet RPC hex estimate',()=>{ + expect(publicationGasLimit('0xb')).toBe(13n); + expect(publicationGasLimit('0xbf3c9e')).toBeGreaterThan(BigInt('0xbf3c9e')); +}); diff --git a/scripts/__tests__/publication-scope.test.mjs b/scripts/__tests__/publication-scope.test.mjs new file mode 100644 index 0000000..523ec70 --- /dev/null +++ b/scripts/__tests__/publication-scope.test.mjs @@ -0,0 +1,12 @@ +import {describe,it,expect} from 'vitest'; +import {selectIpulseProofCohort} from '../lib/publication-scope.mjs'; +const transactions=[{entityId:'silver'},{entityId:'btc'}]; +describe('explicit staging proof sidecar scope',()=>{ + it('retains the full cohort by default',()=>expect(selectIpulseProofCohort(transactions,'ipulse-401013')).toBe(transactions)); + it('permits an explicit known staging subset without changing the sealed plan',()=>{ + expect(selectIpulseProofCohort(transactions,'pulse-staging-e1394','btc')).toEqual([{entityId:'btc'}]); + expect(transactions).toHaveLength(2); + }); + it.each(['','btc,btc','unknown','btc,'])('rejects invalid staging scope %s',ids=>expect(()=>selectIpulseProofCohort(transactions,'pulse-staging-e1394',ids)).toThrow()); + it('never narrows production proof publication',()=>expect(()=>selectIpulseProofCohort(transactions,'ipulse-401013','btc')).toThrow('only for staging QA')); +}); diff --git a/scripts/__tests__/publication-wallet.test.mjs b/scripts/__tests__/publication-wallet.test.mjs index f65f69c..d4d9df8 100644 --- a/scripts/__tests__/publication-wallet.test.mjs +++ b/scripts/__tests__/publication-wallet.test.mjs @@ -3,7 +3,7 @@ import { mkdtemp,readFile,rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { servePublicationWallet,publicationLabel } from '../lib/publication-wallet.mjs'; -import { savePublicationState } from '../lib/publication-state.mjs'; +import { savePublicationState,recordReviewedCancellation } from '../lib/publication-state.mjs'; import eas from '../../src/data/eas-base-sepolia.json' with {type:'json'}; it('labels the publisher, original forecast date range and variable cohort without changing transactions',()=>{ @@ -68,3 +68,42 @@ it('requires a local session, journals before signing, and resumes only the matc log.mockRestore();if(server)await new Promise(resolve=>server.close(resolve));await rm(directory,{recursive:true,force:true}); } }); + +it.each([6,7])('only offers a cancelled retry while its pinned nonce remains unused (current=%s)',async(currentNonce)=>{ + const directory=await mkdtemp(join(tmpdir(),'ofr-wallet-retry-')); + const attester='0x1111111111111111111111111111111111111111',hash='0x'+'ab'.repeat(32); + const tx={entityId:'asset',chainId:8453,to:eas.contracts.eas,value:'0',data:'0x1234',receiptCount:14}; + const plan={planDigest:'sealed',chainId:8453,network:'Base',configuration:{collectionId:'batch-7',assets:[{entityId:'asset',label:'Example'}]},receipts:Array(14).fill({}),registration:{},transactions:[tx]}; + const journal=recordReviewedCancellation({planDigest:'sealed',attester,transactions:{asset:{status:'submitted',hash,previousAttempts:[]}}},'asset',{hash,nonce:6,checkedAt:'2026-09-27T11:30:00Z',walletOutcome:'smart_transaction_cancelled_failed_timeout',observations:['a','b'].map(url=>({url,transaction:null,receipt:null,latestNonce:6,pendingNonce:6}))}); + const client={readContract:async()=>({uid:eas.schemaUid}),getTransactionCount:async()=>currentNonce}; + const log=vi.spyOn(console,'log').mockImplementation(()=>{});let server; + try{ + server=await servePublicationWallet({plan,client,attester,journalPath:join(directory,'journal.json'),journal,signedPath:join(directory,'signed.json'),saveSigned:savePublicationState}); + const url=new URL(log.mock.calls[0][0].split(' ').at(-1)); + const headers={Authorization:'Bearer '+url.hash.slice(1)}; + const response=await fetch(url.origin+'/api/state',{headers});const state=await response.json(); + expect(response.status).toBe(currentNonce===6?200:400); + if(currentNonce===6){expect(state.next.nonce).toBe(6);expect(state.next.data).toBe(tx.data);expect(state.next.recoveryReview.hash).toBe(hash);} + else expect(state.error).toContain('no longer unused'); + const intent=await fetch(url.origin+'/api/intent',{method:'POST',headers:{...headers,Origin:url.origin,'Content-Type':'application/json'},body:JSON.stringify({id:'asset'})}); + expect(intent.status).toBe(currentNonce===6?200:400); + const saved=JSON.parse(await readFile(join(directory,'journal.json'),'utf8')).transactions.asset; + expect(saved.nonce).toBe(6);expect(saved.previousAttempts[0].hash).toBe(hash); + expect(saved.status).toBe(currentNonce===6?'awaiting_wallet':'reviewed_retry'); + }finally{log.mockRestore();if(server)await new Promise(resolve=>server.close(resolve));await rm(directory,{recursive:true,force:true});} +}); + +it.each([true,false])('distinguishes a visible pending transaction from a wallet hash absent on chain (visible=%s)',async(visible)=>{ + const directory=await mkdtemp(join(tmpdir(),'ofr-wallet-pending-')); + const attester='0x1111111111111111111111111111111111111111',hash='0x'+'ab'.repeat(32); + const plan={planDigest:'sealed',chainId:8453,network:'Base',configuration:{collectionId:'batch-7',assets:[{entityId:'asset'}]},receipts:[],registration:{},transactions:[{entityId:'asset'}]}; + const client={readContract:async()=>({uid:eas.schemaUid}),getTransactionReceipt:async()=>{throw Object.assign(new Error('No receipt'),{name:'TransactionReceiptNotFoundError'});},getTransaction:async()=>{if(!visible)throw Object.assign(new Error('No transaction'),{name:'TransactionNotFoundError'});return {hash};}}; + const log=vi.spyOn(console,'log').mockImplementation(()=>{});let server; + try{ + server=await servePublicationWallet({plan,client,attester,journalPath:join(directory,'journal.json'),journal:{planDigest:'sealed',attester,transactions:{asset:{status:'submitted',hash}}},signedPath:join(directory,'signed.json'),saveSigned:savePublicationState}); + const url=new URL(log.mock.calls[0][0].split(' ').at(-1)); + const response=await fetch(url.origin+'/api/state',{headers:{Authorization:'Bearer '+url.hash.slice(1)}}); + const state=await response.json();expect(response.status).toBe(200);expect(state.waiting).toBe(true);expect(state.broadcastStatus).toBe(visible?'pending':'not_visible');expect(state.transactionHash).toBe(hash);expect(state.next).toBeUndefined(); + expect(JSON.parse(await readFile(join(directory,'journal.json'),'utf8')).transactions.asset.hash).toBe(hash); + }finally{log.mockRestore();if(server)await new Promise(resolve=>server.close(resolve));await rm(directory,{recursive:true,force:true});} +}); diff --git a/scripts/__tests__/publication-workflow.test.mjs b/scripts/__tests__/publication-workflow.test.mjs index 02d2d8f..87a11bc 100644 --- a/scripts/__tests__/publication-workflow.test.mjs +++ b/scripts/__tests__/publication-workflow.test.mjs @@ -6,7 +6,7 @@ import { encodeFunctionData,decodeFunctionData } from 'viem'; import eas from '../../src/data/eas-base-sepolia.json' with {type:'json'}; import config from '../../publication/batch-6.json' with {type:'json'}; import { publicationDigest,groupedTransactions,encodeProjection,sealPublicationPlan,validatePublicationPlan,submissionAbi } from '../lib/publication-plan.mjs'; -import { recordSigningIntent,recordTransactionHash,recordWalletRejection,savePublicationState,mergeProofRegistries } from '../lib/publication-state.mjs'; +import { recordSigningIntent,recordTransactionHash,recordWalletRejection,recordReviewedCancellation,savePublicationState,mergeProofRegistries } from '../lib/publication-state.mjs'; import { useSealedBatch6Fixtures,receiptIssuanceTime,proofNetworkCaip2 } from '../lib/publication-input.mjs'; import { verifyPublicPublication } from '../lib/publication-http.mjs'; @@ -46,6 +46,46 @@ describe('repeatable asset publication',()=>{ it('binds proof job network explicitly',()=>{expect(proofNetworkCaip2('base-mainnet')).toBe('eip155:8453');expect(proofNetworkCaip2()).toBe('eip155:84532');expect(()=>proofNetworkCaip2('constructor')).toThrow();}); }); describe('transaction recovery',()=>{ + const sent=()=>recordTransactionHash(recordSigningIntent(journal,'asset'), 'asset',hash); + const review=()=>({hash,nonce:6,walletOutcome:'smart_transaction_cancelled_failed_timeout',checkedAt:'2026-09-27T11:30:00Z',observations:['rpc-a','rpc-b'].map(url=>({url,transaction:null,receipt:null,latestNonce:6,pendingNonce:6}))}); + it('preserves a reviewed cancelled hash and pins its retry to the unused nonce',()=>{ + const ready=recordReviewedCancellation(sent(),'asset',review()); + expect(ready.transactions.asset.previousAttempts[0].hash).toBe(hash); + const pending=recordSigningIntent(ready,'asset'); + expect(pending.transactions.asset.nonce).toBe(6); + expect(pending.transactions.asset.previousAttempts).toEqual(ready.transactions.asset.previousAttempts); + expect(pending.transactions.asset.hash).toBeUndefined(); + expect(()=>recordSigningIntent(pending,'asset')).toThrow(); + const rejected=recordWalletRejection(pending,'asset',4001); + expect(recordSigningIntent(rejected,'asset').transactions.asset.nonce).toBe(6); + }); + it('retains every prior hash when reviewing a terminal wallet failure at its original nonce',()=>{ + const first=recordReviewedCancellation(sent(),'asset',review()); + const secondHash='0x'+'ef'.repeat(32); + const second=recordTransactionHash(recordSigningIntent(first,'asset'),'asset',secondHash); + const r={...review(),hash:secondHash,walletOutcome:'wallet_failed_not_broadcast'}; + const ready=recordReviewedCancellation(second,'asset',r); + expect(ready.transactions.asset.previousAttempts.map(attempt=>attempt.hash)).toEqual([hash,secondHash]); + expect(recordSigningIntent(ready,'asset').transactions.asset.nonce).toBe(6); + expect(ready.transactions.asset.recoveryReview.walletOutcome).toBe('wallet_failed_not_broadcast'); + }); + it.each([undefined,7])('blocks a terminal wallet failure if its original nonce is %s',nonce=>{ + const attempt=sent();attempt.transactions.asset.nonce=nonce; + expect(()=>recordReviewedCancellation(attempt,'asset',{...review(),walletOutcome:'wallet_failed_not_broadcast'})).toThrow('original pinned nonce'); + }); + it.each(['wrong hash','uncertain wallet','one rpc','duplicate rpc','known transaction','receipt present','nonce consumed','pending transaction'])('blocks cancellation recovery with %s',condition=>{ + const r=review(); + if(condition==='wrong hash')r.hash='0x'+'cd'.repeat(32); + if(condition==='uncertain wallet')r.walletOutcome='timeout'; + if(condition==='one rpc')r.observations.pop(); + if(condition==='duplicate rpc')r.observations[1].url=r.observations[0].url; + if(condition==='known transaction')r.observations[1].transaction={hash}; + if(condition==='receipt present')r.observations[0].receipt={status:'0x1'}; + if(condition==='nonce consumed')r.observations[0].latestNonce=7; + if(condition==='pending transaction')r.observations[1].pendingNonce=7; + expect(()=>recordReviewedCancellation(sent(),'asset',r)).toThrow(); + expect(sent().transactions.asset.hash).toBe(hash); + }); it('blocks uncertain attempts and submitted transactions from resending',()=>{const pending=recordSigningIntent(journal,'asset');expect(()=>recordSigningIntent(pending,'asset')).toThrow();const sent=recordTransactionHash(pending,'asset',hash);expect(()=>recordSigningIntent(sent,'asset')).toThrow();expect(recordTransactionHash(sent,'asset',hash)).toEqual(sent);expect(()=>recordTransactionHash(sent,'asset','0x'+'cd'.repeat(32))).toThrow();}); it('only retries explicit wallet rejection and retains its history',()=>{const pending=recordSigningIntent(journal,'asset');expect(()=>recordWalletRejection(pending,'asset',-32000)).toThrow();const rejected=recordWalletRejection(pending,'asset',4001);expect(recordSigningIntent(rejected,'asset').transactions.asset.previousAttempts).toHaveLength(1);}); it('cannot clear an already recorded hash as a rejection',()=>expect(()=>recordWalletRejection(recordTransactionHash(recordSigningIntent(journal,'asset'),'asset',hash),'asset',4001)).toThrow()); diff --git a/scripts/lib/publication-gas.mjs b/scripts/lib/publication-gas.mjs new file mode 100644 index 0000000..c21b6b0 --- /dev/null +++ b/scripts/lib/publication-gas.mjs @@ -0,0 +1,11 @@ +// Base mainnet and Sepolia apply EIP-7825's per-transaction gas ceiling. +// Keep a 10% execution reserve without allowing wallet default multipliers +// to turn a valid estimate into a transaction above the protocol cap. +export const BASE_TRANSACTION_GAS_CAP = 16_777_216n; + +export function publicationGasLimit(estimate) { + const gas=BigInt(estimate); + if(gas<=0n||gas>BASE_TRANSACTION_GAS_CAP)throw new Error('Gas estimate exceeds the Base transaction cap or is invalid; review the complete asset cohort before signing'); + const buffered=(gas*110n+99n)/100n; + return buffered>BASE_TRANSACTION_GAS_CAP?BASE_TRANSACTION_GAS_CAP:buffered; +} diff --git a/scripts/lib/publication-scope.mjs b/scripts/lib/publication-scope.mjs new file mode 100644 index 0000000..2018c4e --- /dev/null +++ b/scripts/lib/publication-scope.mjs @@ -0,0 +1,7 @@ +export function selectIpulseProofCohort(transactions,project,entities) { + if(entities===undefined)return transactions; + if(project!=='pulse-staging-e1394')throw new Error('Explicit partial proof scope is allowed only for staging QA'); + const ids=entities.split(','); + if(!ids.length||ids.some(id=>!id)||new Set(ids).size!==ids.length||ids.some(id=>!transactions.some(tx=>tx.entityId===id)))throw new Error('Declare distinct known staging QA entity IDs'); + return transactions.filter(tx=>ids.includes(tx.entityId)); +} diff --git a/scripts/lib/publication-state.mjs b/scripts/lib/publication-state.mjs index 18db829..048ae28 100644 --- a/scripts/lib/publication-state.mjs +++ b/scripts/lib/publication-state.mjs @@ -16,8 +16,21 @@ export async function savePublicationState(path, value) { export function recordSigningIntent(journal, id) { const previous = journal.transactions[id]; - if (previous && previous.status !== 'wallet_rejected') throw new Error('A signing attempt already exists. Recover its transaction before retrying.'); - return { ...journal, transactions: { ...journal.transactions, [id]: { status: 'awaiting_wallet', startedAt: new Date().toISOString(), previousAttempts: previous ? [...(previous.previousAttempts || []), {status:previous.status,startedAt:previous.startedAt}] : [] } } }; + if (previous && !['wallet_rejected','reviewed_retry'].includes(previous.status)) throw new Error('A signing attempt already exists. Recover its transaction before retrying.'); + const previousAttempts=previous?.status==='reviewed_retry'?previous.previousAttempts:previous?[...(previous.previousAttempts || []),{status:previous.status,startedAt:previous.startedAt}]:[]; + return { ...journal, transactions: { ...journal.transactions, [id]: { status: 'awaiting_wallet', startedAt: new Date().toISOString(), previousAttempts,...(previous?.nonce!==undefined?{nonce:previous.nonce}:{}),...(previous?.recoveryReview?{recoveryReview:previous.recoveryReview}:{}) } } }; +} + +// Absence of a receipt alone never authorizes retry. This path requires a +// reviewed terminal wallet failure and independent RPC observations. The +// retry uses the still-unused nonce, so a late original cannot issue twice. +export function recordReviewedCancellation(journal,id,review) { + const previous=journal.transactions[id]; + if(previous?.status!=='submitted'||previous.hash!==review.hash)throw new Error('Review must match the recorded submitted transaction'); + if(!['smart_transaction_cancelled_failed_timeout','wallet_failed_not_broadcast'].includes(review.walletOutcome)||!Number.isSafeInteger(review.nonce)||review.nonce<0)throw new Error('Explicit terminal wallet failure and unused nonce required'); + if(review.walletOutcome==='wallet_failed_not_broadcast'&&previous.nonce!==review.nonce)throw new Error('Terminal wallet failure recovery requires the original pinned nonce'); + if(!Array.isArray(review.observations)||new Set(review.observations.map(r=>r.url)).size<2||!review.observations.every(r=>r.transaction===null&&r.receipt===null&&r.latestNonce===review.nonce&&r.pendingNonce===review.nonce))throw new Error('Two independent RPCs must agree that the hash is absent and nonce unused'); + return {...journal,transactions:{...journal.transactions,[id]:{status:'reviewed_retry',nonce:review.nonce,previousAttempts:[...(previous.previousAttempts||[]),{...previous,previousAttempts:undefined,walletOutcome:review.walletOutcome,reviewedAt:review.checkedAt}],recoveryReview:review}}}; } // EIP-1193 code 4001 explicitly means the user rejected the request. Timeouts, diff --git a/scripts/lib/publication-wallet.mjs b/scripts/lib/publication-wallet.mjs index 26b6f2b..2281923 100644 --- a/scripts/lib/publication-wallet.mjs +++ b/scripts/lib/publication-wallet.mjs @@ -28,13 +28,24 @@ export async function servePublicationWallet({ plan, client, attester, journalPa for(const call of calls){ if(call.id==='schema' && schema.uid===eas.schemaUid)continue; const saved=journal.transactions[call.id]; - if(!saved || saved.status==='wallet_rejected')return {next:call}; + if(!saved || ['wallet_rejected','reviewed_retry'].includes(saved.status)){ + if(saved?.nonce!==undefined){ + const [latest,pending]=await Promise.all(['latest','pending'].map(blockTag=>client.getTransactionCount({address:attester,blockTag}))); + requireValue(latest===saved.nonce&&pending===saved.nonce,'Reviewed retry nonce is no longer unused; reconcile the wallet before signing'); + } + return {next:{...call,...(saved?.nonce!==undefined?{nonce:saved.nonce}:{}),...(saved?.recoveryReview?{recoveryReview:saved.recoveryReview}: {})}}; + } if(!saved.hash)return {next:call,uncertain:true}; let receipt; try{receipt=await client.getTransactionReceipt({hash:saved.hash});} - catch(error){if(error.name==='TransactionReceiptNotFoundError')return {waiting:true};throw error;} + catch(error){ + if(error.name!=='TransactionReceiptNotFoundError')throw error; + try{await client.getTransaction({hash:saved.hash});return {waiting:true,broadcastStatus:'pending',transactionHash:saved.hash};} + catch(transactionError){if(transactionError.name==='TransactionNotFoundError')return {waiting:true,broadcastStatus:'not_visible',transactionHash:saved.hash};throw transactionError;} + } const tx=await client.getTransaction({hash:saved.hash}); requireValue(receipt.status==='success' && tx.from.toLowerCase()===attester.toLowerCase() && tx.to?.toLowerCase()===call.to.toLowerCase() && tx.input.toLowerCase()===call.data.toLowerCase() && tx.value===0n,'Recorded transaction failed or differs from the plan; manual recovery required'); + requireValue(saved.nonce===undefined||tx.nonce===saved.nonce,'Recovered transaction nonce differs from the reviewed retry'); if(call.id==='schema'){ // The initial latest-state read can precede transaction inclusion. Read // at its actual block before treating successful registration as absent. @@ -54,6 +65,10 @@ export async function servePublicationWallet({ plan, client, attester, journalPa response.writeHead(200,{'Content-Type':'text/html; charset=utf-8','Cache-Control':'no-store','Content-Security-Policy':"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'",'X-Content-Type-Options':'nosniff'}); response.end(await readFile(new URL('../publication-wallet.html',import.meta.url)));return; } + if(request.method==='GET' && request.url==='/publication-gas.mjs'){ + response.writeHead(200,{'Content-Type':'text/javascript; charset=utf-8','Cache-Control':'no-store','X-Content-Type-Options':'nosniff'}); + response.end(await readFile(new URL('./publication-gas.mjs',import.meta.url)));return; + } requireValue(request.headers.authorization===`Bearer ${token}`,'Invalid local session token'); if(request.method==='GET' && request.url==='/api/state'){ const state=await nextState(); diff --git a/scripts/publication-wallet.html b/scripts/publication-wallet.html index 692b7f3..426729c 100644 --- a/scripts/publication-wallet.html +++ b/scripts/publication-wallet.html @@ -9,16 +9,22 @@

Publish forecast proofs

If the browser closes or a request times out after you sign, stop and recover the transaction hash from your wallet. The workflow blocks uncertain attempts instead of sending a duplicate.