@@ -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: