Skip to content

Wallet tightening: adapter consumption, real SPL, T22 filter, partial-write durability#28

Merged
Magicred-1 merged 31 commits into
v3from
epic/wallet-tightening
May 7, 2026
Merged

Wallet tightening: adapter consumption, real SPL, T22 filter, partial-write durability#28
Magicred-1 merged 31 commits into
v3from
epic/wallet-tightening

Conversation

@epicexcelsior
Copy link
Copy Markdown
Collaborator

@epicexcelsior epicexcelsior commented May 7, 2026

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 fresh v3. If this PR merges first, PR #21 can close as redundant.

What's in this PR

Wallet truth pass + send hardening

  • Real SPL transfer scaffold (@solana/spl-token, ATA create-if-missing); kills the simulated-success path
  • Token-2022 mints filtered from the send picker + bottom-line assertSendableSplProgram guard. 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-line programId swap)
  • Legacy SPL entries also temporarily hidden from the send picker pending devnet USDC root-cause; balance card still surfaces holdings
  • LocalWallet.create() writes WALLET_MARKER last so any partial-write failure leaves the keychain in a state where exists() returns false and onboarding runs cleanly instead of delete()-ing the orphan public key
  • parseBaseUnits exact lamport math; summarizeError preserves Error.cause through chained errors; centralized devnet explorer URL builder
  • SPL activity counterparty resolution via owner mapping
  • Solana polyfills loaded before route modules at runtime entry

Recovery, security, permissions

  • Biometric-gated recovery key reveal (base58 export)
  • Recovery modal kept inside secure window (FLAG_SECURE)
  • Hardened recovery key display + chunked formatting
  • BLE permissions consolidated across app.json and the FGS plugin

Onboarding, receive, address book

  • First-run tutorial route at app/tutorial.tsx (lane-clean, no edits to components/onboarding/*)
  • Clarified empty states across home tiles
  • Solana Pay receive QR encodes a solana: URI for cross-wallet interop
  • Address book persistence via SecureStore + recent-recipients UI in the send flow

Test scaffold

  • validate-tier0 pipeline: tsc + lint + service-helper tests + config validator + fake-money string scan + bundle secret scan + Android assembleDebug
  • ~80 assertions covering parseBaseUnits edges, solanaPayUri rejection paths, summarizeError shapes, wallet-denial taxonomy (12 patterns + 8 negatives), assertSendableSplProgram across all program-id shapes, formatRecoveryKey, explorer URLs
  • Send-log capture helper (adb logcat → file)

Validation

  • npx tsc --noEmit clean
  • npm run lint clean
  • npm run validate:tier0:services pass
  • node ./scripts/validate-tier0-config.mjs pass
  • ./gradlew :app:assembleDebug BUILD SUCCESSFUL
  • Fake-money string scan clean (no sim_xxx / "Demo transfer" / "fake success" remnants)
  • Exported bundle secret scan clean (no PRIVATE KEY / mnemonic / example key material)

Device smoke (Solana Seeker, manual)

  • Wipe app data + onboard local wallet → biometric prompt → wallet created → balance loads (covers the partial-write reorder didn't break the create flow)
  • Real devnet SOL send 0.0001 → biometric → real Explorer-visible signature
  • Token-2022 + legacy SPL hidden from send picker; balance card still surfaces both

Known issues — NOT introduced by this PR

  • Devnet USDC SPL send fails on-device with the same error pattern observed on the prior pre-blocker branch state. Picker now hides the broken path; root-cause is a separate effort.
  • @noble/hashes/crypto.js Metro resolution warnings from nested @solana/web3.js and @solana/wallet-standard-util package versions (./crypto.js not listed in exports, falls back to file-based resolution). Cosmetic, not a bundle blocker.
  • iPhone smoke deferred — Android testing only so far, iOS to be tested on TestFlight later.

Follow-ups (separate PRs)

  • Recovery export hardening (screen-capture failsafe, inline error surface, locale comma decimal in receive amount) — branch ready locally, opens after this lands.
  • Legacy SPL send root-cause + iPhone smoke.
  • LXMF rust-core / TCP-public-hubs coordination is the LXMF lane's call (per team lead's note on PR LXMF stability and peer counts #21).
  • Full Token-2022 support with extension probing.

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.

# 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.
@epicexcelsior
Copy link
Copy Markdown
Collaborator Author

epicexcelsior commented May 7, 2026

Review map

29 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 order

1. Money path (start here)

  • fix(wallet): hide non-SOL token entries from send picker pending root-cause — temporary gate so the broken SPL send is not shipped
  • feat(wallet): filter token-2022 mints from send picker — T22 extension landscape; picker filter + assertSendableSplProgram bottom-line guard. Real T22 send queued with a real spec
  • fix(wallet): write marker last so partial creates self-recover — interrupted LocalWallet.create() previously caused WalletFactory to silently destroy partial state including the orphan public key
  • feat(wallet): add real spl sends — SPL transfer scaffold (devnet root-cause queued separately, picker hides it)
  • feat(wallet): resolve spl activity counterparties — SPL activity parsing
  • fix(send): surface wallet transfer failures / parse sol amounts exactly / centralize devnet explorer links

2. Recovery & security

  • feat(settings): gate wallet recovery key — biometric-gated reveal
  • fix(security): keep recovery modal inside secure window — FLAG_SECURE
  • fix(settings): harden recovery key display
  • fix(android): declare and centralize ble permissions
  • fix(runtime): load solana polyfills before route modules

3. Onboarding & UX

  • feat(onboarding): add first-run tutorial — new app/tutorial.tsx route, lane-clean from components/onboarding/*
  • feat(onboarding): clarify empty states
  • feat(receive): encode solana pay qrsolana: URI builder
  • feat(send): remember recent recipients + feat(settings): manage address book — SecureStore-backed CRUD

4. Test scaffolding (skim)

  • chore(test): add tier0 validation scriptvalidate-tier0.sh pipeline (tsc + lint + service tests + config tests + fake-money scan + bundle secret scan + Android assembleDebug)
  • chore(test): cover tier0 service helpers / verify tier0 config gates
  • chore(tier0): extract wallet denial classifier and expand service tests — refactor + ~30-asserts expansion (12 wallet-denial patterns, 9 parseBaseUnits edges, 3 solanaPayUri rejections, 5 summarizeError cases)
  • chore(tier0): add send log capture helperadb logcat triage script
  • fix(errors): preserve Error.cause through safeStringify — small bug surfaced by the test expansion

5. Meta

  • chore(lint): clear existing warnings
  • chore(expo): align sdk validation
  • chore(wallet): clarify transfer comments
  • fix(wallet): recreate incomplete local wallet
  • fix(tier0): log wallet export and label stealth preview

Verified on Solana Seeker (Android)

  • Wipe app data → onboard local wallet → biometric → wallet created → balance loads
  • Real devnet SOL send 0.0001 → biometric → real Explorer-visible signature
  • Token-2022 + legacy SPL hidden from send picker; balance card still surfaces both holdings

Known limitations (deliberate, not regressions)

  • Devnet USDC SPL send fails on-device with same error pattern observed on prior pre-blocker branch state. Picker hides the broken path; root-cause fast-follow.
  • iPhone smoke deferred — Linux dev machine, post-merge.
  • Recovery export hardening (screen-capture failsafe + inline error surface + locale comma decimal) — separate branch ready locally, opens after this lands.
  • @noble/hashes/crypto.js Metro resolution warnings from nested @solana/web3.js and @solana/wallet-standard-util package versions. Cosmetic, falls back to file-based resolution.

Validation

tsc --noEmit clean · expo lint clean · validate:tier0:services (~80 assertions) pass · validate-tier0-config pass · ./gradlew :app:assembleDebug BUILD SUCCESSFUL · fake-money string scan clean · exported bundle secret scan clean.

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.

@epicexcelsior epicexcelsior changed the title Wallet tightening — adapter consumption, real SPL, T22 filter, partial-write durability Wallet tightening: adapter consumption, real SPL, T22 filter, partial-write durability May 7, 2026
@epicexcelsior epicexcelsior requested a review from Copilot May 7, 2026 10:42
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment on lines +2 to +5
const normalized = amount.trim();
if (!/^\d+(\.\d+)?$/.test(normalized)) {
throw new Error("Invalid amount");
}
Comment on lines +313 to +317
direction,
status: failed ? "Failed" : "Settled",
amountBaseUnits: amountAbs.toString(),
amountSol: Number(amountAbs) / Math.pow(10, tokenDelta.decimals),
symbol: tokenDelta.symbol,
Comment on lines 51 to 54
const dismiss = () => {
if (secretKey && !copiedAck) return;
Animated.timing(sheetAnim, { toValue: 0, duration: 220, useNativeDriver: true }).start(onClose);
};
Comment on lines +171 to +174
const rawAmount = parseBaseUnits(amount, decimals);
const fromAta = await getAssociatedTokenAddress(mint, fromPubkey);
const toAta = await getAssociatedTokenAddress(mint, toOwner);

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.

3 participants