From 7598762f48e4e37a08a0a2f765fcae79031ff679 Mon Sep 17 00:00:00 2001 From: Haseeb Rabbani Date: Mon, 24 Aug 2026 15:25:21 -0400 Subject: [PATCH] fix(smoke-web): don't report post-execute canonicalization lag as execution failure GUARDIAN keeps reporting a just-executed proposal's delta until it observes the on-chain commitment. In that window syncProposals re-validates the delta against the local account state the execution already advanced and throws, so the refresh inside the executeProposal command failed the whole command even though the transaction had submitted. Retry the refresh through the canonicalization window, and surface an executed-but-pending snapshot instead of throwing if it still has not converged; any error outside the two known transient signatures still fails the command. --- examples/smoke-web/src/smokeHarness.ts | 56 +++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/examples/smoke-web/src/smokeHarness.ts b/examples/smoke-web/src/smokeHarness.ts index 5f916b1d..d9ec5e5a 100644 --- a/examples/smoke-web/src/smokeHarness.ts +++ b/examples/smoke-web/src/smokeHarness.ts @@ -360,6 +360,30 @@ function normalizeSessionInput(input: InitSessionInput): SessionConfig { }; } +/** + * GUARDIAN keeps reporting a just-executed proposal's delta until + * canonicalization observes the on-chain commitment. In that window + * `syncProposals` re-validates the delta against the local account state the + * execution already advanced, so a refresh right after a successful submit can + * throw even though the transaction landed. (The Rust client never sees this: + * `list_proposals` pre-filters proposals with `nonce <= account.nonce()`.) + */ +const POST_EXECUTE_TRANSIENT_PATTERNS = [ + /metadata does not match tx_summary/i, + /is not greater than local nonce/i, +]; +const POST_EXECUTE_REFRESH_ATTEMPTS = 5; +const POST_EXECUTE_REFRESH_DELAY_MS = 2000; + +function isPostExecuteTransient(err: unknown): boolean { + const message = normalizeError(err); + return POST_EXECUTE_TRANSIENT_PATTERNS.some((pattern) => pattern.test(message)); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + function useStateRef( initialValue: T, ): [T, React.MutableRefObject, (value: SetStateAction) => void] { @@ -1206,7 +1230,37 @@ export function useSmokeHarness(): { } await executeOnlineProposal(currentMultisig, proposalId); - const refreshed = await refreshMultisigState(currentMultisig); + + // The transaction is submitted at this point; a refresh failure below + // must not surface as an execution failure. Retry through GUARDIAN's + // canonicalization window, and if it still has not converged, report + // the executed-but-pending state instead of throwing. + let refreshed: Awaited> | null = null; + let pendingError: string | null = null; + for (let attempt = 1; attempt <= POST_EXECUTE_REFRESH_ATTEMPTS; attempt++) { + try { + refreshed = await refreshMultisigState(currentMultisig); + pendingError = null; + break; + } catch (err) { + if (!isPostExecuteTransient(err)) { + throw err; + } + pendingError = normalizeError(err); + if (attempt < POST_EXECUTE_REFRESH_ATTEMPTS) { + await delay(POST_EXECUTE_REFRESH_DELAY_MS); + } + } + } + + if (!refreshed) { + const message = + 'Proposal executed; local refresh is still pending GUARDIAN ' + + `canonicalization: ${pendingError}`; + setLastError(message); + return buildCurrentSnapshot({ lastError: message }); + } + return buildCurrentSnapshot({ guardianState: refreshed.state, detectedConfig: refreshed.config,