Skip to content

feat(spend): make the one-tap mixed spend reachable — PostHog flag gate + native build gate - #2959

Open
innolope-dev wants to merge 2 commits into
devfrom
feat/session-key-spend-build-gate
Open

feat(spend): make the one-tap mixed spend reachable — PostHog flag gate + native build gate#2959
innolope-dev wants to merge 2 commits into
devfrom
feat/session-key-spend-build-gate

Conversation

@innolope-dev

Copy link
Copy Markdown
Collaborator

Why

The flow Send → amount → button → passkey → loading → second passkey is the mixed spend (smart balance topped up from card collateral): Rain admin EIP-712 sheet, security-verification overlay, UserOp sheet. Ceremony telemetry (#2870) confirms every mixed link_create is exactly those two sheets, and that a cancelled second sheet plus retry reads as four.

The one-tap path built for exactly this spot (ephemeralSpendKey.ts / mixedEphemeralSpend.ts) has been dark since it landed:

  • NEXT_PUBLIC_SESSION_KEY_SPEND is baked into no workflow and no GitHub variable, so the bundler drops the whole path.
  • Its only runtime switch is /dev/session-key-spend, which production builds 404 (shouldBlockDevRoute).
  • PostHog has never received a session_key_spend_attempted event.

TASK-21612 was closed on the telemetry PR; this is the PR that actually reaches for one tap.

What

  • Build gate on for native lanes. NEXT_PUBLIC_SESSION_KEY_SPEND=true in ios-release.yml, android-release.yml, capgo-deploy.yml. Web builds unchanged (still dead code) until the Vercel env is set.
  • Runtime gate = PostHog flag session_key_spend OR device opt-in. Rollout becomes internal cohort → % in the PostHog UI, no release. Prod fails closed when PostHog is unavailable (isFeatureFlagEnabled default).
  • /dev/session-key-spend shows flag / device / effective state.
  • Unit test for the gate matrix (build off wins; flag; device; unreadable storage).

No change to the spend path itself. The fallback invariant stands: any ephemeral-path failure retries the passkey path with the same Rain prep, so double-spend is structurally impossible.

Rollout (after merge + OTA)

  1. Create PostHog flag session_key_spend, targeting internal users only, 0% otherwise.
  2. One real mixed spend on a flagged device. Expect one sheet, session_key_spend_attempted with no session_key_spend_fallback.
  3. If a fallback fires with reason "ephemeral userOp reverted on-chain", the ERC-1271 ordering does not hold on production contracts; the user still completes via two taps. Stop there and investigate.
  4. Widen the flag by %; watch webauthn_ceremony_flow (link_create, strategy=mixed) for ceremony_count dropping to 1.

Verification

  • pnpm typecheck clean.
  • session-key-spend.consts, ephemeralSpendKey, useGrantSessionKey, useSignSpendBundle suites: 5 suites, 21 tests green.
  • prettier --check clean on touched files.

…te + native build gate

The ephemeral session-key path that collapses the mixed spend's two passkey
sheets into one has been dark since it landed: NEXT_PUBLIC_SESSION_KEY_SPEND
was baked into no build, and its only runtime switch was a /dev page that
production builds 404. Nobody could turn it on.

- Bake NEXT_PUBLIC_SESSION_KEY_SPEND=true into the iOS, Android and Capgo OTA
  lanes so the path ships in native bundles (still dead code on the web).
- Runtime gate is now the PostHog flag session_key_spend OR the per-device
  opt-in, so rollout goes internal cohort -> % without another release.
- /dev/session-key-spend shows flag, device and effective state.
- Unit test for the gate matrix.
@vercel

vercel Bot commented Sep 3, 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 3, 2026 6:06pm UTC

Request Review

@coderabbitai

coderabbitai Bot commented Sep 3, 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: 33338fad-d3ad-488f-b23c-2dc375c8986e

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 3, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 7062.75 → 7063.2 (+0.45)
Findings: 0 net (+2 new, -2 resolved)

🆕 New findings (2)

  • low high-mdd — src/app/(mobile-ui)/dev/session-key-spend/page.tsx:26 — SessionKeySpendPage: MDD 16.7 (uses across many lines from declarations)
  • low missing-return-type — src/app/(mobile-ui)/dev/session-key-spend/page.tsx:26 — SessionKeySpendPage: exported fn missing return type annotation

✅ Resolved (2)

  • src/app/(mobile-ui)/dev/session-key-spend/page.tsx:21 — SessionKeySpendPage: MDD 26.3 (uses across many lines from declarations)
  • src/app/(mobile-ui)/dev/session-key-spend/page.tsx:21 — SessionKeySpendPage: exported fn missing return type annotation

📈 Painscore deltas (top movers)

File Before After Δ
src/constants/session-key-spend.consts.ts 2.7 3.4 +0.7

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 5614 ran, 0 failed, 0 skipped, 1.8m

📊 Coverage (unit)

metric %
statements 74.0%
branches 59.4%
functions 67.6%
lines 75.0%
⏱ 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 › 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_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 › 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 › 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 › 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 › MANTECA_MERCHANT_VOLUME_NEAR_CAP fails fast with copy that names the real cause
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
3.3s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
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
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@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 rollout gate and activated spend path are sound, but the new diagnostic page can show stale PostHog flag state.

Findings

  • MINOR · src/app/(mobile-ui)/dev/session-key-spend/page.tsx:30 · Subscribe the diagnostics page to feature-flag loading
    On a direct page load before PostHog finishes loading flags, this non-reactive read returns false and nothing here subscribes the component to the later flag update. A targeted tester can therefore keep seeing both the flag and effective state as off even though the spend handler will use the enabled flag. Use useFeatureFlags() (or another PostHog flag-load subscription) so this rollout-verification page re-renders when flags arrive.

  • MAJOR · src/constants/session-key-spend.consts.ts:42 · [claude-opus] One-tap mixed spend becomes reachable in production with no test on the branch
    Before this PR, NEXT_PUBLIC_SESSION_KEY_SPEND was set in no build, so SESSION_KEY_SPEND_BUILD_ENABLED was false everywhere and the ephemeral-key spend was dropped by the bundler — literally unreachable. This PR bakes NEXT_PUBLIC_SESSION_KEY_SPEND=true into all three native lanes (ios-release.yml:193, android-release.yml:139, capgo-deploy.yml:131 — and that OTA lane's own header comment says "Production OTA reaches every shipped iOS and Android install"), and adds || isFeatureFlagEnabled(SESSION_KEY_SPEND_FLAG) so a server-side PostHog flip alone now routes real spends down tryMixedEphemeralSpend.

The gate itself is well covered by the new session-key-spend.consts.test.ts. The money path it unlocks is not covered at all. Searching src and e2e, no test references tryMixedEphemeralSpend, mixedEphemeralSpend or useSpendBundle; src/utils/tests/ephemeralSpendKey.test.ts only exercises derivePermissions (6 cases, all pure scoping) and never the orchestration; and mono/engineering/qa (Nutcracker) has no session-key/one-tap scenario either — its only "ephemeral" hits are an unrelated DB TODO and a harness-label line.

The two specific untested cases, both of which move money:

  1. Success: useSpendBundle.ts:301-321 — when attempt.ok, the flow stamps the Rain withdrawal with the resolved tx hash and returns intentId: prep.preparationId, skipping the withCeremonyPurpose('admin_eip712') passkey signature entirely. Nothing asserts that the stamp fires with the ephemeral userOp's hash, or that CARD_WITHDRAW_SUCCEEDED carries engine: 'session-key'.
  2. Fallback: useSpendBundle.ts:323-331 — when attempt.ok === false (including the post-broadcast ambiguous case handled at mixedEphemeralSpend.ts:135-149), the passkey path must run against the SAME prep. The whole no-double-spend argument in mixedEphemeralSpend.ts:18-23 rests on the adminNonce being shared, i.e. on prepareWithdrawal not being called a second time. No test pins that: a future refactor that re-preps on fallback would silently produce two independently-executable withdrawals, and CI would stay green.

Fix: add a Jest test around the mixed branch of useSpendBundle with sessionKeySpendEnabled() mocked true and tryMixedEphemeralSpend mocked — one case asserting the ok path stamps once and never calls signTypedData, one asserting the not-ok path falls through to the passkey signature with rainApi.prepareWithdrawal having been called exactly once. That is cheap and does not need a chain. A Nutcracker scenario for the on-chain ERC-1271 ordering can follow before the flag is widened past internal users, as the consts comment already plans.

Checked clean

  • Verified the detached worktree head, trusted author, dev base ref, and exact base/head SHAs.
  • Reviewed all six changed files plus the activated mixed-spend, ephemeral-key permission, feature-flag, and production dev-route guards.
  • Checked build-time env injection across Android, iOS, and Capgo native release lanes.
  • Verified fail-closed PostHog behavior and the build-off gate matrix; exact-head unit, typecheck, lint, format, native-export, and analysis checks are green.
  • Local focused Jest execution was unavailable because this detached worktree has no installed Jest binary; the exact-head unit check completed successfully in CI.
  • Confirmed the diff is whitespace-clean and left the detached worktree unchanged.

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: 6da1adb68250 · Context: repo, engineering · Took 6m

const [, setTick] = useState(0)
const runtimeOn = sessionKeySpendEnabled()
const deviceOn = sessionKeySpendDeviceOptIn()
const flagOn = isFeatureFlagEnabled(SESSION_KEY_SPEND_FLAG)

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.

MINOR: Subscribe the diagnostics page to feature-flag loading

On a direct page load before PostHog finishes loading flags, this non-reactive read returns false and nothing here subscribes the component to the later flag update. A targeted tester can therefore keep seeing both the flag and effective state as off even though the spend handler will use the enabled flag. Use useFeatureFlags() (or another PostHog flag-load subscription) so this rollout-verification page re-renders when flags arrive.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🖼 Visual diff — 1 screen moved

1 of 62 shots changed · 61 identical · baseline 367da93 → head bfff29b

worst % screen widths
2.83% profile 320

job summary · before/after/diff images — artifact

Fixture screenshots, no backend. Advisory — this check never blocks a merge. Posted from the default branch by ds-shots-comment.yml; the report it renders is untrusted data.

PostHog delivers flags after page load; a one-shot isFeatureFlagEnabled read
showed a targeted tester "off" until a reload. useFeatureFlags re-renders on
flag load, and the effective state is derived from the same reactive read.

@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 diagnostics page now updates when PostHog flags load, but the newly production-reachable mixed-spend branch still lacks a regression test.

Findings

  • MAJOR · src/constants/session-key-spend.consts.ts:42 · Cover the production-enabled mixed-spend branch
    With the native build gate on and the PostHog flag true, this now sends mixed spends through the previously dark ephemeral signer and fallback branch. The added tests only exercise this boolean gate; none enters useSpendBundle with a mixed spend. A regression that treats a reverted ephemeral UserOp as success or prepares a second withdrawal during fallback could therefore pass unit CI while producing a false success or a second money-moving attempt. Add branch-level tests for ephemeral success and reverted/post-broadcast fallback, asserting that fallback reuses the same preparation and that the returned/stamped transaction is the settled one.

  • MAJOR · src/constants/session-key-spend.consts.ts:42 · [claude-opus] One-tap mixed spend goes live with the spend path itself untested
    This PR is what makes the ephemeral-session-key mixed spend reachable in production: the three native release lanes now bake NEXT_PUBLIC_SESSION_KEY_SPEND=true (.github/workflows/android-release.yml:139, ios-release.yml:193, capgo-deploy.yml:131) and sessionKeySpendEnabled() now turns on from a PostHog flag that can be flipped without a release (src/constants/session-key-spend.consts.ts:42). Until this PR the build gate was off in every shipped build, so the path was dead code and the gap was academic.

The new test file covers only the gate constant (build-off wins, flag-on, device-opt-in, localStorage throw). The code the gate now switches money through has no test at all: tryMixedEphemeralSpend (src/hooks/wallet/mixedEphemeralSpend.ts) has no test file, and the session-key branch in src/hooks/wallet/useSpendBundle.ts:282-326 is never exercised — every suite that touches useSpendBundle (useSendMoney.test.tsx:39, useWalletSpendable.test.tsx:66, qr-pay-states.test.tsx:128) mocks the whole hook. src/utils/tests/ephemeralSpendKey.test.ts covers permission scoping only, not the spend.

The exact untested cases, both of which move real USDC out of Rain collateral:

  1. tryMixedEphemeralSpend returns {ok:false} for a mined-but-reverted UserOp (mixedEphemeralSpend.ts:135-137) and for a rescued-but-reverted one (:146-148), and useSpendBundle then completes the spend exactly once via the passkey fallback reusing the SAME prep. Nothing asserts that a reverted ephemeral attempt reports failure rather than success — a wrong branch here stamps the withdrawal with a userOp hash for a spend that never happened.
  2. tryMixedEphemeralSpend returns {ok:true} and useSpendBundle returns immediately without falling through to the admin-EIP-712 passkey path (useSpendBundle.ts:301-321). Nothing asserts the fallback does not also run after a successful on-chain spend; the adminNonce argument in the file comment is the only thing standing between that and a double withdrawal, and it is asserted nowhere.

Fix: add a unit test for tryMixedEphemeralSpend with a stubbed session client covering success, success:false receipt, and throw-then-rescue-reverted; plus a useSpendBundle mixed-strategy test with sessionKeySpendEnabled mocked true asserting exactly one of the two paths signs and that a false return still settles the spend once.

Checked clean

  • Confirmed the detached worktree HEAD, trusted author, dev base ref, and exact base/head SHAs match the supplied review target.
  • Reviewed the feature-gate matrix, native workflow build flags, diagnostics-page reactivity, mixed-spend invocation, ephemeral permission scope, receipt rescue, and passkey fallback path.
  • Exact-head CI reports unit, typecheck, lint, format, analyze, native-export, provenance, and aggregate ci-success checks successful; visual screenshots and preview deployment were still in progress at the final check.
  • Local focused tests could not run because this detached read-only worktree has no node_modules; exact-head unit CI succeeded.

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: bfff29b7030a · Context: repo · Took 5m


export function sessionKeySpendEnabled(): boolean {
if (!SESSION_KEY_SPEND_BUILD_ENABLED) return false
return sessionKeySpendDeviceOptIn() || isFeatureFlagEnabled(SESSION_KEY_SPEND_FLAG)

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.

MAJOR: Cover the production-enabled mixed-spend branch

With the native build gate on and the PostHog flag true, this now sends mixed spends through the previously dark ephemeral signer and fallback branch. The added tests only exercise this boolean gate; none enters useSpendBundle with a mixed spend. A regression that treats a reverted ephemeral UserOp as success or prepares a second withdrawal during fallback could therefore pass unit CI while producing a false success or a second money-moving attempt. Add branch-level tests for ephemeral success and reverted/post-broadcast fallback, asserting that fallback reuses the same preparation and that the returned/stamped transaction is the settled one.

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