Wallet tightening: adapter consumption, real SPL, T22 filter, partial-write durability#28
Conversation
# Conflicts: # mobile_app/app/_layout.tsx # mobile_app/components/nodes/BeaconRegistry.tsx
JSON.stringify on an Error instance returns "{}" because Error fields
are not enumerable, so Error.cause was being silently dropped from
summarizeError output. Send-failure toasts and structured logs lost
the inner reason whenever the wrapping Error chained to another Error
(common with rpc adapter wrappers).
Match the string-input branch by formatting nested Errors as "Name:
message" before falling back to JSON.stringify.
Move the wallet-denial taxonomy out of sendTransaction.ts (which pulls in @solana/web3.js + react-native polyfills at the top of the file) into src/utils/walletDenial.ts so the validate-tier0-services loader can exercise the 12 denial-fragment patterns without dragging in React Native dependencies under Node. Expanded validate-tier0-services.mjs: - 12 wallet-denial positives across Error, plain-object error, and code-only shapes; 8 negatives covering network errors, RPC param errors, null/undefined, and empty objects - 5 additional parseBaseUnits edges (whitespace, scientific notation, comma decimal, just ".", empty string, large whole numbers) - 3 solanaPayUri rejection paths (10-decimal precision overflow, negative, non-numeric) asserting the bad amount is dropped from the URI rather than encoded - 5 additional summarizeError cases covering Error with no message, Error chained to Error via cause, null/undefined inputs, and message/error/name precedence - Realistic-length explorer-tx URL assertion Enable allowImportingTsExtensions so walletDenial.ts can use the explicit ./errors.ts relative import that the Node ESM loader needs under --experimental-transform-types. Other source files still use @/ aliases via the bundler resolver and are unaffected.
Previously LocalWallet.create() wrote WALLET_MARKER='true' before WALLET_SECRET. A crash between those two writes left the keychain in a "marker present, secret missing" state. On the next launch WalletFactory.hasLocalWallet() called LocalWallet.delete() on that partial state — including the orphan WALLET_PUBKEY — and routed the user to onboarding for a fresh keypair. Any funds sent to the first keypair before the crash were unrecoverable from the device. Reorder the four secureSet calls so WALLET_MARKER is the final write. Any earlier failure now leaves the marker absent → exists() returns false → onboarding runs cleanly instead of the recovery branch that destroys partial state. The orphan AES_KEY/PUBKEY/SECRET sit harmlessly until the next create() overwrites them. WalletFactory.createLocal()'s defensive delete-on-not-fully-intact branch is preserved for legacy state from older builds. A confirm dialog around that branch is tracked separately for fast-follow.
The send path uses @solana/spl-token transfer helpers without an explicit programId argument, which defaults to the legacy SPL-Token program. Token-2022 mints belong to a different program and can carry transfer-fee, confidential-transfers, or interest-bearing extensions that the legacy transferInstruction cannot honour. The previous behaviour quietly listed T22 balances as sendable in the picker; the send would either fail with a generic submission error or undercount the recipient amount on transfer-fee mints. Real T22 send is tracked as P3 in HUNTER_PLAN with a real spec (transferCheckedWithFee + extension probe). This change tags every TokenBalance with its owning program at fetch time, hides T22 entries from the send TokenPicker, surfaces a "SPL-2022 · view only" hint on the home balance card so users still see what they hold, and adds an assertSendableSplProgram guard at the bottom of the send pipeline (buildSplTransferTransaction) so even a deep-link or tampered router param cannot reach Keypair.sign or MWA signTransactions with a T22 mint. The programId now flows through the router params chain (RecipientPicker → AmountKeypad → ReviewScreen → ReviewCard) so the review screen can refuse T22 with an explicit explanation before any estimate or send call. The SOL path is unchanged. Tests cover assertSendableSplProgram across all programId shapes (legacy, T22, undefined, empty, unknown).
…-cause Legacy devnet SPL transfers (USDC) are currently failing on-device with a separate pre-existing error that needs root-cause work. Until that lands, the picker should not offer a send path that does not work; the known-issues banner in the PR description is not enough on its own. Extend the existing send-picker filter — already hiding Token-2022 mints because legacy transferInstruction misbehaves on T22 extensions — to also hide every legacy SPL entry. The balance card still surfaces SPL holdings so users see what they hold; the send picker simply does not list them. The picker footer switches from "Balances pulled live from devnet" to "Token sends temporarily SOL-only — coming soon" whenever SPL holdings are present but hidden. SOL send is the only adapter-routed transfer that has been verified end-to-end, so this commit ships a wallet that does what its UI says instead of one that surfaces a broken submission path.
Review map29 unique commits (this PR's wallet/recovery/onboarding/tests, independent of PR #21's 2 LXMF commits which appear transitively in history — see PR description for the relationship). Latest commit gates the broken devnet USDC send out of the picker so the PR ships a wallet that does what its UI says. Suggested review order1. Money path (start here)
2. Recovery & security
3. Onboarding & UX
4. Test scaffolding (skim)
5. Meta
Verified on Solana Seeker (Android)
Known limitations (deliberate, not regressions)
Validation
RollbackEach commit independently revertable. The picker-gating commit can be reverted alone if SPL root-cause lands; the rest of the wallet work stays valid. |
There was a problem hiding this comment.
Pull request overview
This PR tightens wallet behavior and onboarding/security flows: it hardens transaction building/submission (including real SPL scaffolding + Token-2022 guarding), improves wallet durability and error handling, and adds local-only UX features like tutorial gating and an address book, backed by a new “tier0” validation scaffold.
Changes:
- Implemented real SPL transfer scaffolding (ATA create-if-missing), Token-2022 send blocking, improved amount parsing/error summaries, and centralized devnet explorer URL building.
- Added onboarding tutorial route/state, Solana Pay receive URI generation, and a SecureStore-backed local address book with UI integration.
- Added tier0 validation scripts and updated dependencies/config/plugins to support the new wallet/recovery flows.
Reviewed changes
Copilot reviewed 58 out of 61 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| mobile_app/tsconfig.json | Enables TS extension imports for new TS module import patterns. |
| mobile_app/src/utils/walletDenial.ts | Centralizes wallet “user denied/cancelled” detection. |
| mobile_app/src/utils/recoveryKey.ts | Adds recovery key chunking/formatting helper. |
| mobile_app/src/utils/errors.ts | Adds structured error summarization with cause support. |
| mobile_app/src/utils/amount.ts | Adds exact base-unit parsing (string → bigint). |
| mobile_app/src/storage/index.ts | Adds SecureStore keys for address book + tutorial completion. |
| mobile_app/src/services/walletData.ts | Tags SPL program IDs, blocks Token-2022 sends, and expands activity parsing (memo/fee/slot + SPL deltas). |
| mobile_app/src/services/tutorialState.ts | Adds SecureStore-backed tutorial completion state helpers. |
| mobile_app/src/services/solanaPayUri.ts | Builds solana: / Solana Pay receive URIs with validation. |
| mobile_app/src/services/sendTransaction.ts | Refactors send flow; adds SPL transfer build/sign/submit + fee estimation; improves error handling + explorer URLs. |
| mobile_app/src/services/explorer.ts | Centralizes devnet explorer transaction URL generation. |
| mobile_app/src/services/addressBookCore.ts | Implements address book normalization/dedup/sort/cap/upsert primitives. |
| mobile_app/src/services/addressBook.ts | Adds SecureStore persistence + a useAddressBook() hook. |
| mobile_app/src/infrastructure/wallet/WalletFactory.ts | Recreates local wallet when storage is partial/corrupt. |
| mobile_app/src/infrastructure/wallet/MWAWallet.ts | Ensures polyfills load before MWA wallet module execution. |
| mobile_app/src/infrastructure/wallet/LocalWallet.ts | Reorders wallet marker write last for partial-write durability; loads polyfills early. |
| mobile_app/src/infrastructure/network/MeshRpcAdapter.ts | Ensures polyfills load before mesh RPC adapter module execution. |
| mobile_app/src/hooks/useNetworkMode.ts | Ensures polyfills load before network mode hook module execution. |
| mobile_app/scripts/validate-tier0.sh | Adds tier0 validation pipeline script (tsc/lint/checks/export/build). |
| mobile_app/scripts/validate-tier0-services.mjs | Adds service-level assertions for core wallet utilities and guards. |
| mobile_app/scripts/validate-tier0-config.mjs | Adds config validation (permissions + env example expectations). |
| mobile_app/scripts/capture-tier0-send-logcat.sh | Adds helper script to capture Android logs for send/recovery debugging. |
| mobile_app/screens/SettingsScreen.tsx | Adds address book route and renames export flow to “reveal recovery key”. |
| mobile_app/screens/NodesScreen.tsx | Consolidates BLE permission requests via shared helper. |
| mobile_app/screens/MessagesScreen.tsx | Ensures polyfills load before messages route module execution. |
| mobile_app/plugins/withDisableAndroidContentCapture.js | Adds config plugin to disable Android ContentCapture in MainActivity. |
| mobile_app/plugins/withAndroidForegroundService.js | Ensures BLE-related permissions are added via the FGS plugin. |
| mobile_app/package.json | Adds tier0 validation scripts; updates/introduces wallet-related deps (e.g., spl-token, screen-capture). |
| mobile_app/package-lock.json | Locks dependency changes corresponding to package.json updates. |
| mobile_app/context/WalletContext.tsx | Adds logging for export failures to aid debugging. |
| mobile_app/context/LxmfContext.tsx | Improves LXMF startup timing, peer pruning, and TCP interface filtering/config. |
| mobile_app/components/wallet/TxDetailModal.tsx | Adds transaction detail modal (memo/fee/slot/mint) + explorer open. |
| mobile_app/components/wallet/ReceivePanel.tsx | Updates receive panel messaging/iconography. |
| mobile_app/components/ui/Input.tsx | Removes unused Pressable import. |
| mobile_app/components/settings/QRCode.tsx | Removes now-unnecessary eslint disable comment. |
| mobile_app/components/settings/KeyBox.tsx | Uses recovery-key formatter and updates copy/error UX. |
| mobile_app/components/settings/ExportWalletModal.tsx | Hardens recovery key reveal UI, blocks screen capture, adds acknowledgement gate, and refactors overlay implementation. |
| mobile_app/components/send/TokenPicker.tsx | Temporarily filters send picker to SOL-only and updates helper text. |
| mobile_app/components/send/SuccessCard.tsx | Uses centralized explorer URL builder. |
| mobile_app/components/send/ReviewCard.tsx | Adds SPL fee estimation + send path; adds Token-2022 blocking and richer error logging. |
| mobile_app/components/send/RecipientPicker.tsx | Adds recent recipients UI (address book) and passes mint/decimals/programId params forward. |
| mobile_app/components/send/AmountKeypad.tsx | Threads token metadata params through to review screen. |
| mobile_app/components/primitives/NumericKeypad.tsx | Removes unused StyleSheet usage; keeps keypad logic. |
| mobile_app/components/primitives/IconButton.tsx | Removes unused View import. |
| mobile_app/components/nodes/PendingCosigns.tsx | Consolidates icon imports. |
| mobile_app/components/nodes/MeshMap.tsx | Updates empty-state copy and centers layout. |
| mobile_app/components/nodes/constants.ts | Simplifies array type annotation. |
| mobile_app/components/messages/MediaBubble.tsx | Consolidates theme imports. |
| mobile_app/components/home/RecentActivity.tsx | Replaces direct explorer linking with an in-app Tx detail modal; updates empty-state messaging. |
| mobile_app/components/home/NearbyPeersCard.tsx | Updates peer strip messaging to align with pruned peer model. |
| mobile_app/components/home/BalanceCard.tsx | Marks Token-2022 holdings as “view only” in UI. |
| mobile_app/app/tutorial.tsx | Adds first-run tutorial screen/route and completion handling. |
| mobile_app/app/send/review.tsx | Plumbs mint/decimals/programId params into ReviewCard. |
| mobile_app/app/receive.tsx | Builds Solana Pay receive QR (optionally with amount) and updates stealth-mode messaging. |
| mobile_app/app/onboarding.tsx | Routes first-time users to tutorial before entering tabs. |
| mobile_app/app/contacts.tsx | Adds address book management screen (labels/recents CRUD). |
| mobile_app/app/_layout.tsx | Registers tutorial + contacts routes; refactors notification hooks into a bridge component. |
| mobile_app/app.json | Adds Android BLE permissions, adds ContentCapture plugin, and registers expo-web-browser plugin. |
| mobile_app/.env.example | Documents LXMF TCP bridge config and adds log level env var. |
Files not reviewed (1)
- mobile_app/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const normalized = amount.trim(); | ||
| if (!/^\d+(\.\d+)?$/.test(normalized)) { | ||
| throw new Error("Invalid amount"); | ||
| } |
| direction, | ||
| status: failed ? "Failed" : "Settled", | ||
| amountBaseUnits: amountAbs.toString(), | ||
| amountSol: Number(amountAbs) / Math.pow(10, tokenDelta.decimals), | ||
| symbol: tokenDelta.symbol, |
| const dismiss = () => { | ||
| if (secretKey && !copiedAck) return; | ||
| Animated.timing(sheetAnim, { toValue: 0, duration: 220, useNativeDriver: true }).start(onClose); | ||
| }; |
| const rawAmount = parseBaseUnits(amount, decimals); | ||
| const fromAta = await getAssociatedTokenAddress(mint, fromPubkey); | ||
| const toAta = await getAssociatedTokenAddress(mint, toOwner); | ||
|
|
Summary
Wallet truth-pass closer + recovery key reveal + onboarding/receive/address-book/tutorial work + test scaffold. Latest commit gates the broken devnet USDC send out of the picker so the PR ships a wallet that does what its UI says.
Relationship to PR #21
This branch was built locally on top of PR #21's 2 commits (
fix: improve lxmf startup and peer counts+ the merge of upstream/v3 PR #27 + Android FGS + 1.0.1), so those commits appear in this PR's history transitively. The 29 unique commits in this PR are wallet/recovery/onboarding/tests and do not depend on the LXMF work — treat this PR's review surface as wallet-only. PR #21's resolution can proceed independently; if it merges first, this PR is rebasable onto freshv3. If this PR merges first, PR #21 can close as redundant.What's in this PR
Wallet truth pass + send hardening
@solana/spl-token, ATA create-if-missing); kills the simulated-success pathassertSendableSplProgramguard. T22 stays read-only-visible on the home balance card. Real T22 send is queued for a separate effort with a real spec (transferCheckedWithFee+ extension probe, not a one-lineprogramIdswap)LocalWallet.create()writesWALLET_MARKERlast so any partial-write failure leaves the keychain in a state whereexists()returns false and onboarding runs cleanly instead ofdelete()-ing the orphan public keyparseBaseUnitsexact lamport math;summarizeErrorpreservesError.causethrough chained errors; centralized devnet explorer URL builderRecovery, security, permissions
app.jsonand the FGS pluginOnboarding, receive, address book
app/tutorial.tsx(lane-clean, no edits tocomponents/onboarding/*)solana:URI for cross-wallet interopTest scaffold
validate-tier0pipeline: tsc + lint + service-helper tests + config validator + fake-money string scan + bundle secret scan + AndroidassembleDebugparseBaseUnitsedges,solanaPayUrirejection paths,summarizeErrorshapes, wallet-denial taxonomy (12 patterns + 8 negatives),assertSendableSplProgramacross all program-id shapes,formatRecoveryKey, explorer URLsadb logcat→ file)Validation
npx tsc --noEmitcleannpm run lintcleannpm run validate:tier0:servicespassnode ./scripts/validate-tier0-config.mjspass./gradlew :app:assembleDebugBUILD SUCCESSFULsim_xxx/ "Demo transfer" / "fake success" remnants)PRIVATE KEY/ mnemonic / example key material)Device smoke (Solana Seeker, manual)
Known issues — NOT introduced by this PR
@noble/hashes/crypto.jsMetro resolution warnings from nested@solana/web3.jsand@solana/wallet-standard-utilpackage versions (./crypto.jsnot listed inexports, falls back to file-based resolution). Cosmetic, not a bundle blocker.Follow-ups (separate PRs)
Rollback
Each commit independently revertable. The picker-gating commit can be reverted alone if SPL root-cause lands; the rest of the wallet work stays valid.