Skip to content

feat(card): on card / off card — split line, On card screen, pre-tap card-pull notice (TASK-22293) - #2992

Draft
innolope-dev wants to merge 10 commits into
devfrom
feat/oncard-offcard-balances
Draft

feat(card): on card / off card — split line, On card screen, pre-tap card-pull notice (TASK-22293)#2992
innolope-dev wants to merge 10 commits into
devfrom
feat/oncard-offcard-balances

Conversation

@innolope-dev

@innolope-dev innolope-dev commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Frontend half of the on card / off card change (TASK-22293). The app showed one balance while the money sat in two buckets with very different exit costs, and nothing on screen explained why a send waited for a "card cool-down" or why a tap declined with money in the account.

  • Home: card holders get a quiet second line under the total — "$100.00 on card · $28.40 off card" — linking to the new screen. Hidden-balance mode masks both halves.
  • Card › On card (/card/on-card): both halves, Keep on card (the balancer's target), Keep off card (the floor the sweep never crosses), Load everything to card, and manual moves. Move to card runs through the stored session key (no passkey, lands in about a minute). Move off card is one passkey and pins the target below the remaining amount first, so the balancer cannot sweep the money straight back. Lowering Keep on card offers the excess back the way the limit decrease used to.
  • Per-purchase limit is only that now: the limit screen no longer moves collateral. useReturnExcessCollateral becomes useMoveOffCard (same contract, amount-driven).
  • CollateralPullNotice on every amount / review step — direct send, payment link, request pay, QR pay, crypto / Manteca / bank withdraw. It mirrors computeSpendStrategy on the displayed balances and says, before the passkey, "$X of this comes off your card balance" and, when the card's withdrawal lock is armed, a live countdown (read from RainCooldownContext, which until now only spoke after the 425).
  • card_withdraw_attempted now carries amount_cents (the data gap that stopped the floor from being sized against real send sizes).
  • Copy in en, es-419, pt-BR and es-AR (voseo deltas). The cooldown intro modal no longer tells users to lower their card limit.

Full analysis, industry comparison and the vocabulary decision: https://claude.ai/code/artifact/cc606d64-993b-408b-ac2a-5604074fd53a

Task

TASK-22293 — Card collateral: wallet floor + separate card-balance target so in-app transfers stop hitting the Rain cooldown

Cross-repo

Product truth and help copy move in mono #138 (card.md funding model and card-limit bullets, quick-ref, currencies, security, lessons-from-corrections, and the collateral / custody help articles in en, es-419, pt-br) — content ships separately from code per CONTRIBUTING.

Pairs with peanut-api-ts feat/oncard-offcard-balances (target, floor, debounce, deferred refill, move-to-card, tuner). Backend first. RainCardSummary.collateral is optional so an old backend still renders Home and the card screen; the On card screen shows a spinner until the backend ships. src/types/api.openapi.json + api.generated.ts regenerated from that branch's snapshot (the copy in this repo was also behind dev on unrelated routes, hence the larger diff).

Risks

  • Every surface the notice sits on already gates on balance; the notice renders only when the amount is affordable and will pull from the card, so it cannot appear alongside an insufficient-balance error. It reads the same displayed balances the gates read.
  • useHomeFlow now reads the Rain overview — same query key the Home banners already poll, so no extra requests.
  • Analytics: seven new client events, all amounts in cents, no counterparty data.
  • OTA-shippable; no native change.

QA

  • pnpm typecheck clean, eslint clean on touched files, prettier applied.
  • New: collateralPull.utils.test.ts (routing mirror: off-card covers / card covers whole / mixed shortfall / true shortfall / unknowns / junk), useMoveOffCard.test.tsx (threshold skip, forced collateral-only, cap at spending power, fail-closed on missing wallet, no submit on cancelled passkey). Updated: useHomeFlow.test.ts (split present only with an active card and both halves known), link-send view test (mocks the notice — the view test mounts no wallet providers). i18n parity suites green (es-AR stays a subset of es-419).
  • Ran: src/features/home, src/features/payments, src/app/(mobile-ui)/withdraw, src/app/(mobile-ui)/qr-pay, src/components/Withdraw, src/components/Send, src/components/Card, src/components/Home, src/i18n — 504 tests green.

Screenshots: Home split line and On card screen require a card user against the paired backend; not captured in this environment.

Rollout order

  1. Merge mono #138 first — product truth (funding model, floor, inflow hold, controls) and the help articles must describe the new policy before users see it.
  2. Migrate and deploy peanut-api-ts #1527.
  3. Deploy this PR.

…card-pull notice (TASK-22293)

Pairs with peanut-api-ts (collateral target, wallet floor, move-to-card).

- Home shows "$100.00 on card · $28.40 off card" under the total for card
  holders, linking to the new Card › On card screen.
- On card screen: both halves, "Keep on card" (the auto-balancer's target),
  "Keep off card" (the floor the sweep never crosses), "Load everything to
  card", and manual "Move to card" (session key, no passkey) / "Move off
  card" (one passkey; pins the target first so the balancer cannot sweep it
  straight back).
- The per-purchase limit screen is only that now: changing it no longer
  moves collateral. useReturnExcessCollateral becomes useMoveOffCard.
- CollateralPullNotice on every amount / review step (direct send, payment
  link, request pay, QR pay, crypto / Manteca / bank withdraw): says before
  the passkey that the spend will pull from the card and, if the card's
  withdrawal lock is armed, how long it has left.
- card_withdraw_attempted now carries amount_cents.
- Copy in en, es-419, pt-BR and es-AR; cooldown intro modal no longer tells
  users to lower their card limit.
@notion-workspace

Copy link
Copy Markdown

@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated
peanut-wallet Ready Ready Preview Sep 5, 2026 2:24am UTC

Request Review

@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: b0811123-64d6-49cd-ad47-ab67d265d465

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 7167.98 → 7196.09 (+28.11)
Findings: +20 net (+276 new, -256 resolved)

🆕 New findings (276)

  • critical complexity — src/app/(mobile-ui)/qr-pay/page.tsx — CC 329, MI 52.65, SLOC 1209
  • critical complexity — src/app/(mobile-ui)/withdraw/manteca/page.tsx — CC 160, MI 51.26, SLOC 626
  • critical complexity — src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx — CC 108, MI 52.98, SLOC 397
  • critical complexity — src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts — CC 106, MI 49.1, SLOC 463
  • critical method-complexity — src/app/(mobile-ui)/qr-pay/page.tsx:104 — QRPayPage CC 84 SLOC 446
  • critical complexity — src/services/rain.ts — CC 75, MI 59.37, SLOC 309
  • critical complexity — src/features/payments/flows/semantic-request/views/SemanticRequestConfirmView.tsx — CC 66, MI 56.54, SLOC 150
  • critical complexity — src/components/Card/OnCardScreen.tsx — CC 61, MI 55.25, SLOC 318
  • critical complexity — src/components/Withdraw/views/Confirm.withdraw.view.tsx — CC 50, MI 57.35, SLOC 76
  • critical complexity — src/constants/analytics.consts.ts — CC 1, MI 29.59, SLOC 230
  • high hotspot — src/app/(mobile-ui)/qr-pay/page.tsx — 145 commits, +1963/-1654 lines since 6 months ago
  • high hotspot — src/app/(mobile-ui)/withdraw/manteca/page.tsx — 88 commits, +813/-501 lines since 6 months ago
  • high structural-dup — types/api.generated.ts:10347 — 83 duplicate lines / 388 tokens with types/api.generated.ts:10935
  • high structural-dup — types/api.generated.ts:10357 — 74 duplicate lines / 348 tokens with types/api.generated.ts:10945
  • high hotspot — src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx — 68 commits, +605/-320 lines since 6 months ago
  • high hotspot — src/constants/analytics.consts.ts — 68 commits, +486/-27 lines since 6 months ago
  • high complexity — src/features/payments/flows/semantic-request/views/SemanticRequestInputView.tsx — CC 43, MI 56.01, SLOC 94
  • high method-complexity — src/app/(mobile-ui)/withdraw/manteca/page.tsx:107 — MantecaBankWithdrawFlow CC 42 SLOC 248
  • high complexity — src/components/Send/link/views/Initial.link.send.view.tsx — CC 39, MI 47.3, SLOC 188
  • high method-complexity — src/components/Withdraw/views/Confirm.withdraw.view.tsx:69 — ConfirmWithdrawView CC 33 SLOC 53

…and 256 more.

✅ Resolved (256)

  • src/app/(mobile-ui)/qr-pay/page.tsx — CC 328, MI 52.66, SLOC 1208
  • src/app/(mobile-ui)/withdraw/manteca/page.tsx — CC 159, MI 51.29, SLOC 625
  • src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx — CC 106, MI 53.01, SLOC 397
  • src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts — CC 106, MI 49.12, SLOC 462
  • src/app/(mobile-ui)/qr-pay/page.tsx:102 — QRPayPage CC 83 SLOC 446
  • src/services/rain.ts — CC 68, MI 59.59, SLOC 280
  • src/features/payments/flows/semantic-request/views/SemanticRequestConfirmView.tsx — CC 64, MI 56.71, SLOC 149
  • src/constants/analytics.consts.ts — CC 1, MI 30, SLOC 222
  • src/app/(mobile-ui)/qr-pay/page.tsx — 143 commits, +1956/-1653 lines since 6 months ago
  • src/app/(mobile-ui)/withdraw/manteca/page.tsx — 86 commits, +806/-500 lines since 6 months ago
  • types/api.generated.ts:10320 — 83 duplicate lines / 388 tokens with types/api.generated.ts:10908
  • types/api.generated.ts:10330 — 74 duplicate lines / 348 tokens with types/api.generated.ts:10918
  • src/constants/analytics.consts.ts — 67 commits, +475/-27 lines since 6 months ago
  • src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx — 66 commits, +599/-319 lines since 6 months ago
  • src/components/Withdraw/views/Confirm.withdraw.view.tsx — CC 48, MI 57.55, SLOC 76
  • src/app/(mobile-ui)/withdraw/manteca/page.tsx:105 — MantecaBankWithdrawFlow CC 41 SLOC 248
  • src/features/payments/flows/semantic-request/views/SemanticRequestInputView.tsx — CC 40, MI 56.37, SLOC 92
  • src/components/Send/link/views/Initial.link.send.view.tsx — CC 38, MI 47.35, SLOC 188
  • src/components/Withdraw/views/Confirm.withdraw.view.tsx:68 — ConfirmWithdrawView CC 31 SLOC 53
  • src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx:63 — WithdrawBankPage CC 30 SLOC 168

…and 236 more.

📈 Painscore deltas (top movers)

File Before After Δ
src/hooks/wallet/useMoveOffCard.ts 0.0 9.1 +9.1
src/components/Card/OnCardScreen.tsx 0.0 9.0 +9.0
src/utils/collateralPull.utils.ts 0.0 6.4 +6.4
src/hooks/wallet/useBalanceSplit.ts 0.0 5.3 +5.3
src/hooks/wallet/useCollateralPullPreview.ts 0.0 5.3 +5.3
src/components/Global/CollateralPullNotice.tsx 0.0 5.1 +5.1
src/app/(mobile-ui)/card/on-card/page.tsx 0.0 2.3 +2.3
src/services/rain.ts 9.1 9.7 +0.6
src/components/Card/CardLimitEditModal.tsx 8.7 8.1 -0.7
src/hooks/wallet/useReturnExcessCollateral.ts 7.8 0.0 -7.8

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 5935 ran, 0 failed, 0 skipped, 1.6m

📊 Coverage (unit)

metric %
statements 74.8%
branches 60.2%
functions 68.6%
lines 75.8%
⏱ 10 slowest test cases
time test
🐢 9.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › Network failure keeps loading while retries remain, then shows the generic error
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_USER_NOT_PROVISIONED fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_SOURCE_OVER_MONTHLY_CAP fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › User KYC not approved fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_MERCHANT_RECENT_REFUND fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › a refused idempotency key tells the user to scan again, not to contact support
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › routes the KYC rejection on its wire code, and does not retry it
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_MERCHANT_VOLUME_NEAR_CAP fails fast with copy that names the real cause
3.1s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › Going offline blames the connection, and reconnecting clears it for the recovered scan
3.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › Scan that recovers on the retry lands on the payment screen, not an error
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

🖼 Visual diff — ⚠️ no result for the latest push

The newest Tests run produced no diff report in any attempt: ds-shots was skipped, failed before the diff, or had no cached baseline.
The previous result on this comment no longer reflects the latest run, so it was cleared.

Fixture screenshots, no backend. Advisory — this check never blocks a merge.

@chip-peanut-bot chip-peanut-bot 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.

Chip review — changes requested

One blocking, seven major, and two minor findings remain. The new idempotency key prevents an exact retry from moving twice, but edited retries still reuse the old operation key. P12 is fixed; P9, P10, and P13 duplicate P1, P8, and P7 respectively.

Findings

  • BLOCKING · src/components/Card/OnCardScreen.tsx:274 · Disable load-all before moving funds off card
    When loadAllToCard is enabled, this path only lowers the target before withdrawing. Load-all ignores the target, floor, and debounce, so the balancer can sweep the returned funds straight back onto the card and undo the user's explicit move. Disable load-all in the awaited pre-withdrawal settings update, prevent a concurrent re-enable, and cover this state at screen level.

  • MAJOR · src/utils/collateralPull.utils.ts:35 · Model multi-call spends as mixed in the preview
    The preview reports the whole amount from card whenever collateral alone covers it, but sendTransactions always supplies subsequent kernel calls, which makes collateral-only execution unavailable. With $28.40 off card, $100 landed on card, and a $50 cross-chain spend, execution pulls only the $21.60 shortfall while this branch reports $50. Thread the route capability into the preview and use mixed-shortfall math for multi-call flows.

  • MAJOR · src/components/Card/OnCardScreen.tsx:424 · Do not offer in-transit collateral as movable
    This modal's maximum uses onCardCents, which includes top-ups still in transit, while useMoveOffCard silently caps execution to landed spendingPower. With $20 landed and $80 in transit, Max accepts $100, lowers the target as though $100 will leave, then moves only $20 and closes successfully. Base eligibility, maximum, and remaining-target math on landed spending power and show pending collateral separately.

  • MAJOR · src/types/api.openapi.json:15051 · Preserve the push-provisioning API contract
    The regenerated snapshot still deletes POST /rain/cards/{cardId}/provisioning-data, although rainApi.getProvisioningData and usePushProvisioning still consume it. Generated clients and drift checks from this head therefore treat a live frontend dependency as nonexistent. Regenerate from a backend snapshot containing both features, or restore this path before committing the contract.

  • MAJOR · src/hooks/wallet/useBalanceSplit.ts:46 · Separate landed collateral from the on-card display
    The displayed on-card balance still adds inTransitToCollateralCents to actual spending power. During a $100 top-up with nothing landed, Home and the card screen say $100 is on card and the pull preview can treat it as available even though the card cannot spend it yet. Keep landed spending power as the on-card amount and expose the in-transit amount explicitly as pending.

  • MAJOR · src/hooks/wallet/useCollateralPullPreview.ts:20 · Arm the countdown after a successful card pull
    The context is still populated only after a later /withdraw/prepare call returns 425. A successful collateral pull starts the server-side lock but leaves cooldownEndsAt null, so the next payment preview cannot show the promised countdown until that payment has already failed. Arm the shared cooldown from the successful withdrawal path using a server-confirmed expiry or retry interval.

  • MINOR · src/hooks/wallet/useBalanceSplit.ts:34 · Floor off-card cents for display and move limits
    usdcUnitsToRainCents deliberately rounds up for Rain withdrawal inputs, but both split calculations reuse it for display and UI bounds. A wallet holding 1.000001 USDC is shown as $1.01 off card and can offer an unfundable $1.01 Max. Use a floor-to-cents conversion for display and move limits while keeping ceiling conversion at the Rain input boundary.

  • MINOR · src/components/Card/OnCardScreen.tsx:120 · Finish migrating the amount field to input primitives
    The wrapper now uses the shared Card, but the editable amount, Max action, and inline error remain raw elements. They bypass BaseInput's focus, error, and disabled treatment plus the shared button interaction states. Compose the field from BaseInput, FieldColumn/FieldError, and the sanctioned button primitive.

  • MAJOR · src/features/payments/flows/semantic-request/views/SemanticRequestConfirmView.tsx:256 · Cover the actual request-payment debit
    Same-chain USDC requests execute directly from the input view, where no pull notice is mounted. Cross-chain requests reach this notice but pass the requested principal even though execution uses calculatedPayAmount, including the Rhino fee. For a $100 request with a $1.50 fee and $100.50 off card, the notice stays hidden while execution pulls $1 from card. Add the direct-flow notice and pass the calculated pay amount here.

  • MAJOR · src/components/Card/OnCardScreen.tsx:257 · Bind move retry state to its original amount
    After any failed or timed-out request, the modal remains editable but moveKeyRef keeps the previous key for every later submission. If a $10 move succeeds server-side and its response is lost, editing the field to $1 resends the $10 key; the server must replay the old move or reject the mismatched payload, so the visible retry cannot perform the amount just submitted. Track the key with its original cents and, after an ambiguous failure, lock/restore that amount or reconcile the first outcome before allowing a new keyed operation; cover both exact and edited retries.

  • MAJOR · src/i18n/app/messages/en.json:1500 · [claude-opus] Product truth still says collateral is capped by the card limit
    This change breaks the promise recorded in /home/chip/mono/product/. card.md:200 states "The auto-balancer parks only up to the user's card limit (default $100) in that contract; everything above the limit stays in the user's own passkey-controlled smart wallet and never leaves it", and card.md:222 states "Card limit: ... Raising it auto-tops-up collateral from the wallet. Lowering it does not auto-return the difference". Both are now false. The PR gives the on-card amount its own setting (collateralTargetCents, defaulted from RAIN_DEFAULT_CARD_LIMIT), adds a $25 off-card floor, adds a loadAllToCard switch that sweeps the entire balance into collateral, and removes the excess-return from CardLimitEditModal — and the new copy says so in as many words: en.json:1500 "The most a single card purchase can be. It doesn't change how much stays on the card." It also newly auto-returns collateral when the target is lowered (OnCardScreen.saveTarget), which the doc's "lowering does not auto-return" line and lessons-from-corrections.md:239 explicitly deny.

The code is the intended new behaviour; the docs are what is wrong. Because "only up to the card limit is parked" is repeated as a custody claim in security.md:106 and quick-ref.md:129, and as a currency claim in currencies.md:83 and :141, support and marketing will be quoting a false statement about where a user's money sits — under loadAllToCard the entire balance can be in the Rain-managed collateral contract, which is exactly the claim security.md was written to bound.

Fix: before or with this merge, update product/card.md (Funding model bullet 1, the Card limit bullet under "Using the card", and lessons-from-corrections.md:239), plus the derived lines in currencies.md:83/:141, security.md:106 and quick-ref.md:129, to describe the new model — a user-set on-card target, an off-card floor, load-everything, and the per-purchase limit as a Rain control that no longer moves collateral.

  • MAJOR · src/components/Card/OnCardScreen.tsx:256 · [claude-opus] No test covers the On card screen's money moves
    OnCardScreen is the only screen in the app that both moves money and mutates the balancer's shared policy, and it ships with no test file (src/components/Card/tests/ has no OnCardScreen entry). useMoveOffCard's own contract tests were renamed and kept, but they cover the hook in isolation — none of the screen's orchestration is exercised.

The exact untested cases:

  1. move-to-card idempotency-key reuse (OnCardScreen.tsx:255-262). The key is ${cardId}-${cents}-${uuid}, stored in moveKeyRef, and cleared only on success. After a failed or timed-out $50 move the modal stays open with its error; if the user edits the amount to $500 and retries, the stale key is reused, and the backend's writeIntent returns the first attempt's row — so the user is told $500 moved (or $50 moved) while the ledger holds the original $50 intent. No test asserts that the key is rebuilt when the amount changes.
  2. move-off-card's pin-then-withdraw ordering (OnCardScreen.tsx:274-288): that collateralTargetCents is PATCHed down to remaining before moveOffCard signs, and that a failed PATCH aborts the withdrawal rather than leaving the target high so the balancer sweeps the money straight back.
  3. saveTarget's automatic excess return: that lowering the target by more than EXCESS_COLLATERAL_MIN_CENTS returns exactly onCardCents - cents and that a failure there is swallowed without reverting the already-landed target.

A render test with rainApi.moveToCard / rainApi.updateCollateralSettings / useMoveOffCard mocked covers all three cheaply.

  • MINOR · src/i18n/app/messages/en.json:3499 · [claude-opus] Card-pull notice promises the payment will wait out the lock
    The new pre-tap notice renders "The card is locked for {time} after its last pull — this payment will wait for it." (en.json:3499, shown by CollateralPullNotice when cooldownEndsAt is in the future). Nothing waits. A spend attempted during the lock hits the 425 from /rain/cards/withdraw/prepare (services/rain.ts:431), throws RainCooldownError, and fails — there is no queue and no auto-retry anywhere in the spend path. The app's own error string for the same state says the opposite: "A previous card withdrawal is still active. Try again in about {minutes} minutes" (en.json:3539). product/card.md:204 describes the cool-down as a lock on the next pull, not as a queue.

A user reading this taps the passkey expecting the payment to go through on its own, and gets a failure instead. Reword to match the retry string, e.g. "The card is locked for {time} after its last pull — you can pay once it clears." The pt-BR/es-419 copies of the same key need the same fix.

Inline anchors unavailable for 1 finding(s); the findings remain in this summary.

Checked clean

  • Verified detached HEAD, trusted author, base ref, and exact base/head SHAs.
  • Reconciled P1-P13 against the exact head. P12 is fixed: the QR-pay state suite remains enabled, its bare fixtures no longer throw, and the exact-head unit job passed. P9, P10, and P13 duplicate the still-present P1, P8, and P7 defects.
  • Reviewed the new move-to-card idempotency request, generated schema, retry lifecycle, and existing test coverage; no screen-level move-to-card or idempotency tests were added.
  • Compared the exact base/head OpenAPI path inventories and current frontend consumers; push provisioning remains the only removed path still consumed by this frontend.
  • Exact-head unit, typecheck, eslint, format, native export, design-system lint, backend baseline, analysis, preview deploy, provenance, and aggregate CI passed. The screenshot job alone failed inside webpack's WasmHash after compilation; the exact-head preview build succeeded, so this was not attributable to the changed code.
  • Reviewed collateral signing, card ownership call sites, client trust boundaries, and secret handling; no additional authorization bypass or secret exposure was found beyond the money-state defects reported above.
  • The detached worktree has no installed dependencies, so no duplicate local Jest run was attempted; git diff whitespace validation passed.

Security review by moonshotai/kimi-k3: 0 finding(s), marked with the model name. It reads the diff only and answers only security, privacy and money, so treat its findings as advice.

Third opinion by claude-opus: 3 finding(s), marked with the model name. It answers only product truth, missing tests and the cross-repo contract, so treat its findings as advice.

Exact head: d8829c29dff8 · Context: repo · Took 15m

Comment thread src/components/Card/OnCardScreen.tsx
Comment thread src/utils/collateralPull.utils.ts Outdated
Comment thread src/components/Card/OnCardScreen.tsx
Comment thread src/hooks/wallet/useBalanceSplit.ts Outdated
Comment thread src/hooks/wallet/useCollateralPullPreview.ts
Comment thread src/hooks/wallet/useBalanceSplit.ts Outdated
Comment thread src/components/Card/OnCardScreen.tsx Outdated
Comment thread src/features/payments/flows/semantic-request/views/SemanticRequestConfirmView.tsx Outdated
Comment thread src/components/Card/OnCardScreen.tsx Outdated
…ll off before a move off card, keyed retries, lock armed from a successful pull

- On card / off card split reports LANDED spending power as on card and the
  in-transit top-up as pending (Home line and On card tiles); moves and
  the pull preview never treat pending collateral as available. Off-card
  cents are floored for display and bounds.
- Move off card turns load-everything off in the same pre-withdrawal PATCH
  that pins the target, so the balancer cannot sweep the return back; a
  failed PATCH withdraws nothing.
- Move to card's idempotency key is bound to its amount: an exact retry
  reuses it, an edited amount is a new request. UUID fallback for WebViews
  without crypto.randomUUID.
- The card-pull preview models multi-call flows (cross-chain withdrawals
  and request payments) as mixed, so only the shortfall is reported; the
  request-pay input view gets the notice and the confirm view passes the
  calculated pay amount.
- A successful collateral pull arms the shared cooldown from the
  server-confirmed lock duration (submit and stamp), silently — countdown
  pill without the intro modal — so the next review step can show it.
- Lock copy no longer promises the payment will wait; amount field on the
  BaseInput / FieldColumn / LinkButton primitives.
- OpenAPI copy rebuilt from dev plus only the changed paths, so the
  push-provisioning route stays in the contract.
- OnCardScreen render tests: key reuse vs re-mint, pin-before-withdraw with
  load-all off, pin failure aborts, pending never movable, target lowering
  returns the excess and survives a failed return.

@chip-peanut-bot chip-peanut-bot 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.

Chip review — no blocking findings — this is not an approval

Three major defects remain: send-link previews still misstate multi-call pulls, coordinated QR/off-ramp pulls do not arm the cooldown countdown, and a cancelled manual move leaves the balancing policy changed. Exact-head CI is green.

Findings

  • MAJOR · src/components/Send/link/views/Initial.link.send.view.tsx:294 · Mark send-link creation as a multi-call spend
    Creating a send link always executes the approve + makeDeposit bundle in useCreateLink, so useSpendBundle forbids collateral-only routing. This call leaves collateralOnlyAllowed undefined, which defaults to true: with $10 off card, $100 on card, and a $50 link, the notice says $50 comes off the card even though execution pulls only the $40 shortfall. Pass collateralOnlyAllowed={false} here, and preferably require every notice callsite to declare its routing mode.

  • MAJOR · src/services/rain.ts:301 · Arm cooldown after coordinated card pulls
    armCooldownFromSuccess is reached only through rainApi.submitWithdrawal and rainApi.stampWithdrawal. Card-funded QR payments and Manteca off-ramps submit through mantecaApi instead, and their success paths never dispatch this event. After one of those pulls, the next review still sees cooldownEndsAt as null and offers a passkey attempt that ends in a 425. Return the server-confirmed cooldown from the coordinated endpoints and feed it through the same arming helper for collateral-only and mixed success.

  • MAJOR · src/components/Card/OnCardScreen.tsx:293 · Do not persist move policy before passkey consent
    With a $100 target and $100 on card, choosing a $40 manual move PATCHes the target to $60 (and may disable load-all) before moveOffCard opens WebAuthn. If the user cancels the passkey, AmountModal only displays the error; no money moves, but the persistent balancing policy remains changed. Acquire the signature before mutating policy, or restore the prior policy on a definitively pre-broadcast cancellation while preserving patch-before-broadcast ordering.

  • MAJOR · src/components/Card/OnCardScreen.tsx:248 · [claude-opus] Load-all toggle and wallet-floor save ship untested
    The new OnCardScreen.test.tsx closes most of the earlier gap — it pins the move-to-card idempotency key, the target-pin-before-withdraw ordering, the landed-only cap and the excess return. Two mutations on the same screen are still uncovered, and both are squarely inside CONTRIBUTING.md:506 ("if code moves money or mutates shared state, it needs a test before merge"):

  1. toggleLoadAll (OnCardScreen.tsx:248) PATCHes loadAllToCard, which per peanut-api-ts#1527's schema comment means "sweep everything to the card — no target, no floor, no inflow debounce". One tap hands the user's entire off-card balance to the balancer, and getting it back costs a passkey and a lock wait. Untested case: toggling it on sends {loadAllToCard: true} for this cardId, and a rejected PATCH surfaces the error toast and leaves the toggle in its previous position rather than showing it as on.
  2. saveFloor (OnCardScreen.tsx:230) PATCHes walletFloorCents, the amount the sweep must leave off the card — shared balancer state with no test at all.

Both are cheap additions to the existing file, which already mocks rainApi.updateCollateralSettings.

  • MAJOR · src/components/Card/OnCardScreen.tsx:295 · [claude-opus] Move off card silently pins the target, disabling the paired nightly tuner
    moveOff (OnCardScreen.tsx:285) PATCHes collateralTargetCents before every withdrawal whenever remaining < policy.targetCents — which is nearly always, since you can only move off what is on the card. In peanut-api-ts#1527 that field carries a side effect the UI never asked for: computeCollateralSettingsEffect sets collateralTargetPinnedAt = now for any supplied target ("A supplied target is always pinned: sending it is the user's choice even when the number is unchanged"), and collateral-target-tuner.ts only resizes cards where collateralTargetPinnedAt IS NULL.

So a user who taps "Move off card" once — never touching "Keep on card" — permanently opts their card out of auto-sizing, frozen at whatever onCardCents - moved happened to be. The screen then labels that number "Set by you" (policy.targetPinned ? t('pinned') : t('tuned'), line 495) for a number they never chose, and the tuner stops fixing their declines forever.

This does not break peanut-api-ts — the request is well-formed and both sides agree on the shape. It is a semantics mismatch, and the other half is the open PR I cannot see, so the fix probably belongs there: peanut-api-ts#1527 needs a way to lower a target without pinning it (an explicit pinTarget: false, or an internal-only lowering path the move-off route uses), and this call site should use it. If that PR already has one, this is a no-op.

  • MINOR · src/i18n/app/messages/en.json:1674 · [claude-opus] Keep-on-card hint drops the 1–2 minute decline-refill wait
    en.json:1674 tells the user, at the exact moment they pick their on-card number: "Card purchases above it still go through: the card refills and the retry works."

product/card.md:359 documents that path differently: a decline for insufficient collateral "automatically starts a top-up from the user's wallet balance. Tell the user to wait 1–2 minutes and try the purchase again — the retry normally goes through", and support is told to escalate if it declines twice more. The copy states as a guarantee what product truth hedges, and omits the wait entirely — so a user who sets a low target on the strength of this line gets declined at the till with no idea they need to stand there for a minute or two first.

The doc is right here. Suggested rewrite: "...the card tops itself up and the purchase usually works if you try again after a minute or two." Same change needed in es-419.json, es-AR.json and pt-BR.json.

Checked and not raised again

  • MAJOR · src/i18n/app/messages/en.json:1500 · [claude-opus] Product truth still ties collateral to the card limit — this review checked it and does not believe it. No task filed.

Checked clean

  • Verified the detached worktree HEAD, supplied base SHA, trusted author, target branch, and PR metadata.
  • Reviewed the complete base-to-head diff and the latest fix commits across card moves, balance splitting, payment previews, cooldown state, copy, tests, and generated API contracts.
  • Compared the push-provisioning OpenAPI node with the base; its request and response contract is preserved exactly.
  • Verified the move-to-card OpenAPI request requires idempotencyKey and the runtime sends an amount-bound key.
  • Exact-head CI completed successfully, including unit, typecheck, eslint, format, analyze, design-system lint/screenshots, native export, preview, provenance, and aggregate gates.
  • The canonical Notion Lexicon has no on-card, off-card, collateral, or withdrawal-lock definition; copy was checked against the implemented API and routing behavior without inventing a product definition.
  • Security and money pass covered active-card scoping, amount bounds, idempotency transport, analytics payloads, credential exposure, and collateral-routing trust boundaries.

Security review by moonshotai/kimi-k3: 0 finding(s), marked with the model name. It reads the diff only and answers only security, privacy and money, so treat its findings as advice.

Third opinion by claude-opus: 4 finding(s), marked with the model name. It answers only product truth, missing tests and the cross-repo contract, so treat its findings as advice.

Exact head: 72a78a4a2545 · Context: repo, product · Took 17m

Comment thread src/components/Send/link/views/Initial.link.send.view.tsx Outdated
Comment thread src/services/rain.ts Outdated
Comment thread src/components/Card/OnCardScreen.tsx Outdated
… cooldown from Manteca pulls, routing-aware pull notices

- Move off card: the target-lowering PATCH now runs between the passkey
  and the broadcast (useMoveOffCard beforeSubmit). A cancelled passkey
  changes no policy; a failed PATCH leaves the signed withdrawal unsent.
  The lowering sends pinTarget: false, so a manual move no longer opts an
  auto-sized card out of the nightly tuner.
- Card-funded QR payments and Manteca off-ramps arm the shared cooldown
  countdown from the server-confirmed cooldownSec their completions now
  carry, through the same helper the direct withdrawal uses.
- CollateralPullNotice requires the flow's routing mode: send links are an
  approve + deposit bundle and can only pull the shortfall; request-pay's
  amount step mirrors the confirm step's cross-chain flag.
- Keep-on-card hint says a purchase above the target is declined once and
  usually works on a retry after a minute or two (en, es-419, pt-BR).
- Tests: load-all toggle (on, and a rejected PATCH leaves the switch off),
  floor save, passkey-cancel leaves policy untouched, beforeSubmit ordering.

@chip-peanut-bot chip-peanut-bot 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.

Chip review — no blocking findings — this is not an approval

One prior major defect remains: token-denominated request inputs still preview the token quantity as dollars, so the pre-tap notice can hide a real collateral pull. The other 18 supplied findings are fixed or no longer apply, and exact-head CI is green.

Findings

  • MAJOR · src/features/payments/flows/semantic-request/views/SemanticRequestInputView.tsx:188 · Preview token-denominated requests in USD
    The routing flag is now correct, but this input still passes amount, which the flow stores in token units for token-denominated requests; execution later spends the route's USDC payAmount. For example, a 0.1-token request worth $300 is previewed as $0.10, so a small off-card balance suppresses the notice even when the real route pulls hundreds of dollars from the card. Pass the USD debit available for this step, or suppress routed previews until their actual pay amount is known.

  • MAJOR · src/components/Card/OnCardScreen.tsx:36 · [claude-opus] Product truth still caps parked collateral at the card limit
    This PR decouples collateral from the per-purchase limit and gives the user two ways to park more than the limit: the Keep-on-card target accepts up to MAX_TARGET_CENTS = $100,000 with no cardLimit cap (src/components/Card/OnCardScreen.tsx:36, passed as maxCents at :427), and the Load-everything-to-card toggle sweeps the whole balance with 'no target, no floor' (:324, en.json card.onCard.loadAllHint). The backend half agrees: peanut-api-ts#1527 drops the cardLimit: { not: null } filter from rebalance-sweep and gates the sweep on collateralTargetCents instead.

The product docs still say the opposite, in three places that support and marketing read from:

  • /home/chip/mono/product/card.md:200 — 'The auto-balancer parks only up to the user's card limit (default $100) in that contract; everything above the limit stays in the user's own passkey-controlled smart wallet and never leaves it.'
  • /home/chip/mono/product/card.md:222 — 'Raising [the card limit] auto-tops-up collateral from the wallet. Lowering it does not auto-return the difference.' Neither is true any more: CardLimitEditModal.tsx now explicitly moves no collateral, and useReturnExcessCollateral is deleted.
  • /home/chip/mono/product/quick-ref.md:129 — 'only up to the card limit is parked in a per-user collateral contract'.

The code is what's right; the docs are wrong. The one that matters most is card.md:200 / quick-ref.md:129, because it is a self-custody claim: after this ships a user with the default $100 limit can put their entire balance into the Rain-managed collateral contract (one-way — it comes back only via passkey + Rain co-signature, and only once every ~2–3 min). A Crisp agent answering 'how much of my money leaves my wallet' from quick-ref.md would give a wrong answer the day this merges.

Fix: update card.md:200, card.md:222 and quick-ref.md:129 to describe the on-card target / off-card floor / load-all as the thing that sizes collateral, and state that the per-purchase limit no longer moves any money. The in-app copy in this PR (en.json card.limits.subtitle, card.onCard.*) is already correct and can be the wording source.

  • MAJOR · src/context/RainCooldownContext.tsx:94 · [claude-opus] Success-armed cooldown mutates the app-wide spend gate untested
    armRainCooldownFromSuccess (src/services/rain.ts:305-316) is a new writer into the app-wide Rain cooldown — the state that gates every subsequent collateral pull and drives the 'Please wait' intro modal — and it is now wired into four call sites: rainApi.submitWithdrawal, rainApi.stampWithdrawal, the QR-pay completion (src/app/(mobile-ui)/qr-pay/page.tsx:964) and the Manteca off-ramp completion (src/app/(mobile-ui)/withdraw/manteca/page.tsx:496). RainCooldownContext.tsx:94 gained a matching branch: if (isFresh && !detail.silent) setShowIntroModal(true).

src/context/tests/RainCooldownContext.test.tsx already exists and pins the intro-modal logic case by case (fresh pops it, mid-cooldown retry does not, a new cooldown after expiry does). This PR changes that logic and does not touch the file. Two exact cases are untested:

  1. A silent event — { retryAfterSec: 150, message: '', silent: true } — must arm cooldownEndsAt but leave showIntroModal false. Nothing asserts this, so a later refactor that drops the !detail.silent guard (or a producer that forgets silent: true) pops a 'Please wait' modal at the user immediately after their payment succeeded, and no test fails.
  2. armRainCooldownFromSuccess's own guards: cooldownSec undefined, 0, NaN, or a non-number must dispatch nothing. Manteca is the live risk here — qrPayment.cooldownSec and result.data?.cooldownSec are hand-declared optional fields in src/services/manteca.ts and src/types/manteca.types.ts, not in the regenerated api.openapi.json, so undefined is a real runtime value. If the guard ever lets a falsy-but-numeric value through, every QR payment arms a lock and blocks the next spend.

Fix: add two cases to RainCooldownContext.test.tsx (silent arms the countdown without the modal; a silent event mid-cooldown still does not re-pop it), and a small unit test for armRainCooldownFromSuccess covering undefined / 0 / NaN / non-number → no event dispatched.

Checked clean

  • Confirmed the detached worktree HEAD, supplied base SHA, merge base, trusted author, dev base ref, and PR metadata match the requested target.
  • Reviewed the complete base-to-head diff and reconciled all 19 supplied findings across card moves, balance splitting, payment previews, cooldown state, copy, tests, and generated API contracts.
  • Verified move-off policy changes run after passkey signing but before broadcast, disable load-all when needed, lower without pinning, and leave policy untouched on passkey cancellation.
  • Verified landed collateral alone controls the on-card display and move bound, pending top-ups are separate, off-card units are floored, and move-to-card retries are amount-keyed.
  • Checked every collateral notice callsite against its direct-versus-multi-call execution route; direct send, send link, QR, bank, Manteca, withdraw confirmation, and request confirmation align, with the token-denominated request input exception reported above.
  • Verified successful direct, mixed, QR, and Manteca card pulls feed the shared cooldown from server-confirmed cooldownSec values.
  • Checked the live Notion Lexicon and the PR's linked product-document rollout; the Lexicon defines no on-card, off-card, target, or floor term, and the changed app copy no longer ties collateral to the per-purchase limit.
  • Reviewed active-card scoping, amount bounds, idempotency transport, analytics payloads, credential exposure, and collateral-routing trust boundaries; no additional security or money defect was found.
  • Exact-head CI completed successfully, including unit, typecheck, eslint, format, analyze, design-system lint and screenshots, native export, preview, provenance, and aggregate gates.

Security review by moonshotai/kimi-k3: 0 finding(s), marked with the model name. It reads the diff only and answers only security, privacy and money, so treat its findings as advice.

Third opinion by claude-opus: 2 finding(s), marked with the model name. It answers only product truth, missing tests and the cross-repo contract, so treat its findings as advice.

Exact head: 2179f716b742 · Context: repo, product, github_ci · Took 19m

… the silent cooldown arming

- a token-denominated request holds its amount in token units, and the
  USD debit is only known once the route prices it: the amount step no
  longer previews it as dollars (the confirm step previews the priced
  payAmount instead)
- tests: a silent (success-armed) cooldown event starts the countdown
  without the intro modal, also mid-cooldown; armRainCooldownFromSuccess
  dispatches only for a positive finite number of seconds

@chip-peanut-bot chip-peanut-bot 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.

Chip review — no blocking findings — this is not an approval

All 19 supplied prior findings are fixed or no longer apply at this head. The exact-head required CI is green; the non-gating screenshot job ended during its webpack build without an actionable diagnostic.

Findings

  • MAJOR · src/i18n/app/messages/en.json:1502 · [claude-opus] Product truth still says raising the card limit tops up collateral
    The limits screen copy now tells users the opposite of product truth. en.json:1502 reads "The most a single card purchase can be. It doesn't change how much stays on the card.", and CardLimitEditModal.tsx:169-170 removed the excess-return entirely ("The purchase limit is a Rain control only"). product/card.md:222 still says: "Card limit: changeable anytime. Raising it auto-tops-up collateral from the wallet. Lowering it does not auto-return the difference."

The code is the correct side. After this PR the per-authorization limit PATCH no longer moves any collateral in either direction — raising it does not top up (the balancer targets collateralTargetCents), and the previous excess-return-on-lower is deleted. Support answering "why didn't my balance move when I raised my limit?" from card.md:222 would give the wrong answer, and the same claim is what the old rainCooldownIntroModal copy was built on ("lower your card limit to be below your wallet balance") — this PR rewrote that string but not the doc behind it.

Fix: rewrite product/card.md:222 so the per-purchase limit is described as a Rain authorization control with no collateral side effect, and point the top-up/return behaviour at the On card screen's target / floor / move actions.

Checked and not raised again

  • MAJOR · src/components/Card/OnCardScreen.tsx:36 · [claude-opus] Product truth still caps parked collateral at the card limit — this review checked it and does not believe it. No task filed.

Checked clean

  • Confirmed the detached worktree HEAD, supplied base SHA, merge base, trusted author, dev base ref, and pull-request metadata match the requested target.
  • Reconciled all 19 supplied findings against the exact-head card-move orchestration, balance split, spend previews, cooldown arming, copy, generated contracts, and focused tests.
  • Verified move-off policy changes run after passkey signing but before broadcast, disable load-all when needed, lower the target without pinning it, and leave policy untouched when signing is cancelled.
  • Verified landed spending power alone controls the on-card display and move bound, in-transit top-ups are shown separately, and off-card USDC units are floored for display and limits.
  • Checked direct, send-link, QR, bank, Manteca, crypto-withdraw, and semantic-request notices against their execution amount and direct-versus-multi-call route; token-denominated request input now waits for the priced confirmation debit.
  • Verified successful direct, mixed, QR, and Manteca card pulls feed server-confirmed cooldown durations into the shared countdown, with guards and context behavior covered by tests.
  • Checked the Lexicon and current card product source together with the pull request's explicitly linked product-document change and rollout order; the app consistently separates the per-purchase limit from the on-card target, floor, and load-all policy.
  • Reviewed amount bounds, retry idempotency, card selection, analytics payloads, credential exposure, and collateral-routing trust boundaries; no additional security or money defect was found.
  • Exact-head aggregate, unit, typecheck, eslint, format, native-export, analysis, design-system lint, provenance, review, and deployment-preview checks pass. The non-gating design-system screenshot job stopped during webpack without an actionable error.
  • A focused local Jest invocation could not start because the detached worktree has no node_modules; the corresponding exact-head unit check passed in CI.

Security review by moonshotai/kimi-k3: 0 finding(s), marked with the model name. It reads the diff only and answers only security, privacy and money, so treat its findings as advice.

Third opinion by claude-opus: 2 finding(s), marked with the model name. It answers only product truth, missing tests and the cross-repo contract, so treat its findings as advice.

Exact head: 82e31ca50822 · Context: repo, product, github_ci · Took 12m

@innolope-dev
innolope-dev marked this pull request as draft September 5, 2026 06:23
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