feat(send): real on-chain confirmation + failure-state screen#45
Merged
epicexcelsior merged 18 commits intoMay 13, 2026
Merged
Conversation
Phantom-style full-screen loading overlay for transactional flows. Uses animated WebP via the already-installed expo-image (no new deps). - assets/animations/sending.webp — 240x240 q70 animated WebP, ~350KB - components/ui/PigeonLoader.tsx — Modal overlay with optional cancel - app/dev/pigeon-loader.tsx — preview route for testing without a real tx Reachable for QA via: anonmesh://dev/pigeon-loader Component is unwired; send-flow integration will follow in a separate PR to keep this lane-clean against components/send/.
Device review showed two issues: 1. Ghost frames stuck on screen during loop. Root cause: ffmpeg's webp encoder writes frames with dispose=none + blend=alpha, so each frame composites onto the previous. Sparse transparent regions left trailing pixels. Fix: pre-composite every source frame onto a solid #00080c (theme background) via Pillow, then save without alpha. No alpha plane = every frame is fully opaque = ghosting is impossible. Side effect: 358KB -> 292KB (smaller, no alpha plane). 2. Loader felt too small. Image render 160 -> 220 (≈37% bigger). Source resolution bumped 240 -> 280 to keep sharpness at the larger render size on 3x devices. 3. Backdrop opacity dropped from 92% to fully opaque, matching the Phantom-wallet pattern for blocking loaders. Also avoids the visible-rectangle artifact that would appear if a fully-opaque sprite sat on a translucent backdrop of a slightly different blended color.
Elevate the send loader from a generic spinner into a brand moment that tells the AnonMesh routing story while a tx is in flight. Changes: - Bigger hero — pigeon render 220 -> 280pt (fills the central area) - Larger label (24pt semibold), sublabel hidden by default — less text - Subtle pulse on the pigeon (1.0 ↔ 1.03 / 1.8s sine) so the image doesn't sit still during the WebP's quieter frames - Ambient mesh background — 12 drifting cyan nodes + 19 connecting lines on the brand primary color, driven by a single shared phase via Reanimated worklets. Renders behind the pigeon at low opacity. Tells the "your tx is hopping across peers" story visually. - New success state (`status: 'loading' | 'success'`): pigeon crossfades out, Feather check springs in with a brand-green glow ring. Fires the success notification haptic. - Mount haptic — medium impact when the loader becomes visible, announcing the blocking state. Asset: - assets/animations/sending.webp re-encoded with alpha so the mesh BG reads through the transparent regions around the pigeon. Avoids ghosting via explicit per-frame disposal=2 (background) in the WebP encode (Pillow), so each frame fully replaces the previous. 320x320 q=70 alpha_q=80 → 614KB. Dev route: - "Full lifecycle" buttons play sending → success → dismiss so the whole sequence can be felt without a real tx - Manual status toggle exposes the loading↔success transition - Sublabel and cancel button toggles still present Deps: zero new packages. Uses expo-image, expo-haptics, react-native-reanimated, react-native-svg — all already installed.
Two device-review fixes:
1. Mesh BG redrawn from the actual AnonMesh logo geometry.
- Node positions extracted from assets/icons/anonmesh_white_icon.png
via Pillow distance-transform (6 nodes, 10 edges).
- Logo scaled 1.15× the screen, centered. The pigeon mounts in
front of the central node, exactly like the brand icon's core.
- Static dots dropped in favor of stable topology that matches the
logo silhouette.
- One bright "message" dot per edge, all driven by a single
11-second shared phase. Each message travels half the cycle
(visible) then fades out for the other half. Phases staggered
so messages don't all arrive simultaneously.
- Color cohered to brand primary cyan at three opacity tiers:
edges 15%, nodes 20%, traveling messages 85%.
2. Success ring rebuilt in SVG to kill the octagon artifact.
The previous View used borderRadius=48 + borderWidth=2.5 +
elevation=10. On Android, large-radius rounded views with
elevation shadows render with quantized polygon edges — that
was the "incomplete shape inside the circle" symptom.
Now: <Svg> with three concentric Circles (outer halo, mid halo,
ring stroke) for guaranteed anti-aliased curvature and a soft
glow that doesn't rely on platform shadow clipping.
Three improvements rolled together; the perf one matters most.
1. Center node aligned to screen center.
The logo's central node sits at (0.596, 0.417) in source coords, not
(0.5, 0.5). Previously the bounding box was centered, which made the
visual center sit right-of-center on screen. New positioning anchors
that single node to (width/2, height/2). The pigeon now flies over
the logo's actual core, and the whole pattern reads as the brand mark
instead of a generic web.
2. Scaled 1.15× → 1.45×.
Outer nodes intentionally drift past the screen edges. Pattern reads
as ambient / abstract rather than "here is a small diagram." Static
node/edge opacities lowered to compensate (14% / 12%) so the mesh
stays subordinate to the pigeon.
3. Animated messages moved off react-native-svg onto Animated.View
transforms. SVG <Circle> with useAnimatedProps was triggering native
tree updates each frame across 10 elements — that's where the perf
cost was hiding. Each message is now a positioned Animated.View
driven by useAnimatedStyle, so the transform updates run UI-thread
only and never round-trip to RN's view manager.
Side benefits from the rewrite:
- Smoothstep easing on the position so dots accelerate out of one
node and decelerate into the next. Reads as more deliberate motion.
- Three-stage opacity curve: fade in over 18% of the visible window,
hold at peak, fade out over the last 18%. No more abrupt blink-on
/ blink-off at edge endpoints.
- Stacked core + halo (one bright inner, one softer outer) for a
glow without relying on platform shadow primitives.
- Visible window dropped to 42% per cycle, travel period 11s → 7s.
Net result: fewer dots on screen at once but each one is brighter
and faster, which reads as more pronounced motion without crowding.
Static layer (nodes + edges) still SVG — drawn once, zero per-frame
cost.
Mesh BG didn't add enough to justify the visual + perf cost on top of the pigeon. Loader is now just the dark backdrop, pigeon, and label. Cleaner base to layer alternative polish on.
Three polish layers replacing the dropped mesh BG. None of them add motion that competes with the pigeon during normal loading. 1. Radial spotlight backdrop. Static SVG <RadialGradient> centered on screen — cyan primary at ~11% opacity in the core, fading to transparent at 100%. The void backdrop now reads as an intentional stage rather than dead black. Zero motion, single-render, no per-frame cost. 2. SlideLabel — animated label transitions. When the label text changes (Sending → Sent on success, or any other override), the outgoing text eases up and fades out while the new text rises up and fades in. State change feels announced instead of just swapped. The transition is keyed on text equality so identical re-renders don't trigger it. 3. Success shockwave. Single ring positioned at screen center, hidden until the success transition. Fires alongside the check spring with the same 80ms delay so the ring reads as the wave's anchor. Scales 0.85 → 3.6 over 720ms with opacity 0.55 → 0, easing out cubic. The wave moves off-screen by the end of the animation, which is the intended "exclamation point" feel for the success moment. Reset on status reversal (success → loading) snaps all three success animations to their idle state, so the dev route's manual toggle stays clean across rapid changes.
Loader now appears for real sends. Path:
1. Slider hit -> setIsConfirming(true), setTxStatus('loading')
PigeonLoader Modal fades in (built-in animationType="fade"), pigeon
pulses, mount haptic fires.
2. await sendSolTransfer/sendSplTransfer -> result.signature
3. saveAddressBookRecipient(to) (in the same try block)
4. setTxStatus('success') -> loader crossfades to check ring,
shockwave + confirm haptic, label slides Sending -> Sent.
5. setTimeout 1200ms -> router.replace('/send/success', ...). The
1.2s hold lets the success state actually register before nav.
6. ReviewCard unmounts, loader Modal closes alongside, Success
screen with explorer link is now visible.
Error path: catch -> setTxStatus('loading'), setIsConfirming(false),
slider resets. Loader fades out, error panel visible on Review for
retry. Drops the previous `finally { setIsConfirming(false) }` since
the success path now owns its own dismissal via navigation.
Lane note: QVAC's epic/qvac-integration branch also edits
ReviewCard.tsx (memo prop plumbing). The diffs do not overlap line-
for-line — my edits are around state declarations and try/catch
control flow; theirs are around prop signature, fee estimate args,
and adding a Memo DetailRow. 3-way merge should clear without
manual intervention, but whoever lands second should verify.
Standard wallet pattern: the loader does loading, the success screen
does the success moment. One haptic, one check, no contrived hold.
Bug fix:
Previously the loader fired haptics.confirm() in its success state,
held 1.2s, navigated. Then SuccessCard fired haptics.confirm() on
mount. Two confirm haptics 1.2s apart on every send. Two check
rings (animated then static). Removed by:
- dropping setTxStatus('success') + setTimeout from ReviewCard;
we navigate immediately when sendSolTransfer/sendSplTransfer
returns the signature
- SuccessCard now owns the celebration: SVG check ring + shockwave
spring in on mount, single confirm haptic, lifted from the
PigeonLoader work (same dimensions, same easing)
Other:
- SuccessCard title "transfer in motion" → "sent" (present tense
on a screen the user lands on AFTER submit)
- Loader's status prop kept in PigeonLoader API for future non-send
consumers (refresh/connect/mesh-ack flows), just unused here
"Submitted to devnet" → "Awaiting confirmation". The screen mounts on signature receipt, but the tx isn't yet confirmed on chain (no polling on this code path). The previous copy claimed a status we hadn't verified. The new copy is what's actually true at that moment. Subtitle already acknowledges the lag. Proper fix (status polling + failure-state route) lives in a follow-up PR; this is the cheap honesty change.
Extend IRpcAdapter with getSignatureStatus(signature) so callers can
poll for on-chain confirmation in a transport-blind way. Direct and
mesh implementations both delegate to their existing infrastructure:
- DirectRpcAdapter.getSignatureStatus: connection.getSignatureStatus
with searchTransactionHistory=false (recent submission only)
- MeshRpcAdapter.getSignatureStatus: routes through the same LXMF
relay path as the existing RPC calls, using getSignatureStatuses
with a single-element array (the only multi-sig form web3.js's
JSON-RPC exposes)
- IsolatedRpcAdapter: throws "No Solana route available" matching
the existing pattern
Adds confirmTransaction(rpcAdapter, signature, { signal? }) in
sendTransaction.ts:
- Polls every 500ms online / 1000ms mesh
- Overall budget 60s online / 120s mesh (per-call mesh timeout is
30s — need headroom for ≥3 stuck calls)
- Per-call rejections are caught and logged inside the loop so a
single hung getSignatureStatus doesn't abort confirmation
- Returns ConfirmResult discriminated union: 'confirmed' | 'failed'
(reason: 'on-chain' | 'timeout') | 'cancelled' for AbortSignal use
- Cancellation triggers an early sleep-exit so unmount doesn't have
to wait out a full pollInterval before returning
No call sites wired yet. ReviewCard rewire + UI states land in
follow-up commits on this branch.
src/services/sendErrorMessages.ts (pure, no React).
describeSendFailure(result, mode) maps a ConfirmResult failure into:
{ subtitle, pillLabel }
Pattern-matches common Solana errors against a combined haystack of:
- summarizeError's extracted view (message, name, raw, cause)
- JSON.stringify of the raw err for object-form TransactionError keys
like { InstructionError: [...] }
Covered cases: blockhash expired, insufficient funds, missing account,
program error, generic on-chain failure, network timeout (mode-aware:
"Mesh timeout" vs "Network timeout"). Anything unmatched falls through
to a generic message that directs the user to the raw error toggle.
formatRawError(err) produces the multi-line monospace block for the
FailureCard's collapsible "View error details" section.
Mirrors SuccessCard structure with the same SVG ring spring + shockwave
pattern; tone shifts to colors.error and the inner icon is an X drawn
as two SVG lines instead of a Feather check.
Component (FailureCard.tsx):
- haptics.warning() on mount (not confirm — different signal)
- Title "couldn't send" (lowercase, brand-consistent)
- Subtitle + pillLabel arrive as props from ReviewCard, computed via
describeSendFailure() so the failure copy is captured at the moment
of failure (not recomputed later in case mode changes)
- Amount tile so the user sees what failed
- Glass card with status pill + signature (when present; even failed
txs land on chain and have copyable explorer-linkable signatures)
- "View error details" collapsible toggle revealing rawError in
monospace with a Copy button
- Footer: Done (secondary, back to wallet) + Try again (primary,
router.back to Review with state intact)
- View on explorer button below the error detail when a signature
exists (failed txs are still inspectable)
Route (app/send/failure.tsx):
- Thin wrapper, reads params via useLocalSearchParams
- Inline <Stack.Screen options={{ gestureEnabled: false }} /> to
prevent swipe-back to a phantom Review state (no _layout.tsx edit
needed — same pattern as app/dev/pigeon-loader.tsx)
- asString() helper to defend against query-string arrays from
Expo Router when params are bracketed
Not wired into ReviewCard yet — that's the next commit.
Send flow becomes two-phase:
1. Submit (existing sendSolTransfer / sendSplTransfer call) → signature
2. Confirm (new): poll getSignatureStatus via confirmTransaction until
the chain reports 'confirmed' or 'finalized', errors out, or the
budget elapses
State:
- txPhase: 'submitting' | 'confirming' | null drives loader sublabel
- confirmAbortRef: AbortController for cancellation on unmount
PigeonLoader sublabel updates as the phase advances. The loader stays
visible through both phases so users see the actual work happening.
Sublabels:
'submitting' → "Submitting"
'confirming' → "Confirming on devnet"
Outcome routing:
- 'confirmed' → router.replace('/send/success', { txId, ... })
- 'failed' → router.replace('/send/failure', { subtitle, pillLabel,
rawError, txId, ... }) with copy computed by
describeSendFailure() at the moment of failure
- 'cancelled' → return silently. Caller already unmounted.
The unmount useEffect aborts any in-flight confirmation. Combined with
the 'cancelled' result branch, this prevents two classes of bugs:
- ghost router.replace on a phantom ReviewCard after user back-swipes
- orphan polling loops that nobody is listening to
Existing submission-stage error path is unchanged — wallet rejection,
RPC unreachable, validation failures still surface inline on Review.
Phase 1 SuccessCard mounted on signature receipt with an "Awaiting confirmation" pill — honest about not having verified the chain. Phase 2 confirms before navigation, so the copy can land: - Subtitle: "Settlement confirmed on devnet." (no more lag caveat because we've already waited for the lag) - Pill: "Confirmed" / green tone (no more "Awaiting" / cyan) Behavior unchanged otherwise: check ring spring + shockwave + single confirm haptic on mount.
…arry-through) Stack carries PR anonmesh#44's PigeonLoader through this branch, so the accessibility fix needs to land here too. Mechanical mirror of the fix on epic/pigeon-loader-component: `accessible` + alert role moved from the backdrop View to the content stack so the optional Cancel Pressable remains independently focusable for screen readers.
…replace, timeout retry guard
1. AbortSignal listener leak in confirmTransaction's per-poll sleep.
Each iteration's `signal.addEventListener('abort', onAbort, { once: true })`
was never removed when the timer fired normally — only when abort
itself fired. Across a 60s budget at 500ms cadence, that's up to 120
dead listeners on the same controller before unmount. Now the timer
handler explicitly removeEventListener's the onAbort, so listeners
are bounded to one live entry per cycle.
2. router.replace → router.push for the failure path.
With replace, /send/review got swapped out of the stack before
navigation to /send/failure. router.back() from FailureCard landed
on /send/amount instead of /send/review, losing the form state.
With push, /send/review stays on the stack, "Try again" returns
the user exactly where they were with amount/recipient intact.
Success path stays as replace (terminal state, going back is
meaningless).
3. Timeout-failure double-send guard.
reason='timeout' means the tx submitted and we stopped watching
before getting a terminal status. On online mode, the blockhash
may still be live for up to 30s after the 60s budget — meaning
the tx COULD still confirm. If the user taps "Try again" in that
window, they double-send. New plumbing:
- ReviewCard passes `reason` as a route param
- app/send/failure.tsx reads it and computes `isTimeout`
- FailureCard hides "Try again" entirely on timeout, swaps Done
to primary, and bumps "View on explorer" from secondary to
primary so it reads as the recommended action
User intending to retry on a true timeout just taps Done → opens
wallet → re-enters Send. One extra tap to avoid a real money risk.
# Conflicts: # mobile_app/components/send/ReviewCard.tsx # mobile_app/components/send/SuccessCard.tsx
Collaborator
Author
|
@copilot review this PR please |
epicexcelsior
added a commit
to epicexcelsior/anon0mesh
that referenced
this pull request
May 16, 2026
Adds `docs/SMOKE_TEST.md` — the 5-minute manual gate that runs before every tester APK release. Seven numbered steps with explicit pass/fail criteria, each linked to an AUDIT / ROADMAP ID for traceability. Steps: 1. Cold install + onboarding 2. Local wallet generation 3. DEVNET banner visible (Tier 0.5 / B1) 4. Send 0.001 SOL to self (PR anonmesh#45 confirmation) 5. Nodes screen honesty (no fixture data) 6. Seed reveal biometric gate 7. Airplane-mode toggle Adapted from § 10 of LOCAL_NOTES/REVIEW-2026-05-13/raw-tracks/ OFFGRID_FALLBACK_AUDIT.md ("Off-grid testing playbook") for the team's actual release workflow. Includes a sign-off table.
epicexcelsior
added a commit
that referenced
this pull request
May 16, 2026
* chore(preview): wrap dead Yield/Swap/Send panel controls in PreviewedActions These three wallet panels are not exported from `components/wallet/index.ts` today (per PR #47) but the source still ships in the bundle and contains live Pressables that look real. Wrap the dead CTAs in <PreviewedActions> and add a <PreviewBadge label="…coming soon"> above each panel so that if anything ever does render them, the affordance reads as preview-only. - YieldPanel: deposit/withdraw vault buttons wrapped (the expand/collapse Pressable is purely local UI state, left alone). - SwapPanel: SWAP + "new order" CTAs wrapped (flip-asset Pressable left). - SendPanel: "send privately" + "new transfer" CTAs wrapped (asset picker and MAX shortcut left — local state only). Per AUDIT A6 / ROADMAP § 0.A.8. * feat(a11y): honor reduce-motion on MeshMap + RadarScan loops Two of the largest infinite Animated.loop / withRepeat(-1) sites that were deferred from PR #52. Adds `useReducedMotion()` guards using the same pattern as PulseDot / Skeleton / NetworkStatusBadge / PigeonLoader. - MeshMap: the "me" node pulse halo + ghost skeleton breathe both honor reduce-motion. Pinned to mid-frame opacity so the indicators still read but don't loop. No structural changes. - RadarScan: rotation sweep pinned at 0deg + center dot held at resting scale under reduce-motion. Static ring guides still convey state. Both visual-only — happy path unchanged. Per ROADMAP Tier 4.1 (a11y motion audit). * docs(release): pre-release smoke-test playbook (ROADMAP Tier 1.2) Adds `docs/SMOKE_TEST.md` — the 5-minute manual gate that runs before every tester APK release. Seven numbered steps with explicit pass/fail criteria, each linked to an AUDIT / ROADMAP ID for traceability. Steps: 1. Cold install + onboarding 2. Local wallet generation 3. DEVNET banner visible (Tier 0.5 / B1) 4. Send 0.001 SOL to self (PR #45 confirmation) 5. Nodes screen honesty (no fixture data) 6. Seed reveal biometric gate 7. Airplane-mode toggle Adapted from § 10 of LOCAL_NOTES/REVIEW-2026-05-13/raw-tracks/ OFFGRID_FALLBACK_AUDIT.md ("Off-grid testing playbook") for the team's actual release workflow. Includes a sign-off table. * chore(diagnostics): warn on 3 silent-failure sites (TxDetailModal + MeshRpcAdapter) Drops overlap w/ #53 (ReceivePanel + LxmfContext warns) — #53 owns those. Per AUDIT § 3 silent failure inventory.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the authenticity gap left by PR #44. The send flow now waits for real on-chain confirmation before showing the success screen, and routes to a dedicated FailureCard for txs that submit-then-fail (expired blockhash, program error, timeout).
Stacked on PR #44 —
baseshould ideally beepic/pigeon-loader-componentbut is set tov3for visibility. The first 8 commits (61d2c14..53022a2) are PR #44's; this PR's net contribution is the 7 commits at the top (fddf96c..HEAD). When PR #44 merges, the diff here collapses to just Phase 2.What's in (Phase 2 commits only)
Service layer
IRpcAdapterextended withgetSignatureStatus(signature). Direct + Mesh + Isolated implementations.confirmTransaction(rpcAdapter, signature, { signal? })polls until 'confirmed' / 'finalized' / on-chain error / overall budget timeout.Send flow
ReviewCard becomes 2-phase: submit → confirm. Loader stays visible throughout, sublabel updates
"Submitting"→"Confirming on devnet". Outcomes:confirmed→/send/success(now an honest success —"Confirmed"pill / green, subtitle"Settlement confirmed on devnet.")failed→/send/failurewith friendly subtitle + status pill computed at the moment of failure viadescribeSendFailure(), plus raw error pre-formatted for the togglecancelled→ no-op (caller unmounted via back-navigation; abort cleaned up the loop)FailureCard
Mirrors SuccessCard structure with red X ring (SVG-based, spring-in on mount), shockwave in red tone,
haptics.warning()on mount. Glass card shows status pill + signature (failed txs still have on-chain records — copyable + Explorer link). Collapsible "View error details" toggle reveals the raw RPC error in mono font with a Copy button. Footer: Try again (router.back, slider reset preserved) + Done (back to wallet).Inline
<Stack.Screen options={{ gestureEnabled: false }} />on the route prevents swipe-back to a stale Review state. Same pattern asapp/dev/pigeon-loader.tsx— no_layout.tsxedit needed.Honesty improvements
Test plan
Lane note
ReviewCard.tsx— QVAC's branch also edits this file (memo plumbing). My changes are state + control flow inhandleConfirm; theirs are prop signature + Memo DetailRow. 3-way merge expected clean (same as PR feat(ui): pigeon-loader sending screen + send-flow wire-up #44)._layout.tsx— untouched (inline Stack.Screen options handle the new route).package.json— untouched (no new deps).Squash recommended
Net Phase 2: ~+500 LOC across 7 commits. Squash on merge.
Out of scope (deliberate)
onSignaturesubscription — polling works for both transports.