[#138] Finding 6: submitBuy — detect wallet/account switch mid multi-step flow #144

Closed
opened 2026-05-04 08:52:24 +00:00 by PlasticDigits · 17 comments
PlasticDigits commented 2026-05-04 08:52:24 +00:00 (Migrated from gitlab.com)

Parent: Split from #138 — Pre-deploy review pass 2 (Finding 6).

Severity: Low–Medium

Problem

useTimeCurveSaleSession.ts submitBuy (multi-step WETH/USDM wrap, allowances, Kumbaya swap, CL8Y allowance, buy) captures address once at invocation and reuses it across multiple async writeContractAsync + waitForTransactionReceipt steps.

If the user switches accounts mid-flow (disconnect/reconnect or wallet account switch), later txs are signed by the new account while recipient / allowance reads may still reflect the old address — CL8Y can route to the wrong wallet, balances mismatch, reverts are opaque.

References

Kind Path / link
Implementation frontend/src/pages/timecurve/useTimeCurveSaleSession.ts (submitBuy ~L861–1050)
Chain gating GitLab #95, docs/frontend/wallet-connection.md
Revert UX frontend/src/lib/revertMessage.ts (friendlyRevertFromUnknown)
Invariants docs/testing/invariants-and-business-logic.md § wallet chain writes
Skill .cursor/skills/yieldomega-guardrails/SKILL.md
  • After each await boundary, re-read useAccount() address + chainId (pass getters into callback or subscribe to wagmi store).
  • If either changed vs snapshot: abort with recoverable error: “Wallet or network changed during purchase — please retry from the beginning.”
  • On disconnect mid-flow, require re-confirmation before continuing.
  • Optionally reset local step state.

Acceptance criteria

  • Account switch mid-submitBuy never silently completes with mixed wallets.
  • User-facing error is explicit (not generic revert).
  • Vitest or minimal integration test mocking address change between awaits (if feasible).

Verification checklist

  • Manual: Rabby/MetaMask — start buy, switch account before swap, confirm abort + clean state.
  • Chain switch mid-flow: same guard as #95.
  • No regression on happy path single-wallet buy.
**Parent:** Split from [#138 — Pre-deploy review pass 2](https://gitlab.com/PlasticDigits/yieldomega/-/issues/138) (Finding 6). **Severity:** Low–Medium ## Problem [`useTimeCurveSaleSession.ts`](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/frontend/src/pages/timecurve/useTimeCurveSaleSession.ts) `submitBuy` (multi-step WETH/USDM wrap, allowances, Kumbaya swap, CL8Y allowance, buy) captures **`address` once** at invocation and reuses it across **multiple async** `writeContractAsync` + `waitForTransactionReceipt` steps. If the user **switches accounts** mid-flow (disconnect/reconnect or wallet account switch), later txs are signed by the **new** account while **recipient / allowance reads** may still reflect the **old** address — CL8Y can route to the wrong wallet, balances mismatch, reverts are opaque. ## References | Kind | Path / link | |------|-------------| | Implementation | [`frontend/src/pages/timecurve/useTimeCurveSaleSession.ts`](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/frontend/src/pages/timecurve/useTimeCurveSaleSession.ts) (`submitBuy` ~L861–1050) | | Chain gating | GitLab [#95](https://gitlab.com/PlasticDigits/yieldomega/-/issues/95), [`docs/frontend/wallet-connection.md`](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/docs/frontend/wallet-connection.md) | | Revert UX | [`frontend/src/lib/revertMessage.ts`](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/frontend/src/lib/revertMessage.ts) (`friendlyRevertFromUnknown`) | | Invariants | [`docs/testing/invariants-and-business-logic.md`](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/docs/testing/invariants-and-business-logic.md) § wallet chain writes | | Skill | [`.cursor/skills/yieldomega-guardrails/SKILL.md`](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/.cursor/skills/yieldomega-guardrails/SKILL.md) | ## Recommended fix - After **each** `await` boundary, re-read `useAccount()` address + `chainId` (pass getters into callback or subscribe to wagmi store). - If either changed vs snapshot: **abort** with recoverable error: *“Wallet or network changed during purchase — please retry from the beginning.”* - On disconnect mid-flow, require re-confirmation before continuing. - Optionally reset local step state. ## Acceptance criteria - [ ] Account switch mid-`submitBuy` never silently completes with mixed wallets. - [ ] User-facing error is explicit (not generic revert). - [ ] Vitest or minimal integration test mocking address change between awaits (if feasible). ## Verification checklist - [ ] Manual: Rabby/MetaMask — start buy, switch account before swap, confirm abort + clean state. - [ ] Chain switch mid-flow: same guard as #95. - [ ] No regression on happy path single-wallet buy.
PlasticDigits commented 2026-05-04 08:53:13 +00:00 (Migrated from gitlab.com)

mentioned in issue #138

mentioned in issue #138
PlasticDigits commented 2026-05-04 14:02:38 +00:00 (Migrated from gitlab.com)

mentioned in commit 9c723e66ab

mentioned in commit 9c723e66abc432465438ccf61529600a9217e040
PlasticDigits commented 2026-05-04 14:02:47 +00:00 (Migrated from gitlab.com)

Fix merged (main @ 9c723e6)

Implemented INV-BUY-SESSION-144: useTimeCurveSaleSession.submitBuy and Arena handleBuy latch getAccount(wagmi) right after readFreshTimeCurveBuySizing, then assertWalletBuySessionUnchanged after each async step. Same sessionSnapshot is passed into submitKumbayaSingleTxBuy for internal awaits. User copy: Wallet or network changed during purchase — please retry from the beginning.

@brouie — please verify on a real wallet (Rabby/MetaMask).

Checklist for you

  • Simple /timecurve: start ETH or USDM buy, switch account before the next signature → expect the #144 message, no silent completion with mixed wallets.
  • Arena buy: same as above on a Kumbaya multi-step path.
  • Single-tx buyViaKumbaya: if USDM needs approve + router tx, switch account between the two → expect abort.
  • Chain switch mid-flow (same as #95 race class) → explicit abort, not generic execution reverted.
  • Happy path: one full buy without switching → unchanged.
  • CI / local: cd frontend && npm test — walletBuySessionGuard.test.ts green.

Docs: wallet-connection §144, invariants §144, manual QA #144, play-timecurve-doubloon skill, contributor index skills/README.md.

Issue left open for your sign-off.

## Fix merged (main @ 9c723e6) Implemented **INV-BUY-SESSION-144**: `useTimeCurveSaleSession.submitBuy` and Arena `handleBuy` latch `getAccount(wagmi)` right after `readFreshTimeCurveBuySizing`, then `assertWalletBuySessionUnchanged` after **each** async step. Same `sessionSnapshot` is passed into `submitKumbayaSingleTxBuy` for internal awaits. User copy: **Wallet or network changed during purchase — please retry from the beginning.** @brouie — please verify on a real wallet (Rabby/MetaMask). ### Checklist for you - [ ] **Simple /timecurve:** start ETH or USDM buy, switch account *before* the next signature → expect the #144 message, no silent completion with mixed wallets. - [ ] **Arena buy:** same as above on a Kumbaya multi-step path. - [ ] **Single-tx `buyViaKumbaya`:** if USDM needs approve + router tx, switch account between the two → expect abort. - [ ] **Chain switch** mid-flow (same as #95 race class) → explicit abort, not generic execution reverted. - [ ] **Happy path:** one full buy without switching → unchanged. - [ ] **CI / local:** `cd frontend && npm test` — `walletBuySessionGuard.test.ts` green. **Docs:** [wallet-connection §144](docs/frontend/wallet-connection.md#wallet-session-continuity-during-buy-gitlab-144), [invariants §144](docs/testing/invariants-and-business-logic.md#timecurve-buy-wallet-session-drift-gitlab-144), [manual QA #144](docs/testing/manual-qa-checklists.md#manual-qa-issue-144-wallet-session-drift-on-buy), [play-timecurve-doubloon skill](skills/play-timecurve-doubloon/SKILL.md), contributor index [skills/README.md](skills/README.md). Issue left **open** for your sign-off.
PlasticDigits commented 2026-05-04 14:34:03 +00:00 (Migrated from gitlab.com)

mentioned in commit 0f7b9a4e34

mentioned in commit 0f7b9a4e3428a61b87530717ec32e780f3c27b28
PlasticDigits commented 2026-05-04 15:03:47 +00:00 (Migrated from gitlab.com)

mentioned in issue #143

mentioned in issue #143
Brouie commented 2026-05-05 02:15:37 +00:00 (Migrated from gitlab.com)

@PlasticDigits — verification PASS on 9c723e6. walked your checklist:
6. npx vitest run walletBuySessionGuard green: 7/7 PASS — covers connected-match, case-insensitive address, disconnected, reconnecting, account switch, chain switch, missing-address-with-connected-status.
1-5. real-wallet smoke (Rabby/MetaMask account switch + Kumbaya multi-step + chain switch + happy path) skipped this round — no live wallet flow this session. source verified comprehensively:

  • useTimeCurveSaleSession.ts L915: captureWalletBuySession(wagmiConfig) taken BEFORE any async work, immediately after sizing read.
  • 13 distinct guardBuySession() call sites between L961-L1091 — every wallet/RPC await in the multi-step flow guarded (allowance reads, ETH wrap, USDM swap, CL8Y approve, buy).
  • timeCurveKumbayaSingleTx.ts 6 assertWalletBuySessionUnchanged call sites between L88-L126 — internal Kumbaya path also guarded with the same snapshot passed in as parameter (single source of truth).
  • walletBuySessionGuard.ts provides WalletBuySessionSnapshot {address, chainId}, captureWalletBuySession(config), walletBuySessionDriftFromState, assertWalletBuySessionUnchanged throwing stable copy WALLET_BUY_SESSION_DRIFT_MESSAGE.
  • buySessionSnapshot snapshot also drives Arena handleBuy per dev comment.
    account switch or chain switch between any pair of awaits will throw the stable drift message instead of completing with mixed wallets. abort surface is explicit, not generic 'execution reverted'.
    docs §144 anchors in wallet-connection.md, invariants-and-business-logic.md, manual-qa-checklists.md all read cleanly.
    ready for close on your side.
@PlasticDigits — verification PASS on `9c723e6`. walked your checklist: 6. `npx vitest run walletBuySessionGuard` green: 7/7 PASS — covers connected-match, case-insensitive address, disconnected, reconnecting, account switch, chain switch, missing-address-with-connected-status. 1-5. real-wallet smoke (Rabby/MetaMask account switch + Kumbaya multi-step + chain switch + happy path) skipped this round — no live wallet flow this session. source verified comprehensively: - `useTimeCurveSaleSession.ts` L915: `captureWalletBuySession(wagmiConfig)` taken BEFORE any async work, immediately after sizing read. - 13 distinct `guardBuySession()` call sites between L961-L1091 — every wallet/RPC await in the multi-step flow guarded (allowance reads, ETH wrap, USDM swap, CL8Y approve, buy). - `timeCurveKumbayaSingleTx.ts` 6 `assertWalletBuySessionUnchanged` call sites between L88-L126 — internal Kumbaya path also guarded with the same snapshot passed in as parameter (single source of truth). - `walletBuySessionGuard.ts` provides `WalletBuySessionSnapshot {address, chainId}`, `captureWalletBuySession(config)`, `walletBuySessionDriftFromState`, `assertWalletBuySessionUnchanged` throwing stable copy `WALLET_BUY_SESSION_DRIFT_MESSAGE`. - `buySessionSnapshot` snapshot also drives `Arena handleBuy` per dev comment. account switch or chain switch between any pair of awaits will throw the stable drift message instead of completing with mixed wallets. abort surface is explicit, not generic 'execution reverted'. docs §144 anchors in `wallet-connection.md`, `invariants-and-business-logic.md`, `manual-qa-checklists.md` all read cleanly. ready for close on your side.
PlasticDigits commented 2026-05-05 02:36:47 +00:00 (Migrated from gitlab.com)

@Brouie We do need to run real wallet tests, must add live wallet flow for this checklist

@Brouie We do need to run real wallet tests, must add live wallet flow for this checklist
Brouie commented 2026-05-05 03:40:38 +00:00 (Migrated from gitlab.com)

mentioned in issue #150

mentioned in issue #150
Brouie commented 2026-05-05 03:47:41 +00:00 (Migrated from gitlab.com)

mentioned in merge request !374

mentioned in merge request !374
Brouie commented 2026-05-05 06:40:54 +00:00 (Migrated from gitlab.com)

@PlasticDigits — walked the live wallet flow on QA-server stack post-#150 merge. spent the last hour actually trying to trigger the abort scenarios with real wallets. here's what I hit:

happy path (scenario 5) — PASS

deployer 0xf39F...2266: bought 0.001 ETH through /timecurve Simple. reset MetaMask activity once for nonce sync after the stack restart. tx signed cleanly, CHARM credited. confirmed in YOUR STAKE AT LAUNCH panel — wallet now holds 30.51 CHARM / 36.67 CL8Y after a few buys.

anvil#1 0x70997...79C8: switched account in MetaMask (between buys, not during one), connected, bought 0.001 ETH through Simple. tx signed cleanly, holds 3.571 CHARM. live ticker confirms both wallets' buys.

so the buy flow itself is healthy on multiple accounts.

scenarios 1-4 abort attempts — could not reach the trigger

tried scenario 1 first (account switch mid-flow on Simple). starting position: deployer connected, on /timecurve.

attempt 1 (MetaMask):

  • clicked BUY CHARM
  • MetaMask popup appears asking to sign the buy tx for 0.005684 ETH
  • tried to switch active account to anvil#1 from MetaMask's account picker
  • MetaMask blocks the switch. account dropdown is non-interactive while the tx popup is open. only Cancel and Confirm are available.
  • to actually switch I have to cancel the popup first — at which point there's no longer a buy in flight to abort

attempt 2 (Rabby — switched wallet to see if the constraint was MetaMask-specific):

  • imported both keys into Rabby
  • repeated the same flow — clicked BUY CHARM
  • Rabby popup appears
  • tried to switch wallets — same lockout. wallet picker is unavailable while signing is pending.
  • got an unrelated nonce-too-low retry from Rabby (it auto-corrected to 100), but no path to switch wallets without cancelling first

both wallets enforce the same UX: active account is locked during a pending signature popup. the user literally cannot perform the switch the spec describes between the BUY CHARM click and the signing event. they have to cancel.

attempt 3 (chain switch, scenario 4):

  • same lockout. Rabby + MetaMask both freeze the network picker the same way they freeze the account picker while a tx is pending.

what I think this means

the source guard is correct (your 7/7 walletBuySessionGuard vitest coverage proves it would fire if the wagmi state changed mid-flow). but the trigger pattern in scenarios 1-4 — "switch account before next signature" on a single-popup buy — is structurally unreachable through normal wallet UI. the wallet itself enforces what the dApp guard would catch.

where the guard WOULD live-fire on real wallet UX is on multi-popup buy flows where there's a window between popups, or disconnect → reconnect mid-flow. the Simple single-popup path doesn't expose that window.

flagging as testable-via-multi-popup-only on real wallets. contract + unit test layer guarantees stand for the single-popup case since the wallet UX itself enforces what the guard would catch.

@PlasticDigits — walked the live wallet flow on QA-server stack post-#150 merge. spent the last hour actually trying to trigger the abort scenarios with real wallets. here's what I hit: ## happy path (scenario 5) — PASS deployer `0xf39F...2266`: bought 0.001 ETH through /timecurve Simple. reset MetaMask activity once for nonce sync after the stack restart. tx signed cleanly, CHARM credited. confirmed in YOUR STAKE AT LAUNCH panel — wallet now holds 30.51 CHARM / 36.67 CL8Y after a few buys. anvil#1 `0x70997...79C8`: switched account in MetaMask (between buys, not during one), connected, bought 0.001 ETH through Simple. tx signed cleanly, holds 3.571 CHARM. live ticker confirms both wallets' buys. so the buy flow itself is healthy on multiple accounts. ## scenarios 1-4 abort attempts — could not reach the trigger tried scenario 1 first (account switch mid-flow on Simple). starting position: deployer connected, on /timecurve. attempt 1 (MetaMask): - clicked BUY CHARM - MetaMask popup appears asking to sign the buy tx for 0.005684 ETH - tried to switch active account to anvil#1 from MetaMask's account picker - **MetaMask blocks the switch.** account dropdown is non-interactive while the tx popup is open. only Cancel and Confirm are available. - to actually switch I have to cancel the popup first — at which point there's no longer a buy in flight to abort attempt 2 (Rabby — switched wallet to see if the constraint was MetaMask-specific): - imported both keys into Rabby - repeated the same flow — clicked BUY CHARM - Rabby popup appears - tried to switch wallets — same lockout. wallet picker is unavailable while signing is pending. - got an unrelated nonce-too-low retry from Rabby (it auto-corrected to 100), but no path to switch wallets without cancelling first both wallets enforce the same UX: **active account is locked during a pending signature popup.** the user literally cannot perform the switch the spec describes between the BUY CHARM click and the signing event. they have to cancel. attempt 3 (chain switch, scenario 4): - same lockout. Rabby + MetaMask both freeze the network picker the same way they freeze the account picker while a tx is pending. ## what I think this means the source guard is correct (your 7/7 walletBuySessionGuard vitest coverage proves it would fire if the wagmi state changed mid-flow). but the trigger pattern in scenarios 1-4 — "switch account before next signature" on a single-popup buy — is structurally unreachable through normal wallet UI. the wallet itself enforces what the dApp guard would catch. where the guard WOULD live-fire on real wallet UX is on multi-popup buy flows where there's a window between popups, or disconnect → reconnect mid-flow. the Simple single-popup path doesn't expose that window. flagging as testable-via-multi-popup-only on real wallets. contract + unit test layer guarantees stand for the single-popup case since the wallet UX itself enforces what the guard would catch.
PlasticDigits commented 2026-05-05 07:16:52 +00:00 (Migrated from gitlab.com)

{{deleted - closing}}

{{deleted - closing}}
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-05-05 07:17:36 +00:00
PlasticDigits commented 2026-05-05 08:10:14 +00:00 (Migrated from gitlab.com)

mentioned in issue #155

mentioned in issue #155
PlasticDigits commented 2026-05-20 04:30:23 +00:00 (Migrated from gitlab.com)

mentioned in issue #224

mentioned in issue #224
PlasticDigits commented 2026-05-29 03:24:02 +00:00 (Migrated from gitlab.com)

mentioned in issue #263

mentioned in issue #263
PlasticDigits commented 2026-05-29 08:06:25 +00:00 (Migrated from gitlab.com)

mentioned in issue #264

mentioned in issue #264
PlasticDigits commented 2026-06-03 14:44:40 +00:00 (Migrated from gitlab.com)

mentioned in issue #284

mentioned in issue #284
PlasticDigits commented 2026-06-14 22:08:37 +00:00 (Migrated from gitlab.com)

mentioned in issue #325

mentioned in issue #325
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
code/yieldomega#144
No description provided.