fix: give data-less batch reverts a readable error and pre-check the batch (WA-3267) - #8559
fix: give data-less batch reverts a readable error and pre-check the batch (WA-3267)#8559Clóvis Neto (clovisdasilvaneto) wants to merge 1 commit into
Conversation
…batch (WA-3267) MultiSendCallOnly does `revert(0, 0)` when an inner execTransaction fails, so a broken bulk execution reaches the client as a bare `require(false)` with no payload to decode. The toast rendered that verbatim — "Execution failed. Require(false)." — with a ~4KB ethers dump behind Details, and nothing in the flow tried to prevent the revert in the first place. Classify the case (`isOpaqueRevertError`) and give it honest copy in the shared code-keyed source: it says something in the transaction fails on-chain and that splitting the batch is what surfaces the real reason. Both the toast and the inline alert render it, so one failure tells one story. Sanitise Details through a single shared `getErrorReference`. A revert never yields its raw dump; what survives is what we can name — the GS code, or a decoded Error(string) reason, which the chain wrote for a human to read. Anything else gets no Details rather than an empty one. Run the GS026 pre-checks in the bulk flow too. They were wired only into the single-transaction path, so a stale Safe nonce or a signature that no longer verifies was invisible in bulk and cost the user gas. The batch variant checks each queued transaction and names the offending one by its position. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Branch preview✅ Deploy successful! Storybook: |
tx-builder Preview✅ Deploy successful! Preview URL: |
📦 Next.js Bundle Analysis for @safe-global/webThis analysis was generated by the Next.js Bundle Analysis action. 🤖
|
| Page | Size (compressed) |
|---|---|
global |
1.53 MB (🟡 +312.45 KB) |
Details
The global bundle is the javascript bundle that loads alongside every page. It is in its own category because its impact is much higher - an increase to its size means that every page on your website loads slower, and a decrease means every page loads faster.
Any third party scripts you have added directly to your app using the <script> tag are not accounted for in this analysis
If you want further insight into what is behind the changes, give @next/bundle-analyzer a try!
New Page Added
The following page was added to the bundle from the code in this PR:
| Page | Size (compressed) | First Load |
|---|---|---|
/spaces/activity |
546 B |
1.53 MB |
Sixty-one Pages Changed Size
The following pages changed size from the code in this PR compared to its base branch:
| Page | Size (compressed) | First Load |
|---|---|---|
/ |
424 B (🟢 -290 B) |
1.53 MB |
/403 |
635 B (🟢 -86 B) |
1.53 MB |
/404 |
837 B (🟡 +19 B) |
1.53 MB |
/_offline |
1.05 KB (🟢 -133 B) |
1.53 MB |
/addOwner |
551 B (🟢 -1 B) |
1.53 MB |
/address-book |
23.07 KB (🟢 -6.84 KB) |
1.55 MB |
/apps |
44.98 KB (🔴 +9.28 KB) |
1.57 MB |
/apps/custom |
42.42 KB (🔴 +10.01 KB) |
1.57 MB |
/apps/open |
9.27 KB (🟢 -104.05 KB) |
1.54 MB |
/balances |
25.5 KB (🟢 -109.6 KB) |
1.55 MB |
/balances/nfts |
16.29 KB (🟢 -3.56 KB) |
1.54 MB |
/balances/positions |
5.96 KB (🟢 -2.02 KB) |
1.53 MB |
/bridge |
553 B (🟢 -66 B) |
1.53 MB |
/cookie |
7.33 KB (🟢 -48 B) |
1.53 MB |
/dashboard/new |
1.42 KB (🟢 -2.09 KB) |
1.53 MB |
/earn |
575 B (🟢 -55 B) |
1.53 MB |
/home |
195.68 KB (🟢 -72.31 KB) |
1.72 MB |
/hypernative/oauth-callback |
2.07 KB (🟡 +58 B) |
1.53 MB |
/imprint |
1.42 KB (🟢 -9 B) |
1.53 MB |
/licenses |
1.96 KB (🟢 -94 B) |
1.53 MB |
/new-safe/advanced-create |
30.99 KB (🟢 -202.55 KB) |
1.56 MB |
/new-safe/create |
29.46 KB (🟢 -203.28 KB) |
1.56 MB |
/new-safe/load |
9.32 KB (🟢 -4.02 KB) |
1.54 MB |
/privacy |
13.21 KB (🟢 -507 B) |
1.54 MB |
/settings |
770 B (🟢 -5.72 KB) |
1.53 MB |
/settings/appearance |
6.17 KB (🟢 -2.25 KB) |
1.53 MB |
/settings/cookies |
6 KB (🟢 -2.04 KB) |
1.53 MB |
/settings/data |
32.36 KB (🟢 -2.66 KB) |
1.56 MB |
/settings/environment-variables |
7.26 KB (🟢 -2.35 KB) |
1.53 MB |
/settings/modules |
6.92 KB (🟢 -4.81 KB) |
1.53 MB |
/settings/notifications |
11.42 KB (🟢 -2.5 KB) |
1.54 MB |
/settings/safe-apps |
8.88 KB (🟢 -11.09 KB) |
1.54 MB |
/settings/security |
6.49 KB (🟢 -2.2 KB) |
1.53 MB |
/settings/setup |
37.76 KB (🟢 -19.4 KB) |
1.56 MB |
/share/safe-app |
6.63 KB (🟢 -1.66 KB) |
1.53 MB |
/spaces |
611 B (🟡 +2 B) |
1.53 MB |
/spaces/address-book |
509 B (🟡 +2 B) |
1.53 MB |
/spaces/create-space |
440 B (🟡 +3 B) |
1.53 MB |
/spaces/members |
499 B (🟡 +3 B) |
1.53 MB |
/spaces/safe-accounts |
507 B (🟡 +1 B) |
1.53 MB |
/spaces/security |
6.75 KB (🟢 -121.95 KB) |
1.53 MB |
/spaces/settings/about |
508 B (🟡 +4 B) |
1.53 MB |
/spaces/settings/account |
509 B (🟡 +3 B) |
1.53 MB |
/spaces/settings/general |
509 B (🟡 +3 B) |
1.53 MB |
/stake |
1.22 KB (🟡 +124 B) |
1.53 MB |
/swap |
744 B (🟢 -417 B) |
1.53 MB |
/terms |
13.71 KB (🟢 -19 B) |
1.54 MB |
/transactions |
35.4 KB (🟢 -128.78 KB) |
1.56 MB |
/transactions/history |
35.36 KB (🟢 -128.78 KB) |
1.56 MB |
/transactions/messages |
15.27 KB (🟢 -104.36 KB) |
1.54 MB |
/transactions/msg |
8.08 KB (🟢 -105.13 KB) |
1.54 MB |
/transactions/queue |
6.22 KB (🟢 -104 KB) |
1.53 MB |
/transactions/tx |
1.01 KB (🟢 -105.67 KB) |
1.53 MB |
/wc |
586 B (🟡 +1 B) |
1.53 MB |
/welcome |
435 B (🟢 -13.87 KB) |
1.53 MB |
/welcome/accounts |
610 B (🟢 -112 B) |
1.53 MB |
/welcome/create-space |
444 B (🟡 +2 B) |
1.53 MB |
/welcome/invite-members |
445 B (🟡 +4 B) |
1.53 MB |
/welcome/select-safes |
448 B (🟡 +8 B) |
1.53 MB |
/welcome/spaces |
308 B (🟢 -197 B) |
1.53 MB |
/welcome/survey |
697 B (🟡 +1 B) |
1.53 MB |
Details
Only the gzipped size is provided here based on an expert tip.
First Load is the size of the global bundle plus the bundle for the individual page. If a user were to show up to your website and land on a given page, the first load size represents the amount of javascript that user would need to download. If next/link is used, subsequent page loads would only need to download that page's bundle (the number in the "Size" column), since the global bundle has already been downloaded.
Any third party scripts you have added directly to your app using the <script> tag are not accounted for in this analysis
Next to the size is how much the size has increased or decreased compared with the base branch of this PR. If this percentage has increased by 20% or more, there will be a red status indicator applied, indicating that special attention should be given to this.
Coverage report for
|
St.❔ |
Category | Percentage | Covered / Total |
|---|---|---|---|
| 🟢 | Statements | 84.93% (+0.06% 🔼) |
35292/41556 |
| 🟡 | Branches | 69.73% (-0.02% 🔻) |
11546/16557 |
| 🟡 | Functions | 73.19% (+0.09% 🔼) |
5368/7334 |
| 🟢 | Lines | 86.15% (+0.04% 🔼) |
31585/36662 |
Show new covered files 🐣
St.❔ |
File | Statements | Branches | Functions | Lines |
|---|---|---|---|---|---|
| 🟢 | ... / ReviewBatch.tsx |
92.79% | 70.18% | 88.89% | 94.39% |
| 🟢 | ... / ConfirmationTitle.tsx |
100% | 50% | 100% | 100% |
| 🟢 | ... / useTxNotifications.ts |
81.82% | 53.23% | 90% | 83.13% |
Show files with reduced coverage 🔻
St.❔ |
File | Statements | Branches | Functions | Lines |
|---|---|---|---|---|---|
| 🟢 | ... / transaction-errors.ts |
98.73% (+0.52% 🔼) |
94.29% (-1.55% 🔻) |
100% | 100% |
| 🟢 | ... / executionPreChecks.ts |
97.53% (-2.47% 🔻) |
93.55% (-6.45% 🔻) |
100% | 98.55% (-1.45% 🔻) |
Test suite run success
7624 tests passing in 876 suites.
Report generated by 🧪jest coverage report action from f6620b2
What it solves
Resolves: WA-3267
A user bulk-executed 2 queued transactions through MultiSendCallOnly 1.4.1. One inner
execTransactionreverted. MultiSendCallOnly doesrevert(0, 0)— it destroys the child's revert reason — so the client got a data-lessCALL_EXCEPTIONwith nothing to decode.What they saw:
Execution failed. Require(false).followed by a ~4KB raw ethers dump — full calldata, contract addresses,version=6.17.0— rendered verbatim in a<pre>.Error submitting the transaction. Please try again.Two separate defects behind that:
Data-less reverts were never classified.
useTxNotifications.tsbuilt`${baseMessage} ${formatError(error)}`, andformatErrorjust capitalizeserror.reason→Require(false).. The override chain below it matched onlyREVERTED, guard errors, nonce-too-low and rate-limit, so a revert with no decodable payload fell straight through to the raw string. This breaks WA-3005 guideline Add SafeInfoService #1 (never show a technical string) and Refactor safe info service #6 (Details is a support reference, never a payload).The bulk flow never ran the GS026 pre-checks.
runExecutionPreChecks(feat(errors): block execution with GS026 pre-checks before broadcast #8499) was wired only into the single-transaction path atcomponents/tx/shared/hooks.ts:27.ExecuteBatch/ReviewBatch.tsxnever called it, so the two most likely causes of this revert — the Safe nonce advanced since the queue loaded, or a collected signature no longer verifies — were caught before broadcast in a normal execution and completely invisible in bulk. The user paid gas for a failure that was detectable client-side.The inline half was already fixed on
devby WA-3005 (d74c47cc8, #8484) →Could not submit the transaction.Note that commit is not in v1.99.0:releasebranched at3c678cb48on Aug 11, that commit landed Aug 13.Out of scope: MultiSendCallOnly's
revert(0, 0)(the reason is destroyed on-chain, no client fix exists), off-chain copy (WA-3006), the rest of WA-3005.How this PR fixes it
Classify the failure.
isOpaqueRevertErrorintransaction-errors.tsmatches a revert with no GS code, no custom-error selector, and positive proof of an empty payload —data === '0x', ethers' "no data present", or viem's "for an unknown reason". Deliberately narrow: a GS revert, anError(string)revert, a guard custom error, an RPC timeout and a rate-limit all stay on their existing paths.Say something honest.
OPAQUE_REVERT_MESSAGElives in the shared code-keyed source that web and mobile both read:Splitting the batch is the genuinely useful advice — a single
execTransactionbubbles its GS code where MultiSendCallOnly discards it. The copy claims nothing about gas, becauseTxEvent.FAILEDfires both pre-broadcast and fromtxMonitor; mined reverts go toTxEvent.REVERTED, which already says gas was spent. Both the toast and the inline alert render it, so one failure no longer tells two stories.Sanitise Details once. A single shared
getErrorReferencedrives both surfaces so they cannot drift: a non-revert keeps its message (usually copy we wrote — relay timeouts, "transaction not found"); a GS revert reduces toError code GS0xx; a revert whose selector is exactlyError(string)yields the already-decoded reason; anything else yields nothing and the affordance is hidden rather than offered empty.This is deliberately not a blanket suppression of every revert.
ERC20: transfer amount exceeds balanceis a sentence the chain wrote for a human to read — hiding it would satisfy the acceptance criteria while making the product worse. The gate is the structural0x08c379a0selector, not a regex over the message.Prevent what we can.
runBatchExecutionPreCheckschecks that the batch's nonces still line up with the chain and that every collected signature still recovers to its signer, naming the offender by its position in the batch — the same number the review screen shows. Wired intoReviewBatchahead of both the wallet and the relay path. This also covers the symptom reported in WA-49: a stalesafe.noncefrom the CGW cache produces a batch whose nonces no longer match the chain.Three deliberate calls inside it:
safeTxHashrather than a recomputed one. Cheaper and SDK-free, at the cost of missing a hash mismatch — a miss, never a false block, which is the right side to err on when on-chain validation is the authority.vvalue; reading a signature type off it would recover a random address and falsely flag a nested-Safe signer.sameAddressreplaced a case-sensitive!==invalidateTxSignatures. Incidental bugfix:ethers.recoverAddressreturns checksummed, so a lowercase signer previously produced a falseBAD_SIGNATUREblock in the single-tx flow and inComboSubmittoo.How to test it
Unit:
yarn workspace @safe-global/web test src/utils/__tests__/transaction-errors.test.ts src/services/tx/__tests__/executionPreChecks.test.ts src/hooks/__tests__/useTxNotifications.test.ts src/components/tx-flow/flows/ExecuteBatch src/components/tx/ErrorMessage src/components/tx/TxSubmitErrorManually, the opaque revert: queue two transactions where the second is guaranteed to revert (e.g. an ERC-20 transfer exceeding the Safe's balance), fully sign both, and use Bulk execute. Estimation fails; the toast and the inline alert should both show the message above, with no
Require(false), no hex, noversion=, and no Details affordance.Manually, the pre-check: build a batch, then execute one of its transactions from another tab (or have a co-signer do it) so the Safe nonce advances. Submit the batch. It should block before the wallet opens, naming the offending position — "Transaction 1 in this batch no longer matches the queue. Refresh to get the current one." — instead of burning gas on an opaque revert.
Regression: confirm a GS revert still shows its mapped copy and the code-only reference, an
Error(string)revert still shows its reason behind Details, and an RPC failure still says "could not check" rather than predicting failure.Affected flows
TxSubmitError) — a data-less revert now matches the toast instead of saying "Could not submit the transaction."ErrorMessage, 41 call sites) — behaviour changes only for errors that passisRevertErrorBlast radius
packages/utils/.../contractErrors.ts— the shared code-keyed copy source, read by web and mobile. Additive only:OPAQUE_REVERT_MESSAGE,Gs026BatchReason,getGs026BatchMessage. No existing export changed; the mobile parity test passes untouched. New strings are covered by the existing content-rule test (no URLs, versions, "please", exclamation marks, hex blobs).transaction-errors.ts— consumed byTxSubmitError,TxCheckError,ErrorMessage,useTxNotifications.isRevertErroris unchanged; the new exports are additive.ErrorMessage— 41 call sites. Non-transaction consumers (ImportDialog, spaces dialogs, proposers,NftCollections,PaginatedTxns) pass HTTP/validation errors, which are not reverts and keep their message unchanged.ExecuteThroughRoleForm's Zodiac Roles custom errors still take the GS013 + decoded-name path.executionPreChecks.ts—validateTxSignaturesis also consumed byuseValidateTxData→ComboSubmit. Refactored to share one verification helper; all 16 pre-existing tests pass unchanged, and both consumers become strictly more permissive (thesameAddressfix and the over-long-blob skip only remove false blocks).useTxNotifications— single consumer (_app.tsx:116), but it handles every transaction lifecycle event.formatErroritself was left untouched, so the recovery, counterfactual and safe-message notification hooks are unaffected.getNoncesis called once per batch submit, withforceRefetch.Risks / not checked
getBuiltinCallException, not from a live revert.packages/utilsis shared and type-checks, and the parity test passes, but no mobile surface renders the new copy yet.nonce !== currentNonce + index) rather than tolerant. Justified by theuseBatchedTxsinvariant and the single entry point viaBatchExecuteButton. If CGW's/noncesever lags the cachedsafe.nonce, the user gets a hard block whose remedy ("Refresh") will not help.execTransactionand land as the same opaque revert. Not detectable by these pre-checks; the fallback copy reaching both surfaces is the mitigation, not a fix.SpeedUpModalstill passes a rawerror.messagetodetailedMessage— same defect class, deliberately out of scope.isRevertErrorclassifies viem contract reverts backwards.viem/errors/contract.js:199builds "…reverted with the following reason:" for the case that has a reason, which matches/reverted with/;:202builds "The contract function "X" reverted." for the reasonless case, which matches nothing. So a viem revert with a reason is treated as "will most likely fail" and one without as "could not check" — backwards, since the reasonless case is the one we are most certain reverted. Fixing it flips a class of errors between two states WA-3005 deliberately separated, so it needs its own ticket rather than a side-effect change here.Panic(uint256)(0x4e487b71) is a standard selector, so it is neither decoded as a custom error nor matched asError(string). An arithmetic overflow in a target contract surfaces with nothing behind Details. The panic codes are a finite documented set — the natural next candidate.Visual summary
Before — the reason is destroyed on-chain and the raw dump reaches the user:
After — detectable causes are stopped before broadcast, and what does get through is named:
flowchart TB A["Bulk execute<br/>2 queued txs"] --> P{"runBatchExecutionPreChecks"} P -->|"nonce moved"| Q["Blocked, no gas spent<br/>'Transaction 2 in this batch<br/>no longer matches the queue.'"] P -->|"signature invalid"| R["Blocked, no gas spent<br/>'Could not verify a signature<br/>on transaction 2 in this batch.'"] P -->|"all clear"| B["MultiSendCallOnly.multiSend"] B --> D["Inner tx reverts anyway<br/>revert(0, 0)"] D --> F["isOpaqueRevertError"] F --> G["OPAQUE_REVERT_MESSAGE<br/>(shared source)"] G --> H["Toast"] G --> I["Inline alert"] F --> J["getErrorReference → undefined<br/>no Details, no payload"]How every revert class now resolves behind Details:
flowchart LR E["Error"] --> R{"isRevertError?"} R -->|no| M["keep our own message"] R -->|yes| G{"GS code?"} G -->|yes| C["'Error code GS0xx'"] G -->|no| S{"Error(string)<br/>selector?"} S -->|yes| D["decoded reason<br/>e.g. 'ERC20: transfer amount<br/>exceeds balance'"] S -->|no| N["nothing —<br/>affordance hidden"]Checklist
packages/utilstype-checks and the parity test passes, but no mobile surface renders the new copy yetErrors._804, matching the single-tx flow; worth revisiting if WA-3005's error-rate KR should exclude prevented failuresyarn verify:changed:webexits 0: 643 suites, 5487 tests. Plus@safe-global/utils(110) and the mobile parity testCLA signature
With the submission of this Pull Request, I confirm that I have read and agree to the terms of the Contributor License Agreement.
🤖 Generated with Claude Code