Skip to content

refactor(qr-pay): split the 1.9k-line page into a features/ flow (TASK-21457) - #3008

Merged
jjramirezn merged 494 commits into
tech-debtfrom
feat/TASK-21457-qr-pay-split
Sep 9, 2026
Merged

jjramirezn merged 494 commits into
tech-debtfrom
feat/TASK-21457-qr-pay-split

Conversation

@kushagrasarathe

@kushagrasarathe kushagrasarathe commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Rebuild of the qr-pay page (TASK-21457, DS tech-debt project). The 1,919-line app/(mobile-ui)/qr-pay/page.tsx monolith (11 early-return render branches, ~20 useState, no step variable) becomes a features/payments/flows/qr-pay/ flow, modeled on semantic-request: a 19-line entry, one provider, one behavior hook, and no-prop views. Behavior-parity migration — the DOM, copy, i18n keys, and API calls are unchanged.

Note on the diff: this branch carries origin/tech-debt merged onto dev (the qr-pay split needs useFlowStepper's home + the redux deletion). Until the official tech-debt→dev merge-back lands, the diff shows all of tech-debt. The qr-pay work itself is commits 7accc694, 08083f69, 4a59d521. Merge-resolution notes (including dev withdraw fixes that had no home in the rebuilt structure, flagged for the merge-back) live in the state log.

Task

TASK-21457

What moved where

Concern File
nuqs entry, provider keyed per scan app/(mobile-ui)/qr-pay/page.tsx (19 lines)
provider + switch(view) QrPayPage.tsx
state bag + accessor QrPayFlowContext.tsx + qr-pay-flow.types.ts
lock query, scan outcome, payment, currency, staleness redirect useQrPayFlow.ts
capability→KYC-gate memo (consumes useMultiPhaseKycFlow/useCapabilities untouched) useQrPayKycGate.ts
hold-to-claim timers/haptics/confetti, owns all cleanup usePerkHoldToClaim.ts
the 11-branch precedence ladder as one pure function derive-view.ts
i18n failure maps, beside the (moved) classifier useQrFailureCopy.ts
views (no props; read the flow hook) views/ — Form, Success, KycGate, Blocked (maintenance/init-error/order-not-ready), ProviderRejection, PageLoading

Design notes / accepted trade-offs

  • useFlowStepper deliberately NOT adopted. Every qr-pay screen is outcome-derived (KYC gate, scan errors, payment success) — there are zero user-navigable steps, so a URL step param would let ?step=success deep-link a payment that never happened. deriveQrPayView encodes the exact precedence ladder (the old "this must come BEFORE" ordering comments are now one readable function).
  • Provider remount per scan (key on qrCode|t) replaces the old resetState()-in-an-effect; a late in-flight setIsSuccess now lands on a dead provider instead of a fresh scan's state.
  • Intentional rider fixes, only behavior deltas: nuqs for ?qrCode/?t/?type; strict-mode-safe perk-dismissal analytics (regression-tested); guarded currency fetch; timer cleanup consolidated; deprecated next/image layout="fill" removed; balance/entry-guard errors derived instead of effect-latched.
  • The order-not-ready latch stays as state (tests pin it; single-mounted and idempotent now).

Tests

The 2,050-line qr-pay state-matrix suite runs unchanged through the page entry — the harness only gained NuqsTestingAdapter (~13-line diff). Classifier test moved with its file. New usePerkHoldToClaim StrictMode suite (fails against the old implementation). 503 suites / 6,203 tests green; typecheck, prettier, build, ds-lint ratchet all pass.

Risks / breaking changes

  • No API/contract change — requests are verbatim-identical to the old page. No cross-repo action.
  • Money path (Manteca QR payment execution): handleMantecaPayment is byte-equivalent to the old page modulo renames; independently reviewed hunk-by-hunk against the monolith (internal adversarial review: no dropped guard, no inverted condition found).
  • The tech-debt merge content ships here only if this merges after the official merge-back; do not merge this PR before tech-debt lands in dev.

QA

  • npm test — qr-pay suite exercises the full state matrix (KYC gates, scan failures, payment errors, success + perk claim).
  • Sandbox: /qr-pay?qrCode=qr3 via the Nutcracker e2e-qr-pay-ar scenario path.
  • Screenshots: see PR comments/section below.

Screenshots

Sandbox capture (local qa stack, seeded users, mobile Chrome UA). PR = this branch on :3055; baseline = dev (367da9366) on :3050, same users/API/DB. The pr-assets-3008 branch holds these images only — delete it after merge.

State PR (this branch) Baseline (dev)
Init/scan error — /qr-pay?qrCode=qr3&type=ARGENTINA_QR3, manteca-AR user, 375×667
Init/scan error, 360×667
Init/scan error, 390×844
Init/scan error, 430×932
Entry guard — /qr-pay no params, 375×667
KYC gate — AR QR, un-KYC'd (manteca) user, 375×667

Not reachable in sandbox (not faked): FORM/SUCCESS (needs sandbox.manteca.dev credentials — qr3 falls through to the prod decoder and 500s, per e2e-qr-pay-ar), AWAITING_MERCHANT, ORDER_NOT_READY, MAINTENANCE, PROVIDER_REJECTION.

Stack (updated 2026-09-07)

Base retargeted dev → tech-debt. This PR is the bottom of a 2-PR stack and carries dev's latest (incl. the DS changes) into tech-debt:

  1. refactor(qr-pay): split the 1.9k-line page into a features/ flow (TASK-21457) #3008 (this) → tech-debt — dev's recent commits + the qr-pay split
  2. refactor: TASK-21854 logic/UI separation — extract inline-state pages and monoliths onto features/ #3007 → this branch — TASK-21854 logic/UI separation, auto-retargets to tech-debt when this merges

The diff therefore shows dev-side commits (residence polish, avatars, review sheet, …) riding along — intended, this PR is the dev-carrier for the stack.

abalinda and others added 30 commits September 3, 2026 19:14
…rting a second

Init publishes oneSignalInitialized before its first login() resolves,
so an opt-in in that window ran syncExternalIdLink again and reached
adapter.login twice for the same id — the double-record race behind
TASK-22209 on one more path. A sync for an id whose login is in flight
now joins that promise; failures still clear it so the next sync retries.
…ssued-on

fix(receipt): remove issued-on date from PDF receipt footer
Conflicts in useHomeFlow: dev (#2929, TASK-22142) removed avatarName and
seeds the top-nav chip from the username directly, which supersedes this
branch's avatar-seeding fix. Took dev's version of the hook and its test.
The name row above the pill now shows the full name or nothing. Falling back to
the username printed the handle twice on a page whose pill already reads
peanut.me/<username>.

ProfileHeader drops the row on an empty name rather than rendering an empty
label, so the two callers without a pill (public profile, profile edit) keep
theirs — both already fall back to the username themselves before passing it.

The showFullName preference still gates it: opting out means there is no name
to show. Avatar initials are unaffected, they already fell back to the
username.
The five-tap switch let any production install self-assign to the Capgo
`staging` channel, which receives every `dev` merge. The gesture controls
discoverability, not eligibility, and Capgo's self-assignment setting is
global — it cannot tell an internal tester from a customer — so the two
together were the whole boundary whenever self-assignment was open.

Gate the join on the `beta-ota-channel` cohort again, with the failure mode
that made the last one useless removed. The cohort now gates the JOIN only:

- The card renders on every native build, as it does today. A missing or
  false flag can no longer hide the switch, which is how the previous gate
  disabled itself for its own testers and looked identical to exclusion.
- A blocked device says why on screen and names the fix, instead of a
  gesture that silently does nothing.
- The off switch stays live whatever the cohort says. Offboarding someone
  mid-beta must not strand them on beta code with no way back to the store
  bundle.

nonProdBypass keeps staging and preview builds open — they are internal by
construction — so the cohort only has to exist for the production binary.

TASK-22248
…spend

Ceremony telemetry shows the mixed spend's second sheet (the UserOp, 1.5 s
after the Rain admin signature) is the one that gets dismissed: Android
NotAllowedError and iOS LOGIN_CANCELED both cluster at that gap, and the
retry then costs four sheets. The overlay between the two taps said only
"Verifying security…", which reads as "done".

- ModalsContext: the overlay takes a variant; 'next-passkey' swaps the copy.
- useSpendBundle passes it before tap #2. useSignSpendBundle showed no
  overlay at all between its two taps; it now shows the same beat.
- en / es-419 / pt-BR copy (es-AR inherits es-419).

Stopgap until the one-tap path (ui#2959) removes the second sheet.
… reveal re-entry

Six days of ceremony telemetry (ui#2870) left three blind spots and one
loop:

- Step-up assertions (card details, PIN, add bank) carried no purpose and
  showed as `unknown` — 99 of them. They are now `step_up`.
- Only link creation was bracketed as a flow, so QR pay, withdraws, direct
  send and card actions had to be reconstructed from per-user gaps. Both
  spend engines now bracket every call as `spend:<kind>` / `sign_spend:<kind>`
  with the chosen strategy; link_create nests inside.
- `signup_login_error` had no `native` flag, so the repeated-login failures
  could not be split native vs web.
- One device fired 13 step-ups in 27 s on /card, each failing in <10 ms with
  overlapped=true: useCardReveal had no re-entry guard, so taps while the
  sheet was up opened a second ceremony that failed instantly. Guarded.
… again

Telemetry: log in, open the card, second passkey sheet 7 s later (login →
step-up). A login assertion seconds old is as fresh as a step-up one, and
the API now mints a step-up proof alongside the session token on
/passkeys/login/verify (peanut-api-ts: login-mints-step-up-token).

The ZeroDev SDK discards the verify body, so the fetch wrapper that already
recovers the session JWT on native now runs on every platform and stashes
the step-up proof under the same ceremony-window rule; the guard commits it
to the step-up cache only when the ceremony resolves. On web the session
token still comes from the cookie — only the proof is taken from the body.

Side effect worth having: non-2xx verify responses are now reported to
Sentry on web too, which is what the "Login not verified" retries need.

The cache moved to step-up-cache.ts so the ceremony guard can seed it
without a step-up.ts ↔ passkeyCeremony.utils import cycle.
public/press is 13.8 MB across 30 files — founder photos, the brand
guidelines PDF and an EPS logotype. Next copies public/ into out/ wholesale,
independent of routes, so all of it ships inside every native binary and
every OTA bundle even though its only consumer, /[locale]/press, is in the
[locale] directory this script already disables.

The assets are PNG/PDF/EPS, so they compress badly and land in the OTA zip
near full size, for a route that 404s in the app.

Reuses the existing pruneExportedAssets pass that already drops the PWA .mov
files and the /dev pages.
A bundle applied from appMovedToBackground() starts its notifyAppReady
countdown as the app is leaving the foreground, and the countdown keeps
running while the OS has the process frozen. One Android 11 device called
notifyAppReady 6 ms after the deadline and had the bundle rolled back
(PEANUT-UI-SVE). Android floors this at 30 s for a pending bundle, so 15 s
was inert there and binding on iOS.
fix: receipt drawer clipped the avatar, profile repeated the username
…tive-export

perf(native): prune the press kit from the static export
…deadlock

fix(ota): quit instead of deadlocking the restart on affected Android binaries
Chip (major): the flag made the ephemeral signer production-reachable
and only the boolean gate was tested. These enter useSpendBundle with a
real mixed routing and assert the two money invariants: a settled
ephemeral op is what is returned and stamped (receipt hash, not the
userOp hash, no passkey signatures), and a reverted or pre-broadcast
failure falls back to the passkey path against the SAME preparation —
one prepareWithdrawal per attempt, the coordinator's adminNonce being the
mutex that keeps the two attempts mutually exclusive.
…authoritative backend

- Scope the pre-flight min guard to external-wallet claims only. A claim into
  the user's own Peanut balance is forced down the same isXChain branch but
  settles to Arbitrum USDC with no product minimum; blocking it dead-ended the
  very "claim to your Peanut balance" fallback the copy points at (review: MAJOR).
- Stop sending a client-reported amount. claim-xchain now forwards the deposit
  identity (chainId, depositIdx, contractVersion) so the backend reads the claim
  amount from chain (authoritative); withdraw/pay-request drop payAmountUsd since
  the backend derives their amount from the charge.
- Keep the dynamic sda.minDepositLimitUsd check as a fail-open backstop for when
  the backend can't derive the amount, and the pre-flight static floor as the
  first-line UX block.
- Add executeClaimXChain regression tests: sub-minimum blocks before signing/
  submitting, boundary passes, priceless/no-min-route cases don't block, and the
  deposit identity is forwarded (review: MAJOR, no money-path test).

Pairs with the peanut-api-ts server-authoritative guard.
… only real receipts

Chip (major x2): getPatchedSudoValidator sat outside the helper's catch, so
a rejection there (cookie cleared mid-session, SDK transport) escaped as a
failed spend after /prepare had run — the passkey path never ran and no
fallback event fired. It now yields ok:false like every other failure.

And an unresolved receipt (bundler timeout, rescue miss) was stamped with
the userOp hash as if it were a tx hash. The branch now stamps only a
receipt's transactionHash; with none it reports the op as submitted, the
same as the passkey path, leaves the intent PENDING for webhook
reconciliation, and tags the success event receipt=unresolved. Returning
ok:false there was rejected on purpose: the fallback would race a possibly
landed op on the same account nonce, fail with AA25 after the money moved,
and invite a user retry against a fresh preparation.
… joining

Replaces the PostHog cohort with the badge the gesture now awards. The cohort
never existed, and a flag that has to be hand-created in a dashboard is the
same setup step that left this switch invisible for months; the badge is
created by the act of tapping, so there is nothing to remember.

The fifth tap claims PEANUT_TEAM and refetches the user before revealing the
card. Revealing first would show a disabled toggle and an "ask for access"
line for a round trip, on the very gesture that just granted access. A failed
claim still reveals the card: a device already on beta needs the off switch,
whatever the network did.

What the badge is: a record of who opted in and a handle to revoke. It is not
an access boundary — anyone who performs the gesture awards it to themselves,
and Capgo's channel self-assignment setting remains the real one. What it
buys over the bare gesture is a list and a way to take it back.

It is never rendered — not in a profile row, not in the home feed, not as a
celebration toast. It says "team" and is handed out on a gesture, so showing
it would let any customer wear Peanut staff colours in a payments app.

Needs peanut-api-ts#PEANUT_TEAM (POST /badge/team) deployed first: without it
the claim fails, the card still reveals, and joining stays blocked with copy
that says to tap again.

TASK-22248
…laim-settle

fix(native): OTA beta switch off PostHog, Peanut-only receive, claim settles on CLAIMED
…d-reveal-guard

fix(passkey): tag step-up ceremonies, bracket every spend, guard card reveal re-entry
…sheet-copy

feat(spend): tell the user a second passkey sheet follows on a mixed spend
# Conflicts:
#	src/services/__tests__/step-up.test.ts
#	src/services/step-up.ts
…d its own flag

QR pay, Manteca withdraw and the card lock/cancel modals sign a mixed spend
through useSignSpendBundle and hand the UserOp to the backend to broadcast.
That engine still costs two passkey sheets (Rain admin EIP-712 + UserOp);
ui#2959 only covers the broadcasting engine.

signMixedEphemeralSpend is the sign-only twin of tryMixedEphemeralSpend:
the same single enable-signature tap, then the ephemeral key signs the
admin EIP-712 and the UserOp silently, uninstall last, and the unbroadcast
artifact goes back in the same SignedSpendArtifact shape. Nothing is sent
from the client, so a signing failure falls back to the two-tap path with
nothing at stake.

Own flag (session_key_spend_sign) rather than the broadcasting engine's:
a permission that fails on-chain surfaces here as the backend's broadcast
reverting, with no client-side retry. Stays off until ui#2959 has proven
the ERC-1271 ordering on production contracts.

The permission lifetime is 10 min for this path (backend broadcasts after
Manteca settles, with a receipt re-poll on timeout); the op stays
single-use through its nonce.
…or cannot be resolved

Same gap Chip found on the broadcasting engine (ui#2959): the validator
lookup sat outside the helper's catch, so a rejection escaped as a failed
spend after /prepare had run. It now yields ok:false and the two-tap path
signs against the same preparation.
Decision: no rollout gate. The fallback is the safety mechanism — every
failure inside the one-tap branch (validator, preflight, reverted op)
falls through to the two-tap passkey path against the same Rain
preparation, and the coordinator's adminNonce keeps the two attempts
mutually exclusive. A gate only decided who proved that first.

- Runtime gates gone: no PostHog flag, no device opt-in, no /dev toggle
  page. Both spend engines take the ephemeral path unconditionally.
- Build gate gone with them: NEXT_PUBLIC_SESSION_KEY_SPEND is read nowhere,
  so the workflow lines are dropped and the web build gets the path too.
- Engine B (QR pay, Manteca withdraw, card lock/cancel) folded in from the
  stacked PR, same treatment.
- Tests keep every branch case; the flag-off cases are gone with the flag.
- useSpendBundle's callback deps now list getPatchedSudoValidator.
The About screen read App.getInfo(), which is frozen at the .0 the binary
shipped with — an install running OTA 1.1.2 still reported 1.1.0. Prefer
the Capgo bundle actually executing for the release segments and keep the
binary's CI build number as the fourth.
…honest copy

Address chip re-review (Peanut-balance carve-out untested + inconsistent). The
prior revision skipped the guard for non-external claims, but a sub-minimum
link on a non-Arbitrum chain claimed into the user's own Peanut balance still
takes the isXChain branch and still bridges via Rhino, which parks it. So:

- Guard EVERY cross-chain claim (external wallet and own Peanut balance alike);
  only the copy branches — external keeps the network-picker message (now
  "Try a different network", dropping the dead-end "claim to your Peanut
  balance" fallback), internal gets a new errors.belowMinimumCrossChain
  ("...ask the sender to reclaim it") in all four locales.
- Always forward amountUsd so the FE dynamic backstop covers both destinations.
- Extract the decision into a pure belowClaimBridgeMinimum() helper and unit-
  test it (same-chain skip, unknown-amount skip, per-chain floors, boundary).

Pairs with the peanut-api-ts fail-closed server-authoritative guard.
…t three

/users/me deliberately returns the badge so the beta switch can read its own
permission, so hiding it from the public profile server-side does not cover
the surfaces that render the CALLER's own list. Two still showed it: the
/badges page and the full history page — a customer who tapped five times
would find "Peanut Team" on their own profile.

Move the rule into displayableBadges() and use it at all four call sites, so
the next surface to render a badge list inherits it instead of re-deciding.
The home feed keeps its separate BETA_TESTER rule, which is about first
impressions rather than about permission records.
Chip (major): the sign-only engine hands the op to the backend, so a
reverted ephemeral op comes back as a backend error and a retry would
enter the one-tap block again — no two-tap fallback — and the Manteca
withdraw broadcaster's receipt waiter does not check success. Engine B
leaves this PR; it returns with a client-side revert marker and the
backend success check. Engine A's fallback is complete and ships now.
…ile-filter

fix(badges): filter PEANUT_TEAM on every own-profile surface
@kushagrasarathe
kushagrasarathe marked this pull request as ready for review September 7, 2026 07:25

@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

Clean at the pinned head. The QR-pay split preserves payment initiation, signing and completion, KYC and error precedence, amount and balance gates, and the success/perk lifecycle; exact-head CI is green.

Findings

  • MAJOR · src/components/TransactionDetails/strategies/registry.ts:30 · [claude-opus] IntentKind is derived from a /history/{entryId} kind enum the pinned peanut-api-ts does not declare
    registry.ts now derives the FE's whole intent vocabulary from the generated types: export type IntentKind = paths['/history/{entryId}']['get']['parameters']['query']['kind'], and the comment above it promises that a backend enum addition 'becomes a COMPILE ERROR on the next type regen'. That promise depends on the backend declaring the kind as a literal union.

Evidence: the committed src/types/api.openapi.json / src/types/api.generated.ts:5603 carry the full 18-value enum (ONRAMP | ... | CHARGEBACK | PERK_REWARD), added by this diff. The pinned peanut-api-ts policy branch declares it loosely — peanut-api-ts/src/routes/user/history.ts:230-238, getHistoryEntrySchema.querystring = Type.Object({ kind: Type.String() }), with the validation done at runtime via VALID_WIRE_KINDS (line 259) rather than in the schema. Every other new path in this regen (/rain/cards/withdraw/prepare/cancel, /rain/cards/withdraw/stamp, /users/saved-addresses, /user/crisp-token, /status/summary, the /healthz/* set) does exist on the pinned branch, so this one entry is the sole drift.

This does not break peanut-api-ts, and nothing breaks at runtime today — the generated file is committed. The failure is on the next regen: pnpm gen:api:live against the merged backend collapses IntentKind to string, STRATEGIES becomes Record<string, TransactionStrategy> (which accepts any subset without error), isIntentKind widens to value is string, and the exhaustiveness guarantee this commit was written to provide disappears silently — exactly the TASK-21403 failure shape the comment cites. pnpm check:api in CI would also start failing or, worse, quietly rewrite the file.

A change like this normally ships as a pair, and the backend half (tightening kind to Type.Union of the TransactionIntentKind literals plus PERK_REWARD) is most likely an open peanut-api-ts PR that is not in this checkout — this is not a claim the author forgot it. Flagging it so the merge order is explicit: confirm the peanut-api-ts schema change is merged before or with this, or the derived-type safety net is inert. If the backend half is not landing, pin the union locally in registry.ts and keep the Record<IntentKind, …> check meaningful rather than deriving from a string.

Checked clean

  • Confirmed the detached worktree head, merge base, trusted author, and dev base exactly match the supplied review coordinates.
  • Compared the QR-pay split against its immediate pre-split image, including nuqs parameter decoding, keyed scan remounts, capability/KYC gating, scan retries and outcome classification, open- and fixed-amount handling, signed-spend request shapes, and success/perk state; no reachable behavior regression survived.
  • Adversarially traced invalid and recurring QR inputs, provider and region blocks, retry and order-not-ready transitions, new scans during in-flight work, balance and limit failures, stale-session/signing failures, and backend completion failures; the moved guards and unchanged state-matrix coverage preserve the intended outcomes.
  • Reviewed the stacked tech-debt integration seams relevant to this head, including workflow trigger expansion, dependency removal, auth/wallet money boundaries, and the final dev merge; no QR-path conflict resolution or privilege escalation was introduced.
  • Exact-head required CI passed, including unit, typecheck, ESLint, format, DS lint, native export, analysis, deploy preview, visual ds-shots, and the aggregate gate.
  • Focused local Jest was not rerun because the detached worktree has no installed dependencies; the exact-head unit check provides the execution result.

Security review: did not run — openrouter-http-402. This review is one reviewer short.

Third opinion by claude-opus: 1 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: 245330f134e0 · Context: repo · Took 6m

@kushagrasarathe
kushagrasarathe changed the base branch from dev to tech-debt September 7, 2026 08:32
…ng qr-pay branch (stacked-PR order: #3008 -> tech-debt, this on top)
@kushagrasarathe

Copy link
Copy Markdown
Contributor Author

/chip review

@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

The QR-pay implementation and tech-debt merge adaptation are clean, but the retargeted 472-commit range cannot pass the required provenance gate because GitHub enumerates only 250 PR commits.

Findings

  • MAJOR · .github/workflows/bot-approval.yml:122 · Reduce the PR below the provenance gate's commit ceiling
    Against tech-debt, GitHub reports 472 commits for this PR. Both latest bot-approval runs on this exact head reach this fail-closed guard after listCommits returns only 250 entries, so the required provenance check cannot inspect every commit and the head is not mergeable despite green product CI. Merge the dev carrier into tech-debt in staged human-owned updates, or otherwise split/rebase the range so this PR exposes at most 250 commits, then rerun the gate.

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

Checked clean

  • Confirmed the detached worktree head, merge base, trusted author, and live tech-debt base exactly match the supplied review coordinates.
  • Decomposed the 472-commit carrier into already-landed dev history, the three QR-pay refactor commits, and the final dev merge; reviewed the QR split against its immediate pre-split image rather than treating inherited history as newly authored code.
  • Traced QR parameter decoding, provider remounts, capability and KYC gates, retry and outcome transitions, open- and fixed-amount handling, signed-spend request shapes, balance and limit failures, backend completion, and success/perk state; no reachable behavior regression survived.
  • Inspected the final merge with remerge-diff. Its only content conflict adapts the residence-polish handlers and tests from Redux dispatches to SetupFlowContext setters, and the resulting state updates are correct.
  • Exact-head product checks are green, including unit, typecheck, ESLint, format, DS lint and shots, native export, analysis, deploy preview, human-authors, and ci-success; the two latest bot-approval runs fail solely on the 472-versus-250 commit enumeration mismatch reported above.
  • Focused local Jest was not rerun because the detached worktree has no installed dependencies; the exact-head unit check provides the execution result.

Security review: did not run — openrouter-http-402. This review is one reviewer short.

Third opinion by claude-opus: 0 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: 245330f134e0 · Context: repo · Took 16m

…ress

The features/ rebuild of the qr-pay flow predates dev's entity-split
funding change (33cb733 / 09199fa, for the 2026-09-14 Manteca
per-entity balance split), so useQrPayFlow signed to the legacy per-rail
constants and the next dev merge would have dropped the fix silently
(the file dev changed no longer exists here).

Hand-ported from dev: pickMantecaDepositAddress + friends in
src/utils/manteca.utils.ts (with tests), depositAddress/legalEntity on
QrPaymentLock and WithdrawPriceLock, the qr-pay signSpend recipient, the
withdraw/manteca signSpend recipient (this branch also rewrote that
page), and the recipient-wiring test group in qr-pay-states.
…KYC views

Dev's shared restart-cooldown dialog (99bfe77) postdates this branch's
last dev merge, and the qr-pay views it changed were rebuilt into
features/ here — so a 429 on restart left the user staring at a prompt
whose only CTA the backend refuses, with no way out.

Hand-ported from dev: the 429 cooldown parse in the restart server
action (with tests), errorCooldown/dismissErrorCooldown through
useSumsubKycFlow and useMultiPhaseKycFlow, KycRestartCooldownModal
mounted by SumsubKycModals (onCooldownClose), the cooldown i18n keys in
all four locales, and the prompt gating
(!kycPromptDismissed && !sumsubFlow.errorCooldown, dismiss leaves the
flow) in QrPayProviderRejectionView and QrPayKycGateView, pinned by the
cooldown test in qr-pay-states.
… flow (PR #3005)

The features/withdraw rebuild kept #3005's charge-pinned broadcast
(setupAmountRef) but dropped the max-withdrawal half: the balance tap
was gone from the amount step, and the crypto path validated and signed
the displayed 2-decimal amount — so 'withdraw everything' stranded the
sub-cent remainder as un-withdrawable dust again (TASK-21899).

Re-ported onto this branch's architecture: isMaxWithdrawal on the flow
context, balanceFillAmount/onBalanceFilled through WithdrawRoot and
WithdrawAmountView (retired on any edit), and resolveWithdrawAmount in
the crypto page (validated, quoted, pinned at charge creation, and used
as every fallback the pinned amount had). Tests: the five balance-fill
cases in withdraw-states and the frozen-spend group (dust remainder,
balance drop, balance rise) in crypto-withdraw-confirm, driven through
the real setup path so the pin is exercised.
…l capture, pay the hover ratchet

Three review findings on the features/ rebuild:

- QrPayPageLoading lost the old layout="fill" when rewritten to
  width/height, parking the mascot at the container's top-left; inset-0
  h-full w-full restores the fill so object-contain centers it again.
- usePerkHoldToClaim's deferred REWARD_CLAIM_DISMISSED cancel lived in
  an instance ref, and the page remounts the flow under a new
  qrCode|timestamp key — a new instance could never cancel the old
  instance's capture, so every keyed remount fired a phantom dismissal.
  The pending timer is module-scoped now (pinned by a keyed-remount
  test).
- Revert the hoverNoActiveFiles 44->45 baseline ratchet and give the
  two relocated hover:text-black links their active:text-black instead
  — the ratchet exists to be paid, not raised.
@jjramirezn

Copy link
Copy Markdown
Contributor

Heads-up: I pushed 4 commits (245330f13..de3903698) directly to this branch — Jota authorized fixing the review findings directly so the branch doesn't sit on them. No force-push, no dev merge, no retarget; base/retarget calls stay with Kush and Jota.

What changed (all are dev work that landed after this branch's last dev merge, in files the split rebuilt — the next dev merge would have conflict-dropped them):

  1. 3e4766737 — Manteca entity deposit address (deadline 2026-09-14). useQrPayFlow signed QR spends to the legacy per-rail constants; dev (PR feat: Manteca entity deposit addresses from the API (TASK-22107) #2933 line) signs to the API-served lock.depositAddress via pickMantecaDepositAddress. Ported src/utils/manteca.utils.ts (+tests), the depositAddress/legalEntity fields on QrPaymentLock/WithdrawPriceLock, the qr-pay recipient, the withdraw/manteca recipient (this branch also rewrote that page), and the recipient-wiring test group.
  2. c6fc5dbae — Sumsub restart-cooldown exit. A 429 on restart left the KYC/rejection prompts up with a CTA the backend refuses. Ported the cooldown slice (server action parse + tests, errorCooldown through the KYC hooks, KycRestartCooldownModal in SumsubKycModals, i18n ×4 locales) and the !kycPromptDismissed && !sumsubFlow.errorCooldown gating + onCooldownClose in QrPayProviderRejectionView/QrPayKycGateView, pinned by the cooldown test.
  3. e51f9e599 — full-balance withdrawal (PR feat(withdraw): tap the balance to withdraw everything #3005). The rebuilt withdraw flow kept the charge-pinned broadcast but dropped the max-withdrawal half: no balance tap, and the crypto path signed the displayed 2 decimals — stranding sub-cent dust again. Re-ported onto this branch's architecture (isMaxWithdrawal on the flow context, balanceFillAmount/onBalanceFilled through WithdrawRoot→WithdrawAmountView, resolveWithdrawAmount in the crypto page, pinned at charge creation). Tests: 5 balance-fill cases in withdraw-states + the frozen-spend group (dust remainder, balance drop/rise) in crypto-withdraw-confirm.
  4. de3903698 — three smaller findings. Loading mascot lost the old layout="fill" (sat top-left) → inset-0 h-full w-full; usePerkHoldToClaim's dismissal cancel couldn't span the page's keyed remount → module-scoped pending timer (+test); reverted the hoverNoActiveFiles 44→45 baseline ratchet and added active:text-black to the two relocated links instead.

Local gate before push: prettier clean, tsc --noEmit clean, all 504 jest suites green (6229 passed; note add-money-states needs an en_US locale locally — pre-existing, machine-locale formatting), next build OK, ds-lint-counts --check green (hoverNoActiveFiles now 43 ≤ 44).

Known remaining drift, deliberately NOT touched (for the next dev merge, Kush): dev's qr-pay page/tests moved further after this branch's merge-base — non-PAID status handling tests (CANCELLED/REFUNDED/FAILED receipt states), error-copy tests (MANTECA_TEMPORARILY_UNAVAILABLE, QR_PAYMENT_CANCELLED), BRL rounding, and services/manteca.ts error-code passthrough. Also the withdraw crypto TASK-22154 retry-recompute work. Those live in files this branch rewrote, so they'll conflict — hand-port, don't take either side wholesale.

removeParamStep() clears the trigger through a raw
window.history.replaceState that nuqs does not observe, so stepFromURL
keeps reporting 'claim' for the rest of the mount — and fetchUser()
during the claim replaces the user object, re-running the trigger
effect mid-claim. That re-run fired a SECOND claim POST, which rejected
against the already-claimed link and painted an error over the success
screen. A consume-once ref (armed only when a step was actually
present) swallows the re-run.

The pinning test now stages the real prod sequence — a user identity
swap after the first claim — instead of relying on a stable user object
that made the double-fire unreachable in the harness.
…afe storage, one flow surface

- useAddMoneyFlow: handleBack's if (countryFromQuery) branch was
  unreachable — every ?country= render path returns a component that
  owns its own back behavior, so the handler never runs with a country
  in the URL. Deleted, with its pinning test replaced by one that
  records why.
- card/utils: the celebration gate read raw window.localStorage inside
  a render-time useState initializer; the bare accessor throws where
  site data is blocked. Now goes through safe-storage.
- QrPayFormView read useWallet() right next to useQrPayFlow(), which
  already holds the wallet; the flow now surfaces balance so views read
  one object. Also dropped InvitesGraph/index.tsx's unused GraphMode
  re-export (types live in types.ts per the export rules).
@jjramirezn

Copy link
Copy Markdown
Contributor

/chip review

@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

Two prior major findings remain: required provenance CI cannot enumerate this 476-commit PR, and the intent-kind exhaustiveness type is still based on a frontend-only enum absent from the pinned backend schema. The QR-pay changes since the previously reviewed head are otherwise clean, and all exact-head code checks pass.

Findings

  • MAJOR · .github/workflows/bot-approval.yml:122 · Reduce the PR below the provenance gate's commit ceiling
    This head is still 476 commits ahead of the exact base, while GitHub's pull-request commits API enumerates at most 250. Both bot-approval and human-authors fail closed at this head, and ci-success consequently fails, so an approval or rerun cannot make required CI green. Merge the dev carrier into tech-debt first, or split/rebase the stack until this PR exposes at most 250 commits.

  • MAJOR · src/components/TransactionDetails/strategies/registry.ts:30 · Define the intent-kind enum in the backend schema
    IntentKind is still derived from a vendored union on /history/{entryId}, while the pinned peanut-api-ts route and OpenAPI schema declare kind as a plain string. On the next automated sync from staging, this type widens to string, so the finite Record<IntentKind, ...> no longer forces a mapping for a newly added backend kind and that row falls through at runtime. Declare the accepted wire-kind literals, including synthetic PERK_REWARD, in the backend route schema, regenerate its OpenAPI, then sync the frontend snapshot.

  • MINOR · src/utils/residence-availability.ts:41 · [claude-opus] Residence card omits SPEI for non-Mexican Bridge residences, against the Bridge cohort rule
    BRIDGE_RAILS is ['eurSepa', 'gbpFps', 'usdAch'] and the comment above it justifies dropping SPEI_MX from REGION_RAIL_MAP on the grounds that "Bridge mints MXN accounts for Mexican residents". product/countries.md contradicts that directly, in the section headed "THE BRIDGE COHORT RULE (verified in code + production, 2026-09-02)": "One Bridge verification opens ALL FOUR Bridge rails — SEPA (EUR), ACH/wire (USD), Faster Payments (GBP) and SPEI (MXN) — to the verified user, whatever their residence", because "Bridge entitlement is per-ENDORSEMENT, not per-country ... with no residence branch". product/providers/fiat/eligibility.md line 392 backs it with production evidence — Nigerian-resident customers holding approved spei endorsements. The pinned peanut-api-ts agrees: REGION_RAIL_MAP.EU and .NA in src/kyc/rails.consts.ts both include SPEI_MX, so the mirror is faithful for the other three rails and diverges only here.

The code is the side that's wrong, but the harm is small: this is an advisory "Available with {country}" list at signup, so it under-lists rather than over-promises, and MXN is a near-dead rail by volume. The fix is to add 'spei' to BRIDGE_RAILS (the rail !== home filter in bankRailsFor already stops Mexico listing it twice) and update the three pinned expectations in src/utils/tests/residence-availability.test.ts. If the residence scoping is in fact real and countries.md is the stale side, that file should be corrected instead — it is the source of truth support and marketing copy are generated from.

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

Checked clean

  • Confirmed the detached worktree is at the supplied head and the supplied base is the merge base.
  • Read the exact-head CI failures: both provenance jobs see 476 commits but can enumerate only 250; unit, typecheck, ESLint, format, native export, and design-system checks pass.
  • Compared the frontend intent registry and generated snapshot with the pinned peanut-api-ts route and OpenAPI schema.
  • Reviewed the QR-pay delta since the previously reviewed head: API-served Manteca deposit-recipient validation, restart cooldown exit, full-balance withdrawal pinning, loading layout, and cross-remount perk analytics cleanup.
  • Reviewed workflow permission and credential-persistence changes; secret-bearing jobs remain isolated from PR-authored execution.

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: 1 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: de3903698714 · Context: repo, sibling · Took 18m

…c-ui-separation

refactor: TASK-21854 logic/UI separation — extract inline-state pages and monoliths onto features/

This branch was successfully deployed

1 active deployment
Preview — 5c9a2acc Deployed Sep 9, 2026 by vercel[bot]
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.

4 participants