Skip to content

fix(withdraw): Continue button can never silently die (no-throw + correct Manteca routing)#2224

Merged
Hugo0 merged 4 commits into
mainfrom
hotfix/withdraw-continue-no-throw
Jun 15, 2026
Merged

fix(withdraw): Continue button can never silently die (no-throw + correct Manteca routing)#2224
Hugo0 merged 4 commits into
mainfrom
hotfix/withdraw-continue-no-throw

Conversation

@Hugo0

@Hugo0 Hugo0 commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

What

Makes the withdraw "Continue" handler (handleAmountContinue) correct-by-construction so it can never silently die:

  1. Route Manteca by method type before the generic saved-bank branch. Manteca (AR/BR) accounts also set selectedBankAccount, so they used to fall into that branch and either mis-route to the Bridge bank page or throw.
  2. Replace throw new Error('Failed to get country from bank account') with setError + console.error. A synchronous throw inside an onClick aborts the router transition with zero UI feedback — the button just goes dead.
  3. Add a terminal else that sets an error, so no selectedMethod shape can leave the handler having done nothing.

Why (root cause)

Customer report (Miranda): withdraw "press Continue, nothing happens." It was a dead button, not a hang: getCountryFromAccount(selectedBankAccount) returned undefined, and the handler threw inside the click. Surfaced in Sentry as incomplete-app-router-transaction — 6 users / 14 days.

The proximate data cause (empty projected country for flat-shape Manteca accounts) is fixed at the source in the API companion PR (peanutprotocol/peanut-api-ts#1021). This PR is the defense-in-depth: even a genuinely-unresolvable country now produces a visible, recoverable error instead of a dead control.

Bug class

A background audit found this is one instance of a broader class (handlers that throw or silently no-op → dead button). Notably a second HIGH: the "Exchange or Wallet" card in the direct Send flow (PaymentMethodActionList) is also a dead button when SendInputView omits onPayWithExternalWallet. Tracked separately.

Test

Adds the first tests for the no-country path (asserts no throw, no navigation, setError shown) and Manteca routing. The harness previously mocked getCountryFromAccount to always succeed, so this path was entirely uncovered — that test gap is why the throw shipped. Suite: 17 pass / 1 pre-existing skip.

@vercel

vercel Bot commented Jun 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview, Comment Jun 15, 2026 10:00pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@Hugo0, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 41 minutes and 31 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9b432e39-1a80-4329-98ef-3f8db92b9ef5

📥 Commits

Reviewing files that changed from the base of the PR and between 4a99002 and 62e67bd.

📒 Files selected for processing (2)
  • src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx
  • src/app/(mobile-ui)/withdraw/page.tsx

Walkthrough

Two independent bug fixes: deriveGate in capability-gate.ts is updated to evaluate the per-operation ready status before blocked-rejection, accept-tos, and fixable-rejection branches, with priority comment updates and a new test suite. The WithdrawPage Continue handler is updated to route manteca accounts before saved-bank accounts, replace throws on unresolved country with setError, and add a final fallback setError for unmatched methods, covered by two new tests.

Changes

deriveGate ready-first priority reordering

Layer / File(s) Summary
deriveGate priority logic reordering
src/utils/capability-gate.ts
Updates gate priority documentation to rank ready at position 2, introduces an early hasReady check using operationStatus(rail, op) === 'enabled' that returns { kind: 'ready' } before any blocking branch, removes the now-redundant later-stage ready block, and adjusts in-function priority comment labels.
ready-first ordering test suite
src/utils/capability-gate.test.ts
Adds the deriveGate — ready-first ordering describe block with tosAction, sumsubAction, and a stuck helper; tests that ready wins over blocked-rejection, fixable-rejection, restart-identity, falls through to accept-tos when no ready rail exists, and is driven by per-operation operations.deposit status.

WithdrawPage Continue dead-button fixes

Layer / File(s) Summary
Continue handler routing and error handling
src/app/(mobile-ui)/withdraw/page.tsx
Promotes the manteca branch before saved-bank routing, replaces the synchronous throw on missing country with console.error + setError, and adds a final fallback branch that logs and surfaces setError when no routing branch matches the selected method.
GROUP 6 Continue never dead-buttons tests
src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx
Introduces mockGetCountryFromAccount as a configurable jest mock in test setup to prevent cross-test leakage, then adds two regression tests: unresolved country produces setError without throwing or navigating; manteca account routes to /withdraw/manteca with the correct country query parameter and takes precedence over generic bank branch.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • peanutprotocol/peanut-ui#2192: Both PRs modify src/utils/capability-gate.ts and change deriveGate logic around how the ready gate is prioritized in relation to other rails, with the withdraw routing fixes built on the same deriveGate behavior.
  • peanutprotocol/peanut-ui#2132: Both PRs modify src/utils/capability-gate.ts/capability-gate.test.ts to change deriveGate priority and ordering semantics with updated test coverage.
  • peanutprotocol/peanut-ui#2191: Both PRs center on deriveGate ready behavior to prevent dead-ended UX, with the main PR updating ready-priority logic and withdraw Continue regression tests.

Suggested reviewers

  • jjramirezn
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main fix: preventing the Continue button from becoming unresponsive and correcting Manteca routing.
Description check ✅ Passed The description is thorough and directly related to the changeset, explaining the root cause, all three implemented fixes, test coverage additions, and the broader context.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/utils/capability-gate.ts (1)

156-157: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Align non-ready branches to per-operation status semantics.

deriveGate now evaluates ready using operationStatus(rail, op), but blocked/requires-info/pending still use rail.status. This can return the wrong gate when operations[op] differs from top-level status.

Suggested fix
-    const blocked = candidates.find((rail) => rail.status === 'blocked')
+    const blocked = candidates.find((rail) => operationStatus(rail, op) === 'blocked')
@@
-    const requiresInfoRails = candidates.filter((rail) => rail.status === 'requires-info')
+    const requiresInfoRails = candidates.filter((rail) => operationStatus(rail, op) === 'requires-info')
@@
-    const hasPending = candidates.some((rail) => rail.status === 'pending')
+    const hasPending = candidates.some((rail) => operationStatus(rail, op) === 'pending')

Also applies to: 173-174, 202-203

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/capability-gate.ts` around lines 156 - 157, The deriveGate function
has inconsistent status evaluation across different branches. The ready branch
correctly uses operationStatus(rail, op) to evaluate per-operation status, but
the blocked, requires-info, and pending branches still use rail.status which
reflects only top-level status and can return incorrect gates when
operations[op] differs from the top-level status. Update all three affected
locations (lines 156-157 for the blocked check, lines 173-174 for the
requires-info check, and lines 202-203 for the pending check) to replace
rail.status with operationStatus(rail, op) to align with the per-operation
status semantics used for the ready evaluation.
src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx (1)

247-249: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Mock property mismatch: balance vs spendableBalance.

The default mock returns { balance: parseUnits('100', 6) }, but the component destructures spendableBalance (line 84 of page.tsx). The GROUP 6 tests correctly override this with spendableBalance, but the existing tests in groups 1–5 may be inadvertently relying on spendableBalance being undefined.

Consider aligning the default mock with the actual property name:

Proposed fix
     mockUseWallet.mockReturnValue({
-        balance: parseUnits('100', 6),
+        spendableBalance: parseUnits('100', 6),
     })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx around lines
247 - 249, The mockUseWallet.mockReturnValue call is providing a `balance`
property, but the component destructures `spendableBalance` instead. Update the
mockUseWallet.mockReturnValue object to use `spendableBalance` as the property
name instead of `balance` to align the mock with the actual property name
expected by the component.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/app/`(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx:
- Around line 247-249: The mockUseWallet.mockReturnValue call is providing a
`balance` property, but the component destructures `spendableBalance` instead.
Update the mockUseWallet.mockReturnValue object to use `spendableBalance` as the
property name instead of `balance` to align the mock with the actual property
name expected by the component.

In `@src/utils/capability-gate.ts`:
- Around line 156-157: The deriveGate function has inconsistent status
evaluation across different branches. The ready branch correctly uses
operationStatus(rail, op) to evaluate per-operation status, but the blocked,
requires-info, and pending branches still use rail.status which reflects only
top-level status and can return incorrect gates when operations[op] differs from
the top-level status. Update all three affected locations (lines 156-157 for the
blocked check, lines 173-174 for the requires-info check, and lines 202-203 for
the pending check) to replace rail.status with operationStatus(rail, op) to
align with the per-operation status semantics used for the ready evaluation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0c9ad7b4-00fc-462c-83b8-499b3029a467

📥 Commits

Reviewing files that changed from the base of the PR and between 5573129 and 661154a.

📒 Files selected for processing (4)
  • src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx
  • src/app/(mobile-ui)/withdraw/page.tsx
  • src/utils/capability-gate.test.ts
  • src/utils/capability-gate.ts

@github-actions

github-actions Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 5793.76 → 5794.08 (+0.32)
Findings: 0 net (+6 new, -6 resolved)

🆕 New findings (6)

  • critical complexity — src/app/(mobile-ui)/withdraw/page.tsx — CC 119, MI 53.93, SLOC 336
  • medium react-long-component — src/app/(mobile-ui)/withdraw/page.tsx:28 — WithdrawPage is 432 lines — split it
  • medium high-mdd — src/app/(mobile-ui)/withdraw/page.tsx:28 — WithdrawPage: MDD 100.1 (uses across many lines from declarations)
  • medium high-dlt — src/app/(mobile-ui)/withdraw/page.tsx:28 — WithdrawPage: DLT 46 (calls 46 distinct functions — high context load)
  • medium method-complexity — src/app/(mobile-ui)/withdraw/page.tsx:257 — CC 17 SLOC 55
  • low high-mdd — src/app/(mobile-ui)/withdraw/page.tsx:257 — handleAmountContinue: MDD 17.3 (uses across many lines from declarations)

✅ Resolved (6)

  • src/app/(mobile-ui)/withdraw/page.tsx — CC 119, MI 54.34, SLOC 322
  • src/app/(mobile-ui)/withdraw/page.tsx:28 — WithdrawPage is 404 lines — split it
  • src/app/(mobile-ui)/withdraw/page.tsx:28 — WithdrawPage: MDD 93.4 (uses across many lines from declarations)
  • src/app/(mobile-ui)/withdraw/page.tsx:28 — WithdrawPage: DLT 45 (calls 45 distinct functions — high context load)
  • src/app/(mobile-ui)/withdraw/page.tsx:257 — CC 17 SLOC 41
  • src/app/(mobile-ui)/withdraw/page.tsx:257 — handleAmountContinue: MDD 10.3 (uses across many lines from declarations)

@github-actions

github-actions Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 1434 ran, 0 failed, 0 skipped, 20.3s

📊 Coverage (unit)

metric %
statements 51.6%
branches 34.1%
functions 39.5%
lines 51.6%
⏱ 10 slowest test cases
time test
0.3s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in updateUserById
0.3s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in updateUserById body
0.3s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every stamp stays within canvas at any count
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle valid 9-digit US account
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle too long for US account
0.1s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › Manteca PIX form ready shows merchant card + amount input + pay button
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle valid German IBAN
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle valid ETH address in lowercase
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle valid US account with spaces 2
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle valid UK IBAN with spaces
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

Hugo0 added 4 commits June 15, 2026 14:54
handleAmountContinue threw 'Failed to get country from bank account' inside
the onClick when getCountryFromAccount returned undefined. A synchronous throw
in a click handler aborts the router transition with zero UI feedback — the
button just goes dead ('press Continue, nothing happens'). Surfaced as a
customer report + Sentry incomplete-app-router-transaction (6 users/14d).

Three changes make the handler correct-by-construction:
- Route Manteca (AR/BR) accounts by method type BEFORE the generic saved-bank
  branch. Manteca accounts also set selectedBankAccount, so they previously
  fell into that branch and either mis-routed to the Bridge bank page or threw.
- Replace the throw with setError + console.error so an unresolved country is a
  recoverable, visible error, not a dead button.
- Add a terminal else that sets an error, so no selected-method shape can ever
  leave the handler having done nothing.

The real country-resolution fix is the API counterpart (Manteca flat metadata
shape); this is the defense-in-depth that guarantees the symptom can't recur.

Adds the first tests for the no-country path and Manteca routing — the harness
previously mocked getCountryFromAccount to always succeed, so this path was
entirely uncovered.
…t-clean

Avoid adding a no-require-imports eslint error: drive the bridge.utils mock
through a hoisted mock fn (matching the file's mockUseWallet idiom) instead of
require()-ing the mocked module inside the test.
CodeRabbit nit: the component destructures spendableBalance; the harness default
mocked the wrong key (balance), leaving maxDecimalAmount=0 for existing tests.
@Hugo0 Hugo0 force-pushed the hotfix/withdraw-continue-no-throw branch from 2d63aa1 to 62e67bd Compare June 15, 2026 21:55
@Hugo0 Hugo0 changed the base branch from dev to main June 15, 2026 21:55
@Hugo0 Hugo0 merged commit 6351aed into main Jun 15, 2026
16 of 19 checks passed
Hugo0 added a commit that referenced this pull request Jun 16, 2026
…dev-20260616

chore: back-merge main → dev (20260616) — withdraw + send dead-button hotfixes (#2224, #2225)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant