Repo: lnflash/flash-pos @ main (commit 31e0225 — "#59 invoice guard, native support chat, PIN performance, timer")
Date: 2026-06-22
Stack: React Native 0.77.1 · TypeScript · Redux Toolkit + Contexts · NFC flashcards · BTCPay/Lightning payouts
Method: Four parallel deep-dives — security, architecture/code-quality, dependencies/build, tests/recent-work. Tests, typecheck, and lint were run locally.
This report supersedes the previous audit (which targeted commit range
e0afb692..815578da, the Merchant Rewards ID System v3 work). Most of that report's critical items have since been remediated — see "What's already good" below.
Solid and noticeably improving — not yet release-ready. The team has already remediated nearly every "critical" item from the previous audit, the supply-chain posture is genuinely good, and recent #59 work is well-guarded and tested. The remaining material risks are payment-flow integrity (double-payout, NFC trust) and process gaps (CI broken on a clean checkout, core money paths untested) — not credential leaks.
- Previous audit largely fixed: merchant-ID input is now sanitized (
src/utils/validation.ts); flashcard LNURL/balance and the PIN moved to Keychain (src/services/secureStorage.ts); PIN uses PBKDF2-SHA256 with constant-time compare and iteration upgrade; reward numeric inputs are bounded (src/utils/rewardCalculations.ts). - Supply chain: no secrets/keystores committed; release signing fails the build if signing vars are missing (
android/app/build.gradle); aresolutionsblock proactively pins past transitive CVEs (lodash,ws,node-forge,shell-quote,picomatch).yarn audit: 0 high/critical. - Native config correct: iOS ATS enforced (
NSAllowsArbitraryLoads=false), encryption-export flag set (ITSAppUsesNonExemptEncryption=false), scoped NFC/location usage strings; Android minimal permissions (NFC+INTERNET,allowBackup=false), SDK 35 / minSdk 24 / NDK 27, new-arch + Hermes. - Tests: 142 real-assertion tests across utils, slices, and services; clean typecheck and lint. The Invoice payment-confirmation suite (the #59 money-guard) is excellent — it covers PAID-but-confirm-pending, confirm errors, transient subscription errors, EXPIRED, and the "store only after network-only confirm" guard.
src/screens/Invoice.tsx:130-137 and src/screens/Rewards.tsx:237 POST to …/api/v1/pull-payments/{id}/payouts with no idempotency key. The Invoice.tsx reward path also has no cooldown (the 5s lock exists only in Rewards.tsx), and its only guard — completedPaymentHashRef (Invoice.tsx:159-162), in memory — does not survive a remount / app-resume / subscription re-fire, nor an axios retry on the POST. A reward can be issued twice.
Fix: send a deterministic idempotency key derived from paymentHash on the payout POST (BTCPay Greenfield supports this), and persist "reward already sent for paymentHash X" rather than relying on an in-memory ref.
src/contexts/Flashcard.tsx:207-230 (getHtml) builds a URL entirely from the scanned tag payload (lnurlw://… → https://…) with no host allowlist, then axios.gets it and scrapes the HTML for an LNURL (src/utils/flashcardParser.ts, which even matches a bare token) that later becomes a payout destination. A cloned/malicious card can point the POS at any attacker host and inject a destination the merchant then funds.
Fix: validate the rewritten URL host against an allowlist of known boltcard/Flash domains; require HTTPS; validate the decoded LNURL domain before using it as a payout destination.
.env is gitignored and absent from the repo; babel-plugin-dotenv-import hard-fails at transform time without REWARDS_ENABLED / BTC_PAY_SERVER. The CI workflow (.github/workflows/ci.yml) never creates a .env, so CI breaks on a fresh clone. Verified locally: 6 suites fail without .env; after cp .env.example .env, all 18 suites / 142 tests pass.
Fix: add a cp .env.example .env (or a CI-provided .env) step before lint/typecheck/test in ci.yml.
Reward calculation is well tested, but the orchestration is not: Flashcard.tsx NFC read/balance/pay, Invoice.sendRewardsToCard, and Rewards.onReward payout flows — the three highest-risk money paths — are untested.
Fix: add integration tests for these three before further feature work. Also add requestTechnology / getTag to the NFC mock in jest.setup.js (currently missing, which will break any test importing the new readFlashcard util).
- Hardcoded Chatwoot widget token (
src/services/chatwoot/config.ts:11), passed as a URL query param (api.ts:28). It is a public widget token (low blast radius — can create/spam support conversations, not read other tenants), but it should be rotated and moved to@envlike the other endpoints; prefer header/body over query string. - PIN has no attempt lockout / throttling (
src/store/slices/pinSlice.ts:125-169). Hashing is strong, but guesses are unlimited. Add a persisted failed-attempt counter with exponential backoff / temporary lockout; consider raising PBKDF2 iterations (≥100k) for a money app. - Pervasive silent error-swallowing on NFC / axios / payment / storage paths — empty
catch {}/else {}inFlashcard.tsx(196,256-260,299),Invoice.tsx:371,amountSlice.ts:125,ApolloClient.ts:58, and several services. Failed scans/payments produce no log and no user feedback. Route through a central logger + user-facing toast. - Business logic trapped in 100-200-line screen callbacks:
Rewards.onReward(~200 lines, 20+ dep array),Invoice.handleSuccessfulPayment(~100 lines),Keypad.onCreateInvoice. Merchant-ID validation is duplicated acrossEventSettings,RewardsSettings,Rewards, andInvoice. Extract into hooks (useRewardRedemption,useInvoicePayment). src/store/slices/rewardSlice.tsis a 611-line god-slice mixing reward config, event config, and live tracking, with ~40(state: any)selectors and a ~130-line duplicated default-config block. Split into config/event/tracking; type selectors withRootState(aspinSlice.tsalready does).- Dual source of truth: flashcard/payment state and the "loading" concept are split across Redux, an unmemoized
FlashcardContext(Flashcard.tsx:341-358rebuildsvalueevery render → re-renders the whole app), andActivityIndicatorContext. Consolidate; memoize context values. - Build-toolchain advisories (8 moderate:
js-yaml,@babel/core) — dev/build-time only, not shipped. Clear viaresolutionsand re-audit. - TypeScript pinned to
5.0.4(RN 0.77 expects ~5.3+); no.nvmrc/JDK-17 pin (CI uses Node 20,enginessays>=18); CI is test-only — no native build job, no scheduledyarn audit, and lint runs--max-warnings 99999.
src/screens/Rewards.tsx:201— a failed reward still consumes the 5s cooldown (lastRewardTimeis set before the payout). Set it only inside the success branch.src/utils/transactionHelpers.ts— IDs/timestamps fromDate.now()in rapid succession risk collisions in POS use. Use a UUID.- New
src/utils/flashcard.tsx(readFlashcard, iOS NFC util) has invisible Unicode characters in itsAlertstring (line ~31) and is not covered by the NFC mock. - ~10 stray
console.*in production paths; 10 TODO/FIXME (notably unfinished Apollo WS reconnection inApolloClient.ts:30,41); 210 hardcoded hex colors with no theme file; paymentpaymentSecretretained in plaintext AsyncStorage transaction history (store/index.ts,Invoice.tsx:219). - The 3
patch-packagepatches (react-native-svg,react-native-screens,react-native-print) are legitimate RN-0.77 compat shims. Track thereact-native-screensone for removal once upstream fixes the regression. - Jest prints "did not exit one second after the test run" — an unclosed async handle (likely a chatwoot socket/timer). Investigate with
--detectOpenHandles.
- Idempotency + persistent reward-sent guard (#1) — highest money risk.
- NFC host / LNURL allowlist (#2).
- Fix CI
.env(#3) — one line; unblocks trustworthy CI. - Tests for the three untested money paths (#4).
- Then the Medium cluster — error handling, Chatwoot token rotation, PIN lockout,
rewardSlicesplit, TS/CI tooling.
Audit generated with Claude Code.