Skip to content

Fund-wallet flow: full funding/success lifecycle + prompt card redesign - #504

Open
dotsolo wants to merge 7 commits into
mainfrom
fundwalletloadfix
Open

Fund-wallet flow: full funding/success lifecycle + prompt card redesign#504
dotsolo wants to merge 7 commits into
mainfrom
fundwalletloadfix

Conversation

@dotsolo

@dotsolo dotsolo commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Partially addresses #476 (the "show an explicit funding state from request through note availability" part — the recovery-prompt separation and security-copy items from that issue are not covered here).

Problem

On iOS, tapping Fund this wallet acked the faucet request within seconds, but the minted note only becomes visible after chain inclusion + a client sync (~30–60s). The prompt card flashed success on the ack and vanished, so the Home screen looked completely idle for the whole gap until the note notification appeared.

What changed

Funding lifecycle (HomePrompts):

  • The faucet card itself is now the tap target (no separate CTA button).
  • Tapping enters a "Funding" hero: animated hourglass tile, indeterminate progress bar along the card's bottom edge, and "Your funds will be available shortly".
  • When the funds land (claimable note or balance), a green "Funds deposited — You have $X ready" success beat plays (~2.4s), then the prompt completes and hands off to the pending-notes card.
  • The pending-notes card holds back while the hero is on stage — it sorts first in the carousel and previously would have pushed the success state off-screen the instant the note arrived.
  • The wait survives remounts and app restarts via a persisted, per-account request marker (faucet_funding_v2:<account>), and falls back to the actionable card 3 minutes after the original request if the mint never lands.
  • The pending-notes card's "See Now" button was folded into a whole-card tap as well.

PromptCard:

  • New optional icon slot (coins icon on the faucet card), filled pill CTA, corner dismiss X that hides while a request is in flight, press-in feedback (active:scale-[0.98]) on tappable cards, and a reusable hero takeover mode (icon tile + wordmark + sub-line lockup).
  • New hourglass icon added to the v2 icon set.

Polish:

  • Home "Assets" heading now uses the heading font (Nunito) — it was falling back to Inter.
  • Onboarding confirmation screen: bold Nunito heading, Nunito body copy, and the daily-reminder note styled as a pill (also fixes a stray font-medium JSX attribute that was never applied).

Rebase note

This PR now targets main directly (it previously stacked on fix/remove-imiden-swap-faucet, so until #498 merges the diff here includes that branch’s commits). Where the two collided (HomePrompts, its tests, locales), this branch's in-card funding lifecycle supersedes the base's WalletFundingDrawer wiring — the drawer and lib/wallet-funding remain in the tree but are no longer opened from the faucet card. The base's native-MIDEN-only faucet() and swap-token ordering are preserved and compose with this flow.

Review fixes (round 2)

Addressed from internal review:

  • Per-account funding wait (critical). The marker is stored per account and the on-screen wait is tagged with the requesting account; display, arrival, and backstop all refuse a wait that isn't the current account's. An account switch clears the presentation but leaves the other account's marker intact, so switching back (or restarting) resumes the right account's wait — and a request that acks or fails after switching away can't paint the new account's card.
  • Baseline-gated arrival. The marker carries the note ids that existed at request time; only a note beyond that baseline (or a balance) counts as the mint landing, so a pre-existing claimable note can't fake an instant success.
  • Request-anchored backstop. The 3-minute fallback is computed from requestedAt, not from the mount, so a resumed wait still ends 3 minutes after the original request.
  • 60s faucet timeout. faucet() rejects if the PoW/mint request hangs, so the loading hero can't hold (and suppress the pending-notes card) with no in-session recovery.

Review fixes (round 3)

  • The 60s faucet timeout now cancels the underlying work. faucet() aborts an AbortController when the timer fires; the signal is threaded through getPowChallenge/requestTokens (into fetch) and checked on every PoW iteration, so a delayed response can't mint a second note behind a retry and the PoW search stops burning CPU after the UI reports failure. Tests assert the signal actually aborts (and that the PoW loop stops), not just that the wrapper rejects.
  • Whole-card actions are keyboard-accessible. The tappable PromptCard is now tabIndex={0} and activates on Enter/Space, with keys on the inner dismiss/CTA buttons ignored so one press can't fire both actions.
  • The 3-minute backstop is anchored to the tap. requestedAt is captured before await faucet(...), so a slow ack no longer stretches the wait toward ~4 minutes.

Testing

  • 101 tests pass across the funding-flow suites (HomePrompts, wallet-prompts, wallet-funding, WalletFundingDrawer, TabLayout), including coverage for: card-tap funding, the Funding→Funded! sequence, arrival via notes and via balance, the persisted-marker resume path, pending-notes suppression during the success beat, tap-to-retry after failure, the account-switch leak (hero stays off the other account, marker preserved, switch-back resumes), pre-existing-note no-instant-success, backstop timing anchored to the request (fake timers), per-account marker roundtrip/shape validation, and the faucet timeout.
  • Verified end-to-end on the iPhone 17 simulator against testnet: onboard → tap card → Funding hero through the ~30s gap → Funds deposited beat → pending-notes hand-off.

@dotsolo
dotsolo requested a review from BrianSeong99 August 6, 2026 03:00
@WiktorStarczewski
WiktorStarczewski self-requested a review August 6, 2026 12:59
@dotsolo dotsolo closed this Aug 6, 2026
@dotsolo
dotsolo deleted the fundwalletloadfix branch August 6, 2026 13:30
@dotsolo
dotsolo restored the fundwalletloadfix branch August 6, 2026 14:26
@dotsolo dotsolo reopened this Aug 6, 2026
@dotsolo
dotsolo force-pushed the fundwalletloadfix branch from ee19549 to 4647f6b Compare August 6, 2026 14:26
@dotsolo
dotsolo changed the base branch from main to fix/remove-imiden-swap-faucet August 6, 2026 14:27
@dotsolo
dotsolo force-pushed the fundwalletloadfix branch from ee19549 to 4647f6b Compare August 6, 2026 14:27
@BrianSeong99

Copy link
Copy Markdown
Collaborator

can you make your claude to update the description to include the original issue to this PR? @dotsolo

@WiktorStarczewski WiktorStarczewski left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Really nice feature — the persisted marker for remount/restart survival is the right call, and a couple of genuinely subtle interactions are handled correctly: suppressing the pending-notes card while the hero is on stage (it sorts first and would otherwise shove the hero off-screen the instant the note lands), and ceding completion authority from the seeding effect to the hero sequence (the !awaitingFaucetFunds && !faucetFundsArrived guard). The comments explain why rather than what, which made this reviewable.

That said, there are three real correctness bugs — one critical — plus a couple of suggestions.

🔴 Critical — the funding marker is global; the "Funding" hero leaks across accounts

FAUCET_FUNDING_REQUESTED_AT_KEY = 'faucet_funding_requested_at_v1' (src/lib/wallet-prompts.ts) has no per-account scoping, and awaitingFaucetFunds / faucetFundsArrived live in HomePrompts state that is not reset on an account switch. ExploreHomePrompts gets account from useAccount() (Zustand currentAccount) and re-renders in place — there's no key={account.publicKey}, so switching accounts does not remount.

Repro (both accounts unfunded):

  1. On account A, tap Fund → awaitingFaucetFunds = true, global marker stamped, Funding hero shows.
  2. Switch to account B → Explore/HomePrompts re-render in place with B's balances/notes; nothing resets the flags → B now shows the "Funding" hero for A's request.
  3. B is genuinely unfunded, so the arrival effect never fires; the spurious hero holds until B's backstop — and that backstop calls setFaucetFundingRequestedAt(null), erasing account A's still-in-flight marker, so an app restart during A's real wait no longer resumes.

The app-restart variant is the same: a fresh mount on B reads the global marker and resumes A's hero on B.

Fix: suffix the storage key with account.publicKey (e.g. faucet_funding_requested_at_v1:<pk>), read/write it per-account, and reset awaitingFaucetFunds / faucetFundsArrived in an effect keyed on account.publicKey.

🟠 "Funded!" fires on any existing or unrelated note/balance — an instant false success

The arrival effect gates on hasPendingNotes || hasBalance, where hasPendingNotes is the raw count of all current claimable notes, not a delta from the request baseline. The faucet card is seeded purely on !hasBalance, and incoming Miden notes sit unconsumed (hasBalance === false) — so a user with a pre-existing claimable note and zero balance sees the faucet card.

Repro: such a user taps Fund → fundWallet resolves and sets awaitingFaucetFunds = true; the arrival effect sees hasPendingNotes === true on the very next commit and fires immediately → the Funding hero flashes for ~0 ms and the card jumps straight to "Funds deposited — you have $X ready" and completes the faucet prompt, while the actual faucet mint is still 30–60 s away. It also mis-attributes the pre-existing note's USD amount to the faucet. The milder variant (an unrelated deposit arriving mid-wait) has the same root cause.

Fix: snapshot a baseline at request time (a note-id set, or note count / balance) and only trip arrival when it increases beyond that baseline.

🟠 The 3-minute backstop is anchored to mount, not to requestedAt — resumed waits can run ~6 min

The backstop effect schedules FAUCET_FUNDS_ARRIVAL_TIMEOUT_MS (3 min) from when awaitingFaucetFunds flips true. On resume, the resume effect sets awaitingFaucetFunds = true for any marker younger than 3 min — so a marker that's 2:59 old starts a fresh 3-min backstop, i.e. ~5:59 of "Funding" total if the mint silently failed. It's bounded, but the value is wrong.

Fix: schedule the backstop for max(0, requestedAt + FAUCET_FUNDS_ARRIVAL_TIMEOUT_MS − Date.now()), reading requestedAt from the marker.

🟡 Suggestions

  • faucet() has no timeout, and the initial loading hero has no backstop. faucetHeroActive includes faucetStatusIndicator === 'loading', but the backstop only covers awaitingFaucetFunds, and faucet() is a bare fetch with no AbortController. If the request hangs, fundingRef stays true, the loading hero holds, and the pending-notes card stays suppressed with no in-session recovery (no marker was stamped yet, so it only self-heals on restart). Consider an AbortController timeout on the faucet call.
  • Test gaps let the three bugs above ship: no account-switch test (critical #1); the tap test starts with claimableNotes={[]} and only adds notes on rerender, so the pre-existing-note instant-false-success (#2) is never exercised; and there's no backstop-timing assertion (#3). The beat tests also use real timers with waitFor(…, { timeout: 3500 }) — slow and flaky; prefer fake timers.
  • Minor / not reachable today: the resume effect returns without clearing a stale marker if faucetIsTerminal is already true (cosmetic, overwritten next funding); the non-dismiss/non-hero branch always renders a ChevronRight even when onClick is undefined (only matters if a non-dismissible prompt is ever added — all current ones are dismissible).

✅ Verified fine

handleDismiss/handleAction both stopPropagation(), so nested-button taps don't bubble to the card's onClick; the PromptCard hero/icon/dismiss/status branches all render correctly (an in-flight card correctly can't be dismissed); no timer leaks (beat + backstop each clearTimeout in cleanup; the removed successTimerRef is fully replaced); the duplicate completePrompt(Faucet) path is idempotent; i18n is fully wired across all locales including the $amount$ placeholder, and both hero icons resolve; and Confirmation.tsx correctly removes an invalid bare font-medium JSX attribute (it had been emitting font-medium="true" on the DOM).

Happy to re-review once the account-scoping (#1) and the arrival-baseline (#2) are addressed.

@dotsolo

dotsolo commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

@WiktorStarczewski Just updated the PR and it should include the requested fixes and @BrianSeong99 it should also be updated to address the original issue!

@BrianSeong99

BrianSeong99 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Reviewed the current head (afee1949). The persisted marker, note baseline, and request-relative resume backstop are solid improvements. I found two P2 issues and one lower-priority suggestion; no P0/P1 correctness issues.

🟠 P2 — the timeout does not cancel the underlying faucet work

faucet() uses Promise.race, but getPowChallenge, the PoW loop, and requestTokens receive no AbortSignal. Once the wrapper rejects after 60 seconds, the original work can continue in the background while the UI permits another attempt.

In the delayed-response case, both attempts could reach the faucet and mint notes. In the PoW case, the original loop can continue consuming resources after the UI reports failure.

Fix: propagate an AbortSignal through both fetch calls and check it inside the PoW loop. The test should verify cancellation, not only that the wrapper promise rejects.

🟠 P2 — whole-card actions are not keyboard accessible

PromptCard renders tappable cards as a div role="button" with onClick, but without tabIndex or Enter/Space handling. This PR moves the faucet and pending-notes actions from real buttons onto that card, making those actions unavailable to keyboard users.

Fix: add complete keyboard-button behavior or retain a semantic button for the primary action.

🟡 Suggestion — the backstop starts after the faucet acknowledgement

requestedAt is created after await faucet(address), so "three minutes after the original request" can be up to roughly four minutes after the user taps when the faucet call approaches its 60-second timeout.

This remains bounded, so I do not consider it a blocker. Either capture the timestamp before awaiting the faucet or adjust the description to say the three-minute wait starts after acknowledgement.

✅ Verified fine

The persisted marker survives remounts, the baseline prevents a pre-existing note from causing immediate false success, and the resumed backstop is correctly calculated from its stored timestamp. The pending-notes suppression during the success beat also remains correct.

PR housekeeping

@0xnullifier 0xnullifier left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just a nit on why did we added animation to main.css when we can use framer or tailwind

Comment thread src/main.css
Comment on lines +531 to +584

/**
* Indeterminate progress runner for PromptCard's loading state.
*/

@keyframes prompt-progress {
from {
transform: translateX(-100%);
}
to {
transform: translateX(300%);
}
}

.prompt-progress-runner {
animation: prompt-progress 1.4s ease-in-out infinite;
}

/**
* PromptCard hero lockup: hourglass flip while loading, pop-in on state swap.
*/

@keyframes prompt-hero-flip {
0%,
35% {
transform: rotate(0deg);
}
50%,
85% {
transform: rotate(180deg);
}
100% {
transform: rotate(360deg);
}
}

.prompt-hero-flip {
animation: prompt-hero-flip 2.2s cubic-bezier(0.77, 0, 0.175, 1) infinite;
}

@keyframes prompt-hero-pop {
from {
transform: scale(0.92);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}

.prompt-hero-pop {
animation: prompt-hero-pop 240ms cubic-bezier(0.23, 1, 0.32, 1) both;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is there a reason why you won't use framer motion here, I mean this works but we can do this by not polluting the main.css file when we already have framer motion and tailwind animate in the bundle

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

how about create a skill.md for this and we can enforce this pattern to agents?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Created this PR for the frontend CSS design pattern for agents, maybe once this #515 PR is reviewed and merged to main, @dotsolo could pull the update and update ths implementation in this PR

@dotsolo
dotsolo changed the base branch from fix/remove-imiden-swap-faucet to main August 7, 2026 14:55
@dotsolo

dotsolo commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review in 277872e:

  • P2 — timeout now cancels the underlying faucet work. faucet() aborts an AbortController when the 60s timer fires; the signal goes through getPowChallenge and requestTokens into their fetch calls and is checked on every solvePowChallenge iteration. The Promise.race remains only as a guarantee the wrapper rejects on time even if the abort isn't observed promptly. Tests now assert cancellation itself: the signal handed to mintFromMidenFaucet flips to aborted on timeout, both fetches receive the signal, and an abort ends an otherwise-unsolvable PoW search.
  • P2 — whole-card actions are keyboard-accessible. Tappable PromptCards are now tabIndex={0} and activate on Enter/Space, with keydowns originating from the inner dismiss/CTA buttons ignored so one press can't fire both actions. Covered by tests (activation, focusability, inner-button guard, non-activation keys).
  • Suggestion — backstop anchor. requestedAt is captured before await faucet(address), so the 3-minute backstop is anchored to the user's tap rather than the ack.

Housekeeping: the PR is retargeted onto main (until #498 merges, the diff here includes that branch's commits — noted in the description), the description now reads "Partially addresses #476", and the attribution footer is removed.

@WiktorStarczewski WiktorStarczewski left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed the current branch (277872edc). The critical + high issues from my last review are all genuinely fixed — nice work, and the fixes have real tests behind them:

Prior finding Status How
🔴 global marker leaks the Funding hero across accounts ✅ Fixed per-account key faucet_funding_v2:${address}; awaitingFaucetFunds derives from fundingWait.address === account.publicKey; reset effect keyed on account.publicKey; the backstop no longer wipes the other account's marker
🟠 false "Funded!" on any pre-existing note ✅ Fixed request-time baselineNoteIds snapshot; arrival trips only on a note beyond the baseline set
🟠 backstop anchored to mount ✅ Fixed max(0, requestedAt + TIMEOUT − now), requestedAt read from the marker
🟡 faucet timeout/abort ✅ Fixed AbortController races a 60s timeout, signal threaded into every fetch + checked each PoW iteration

And the tests genuinely constrain each fix (account-switch leak, baseline false-success, backstop timing anchor, abort-stops-PoW-loop) rather than asserting nothing.

Requesting changes only on the two merge-gating items below; everything else is non-blocking.

Merge-gating

1. 🟠 MEDIUM — funding a second account is a dead-end while the first request is in flight. HomePrompts.tsx:282

fundWallet bails on a single boolean ref: if (fundingRef.current) return. On account A tap Fund → fundingRef=true; switch to B (the reset effect clears B's indicator to idle, so B looks tappable); tap Fund on B → it short-circuits before setFaucetStatusIndicator('loading'), so nothing happens — no request, no loading, no feedback — until A's request settles (up to 60s). Fix: scope the in-flight guard per address (a ref/Set of in-flight publicKeys) so a different account can fund concurrently while double-submit is still blocked per account.

2. 🟠 MEDIUM — arrival isn't faucet-specific; any inbound note/balance mid-wait false-succeeds. HomePrompts.tsx:344

const hasNewNote = pendingNoteIds.some(noteId => !baseline.has(noteId));
if (!hasNewNote && !hasBalance) return;

pendingNoteIds is every claimable note id regardless of faucet (PendingNoteValue drops faucetId/sender), so during the 30–60s wait an unrelated inbound note (a friend's transfer, a bridge/earn deposit, any token) trips the "Funded!" beat, completes the Faucet prompt early, and shows the wrong $ (formattedPendingNotesUsdTotal sums all notes). No funds are lost — the real mint lands later as a normal pending note — but the lifecycle UI lies.

This is the "milder variant" I flagged last time and the PR marked out-of-scope. Fair to accept it, but it's more than cosmetic now that the hero completes the prompt. The clean fix: thread faucetId into PendingNoteValue and require the new note to be the MIDEN faucet's (and don't treat a bare hasBalance as arrival on its own). Your call: fix now, or accept-and-document explicitly — but let's decide rather than leave it implicit.

Non-blocking

  • 🟡 LOW — success branch clobbers the current account's hero (no account guard). HomePrompts.tsx:302 — the two in-memory writes in fundWallet's success path lack the accountKeyRef.current === address guard the catch branch has. Sequence: fund B (hero on B) → switch to A, fund A → switch back to B (resume restores B's hero) → A resolves and setFundingWait({address:A}) replaces B's wait; B's hero silently vanishes mid-mint, its arrival/backstop effects go inert, and re-tapping B double-mints. Narrow, but real — wrap both writes in the same guard the catch uses.
  • 🟡 LOW — "Funded!" amount re-mixes baseline notes. HomePrompts.tsx:425 — the sub-label sums all claimable notes, re-including the pre-existing baseline notes the arrival gate excluded, mis-attributing their value to this funding. Filter to ids beyond baselineNoteIds before totalling, or fall back to the generic sub-label.
  • 🟡 LOW — flaky success-beat tests. HomePrompts.test.tsx:257,299,342 — three completion assertions run on the real 2400ms FAUCET_FUNDED_BEAT_MS with a 600ms waitFor margin; under parallel CI load they can time out intermittently. Use fake timers (advanceTimersByTimeAsync(FAUCET_FUNDED_BEAT_MS)) like the backstop test at :438.
  • 🟡 LOW — dead code shipped. The redesign rewired Home to call faucet() directly, so WalletFundingDrawer.tsx + lib/wallet-funding.ts (+ their two test files) are unreachable — openWalletFunding has zero prod callers, and TabLayout's {walletFundingOpen && <WalletFundingDrawer/>} can never render. Recommend deleting the subsystem (and its TabLayout wiring) rather than shipping a duplicate faucet-request path plus tests that only prove the dead code still runs. If it's intended future work, gate/document it.
  • NIT — marker-clear writes are fire-and-forget. HomePrompts.tsx:327,348,372 — unlike the persist path (:299, which .catches), the three void setFaucetFundingMarker(address, null) clears have no .catch, so a storage write failure is an unhandled rejection (no crash/wedge — the in-memory backstop still clears within 3 min). Add the same .catch.
  • NIT — abort comment overclaims. wallet-prompts.ts:263 — a best-effort abort can't recall an already-dispatched /get_tokens, so a retry after a timeout can still mint a second note. Soften the comment, and/or distinguish the timeout rejection from a hard failure so an immediate retry doesn't race a late mint (minor — devnet test tokens).
  • NIT — CHANGELOG. CHANGELOG.md:6 — the 1.15.20 CHANGE bullet still describes the funding-drawer flow that this PR made unreachable; update it to the in-card lifecycle when you remove the drawer.

Verified clean

Abort/timeout plumbing (signal threaded through all three network steps + PoW loop, single timer cleared in finally, no unhandled rejection, timeout error distinct); marker read robust against malformed/failed storage (non-object/non-finite/non-array → null, reads .catched); rapid same-account double-tap fully guarded (ref latched synchronously before any await); empty/undefined notes on a fresh account safe; clock-skew/past-requestedAt math floored with no immediate-fire hazard; i18n complete across all 14 locales with the $amount$ placeholder; haptics self-gate isMobile; no any.

Overall: the correctness regressions are gone — I'd gate merge on #1 and a decision on #2, and the rest can ride along or follow up.

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