fix(FET-3203): don't surface transient CommitmentTooNew as a revert - #1158
fix(FET-3203): don't surface transient CommitmentTooNew as a revert#1158v1rtl wants to merge 1 commit into
Conversation
Deploying ens-app-v3 with
|
| Latest commit: |
f3adf23
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://6cb53c08.ens-app-v3.pages.dev |
| Branch Preview URL: | https://feature-fet-3203-execution-r.ens-app-v3.pages.dev |
fd15273 to
e2b3c80
Compare
The register step's 60s gate is wall-clock based (CountdownCircle fires at
commitBlock.timestamp + 60 per Date.now()), but the pre-flight in the
transaction modal executes against a block header that can be up to one
slot behind. When the user opens the modal the instant the countdown hits
zero, ETHRegistrarController reverts with
CommitmentTooNew(bytes32 commitment, uint256 minimumCommitmentTimestamp,
uint256 currentTimestamp)
with currentTimestamp exactly 12s behind the deadline. This is transient -
the commitment is valid, the chain just hasn't caught up yet.
Three problems made this user-visible:
- getReadableError short-circuited on EstimateGasExecutionError and never
reached decodeErrorResult, so every custom contract revert collapsed
into viem's generic "execution reverted". Decode revert data first.
(Also guards decodeErrorResult, which throws on an unknown selector
rather than returning nullish - the old !decodedError check was dead
code and could crash.)
- createTransactionRequestQueryFn swallowed the failure into
{ data: null, error }, which react-query treats as success, so it never
retried and got persisted to IndexedDB. Rethrow this one error so it
becomes a real query error.
- The modal rendered it as a terminal error with the confirm button hard
disabled, recoverable only by closing and reopening (refetchOnMount:
'always'). It now shows a spinner reading "Waiting for confirmation"
and retries for two slots, falling back to the normal error path if it
genuinely never resolves.
e2b3c80 to
f3adf23
Compare
|
|
Reviewed with a focus on the error-handling changes. Two things I'd want addressed before merge — both verified against the real viem / react-query paths rather than by reading. 1.
|
| path | error class | pre-PR | post-PR |
|---|---|---|---|
createAccessList (raw client.request) |
InvalidInputRpcError |
already threw | throws |
estimateGas (viem action) |
EstimateGasExecutionError |
returned null, safe |
throws |
The estimateGas row is a regression from the reorder — the old code short-circuited before reaching getViemRevertErrorData. The createAccessList row is pre-existing (InvalidInputRpcError is not instanceof RpcRequestError, so it never matched any of the three guards), but this PR is the natural place to close it.
Impact is worse than a bad message, because isCommitmentTooNewError is now called in three places that can't absorb a throw:
TransactionStageModal.tsx:451-453— computed in the component body, so it throws during renderTransactionStageModal.tsx:434— inside the retry callback, i.e. inside the retryer's.catch()query.ts:277— insidecatch (e), so theTypeErrorreplaces the real error
One-line fix: error.data && typeof error.data === 'object' ? … : …, or move getViemRevertErrorData inside the try. Worth a test next to the unknown-selector one.
Related: the description's mechanism for this bullet doesn't match what I measured
its revert arrives as an
RpcRequestErrorwhosedetailsused to win over the decodable data
It arrives as InvalidInputRpcError, which isn't instanceof RpcRequestError, so details never won — the pre-PR code already decoded that path correctly:
[createAccessList + valid data] OLD getReadableError -> {"message":"CommitmentTooNew","type":"contract"}
[estimateGas + valid data] OLD getReadableError -> null
OLD fallback shortMessage: "Execution reverted for an unknown reason."
The ticket's message came from estimateGas, via the EstimateGasExecutionError short-circuit — which is exactly what the reorder fixes. So the change is right, just described wrong. The new test constructs a bare RpcRequestError under a comment saying it's the createAccessList shape; it isn't, so that test passes without covering the shape production actually produces.
Worth checking separately: createAccessList runs first in calculateGasLimit and the old code decoded its errors fine, so for "execution reverted" to have reached users it can't have thrown. Geth reports eth_createAccessList reverts as an error field in the result object, not as a JSON-RPC error — and AccessListResponse in createAccessList.ts has no error field, so that signal is dropped. I haven't tested against a real node, but it fits the evidence.
2. It never falls back to the error path
If it genuinely never resolves it falls through to the normal error path
It doesn't. query-core retains fetchFailureReason when the retry budget is exhausted (query.js:349 — the error reducer copies fetchFailureReason: error alongside status: 'error'). So isWaitingForCommitment stays true, the button stays a disabled spinner, and TransactionStageModal.tsx:673's && !isWaitingForCommitment keeps the Helper suppressed.
QueryObserver with this PR's exact retry config:
total queryFn attempts: 18
after retries exhausted status=error fetchStatus=idle failureCount=9 isWaitingForCommitment=true
immediately after block status=pending fetchStatus=fetching failureCount=1 isWaitingForCommitment=true
after 2nd round settles status=error fetchStatus=idle failureCount=9 isWaitingForCommitment=true
The budget itself is right (9 attempts, 8×3s ≈ two slots). But useInvalidateOnBlock buys a fresh budget every block, and the flag reads true at every point across both rounds — so the error is suppressed continuously and CommitmentTooNew never reaches the screen. Rendering the modal with createAccessList rejecting on the ticket's payload agrees: 9 attempts, button still waitingForBlock, the string absent from the DOM.
The only escape is closing the dialog — the same escape hatch the PR set out to remove.
Reachable in practice via the finaliseTime bug the description already flags:
transactionStore.ts:126(etherscanDataToMinedData) →parseInt(timeStamp, 10), seconds — used at 198 / 226 / 249transactionStore.ts:446→Number(timestamp) * 1000, ms- both →
useCallbackOnTransaction→TransactionFlowProvider.tsx:131→setTransactionStageFromUpdate→reducer.ts:179finaliseTime = minedData?.timestamp Transactions.tsx:270→commitTimestamp + 60000 < Date.now(), ms semantics
On the Etherscan path that's ~1.7e9 against ~1.7e12, so commitComplete starts true, the pre-flight fires immediately, and the delta runs to ~60s — past the 24s budget. That's the case where this lands as a permanent spinner with no text at all, which is worse than what's on main today.
Suggested direction: derive the waiting state from something that clears — e.g. gate on transactionRequestQuery.status !== 'error' rather than the retained failureReason — or latch an explicit "gave up" flag. Also worth gating isWaitingForCommitment on stage === 'confirm': as written, a live failureReason in stage === 'failed' suppresses a genuine transactionError too, leaving a disabled "Try again" with no explanation.
Smaller
retry: (failureCount, error) => isCommitmentTooNewError(error) && failureCount < 8returnsfalsefor the two guard throws in the query fn (connectorClient is required/address does not match connector), which previously got react-query's default 3 retries. Both are guarded byenabledso likely inert, but it's an unannounced change.- No tests for the two riskiest changes: the rethrow in
stage/query.tsand the modal's waiting/retry state.stage/query.test.tsandTransactionStageModal.test.tsxboth already exist. - Decoding first is a genuine readability win —
execution reverted→CommitmentTooNewon both paths — and I checked the merged ABI for selector collisions (27 error entries, 27 unique selectors, no duplicate names). Only nit:TransactionStageModal.tsx:664renders{t(attemptedTransactionError.message)}while:674renders it raw, so one site is wired for translated copy and the other isn't. Follow-up at most.
Confirmed green locally: the 47 existing TransactionDialogManager tests plus the 4 new ones pass, and tsc --noEmit reports nothing in the touched files.
Generated by Claude Code



Fixes FET-3203.
Root cause
The error data from the ticket decodes cleanly:
The 60s gate on the register step is wall-clock based:
commitTx.finaliseTimeis the commit block header timestamp (transactionStore.ts->reducer.ts->Transactions.tsx), and thorin'sCountdownCirclefiressetCommitComplete(true)atcommitBlockTs + 60perDate.now(). The pre-flight in the modal (eth_createAccessList+estimateGas) then executes against a block header that can be up to one slot behind wall clock, soblock.timestamp < commitment + minCommitmentAgeand the controller reverts.The commitment is valid - the chain just hasn't caught up yet. Click the instant the circle hits zero and you hit it; hesitate a few seconds and you don't, which is why it reproduces intermittently.
Three separate things made this user-visible:
getReadableErrorthrew the detail away. It short-circuited onEstimateGasExecutionErrorand returnednullfor anything that wasn't "insufficient funds", never reaching thedecodeErrorResultbranch below it - so every custom ENS contract revert collapsed into viem's generic"execution reverted".createTransactionRequestQueryFnswallows failures into{ data: null, error }, which react-query treats as success - so no retry, and the failure was persisted to IndexedDB.!!requestError, so the only escape wasrefetchOnMount: 'always'- i.e. literally closing and reopening the modal, exactly as the ticket describes.Changes
src/utils/errors.ts- decode revert data before theEstimateGasExecutionError/RpcRequestErrorbranches. This matters becauseeth_createAccessListuses a rawclient.request, so its revert arrives as anRpcRequestErrorwhosedetailsused to win over the decodable data. AddsdecodeContractError+isCommitmentTooNewError.Also fixes a latent crash:
decodeErrorResultthrows on an unrecognised selector rather than returning nullish, so the oldif (!decodedError) return nullguard was dead code andgetReadableErrorcould throw on any unknown revert. Now guarded.stage/query.ts- rethrowCommitmentTooNewonly, so it becomes a real react-query error. All other errors keep the existing swallow-into-data.errorbehaviour.stage/TransactionStageModal.tsx- retry that error 8x at 3s. A normal slot is 12s but a missed slot pushes the next block to 24s, so this covers the worst case;useInvalidateOnBlockremains the primary recovery path and this is cover for a slow block watcher.isWaitingForCommitmentreads bothfailureReason(set while retries are in flight) and the settled error so the state is stable for the whole window. While waiting: disabled button + spinner reading "Waiting for confirmation", confirm-stage copy explaining we're waiting for on-chain confirmation, and the redHelpersuppressed. If it genuinely never resolves it falls through to the normal error path - now namingCommitmentTooNewrather than "execution reverted".public/locales/en/common.json- two new keys.fallbackLng: 'en'so the other 7 locales inherit them.Testing
4 regression tests in
src/utils/errors.test.tsusing the real payload from the ticket: detection throughRpcRequestError(thecreateAccessListpath) andEstimateGasExecutionError(theestimateGaspath), an insufficient-funds guard for the reorder, and the unknown-selector no-throw case.pnpm vitest- 47 existingTransactionDialogManagertests + 4 new pass.pnpm lintclean.pnpm lint:typesreports 5 errors, all pre-existing inmetadataCache.test.ts/routes.test.ts, neither touched here.Notes for the reviewer
reverseRecord- nothing branches on it inregisterName.ts,query.tsor the estimation hooks, and the controller checksCommitmentTooNewbefore it touches records. Most likely a timing correlation (the extra profile step shifts when the commit lands relative to slot boundaries). The fix is correct either way, but flagging that I haven't fully explained that detail.finaliseTimeis milliseconds attransactionStore.ts:446but seconds attransactionStore.ts:126(the Etherscan-recovery path used for replaced/sped-up txs). If a commit is recovered that way,commitTimestamp + 60000 < Date.now()is instantly true, the countdown completes immediately, andCommitmentTooNewis guaranteed with a delta far larger than the 24s retry window here would cover. Worth its own ticket.