diff --git a/README.md b/README.md index fa4e99a..5fa259e 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ A source-changing hardening release is deployed and certified on Studio Next: `dfb564fbd644fae756808fee2afc1f43c35d0dde095e33ad9f4802569e80007a` - Direct Runtime: **117 passed** - Python non-runtime: **454 passed + 334 subtests** -- frontend Vitest: **43 passed** +- frontend Vitest: **46 passed** - browser E2E: **12 passed** The candidate rejects future-dated evidence, exposes the exact helper binding, @@ -213,12 +213,15 @@ ABORT branches and one-time entitlement consumption. It does not claim authenticated proof of downstream external delivery or production-grade retry/reconciliation for failed external transfers. -The frontend now provides a fail-closed operator observation of triggered child -transactions. It reports `DELIVERED` only for an exact one-child match with -`FINALIZED` plus `FINISHED_WITH_RETURN`, reports `FAILED` only for an exact -match with `FINISHED_WITH_ERROR`, and otherwise reports `PENDING` or -`UNVERIFIED`. This read-only observation does not change the contract boundary -or authorize a retry. +The frontend now provides a fail-closed operator observation of claim parents, +their exact native outbound message, and any triggered child transaction. It +reports `DELIVERED` only after the parent is independently bound to the +certified coordinator, exact `claim_mission` call, connected beneficiary, and +one exact outbound message, followed by an exact child match with `FINALIZED` +plus `FINISHED_WITH_RETURN`. A finalized child execution error is reported as +`FINALIZED_ERROR`, not proof of terminal non-delivery. Missing, ambiguous, or +unreadable child data remains `UNVERIFIED`. This read-only observation does not +change the contract boundary or authorize a retry. See [`docs/OPEN_QUESTIONS.md`](./docs/OPEN_QUESTIONS.md) for the concise submission boundary. diff --git a/components/application/application-shell.tsx b/components/application/application-shell.tsx index a77fb26..ab9c648 100644 --- a/components/application/application-shell.tsx +++ b/components/application/application-shell.tsx @@ -353,7 +353,7 @@ export function ApplicationShell() { ) : ( @@ -420,7 +420,7 @@ export function ApplicationShell() { ) : ( @@ -443,7 +443,7 @@ export function ApplicationShell() { ) : ( @@ -466,7 +466,7 @@ export function ApplicationShell() { ) : ( @@ -489,7 +489,7 @@ export function ApplicationShell() { ) : ( @@ -512,7 +512,7 @@ export function ApplicationShell() { ) : ( @@ -535,7 +535,7 @@ export function ApplicationShell() { ) : ( diff --git a/components/application/claim-mission-flow.tsx b/components/application/claim-mission-flow.tsx index 426cf14..823589f 100644 --- a/components/application/claim-mission-flow.tsx +++ b/components/application/claim-mission-flow.tsx @@ -24,6 +24,10 @@ import { type FinalizedClaimProof, } from "@/lib/genlayer-claim"; import { + CURRENT_DEPLOYMENT_ANCHOR, +} from "@/lib/deployment-anchor"; +import { + isTransactionHash, observeExternalDelivery, persistClaimTransaction, readPersistedClaimTransaction, @@ -84,6 +88,12 @@ export function ClaimMissionFlow({ wallet.address, ), ); + const [claimTransactionId, setClaimTransactionId] = useState(() => + readPersistedClaimTransaction( + initialMissionId, + wallet.address, + )?.transactionId ?? "", + ); const [ delivery, setDelivery, @@ -101,6 +111,11 @@ export function ClaimMissionFlow({ && claimRecord.beneficiary.toLowerCase() === wallet.address.toLowerCase() ? claimRecord : null; + const activeParentClaimTransactionId = isTransactionHash( + claimTransactionId.trim(), + ) + ? claimTransactionId.trim() + : null; function resetReview() { setQuote(null); @@ -162,6 +177,7 @@ export function ClaimMissionFlow({ transactionId: txId, amount: quote.snapshot.missionClaimable, }); + setClaimTransactionId(txId); persistClaimTransaction( quote.snapshot.mission.missionId, quote.snapshot.beneficiary, @@ -181,6 +197,8 @@ export function ClaimMissionFlow({ wallet.client, txId, { + coordinator: CURRENT_DEPLOYMENT_ANCHOR.contractAddress, + missionId: quote.snapshot.mission.missionId, recipient: quote.snapshot.beneficiary, amount: quote.snapshot.missionClaimable, }, @@ -215,10 +233,7 @@ export function ClaimMissionFlow({ } async function refreshDelivery() { - if ( - activeClaimRecord === null - || deliveryBusy - ) { + if (activeParentClaimTransactionId === null || deliveryBusy) { return; } @@ -228,17 +243,19 @@ export function ClaimMissionFlow({ setDelivery( await observeExternalDelivery( wallet.client, - activeClaimRecord.transactionId, + activeParentClaimTransactionId, { + coordinator: CURRENT_DEPLOYMENT_ANCHOR.contractAddress, + missionId, recipient: wallet.address, - amount: activeClaimRecord.amount, + amount: activeClaimRecord?.amount, }, ), ); } catch (caught: unknown) { setDelivery( unverifiedExternalDelivery( - activeClaimRecord.transactionId, + activeParentClaimTransactionId, caught instanceof Error ? caught.message : "The delivery observation could not be completed. No delivery outcome is claimed.", @@ -254,8 +271,8 @@ export function ClaimMissionFlow({ ): string { return phase === "DELIVERED" ? "DELIVERED" - : phase === "FAILED" - ? "FAILED — NO AUTOMATIC RETRY" + : phase === "FINALIZED_ERROR" + ? "FINALIZED EXECUTION ERROR — DELIVERY UNRESOLVED" : phase === "PENDING" ? "PENDING" : "UNVERIFIED"; @@ -285,12 +302,12 @@ export function ClaimMissionFlow({ setMissionId( nextMissionId, ); - setClaimRecord( - readPersistedClaimTransaction( - nextMissionId, - wallet.address, - ), + const persisted = readPersistedClaimTransaction( + nextMissionId, + wallet.address, ); + setClaimRecord(persisted); + setClaimTransactionId(persisted?.transactionId ?? ""); resetReview(); }} placeholder="Mission ID" @@ -298,6 +315,20 @@ export function ClaimMissionFlow({ /> + +
) : null} - {activeClaimRecord !== null ? ( + {activeParentClaimTransactionId !== null ? (
@@ -559,7 +590,16 @@ export function ClaimMissionFlow({
Parent claim
-
{activeClaimRecord.transactionId}
+
{activeParentClaimTransactionId}
+
+
+
Outbound message
+
+ {delivery?.outboundMessage === null + || delivery?.outboundMessage === undefined + ? "Not verified" + : `${delivery.outboundMessage.recipient} · ${delivery.outboundMessage.amount.toString()} wei`} +
Triggered child
@@ -587,8 +627,9 @@ export function ClaimMissionFlow({ {delivery.reason} ) : ( - Select “Observe child transaction” to read the exact child - transaction. No delivery outcome is assumed before then. + Select “Observe child transaction” to verify the parent claim, + outbound message, and any exact child transaction exposed by + Studio Next. No delivery outcome is assumed before then. )}
diff --git a/components/application/create-mission-flow.tsx b/components/application/create-mission-flow.tsx index e3a9712..2bd5438 100644 --- a/components/application/create-mission-flow.tsx +++ b/components/application/create-mission-flow.tsx @@ -24,6 +24,7 @@ import { type MissionDraft, type TransactionProgress, } from "@/lib/genlayer-browser"; +import { writeBrowserStorage } from "@/lib/browser-storage"; type CreateMissionFlowProps = { wallet: ConnectedCommitWallet; @@ -256,7 +257,7 @@ export function CreateMissionFlow({ quote, ); - localStorage.setItem( + writeBrowserStorage( "commit:last-transaction", txId, ); @@ -273,7 +274,7 @@ export function CreateMissionFlow({ ); if (finalResult.successful) { - localStorage.setItem( + writeBrowserStorage( "commit:last-mission", missionId, ); diff --git a/components/application/fund-mission-flow.tsx b/components/application/fund-mission-flow.tsx index e57b043..76cad49 100644 --- a/components/application/fund-mission-flow.tsx +++ b/components/application/fund-mission-flow.tsx @@ -21,6 +21,7 @@ import { type MissionFundingSnapshot, type TransactionProgress, } from "@/lib/genlayer-browser"; +import { writeBrowserStorage } from "@/lib/browser-storage"; type FundMissionFlowProps = { wallet: ConnectedCommitWallet; @@ -169,7 +170,7 @@ export function FundMissionFlow({ quote, ); - localStorage.setItem( + writeBrowserStorage( "commit:last-transaction", txId, ); diff --git a/components/application/resolution-mission-flow.tsx b/components/application/resolution-mission-flow.tsx index c18870d..882feb7 100644 --- a/components/application/resolution-mission-flow.tsx +++ b/components/application/resolution-mission-flow.tsx @@ -35,6 +35,7 @@ import { type RepairEvidenceQuote, type ResolutionInspection, } from "@/lib/genlayer-resolution"; +import { writeBrowserStorage } from "@/lib/browser-storage"; type ResolutionMissionFlowProps = { wallet: ConnectedCommitWallet; @@ -249,7 +250,7 @@ export function ResolutionMissionFlow({ evaluateQuote, ); - localStorage.setItem( + writeBrowserStorage( `commit:evaluation:${missionId}`, txId, ); diff --git a/components/justice/case-room.tsx b/components/justice/case-room.tsx index ba0d100..75d9b92 100644 --- a/components/justice/case-room.tsx +++ b/components/justice/case-room.tsx @@ -35,6 +35,7 @@ import { } from "@/lib/genlayer-appeal"; import { AppealPanel } from "@/components/justice/appeal-panel"; import { EvidenceRecordCard } from "@/components/justice/evidence-record-card"; +import { readBrowserStorage } from "@/lib/browser-storage"; type CaseRoomProps = { wallet: ConnectedCommitWallet; @@ -156,7 +157,7 @@ export function CaseRoom({ return ""; } - return window.localStorage.getItem( + return readBrowserStorage( `commit:evaluation:${initialMissionId}`, ) ?? ""; }); @@ -247,10 +248,10 @@ export function CaseRoom({ ); setSubmittedAppeal(txId); - setAppealBusy(false); - void refreshCase(); + await refreshCase(); } catch (caught: unknown) { setAppealError(errorMessage(caught)); + } finally { setAppealBusy(false); } } @@ -274,11 +275,9 @@ export function CaseRoom({ const nextMissionId = event.target.value; setMissionId(nextMissionId); setEvaluationTxId( - typeof window === "undefined" - ? "" - : window.localStorage.getItem( - `commit:evaluation:${nextMissionId}`, - ) ?? "", + readBrowserStorage( + `commit:evaluation:${nextMissionId}`, + ) ?? "", ); setCaseData(null); setError(null); diff --git a/docs/AGENT_TANK_SUBMISSION.md b/docs/AGENT_TANK_SUBMISSION.md index 694ba07..35af923 100644 --- a/docs/AGENT_TANK_SUBMISSION.md +++ b/docs/AGENT_TANK_SUBMISSION.md @@ -138,7 +138,7 @@ The earlier published release has the following historical certification record: - helper source SHA-256: `dfb564fbd644fae756808fee2afc1f43c35d0dde095e33ad9f4802569e80007a`; - Python regression: **450 passed + 334 subtests**; -- frontend Vitest: **27 passed**; +- frontend Vitest: **46 passed**; - browser E2E: **12 passed**; - public `/`, `/app`, `/verify`, `/api/v1/health`: **HTTP 200**; - public production bodies matched the promoted production clone during final diff --git a/docs/CURRENT_DEPLOYMENT_PROOF_2026-09-16.md b/docs/CURRENT_DEPLOYMENT_PROOF_2026-09-16.md index c40f279..61e5e26 100644 --- a/docs/CURRENT_DEPLOYMENT_PROOF_2026-09-16.md +++ b/docs/CURRENT_DEPLOYMENT_PROOF_2026-09-16.md @@ -61,7 +61,8 @@ Local F18 evidence packet index SHA-256: ## Public application release -- Public URL: https://commitprotocol-genlayer.vercel.app +- Historical public URL: https://commitprotocol-genlayer.vercel.app +- Current canonical public URL: https://commit-protocol.vercel.app - GitHub main: `e9858985495111cf2f21db6dc847c7f75b79c0da` - Vercel production deployment ID: `dpl_6x9XzgAxPsRX8jXr7d6KonmEmL6S` - Production release tree: `3627b57a0baaebda07b15da76cb8b594ee4a18f9` diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 48ab0ee..66ed902 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -76,11 +76,12 @@ application release. It is not the current source-bound coordinator. ## Current source-bound deployment -Source-binding release commit: `6fe34484acb4bb21afd8f438ecac00e7780042e1` +Contract source-binding release commit: `6fe34484acb4bb21afd8f438ecac00e7780042e1` -The current `main` branch may contain later documentation and verification-only -commits; the source-binding commit above is the exact code revision that -introduced the certified frontend anchor. +The current `main` branch is `d2e46de82b9c269d59ed77d97c1844dc9943b7b3` at +this audit base. It contains later frontend, documentation, and verification +commits; the source-binding commit above identifies the exact contract code +revision used for the certified deployment. Base GitHub main before publication: diff --git a/docs/EXTERNAL_DELIVERY_OBSERVATION.md b/docs/EXTERNAL_DELIVERY_OBSERVATION.md index dc3204c..fd036e6 100644 --- a/docs/EXTERNAL_DELIVERY_OBSERVATION.md +++ b/docs/EXTERNAL_DELIVERY_OBSERVATION.md @@ -12,17 +12,30 @@ record. The frontend records the parent claim transaction locally, then uses GenLayer JS `getTriggeredTransactionIds({ hash })` to locate the child transaction created by the external message. -The observer reports `DELIVERED` only when all of these facts are present: +Before inspecting a child, the observer independently verifies all of these +parent facts: + +1. the parent targets the certified Studio Next coordinator; +2. `from_address`, `sender`, and `origin_address` all match the connected + beneficiary; +3. the decoded method is exactly `claim_mission` and its mission ID matches + the selected mission; +4. the parent is `FINALIZED` with `FINISHED_WITH_RETURN`; and +5. exactly one positive native outbound message is exposed, with the exact + beneficiary and message amount. + +It then reports `DELIVERED` only when all of these child facts are present: 1. exactly one triggered child transaction ID is returned; 2. the child transaction is readable; 3. its recipient matches the connected beneficiary address; -4. its value matches the exact mission entitlement; +4. its value matches the exact amount in the verified parent message; 5. its stored status is `FINALIZED`; and 6. its execution result is `FINISHED_WITH_RETURN`. -The observer reports `FAILED` only when the same exact child binding is present -and the finalized execution result is `FINISHED_WITH_ERROR`. +A locally persisted amount is only a fail-closed consistency check; it is never +the source of truth. The parent transaction and its message are read from the +network. ## Fail-closed states @@ -30,15 +43,15 @@ and the finalized execution result is `FINISHED_WITH_ERROR`. | --- | --- | --- | | `PENDING` | The exact child exists but is not finalized. | Keep observing; never infer failure from elapsed time. | | `DELIVERED` | Exact recipient, amount, finality, and successful execution match. | Show delivery as observed. | -| `FAILED` | Exact recipient and amount match, but finalized child execution failed. | Show the failure; do not restore or retry automatically. | +| `FINALIZED_ERROR` | Exact recipient and amount match, but finalized child execution failed. | Show that execution errored; do not call it terminal non-delivery, restore, or retry automatically. | | `UNVERIFIED` | No child ID, malformed/ambiguous child set, unreadable child, mismatched recipient/value, unknown result, or RPC failure. | Show that delivery is unresolved; do not infer success or terminal non-delivery. | The UI exposes an explicit `Observe child transaction` action and keeps the parent claim ID, exact mission ID, beneficiary, and amount in beneficiary-scoped -browser storage. This allows re-observation after navigation without a page -reload or a new write. A record is shown only when both the mission and the -connected beneficiary match; switching wallets cannot surface another wallet's -claim observation. +browser storage. It also accepts a manually pasted parent claim ID for recovery +after storage is cleared. Every supplied ID is re-read and re-bound to the +network transaction before any status is shown; switching wallets cannot +surface another wallet's claim observation. Browser storage is only a convenience cache; it is not trusted contract state. ## What this intentionally does not claim @@ -67,9 +80,10 @@ be weaker than the current fail-closed behavior. The implementation is in [`lib/genlayer-delivery.ts`](../lib/genlayer-delivery.ts) and is covered by [`tests/frontend/genlayer-delivery.test.ts`](../tests/frontend/genlayer-delivery.test.ts). -The tests cover successful finalization, finalized execution failure, pending +The tests cover parent target/caller/method/mission binding, exact outbound +message binding, successful finalization, finalized execution failure, pending children, missing and ambiguous child IDs, malformed reads, recipient/value -binding, and beneficiary-scoped persistence. +binding, tampered local amounts, and beneficiary-scoped persistence. Run the frontend gates from the repository root: diff --git a/docs/LIVE_STUDIO_NEXT_LIFECYCLE_2026-09-16.md b/docs/LIVE_STUDIO_NEXT_LIFECYCLE_2026-09-16.md index 8a2e352..5671343 100644 --- a/docs/LIVE_STUDIO_NEXT_LIFECYCLE_2026-09-16.md +++ b/docs/LIVE_STUDIO_NEXT_LIFECYCLE_2026-09-16.md @@ -98,6 +98,28 @@ After both claims finalized, the coordinator returned: - Worker aggregate claimable: `0` - Both missions remained in their expected terminal states. +## External message observation readback + +The finalized COMMIT claim `0xd5ee347299c793b0fc2c6fdaeda392aa44597452ded74563ca55babd083a543a` +and ABORT claim `0x7df93e138c158d8cea6179c88727f39b2be5bb968d98999f3d36b44a7c29858c` +were independently read from Studio Next on 2026-09-16. For both parents, the +network record showed: + +- target coordinator: `0xEE21cCFF8f3755487f774BFd5Da9Ff51D5688581`; +- `from_address`, `sender`, and `origin_address`: the worker beneficiary + `0x1f87Ae197af539253978d435aD45cCf28Fb95024`; +- decoded method: `claim_mission` with the corresponding mission ID; +- parent result: `FINALIZED` / `FINISHED_WITH_RETURN`; +- exactly one native outbound message to the worker for + `100000000000000` wei (`0.0001 GEN`); and +- `getTriggeredTransactionIds` returned an empty child-ID list for each read. + +This proves the exact finalized parent claim and outbound message that Studio +Next exposed. It does not prove downstream delivery: no child transaction ID +was exposed by the current read path. The frontend therefore shows +`UNVERIFIED` for this observation and never offers timeout-based retry or +restoration. + ## What this proves This record proves, for the exact deployed coordinator source, the complete diff --git a/docs/LOCAL_VERIFICATION.md b/docs/LOCAL_VERIFICATION.md index 2463746..fb7d2fe 100644 --- a/docs/LOCAL_VERIFICATION.md +++ b/docs/LOCAL_VERIFICATION.md @@ -66,7 +66,7 @@ future-publication rejection and indirect-origin claim rejection. - TypeScript: pass - ESLint: pass -- Vitest: **27 passed** +- Vitest: **46 passed** - production Next.js build: pass - Playwright browser E2E: **12 passed** - local production `/app` and `/verify`: HTTP 200 diff --git a/docs/ONCHAIN_JUSTICE_PRODUCT.md b/docs/ONCHAIN_JUSTICE_PRODUCT.md index e367364..a709b7a 100644 --- a/docs/ONCHAIN_JUSTICE_PRODUCT.md +++ b/docs/ONCHAIN_JUSTICE_PRODUCT.md @@ -57,13 +57,16 @@ verification page. ### External delivery observation (`/app` → Claim) After a successful parent claim, the Claim surface can inspect the exact child -transaction created by the native-value message. It binds the child to the -expected beneficiary and exact entitlement before interpreting the lifecycle. +transaction created by the native-value message. It first binds the parent to +the certified coordinator, the connected beneficiary, the exact +`claim_mission` call, the selected mission, and exactly one outbound message. +It then binds any child to that verified message's recipient and amount. `DELIVERED` requires `FINALIZED` plus `FINISHED_WITH_RETURN`; a finalized -execution error is shown as `FAILED`; all missing, pending, ambiguous, or -unreadable results are shown as `PENDING` or `UNVERIFIED`. The parent claim ID -and exact amount are stored only in beneficiary-scoped browser storage so the -user can re-read the observation after navigation. +execution error is shown as `FINALIZED_ERROR — DELIVERY UNRESOLVED`; all +missing, pending, ambiguous, or unreadable results are shown as `PENDING` or +`UNVERIFIED`. The parent claim ID and local amount are only a convenience +cache, and a manually pasted parent ID is re-verified from the network before +it can be observed. This is explicitly an operator observation, not a contract callback or trustless retry mechanism. The product never restores an entitlement or diff --git a/docs/OPEN_QUESTIONS.md b/docs/OPEN_QUESTIONS.md index 162d3df..5a2977a 100644 --- a/docs/OPEN_QUESTIONS.md +++ b/docs/OPEN_QUESTIONS.md @@ -17,7 +17,7 @@ Agent Tank submission. - direct-origin claim guard; - **117** Direct Runtime tests; - **454** non-runtime Python tests + **334** subtests; -- **27** frontend unit tests; +- **46** frontend unit tests; - **12** browser E2E tests; - production frontend build and local security-header checks. diff --git a/docs/STATE_MACHINE.md b/docs/STATE_MACHINE.md index a907cff..5b8223d 100644 --- a/docs/STATE_MACHINE.md +++ b/docs/STATE_MACHINE.md @@ -61,13 +61,14 @@ registration, sealing, and cancellation. External claim dispatch is intentionally not blindly retried. The entitlement is consumed before dispatch to prevent double payment. The frontend can follow -the exact triggered child transaction and reports -delivery only after exact recipient/value binding plus `FINALIZED` and -`FINISHED_WITH_RETURN`; it reports finalized `FINISHED_WITH_ERROR` as failure -and all other cases as pending or unverified. This is an operator-side read, -not contract state. The current revision does not provide a trustless -reconciliation or retry write because a timeout-based restoration could race a -delayed original transfer. +the exact parent claim, its exact outbound message, and any triggered child +transaction. It reports delivery only after parent target/caller/method/mission +binding, exact recipient/value binding, `FINALIZED`, and +`FINISHED_WITH_RETURN`; it reports finalized `FINISHED_WITH_ERROR` as +`FINALIZED_ERROR` without claiming terminal non-delivery. All other cases are +pending or unverified. This is an operator-side read, not contract state. The +current revision does not provide a trustless reconciliation or retry write +because a timeout-based restoration could race a delayed original transfer. No upgrade/admin escape hatch can rewrite a sealed mission or redirect custody. The owner can register or deactivate publisher authorities. Each authority ID diff --git a/docs/STUDIO_NEXT_CHECKPOINT_2026-09-15.md b/docs/STUDIO_NEXT_CHECKPOINT_2026-09-15.md index 0db1e48..7126b34 100644 --- a/docs/STUDIO_NEXT_CHECKPOINT_2026-09-15.md +++ b/docs/STUDIO_NEXT_CHECKPOINT_2026-09-15.md @@ -227,7 +227,9 @@ coordinator deployment, and fresh COMMIT/ABORT lifecycle proofs be prepared. ## Public app/frontend status -- stable public URL: `https://commitprotocol-genlayer.vercel.app` +- historical stable public URL at the time of this checkpoint: + `https://commitprotocol-genlayer.vercel.app` +- current canonical public URL: `https://commit-protocol.vercel.app` - this checkpoint does not change the Vercel project or public URL; - this checkpoint does not change contract source; - this checkpoint does not change frontend source. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index b6073af..3473d7d 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -20,7 +20,7 @@ model; the frontend now exposes a strict read-only child-message observation. | Leader manipulation | Validator path is bound to the consequential evidence result | Fresh current-source COMMIT and ABORT consensus proofs passed; recovery/stale-callback hosted proof remains separately certifiable | | Replay/double allocation | Decision nonce, exact roots, self-callback authentication, terminal allocation flag, recovery race guards | Fresh current-source COMMIT and ABORT callback/claim proofs passed; recovery/stale-callback hosted proof remains separately certifiable | | Trapped funds | Permissionless recovery after declared recovery deadline | Requires network liveness and a successful recovery transaction | -| Indirect claim/reentrancy-style caller confusion | Public claim requires immediate sender == original transaction submitter; entitlement is consumed before dispatch; frontend binds any observed child to the exact beneficiary and value | Downstream chain-layer delivery/reconciliation is outside COMMIT's proven atomic boundary | +| Indirect claim/reentrancy-style caller confusion | Public claim requires immediate sender == original transaction submitter; entitlement is consumed before dispatch; frontend independently verifies parent target, caller, method, mission, and exact outbound child binding | Downstream chain-layer delivery/reconciliation is outside COMMIT's proven atomic boundary | | Fee exhaustion | Fee policy enumerates all message-producing paths and invalidation conditions | Exact live quotes are required before each new message path; the certified deployment and tested lifecycle used measured current-network values | | Oversized remote input | 16 KiB response cap and bounded text/reason/graph fields | Target-network resource behavior must still be certified | @@ -45,8 +45,8 @@ success, or automatic reconciliation as part of semantic atomicity. already present. The repository's GitHub Actions `Verification` workflow is active. Run -`35140950751` completed successfully for the current repository release -commit `abc5b1883b10eec022875becc8660a724fb8f671`. +`35142771890` completed successfully for `main` commit +`d2e46de82b9c269d59ed77d97c1844dc9943b7b3`. The `main` branch now requires the Frontend checks, Python checks, and Direct Runtime checks, with force-push and deletion disabled and conversation diff --git a/lib/browser-storage.ts b/lib/browser-storage.ts new file mode 100644 index 0000000..508ac78 --- /dev/null +++ b/lib/browser-storage.ts @@ -0,0 +1,33 @@ +"use client"; + +/** + * Browser persistence is a convenience layer only. Every caller must remain + * correct when storage is unavailable, blocked, or manually cleared. + */ +export function readBrowserStorage(key: string): string | null { + if (typeof window === "undefined") { + return null; + } + + try { + return window.localStorage.getItem(key); + } catch { + return null; + } +} + +export function writeBrowserStorage( + key: string, + value: string, +): boolean { + if (typeof window === "undefined") { + return false; + } + + try { + window.localStorage.setItem(key, value); + return true; + } catch { + return false; + } +} diff --git a/lib/genlayer-delivery.ts b/lib/genlayer-delivery.ts index 752ec06..6ad11f1 100644 --- a/lib/genlayer-delivery.ts +++ b/lib/genlayer-delivery.ts @@ -1,3 +1,7 @@ +import { + abi, + decodeInputData, +} from "genlayer-js"; import { ExecutionResult, TransactionStatus, @@ -7,6 +11,10 @@ import { import type { ConnectedCommitWallet, } from "@/lib/genlayer-browser"; +import { + readBrowserStorage, + writeBrowserStorage, +} from "@/lib/browser-storage"; /** * This is an operator-side observation of the child transaction created by a @@ -16,7 +24,7 @@ import type { export type ExternalDeliveryPhase = | "PENDING" | "DELIVERED" - | "FAILED" + | "FINALIZED_ERROR" | "UNVERIFIED"; export type ExternalDeliveryChild = { @@ -30,11 +38,19 @@ export type ExternalDeliveryChild = { successful: boolean | null; }; +export type ExternalDeliveryOutboundMessage = { + recipient: `0x${string}`; + amount: bigint; + data: string | null; + onAcceptance: boolean | null; +}; + export type ExternalDeliveryObservation = { parentTransactionId: TransactionHash; childTransactionIds: TransactionHash[]; phase: ExternalDeliveryPhase; child: ExternalDeliveryChild | null; + outboundMessage: ExternalDeliveryOutboundMessage | null; reason: string; observedAt: number; }; @@ -47,8 +63,15 @@ type DeliveryReadClient = Pick< const CLAIM_TX_STORAGE_PREFIX = "commit:claim-tx:"; export type ExternalDeliveryExpectation = { + coordinator: `0x${string}`; + missionId: string; recipient: `0x${string}`; - amount: bigint; + /** + * Optional convenience consistency check from the local claim quote. The + * authoritative amount is always the finalized parent message itself. + * A tampered local amount can only make the observation fail closed. + */ + amount?: bigint; }; export type PersistedClaimTransaction = { @@ -58,7 +81,21 @@ export type PersistedClaimTransaction = { amount: bigint; }; -function isTransactionHash( +type UnknownRecord = Record; + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function valueAt(source: unknown, key: string): unknown { + if (source instanceof Map) { + return source.get(key); + } + + return isRecord(source) ? source[key] : undefined; +} + +export function isTransactionHash( value: string, ): value is TransactionHash { return /^0x[0-9a-fA-F]{64}$/.test(value); @@ -72,6 +109,39 @@ function assertTransactionHash( } } +function isAddress(value: unknown): value is `0x${string}` { + return typeof value === "string" + && /^0x[0-9a-fA-F]{40}$/.test(value); +} + +function addressAt( + source: unknown, + key: string, +): `0x${string}` | null { + const value = valueAt(source, key); + return isAddress(value) ? value : null; +} + +function bigintValue(value: unknown): bigint | null { + if (typeof value === "bigint") { + return value >= BigInt(0) ? value : null; + } + + if ( + typeof value === "number" + && Number.isSafeInteger(value) + && value >= 0 + ) { + return BigInt(value); + } + + if (typeof value === "string" && /^[0-9]+$/.test(value)) { + return BigInt(value); + } + + return null; +} + function executionResultName( transaction: GenLayerTransaction, ): string | null { @@ -88,45 +158,205 @@ function statusName( : null; } -function transactionRecipient( +function transactionTarget( transaction: GenLayerTransaction, ): `0x${string}` | null { const candidate = transaction.to_address ?? transaction.recipient; + return isAddress(candidate) ? candidate : null; +} - return typeof candidate === "string" - && /^0x[0-9a-fA-F]{40}$/.test(candidate) - ? candidate as `0x${string}` - : null; +function transactionRecipient( + transaction: GenLayerTransaction, +): `0x${string}` | null { + const candidate = transaction.to_address ?? transaction.recipient; + return isAddress(candidate) ? candidate : null; } function transactionAmount( transaction: GenLayerTransaction, ): bigint | null { - const value = transaction.value; + return bigintValue(transaction.value); +} - if (typeof value === "bigint") { - return value >= BigInt(0) ? value : null; +function decodeTransactionCallData( + transaction: GenLayerTransaction, +): unknown { + const decoded = transaction.txDataDecoded; + + if ( + isRecord(decoded) + && valueAt(decoded, "callData") !== undefined + ) { + return valueAt(decoded, "callData"); + } + + const target = transactionTarget(transaction); + + if (transaction.txData !== undefined && target !== null) { + const decodedInput = decodeInputData( + transaction.txData, + target, + ); + + if ( + isRecord(decodedInput) + && valueAt(decodedInput, "callData") !== undefined + ) { + return valueAt(decodedInput, "callData"); + } + } + + const calldata = valueAt(transaction.data, "calldata"); + const raw = valueAt(calldata, "raw"); + + if (raw instanceof Uint8Array) { + return abi.calldata.decode(raw); } if ( - typeof value === "number" - && Number.isSafeInteger(value) - && value >= 0 + Array.isArray(raw) + && raw.every((item) => Number.isInteger(item) && item >= 0 && item <= 255) ) { - return BigInt(value); + return abi.calldata.decode(Uint8Array.from(raw)); } - if (typeof value === "string" && /^[0-9]+$/.test(value)) { - return BigInt(value); + throw new Error( + "GenLayer did not expose decodable calldata for the claim transaction.", + ); +} + +function readClaimCall( + transaction: GenLayerTransaction, +): { functionName: string; missionId: string } { + const callData = decodeTransactionCallData(transaction); + const functionName = valueAt(callData, "") ?? valueAt(callData, "method"); + const args = valueAt(callData, "args"); + const missionId = Array.isArray(args) ? args[0] : undefined; + + if (typeof functionName !== "string") { + throw new Error( + "The claim transaction calldata does not identify a method.", + ); } - return null; + if (typeof missionId !== "string") { + throw new Error( + "The claim transaction calldata does not contain a mission ID.", + ); + } + + return { functionName, missionId }; +} + +function readParentIdentity( + transaction: GenLayerTransaction, +): { sender: `0x${string}`; origin: `0x${string}`; from: `0x${string}` } { + const record = transaction as unknown as UnknownRecord; + const sender = addressAt(record, "sender"); + const origin = addressAt(record, "origin_address"); + const from = addressAt(record, "from_address"); + + if (sender === null || origin === null || from === null) { + throw new Error( + "The claim transaction did not expose sender, origin, and from_address together.", + ); + } + + return { sender, origin, from }; +} + +function readOutboundMessage( + transaction: GenLayerTransaction, + expected: ExternalDeliveryExpectation, +): ExternalDeliveryOutboundMessage { + const messages = transaction.messages; + + if (!Array.isArray(messages) || messages.length !== 1) { + throw new Error( + "The finalized claim did not expose exactly one native outbound message.", + ); + } + + const message = messages[0]; + const recipient = addressAt(message, "recipient"); + const amount = bigintValue(valueAt(message, "value")); + + if (recipient === null || amount === null || amount <= BigInt(0)) { + throw new Error( + "The claim outbound message did not expose a valid recipient and positive GEN value.", + ); + } + + if (recipient.toLowerCase() !== expected.recipient.toLowerCase()) { + throw new Error( + "The claim outbound message recipient does not match the beneficiary.", + ); + } + + if (expected.amount !== undefined && amount !== expected.amount) { + throw new Error( + "The claim outbound message amount does not match the locally quoted amount.", + ); + } + + const data = valueAt(message, "data"); + const onAcceptance = valueAt(message, "onAcceptance"); + + return { + recipient, + amount, + data: typeof data === "string" ? data : null, + onAcceptance: typeof onAcceptance === "boolean" ? onAcceptance : null, + }; +} + +function verifyParentClaim( + transaction: GenLayerTransaction, + expected: ExternalDeliveryExpectation, +): ExternalDeliveryOutboundMessage { + const target = transactionTarget(transaction); + + if ( + target === null + || target.toLowerCase() !== expected.coordinator.toLowerCase() + ) { + throw new Error( + "The supplied parent transaction targets a different contract than the certified COMMIT coordinator.", + ); + } + + const identity = readParentIdentity(transaction); + if ( + identity.sender.toLowerCase() !== expected.recipient.toLowerCase() + || identity.origin.toLowerCase() !== expected.recipient.toLowerCase() + || identity.from.toLowerCase() !== expected.recipient.toLowerCase() + ) { + throw new Error( + "The supplied parent transaction was not submitted by the connected beneficiary.", + ); + } + + const call = readClaimCall(transaction); + if (call.functionName !== "claim_mission") { + throw new Error( + "The supplied parent transaction is not a claim_mission call.", + ); + } + + if (call.missionId !== expected.missionId) { + throw new Error( + "The supplied parent transaction belongs to a different mission.", + ); + } + + return readOutboundMessage(transaction, expected); } function childObservation( transactionId: TransactionHash, transaction: GenLayerTransaction, expected: ExternalDeliveryExpectation, + authoritativeAmount: bigint, ): ExternalDeliveryChild { const status = statusName(transaction); const result = executionResultName(transaction); @@ -136,7 +366,7 @@ function childObservation( const bindingVerified = recipient !== null && amount !== null && recipient.toLowerCase() === expected.recipient.toLowerCase() - && amount === expected.amount; + && amount === authoritativeAmount; return { transactionId, @@ -161,6 +391,7 @@ function observation( childTransactionIds: TransactionHash[], phase: ExternalDeliveryPhase, child: ExternalDeliveryChild | null, + outboundMessage: ExternalDeliveryOutboundMessage | null, reason: string, ): ExternalDeliveryObservation { return { @@ -168,34 +399,32 @@ function observation( childTransactionIds, phase, child, + outboundMessage, reason, observedAt: Date.now(), }; } export function unverifiedExternalDelivery( - parentTransactionId: TransactionHash, + parentTransactionId: string, reason: string, ): ExternalDeliveryObservation { + assertTransactionHash(parentTransactionId); + return observation( parentTransactionId, [], "UNVERIFIED", null, + null, reason, ); } /** - * Observe the exact child transaction(s) emitted by a parent claim. - * - * Strict rules: - * - no child ID is not treated as success; - * - more than one child ID is ambiguous for the one-transfer claim path; - * - only FINALIZED + FINISHED_WITH_RETURN is delivered; - * - only FINALIZED + FINISHED_WITH_ERROR is failed; - * - every other state is pending or unverified; - * - this function has no write or retry capability. + * Observe an exact native-value child only after independently verifying its + * parent claim. The parent and outbound message are read from Studio Next; + * browser storage is never treated as authority. No write or retry exists. */ export async function observeExternalDelivery( client: DeliveryReadClient, @@ -204,8 +433,48 @@ export async function observeExternalDelivery( ): Promise { assertTransactionHash(parentTransactionId); - let childTransactionIds: TransactionHash[]; + let parent: GenLayerTransaction; + try { + parent = await client.getTransaction({ hash: parentTransactionId }); + } catch { + return unverifiedExternalDelivery( + parentTransactionId, + "The Studio Next RPC did not return the parent claim transaction. No delivery outcome is claimed.", + ); + } + + const parentStatus = statusName(parent); + if (parentStatus !== TransactionStatus.FINALIZED) { + return observation( + parentTransactionId, + [], + "PENDING", + null, + null, + "The parent claim has not finalized. Do not infer delivery or failure from elapsed time.", + ); + } + + if (executionResultName(parent) !== ExecutionResult.FINISHED_WITH_RETURN) { + return unverifiedExternalDelivery( + parentTransactionId, + "The parent claim did not finalize successfully. No external delivery is claimed.", + ); + } + + let outboundMessage: ExternalDeliveryOutboundMessage; + try { + outboundMessage = verifyParentClaim(parent, expected); + } catch (caught: unknown) { + return unverifiedExternalDelivery( + parentTransactionId, + caught instanceof Error + ? `${caught.message} No delivery outcome is claimed.` + : "The parent claim binding could not be verified. No delivery outcome is claimed.", + ); + } + let childTransactionIds: TransactionHash[]; try { childTransactionIds = await client.getTriggeredTransactionIds({ hash: parentTransactionId, @@ -216,7 +485,8 @@ export async function observeExternalDelivery( [], "UNVERIFIED", null, - "The Studio Next RPC did not return triggered child transaction IDs. No delivery outcome is claimed.", + outboundMessage, + "The parent claim emitted the exact outbound GEN message, but Studio Next did not return triggered child IDs. Delivery is not proven.", ); } @@ -226,7 +496,8 @@ export async function observeExternalDelivery( [], "UNVERIFIED", null, - "No triggered child transaction ID is exposed yet. This is not proof of delivery or non-delivery.", + outboundMessage, + "The parent claim emitted the exact outbound GEN message, but no triggered child transaction ID is exposed. Delivery is not proven.", ); } @@ -236,6 +507,7 @@ export async function observeExternalDelivery( [], "UNVERIFIED", null, + outboundMessage, "The Studio Next RPC returned a malformed child transaction ID. No delivery outcome is claimed.", ); } @@ -246,6 +518,7 @@ export async function observeExternalDelivery( childTransactionIds, "UNVERIFIED", null, + outboundMessage, "The claim produced an unexpected number of child transactions. Delivery is ambiguous and no retry is authorized.", ); } @@ -263,13 +536,16 @@ export async function observeExternalDelivery( childTransactionIds, "UNVERIFIED", null, + outboundMessage, "The Studio Next RPC did not return the exact child transaction. No delivery outcome is claimed.", ); } + const child = childObservation( childTransactionId, transaction, expected, + outboundMessage.amount, ); if (!child.bindingVerified) { @@ -278,7 +554,8 @@ export async function observeExternalDelivery( childTransactionIds, "UNVERIFIED", child, - "The child transaction does not prove the expected recipient and exact GEN amount. No delivery outcome is claimed.", + outboundMessage, + "The child transaction does not match the exact recipient and amount emitted by the verified parent message. No delivery outcome is claimed.", ); } @@ -288,7 +565,8 @@ export async function observeExternalDelivery( childTransactionIds, "DELIVERED", child, - "The exact triggered child transaction finalized successfully.", + outboundMessage, + "The verified parent emitted one exact outbound message and its exact child transaction finalized successfully.", ); } @@ -296,9 +574,10 @@ export async function observeExternalDelivery( return observation( parentTransactionId, childTransactionIds, - "FAILED", + "FINALIZED_ERROR", child, - "The exact triggered child transaction finalized with an execution error. COMMIT does not automatically retry or restore the entitlement.", + outboundMessage, + "The exact child finalized with an execution error. This is not proof of terminal non-delivery and COMMIT does not automatically restore or retry the entitlement.", ); } @@ -308,7 +587,8 @@ export async function observeExternalDelivery( childTransactionIds, "UNVERIFIED", child, - "The child transaction finalized without a recognized successful or failed execution result. No delivery outcome is claimed.", + outboundMessage, + "The child transaction finalized without a recognized execution result. No delivery outcome is claimed.", ); } @@ -317,7 +597,8 @@ export async function observeExternalDelivery( childTransactionIds, "PENDING", child, - "The exact triggered child transaction has not finalized. Do not infer failure from elapsed time and do not retry.", + outboundMessage, + "The exact child transaction has not finalized. Do not infer failure from elapsed time and do not retry.", ); } @@ -332,50 +613,37 @@ export function readPersistedClaimTransaction( missionId: string, beneficiary: `0x${string}`, ): PersistedClaimTransaction | null { - if (typeof window === "undefined") { + const stored = readBrowserStorage( + claimTransactionStorageKey(missionId, beneficiary), + ); + + if (stored === null) { return null; } try { - const stored = window.localStorage.getItem( - claimTransactionStorageKey(missionId, beneficiary), - ); - - if (stored === null) { - return null; - } - const candidate: unknown = JSON.parse(stored); if ( - typeof candidate !== "object" - || candidate === null - || Array.isArray(candidate) - ) { - return null; - } - - const record = candidate as Record; - - if ( - typeof record.missionId !== "string" - || typeof record.beneficiary !== "string" - || !/^0x[0-9a-fA-F]{40}$/.test(record.beneficiary) - || record.missionId !== missionId - || record.beneficiary.toLowerCase() !== beneficiary.toLowerCase() - || typeof record.transactionId !== "string" - || !isTransactionHash(record.transactionId) - || typeof record.amount !== "string" - || !/^[1-9][0-9]*$/.test(record.amount) + !isRecord(candidate) + || typeof candidate.missionId !== "string" + || typeof candidate.beneficiary !== "string" + || !/^0x[0-9a-fA-F]{40}$/.test(candidate.beneficiary) + || candidate.missionId !== missionId + || candidate.beneficiary.toLowerCase() !== beneficiary.toLowerCase() + || typeof candidate.transactionId !== "string" + || !isTransactionHash(candidate.transactionId) + || typeof candidate.amount !== "string" + || !/^[1-9][0-9]*$/.test(candidate.amount) ) { return null; } return { - missionId: record.missionId, - beneficiary: record.beneficiary as `0x${string}`, - transactionId: record.transactionId, - amount: BigInt(record.amount), + missionId: candidate.missionId, + beneficiary: candidate.beneficiary as `0x${string}`, + transactionId: candidate.transactionId, + amount: BigInt(candidate.amount), }; } catch { return null; @@ -388,22 +656,13 @@ export function persistClaimTransaction( transactionId: TransactionHash, amount: bigint, ): void { - if (typeof window === "undefined") { - return; - } - - try { - window.localStorage.setItem( - claimTransactionStorageKey(missionId, beneficiary), - JSON.stringify({ - missionId, - beneficiary: beneficiary.toLowerCase(), - transactionId, - amount: amount.toString(), - }), - ); - } catch { - // Browser persistence is a convenience; delivery observation remains - // available for the current session if storage is unavailable. - } + writeBrowserStorage( + claimTransactionStorageKey(missionId, beneficiary), + JSON.stringify({ + missionId, + beneficiary: beneficiary.toLowerCase(), + transactionId, + amount: amount.toString(), + }), + ); } diff --git a/tests/e2e/product-ui.spec.ts b/tests/e2e/product-ui.spec.ts index 4f9505b..0e7d310 100644 --- a/tests/e2e/product-ui.spec.ts +++ b/tests/e2e/product-ui.spec.ts @@ -556,6 +556,12 @@ test("COMMIT connected wallet exposes beneficiary claim and withdrawal receipt c ), ).toBeVisible(); + await expect( + page.getByLabel( + /Parent claim transaction \(optional recovery\)/i, + ), + ).toBeVisible(); + const calls = await page.evaluate(() => ( ( diff --git a/tests/frontend/genlayer-delivery.test.ts b/tests/frontend/genlayer-delivery.test.ts index 8e7c8b3..98d29e5 100644 --- a/tests/frontend/genlayer-delivery.test.ts +++ b/tests/frontend/genlayer-delivery.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, + vi, } from "vitest"; import { ExecutionResult, @@ -18,15 +19,47 @@ import { const PARENT = `0x${"1".repeat(64)}` as TransactionHash; const CHILD = `0x${"2".repeat(64)}` as TransactionHash; +const COORDINATOR = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" as const; const RECIPIENT = "0x1111111111111111111111111111111111111111" as const; const OTHER_RECIPIENT = "0x3333333333333333333333333333333333333333" as const; +const MISSION_ID = "mission-1"; const AMOUNT = BigInt("500000000000000000"); const EXPECTED = { + coordinator: COORDINATOR, + missionId: MISSION_ID, recipient: RECIPIENT, amount: AMOUNT, }; -function transaction( +function parentTransaction( + overrides: Record = {}, +) { + return { + statusName: TransactionStatus.FINALIZED, + txExecutionResultName: ExecutionResult.FINISHED_WITH_RETURN, + to_address: COORDINATOR, + from_address: RECIPIENT, + sender: RECIPIENT, + origin_address: RECIPIENT, + txDataDecoded: { + callData: { + "": "claim_mission", + args: [MISSION_ID], + }, + }, + messages: [ + { + recipient: RECIPIENT, + value: AMOUNT.toString(), + data: "", + onAcceptance: false, + }, + ], + ...overrides, + } as never; +} + +function childTransaction( child: Partial = {}, ) { return { @@ -39,11 +72,13 @@ function transaction( function client( childIds: string[], - child: ReturnType, + child: ReturnType, + parent = parentTransaction(), ) { return { getTriggeredTransactionIds: async () => childIds, - getTransaction: async () => child, + getTransaction: async ({ hash }: { hash: string }) => + hash === PARENT ? parent : child, } as never; } @@ -51,12 +86,12 @@ describe( "strict external delivery observation", () => { it( - "requires finalized successful execution before reporting delivery", + "requires a verified finalized parent and child before reporting delivery", async () => { const result = await observeExternalDelivery( client( [CHILD], - transaction({ + childTransaction({ statusName: TransactionStatus.FINALIZED, executionResultName: ExecutionResult.FINISHED_WITH_RETURN, }), @@ -68,16 +103,17 @@ describe( expect(result.phase).toBe("DELIVERED"); expect(result.child?.transactionId).toBe(CHILD); expect(result.child?.successful).toBe(true); + expect(result.outboundMessage?.amount).toBe(AMOUNT); }, ); it( - "reports a definitive finalized execution error without authorizing retry", + "reports a finalized execution error without authorizing retry", async () => { const result = await observeExternalDelivery( client( [CHILD], - transaction({ + childTransaction({ statusName: TransactionStatus.FINALIZED, executionResultName: ExecutionResult.FINISHED_WITH_ERROR, }), @@ -86,9 +122,9 @@ describe( EXPECTED, ); - expect(result.phase).toBe("FAILED"); + expect(result.phase).toBe("FINALIZED_ERROR"); expect(result.child?.successful).toBe(false); - expect(result.reason).toContain("does not automatically retry"); + expect(result.reason).toContain("not proof of terminal non-delivery"); }, ); @@ -98,9 +134,9 @@ describe( const result = await observeExternalDelivery( client( [CHILD], - transaction({ + childTransaction({ statusName: TransactionStatus.ACCEPTED, - executionResultName: ExecutionResult.NOT_VOTED, + executionResultName: "NOT_VOTED", }), ), PARENT, @@ -113,12 +149,12 @@ describe( ); it( - "rejects a successful child whose recipient or amount is not exact", + "rejects a child whose recipient or amount is not exact", async () => { const wrongRecipient = await observeExternalDelivery( client( [CHILD], - transaction({ + childTransaction({ statusName: TransactionStatus.FINALIZED, executionResultName: ExecutionResult.FINISHED_WITH_RETURN, recipient: OTHER_RECIPIENT, @@ -130,7 +166,7 @@ describe( const wrongAmount = await observeExternalDelivery( client( [CHILD], - transaction({ + childTransaction({ statusName: TransactionStatus.FINALIZED, executionResultName: ExecutionResult.FINISHED_WITH_RETURN, amount: BigInt("600000000000000000"), @@ -145,26 +181,60 @@ describe( }, ); + it( + "rejects parent transactions with the wrong mission, caller, or target", + async () => { + const wrongMission = await observeExternalDelivery( + client([], childTransaction(), parentTransaction({ + txDataDecoded: { + callData: { "": "claim_mission", args: ["other-mission"] }, + }, + })), + PARENT, + EXPECTED, + ); + const wrongCaller = await observeExternalDelivery( + client([], childTransaction(), parentTransaction({ + sender: OTHER_RECIPIENT, + })), + PARENT, + EXPECTED, + ); + const wrongTarget = await observeExternalDelivery( + client([], childTransaction(), parentTransaction({ + to_address: OTHER_RECIPIENT, + })), + PARENT, + EXPECTED, + ); + + expect(wrongMission.phase).toBe("UNVERIFIED"); + expect(wrongCaller.phase).toBe("UNVERIFIED"); + expect(wrongTarget.phase).toBe("UNVERIFIED"); + }, + ); + it( "does not interpret missing or ambiguous child IDs as success", async () => { const missing = await observeExternalDelivery( - client([], transaction()), + client([], childTransaction()), PARENT, EXPECTED, ); const ambiguous = await observeExternalDelivery( - client([CHILD, PARENT], transaction()), + client([CHILD, PARENT], childTransaction()), PARENT, EXPECTED, ); const malformed = await observeExternalDelivery( - client(["not-a-transaction-hash"], transaction()), + client(["not-a-transaction-hash"], childTransaction()), PARENT, EXPECTED, ); expect(missing.phase).toBe("UNVERIFIED"); + expect(missing.outboundMessage?.recipient).toBe(RECIPIENT); expect(ambiguous.phase).toBe("UNVERIFIED"); expect(malformed.phase).toBe("UNVERIFIED"); }, @@ -175,17 +245,31 @@ describe( async () => { const result = await observeExternalDelivery( { + getTransaction: async () => parentTransaction(), getTriggeredTransactionIds: async () => { throw new Error("RPC unavailable"); }, - getTransaction: async () => transaction(), } as never, PARENT, EXPECTED, ); expect(result.phase).toBe("UNVERIFIED"); - expect(result.reason).toContain("No delivery outcome is claimed"); + expect(result.reason).toContain("Delivery is not proven"); + }, + ); + + it( + "fails closed when a stored amount is tampered with", + async () => { + const result = await observeExternalDelivery( + client([], childTransaction()), + PARENT, + { ...EXPECTED, amount: BigInt("600000000000000000") }, + ); + + expect(result.phase).toBe("UNVERIFIED"); + expect(result.reason).toContain("locally quoted amount"); }, ); @@ -230,5 +314,27 @@ describe( expect(readPersistedClaimTransaction(missionId, RECIPIENT)).toBeNull(); }, ); + + it( + "keeps the transaction flow usable when browser storage is blocked", + () => { + const setItem = vi + .spyOn(window.localStorage, "setItem") + .mockImplementation(() => { + throw new Error("storage blocked"); + }); + + expect(() => { + persistClaimTransaction( + "storage-blocked-mission", + RECIPIENT, + PARENT, + AMOUNT, + ); + }).not.toThrow(); + + setItem.mockRestore(); + }, + ); }, ); diff --git a/tests/test_frontend_product_ui_contract.py b/tests/test_frontend_product_ui_contract.py index 00e9882..f18633a 100644 --- a/tests/test_frontend_product_ui_contract.py +++ b/tests/test_frontend_product_ui_contract.py @@ -267,7 +267,7 @@ def test_funding_flow_enforces_contract_preflight_before_signing() -> None: assert "useEffect" not in flow assert 'useState(\n initialMissionId,\n );' in flow - assert 'key={lastMissionId || "manual-funding"}' in app + assert 'key={`${lastMissionId || "manual-funding"}:${wallet.address}`}' in app def test_workspace_shell_is_wired_to_certified_read_client() -> None: