diff --git a/__tests__/animatedQrThroughput.test.ts b/__tests__/animatedQrThroughput.test.ts new file mode 100644 index 00000000..703b1b61 --- /dev/null +++ b/__tests__/animatedQrThroughput.test.ts @@ -0,0 +1,52 @@ +/** + * Animated QR (UR) throughput. + * + * Scanning a token used to take minutes because react-native-camera-kit defaults + * scanThrottleDelay to 2000 ms — one accepted part every 2 s. ScanScreen now passes + * scanThrottleDelay={0}, so the QRCode display interval is the only limit. + * + * This pins the part budget the timing estimate rests on: if the fountain encoder or + * the fragment length regresses, the wall-clock time goes up with it. + */ +import { UR, UREncoder, URDecoder } from '@gandlaf21/bc-ur' + +// Must mirror src/screens/Wallet/QRCode.tsx. +const ANIMATED_QR_FRAGMENT_LENGTH = 150 +const ANIMATED_QR_INTERVAL = 250 + +const partsToDecode = (payload: string) => { + const encoder = new UREncoder(UR.fromBuffer(Buffer.from(payload)), ANIMATED_QR_FRAGMENT_LENGTH, 0) + const decoder = new URDecoder() + let parts = 0 + + while (!decoder.isComplete()) { + decoder.receivePart(encoder.nextPart()) + parts++ + if (parts > 1000) throw new Error('decoder never completed') + } + + expect(decoder.isSuccess()).toBe(true) + return {parts, decoded: Buffer.from(decoder.resultUR().decodeCBOR()).toString('utf8')} +} + +test('a ~3 kB token round-trips within a few seconds of display time', () => { + const token = 'cashuB' + 'a'.repeat(3000) + const {parts, decoded} = partsToDecode(token) + + expect(decoded).toBe(token) + + // 3000 bytes / 150 = 20 fragments; the fountain encoder sends those in order first, + // so a lossless reader needs no more than a handful of extra parts. + expect(parts).toBeLessThanOrEqual(25) + // The number that actually matters to the user: seconds, not minutes. + expect(parts * ANIMATED_QR_INTERVAL).toBeLessThan(8000) +}) + +test('every displayed part is distinct, so the scanner-side dedupe never stalls', () => { + const encoder = new UREncoder(UR.fromBuffer(Buffer.from('x'.repeat(1500))), ANIMATED_QR_FRAGMENT_LENGTH, 0) + const seen = Array.from({length: 30}, () => encoder.nextPart()) + + // ScanScreen skips a read equal to the previous one. Consecutive repeats from the + // encoder would make that dedupe drop real parts. + seen.forEach((part, i) => expect(part).not.toBe(seen[i - 1])) +}) diff --git a/jest.config.js b/jest.config.js index 54dde50d..26ca6e37 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,13 +7,17 @@ module.exports = { // The `(.*/)?` makes the allowlist apply at any depth: nostr-tools carries its // own ESM-only @noble copies in a nested node_modules, and those need babel too. transformIgnorePatterns: [ - 'node_modules/(?!(.*/)?((jest-)?react-native|@react-native|@react-native-community|@cashu|@noble|@scure|react-native-flash-message)/)', + 'node_modules/(?!(.*/)?((jest-)?react-native|@react-native|@react-native-community|@cashu|@noble|@scure|cborg|react-native-flash-message)/)', ], // Several native or source-shipped packages are unusable under jest, and the // MODEL layer reaches all of them at import time (Mint -> services barrel -> // mmkvStorage / keyChain / db). That is what made MST stores impossible to // instantiate in a test at all; these mappings are what make store tests possible. moduleNameMapper: { + // cborg (via @gandlaf21/bc-ur) is ESM-only and its exports map has an "import" + // condition only, which jest's CJS resolver cannot satisfy. Point at the entry + // file directly and let babel transform it (see transformIgnorePatterns above). + '^cborg$': '/node_modules/cborg/cborg.js', // Same shape as the @scure/bip39 mapping below: the package's exports map // names only the suffixed path, so app code imports it that way. This accepts // both spellings, for transitive dependencies still using the bare one. diff --git a/src/screens/ScanScreen.tsx b/src/screens/ScanScreen.tsx index c6de3721..8172da45 100644 --- a/src/screens/ScanScreen.tsx +++ b/src/screens/ScanScreen.tsx @@ -41,10 +41,16 @@ export const ScanScreen = function ScanScreen({ route }: Props) { const {mintsStore} = useStores() const [shouldLoad, setShouldLoad] = useState(false) - const [isScanned, setIsScanned] = useState(false) const [prevRouteName, setPrevRouteName] = useState('') - const [urDecoder, setUrDecoder] = useState(undefined) const [urDecoderProgress, setUrDecoderProgress] = useState(0) + /* + * Refs, not state: with scanThrottleDelay={0} onReadCode fires on every camera + * frame (~30/s), so a state flag would still be stale on the next few frames and + * let a completed scan navigate several times. + */ + const urDecoder = useRef(new URDecoder()) + const isScanned = useRef(false) + const lastPart = useRef('') const [unit, setUnit] = useState('sat') const [mint, setMint] = useState(undefined) const [error, setError] = useState() @@ -62,8 +68,6 @@ export const ScanScreen = function ScanScreen({ route }: Props) { setPrevRouteName(prevRoute) } - const decoder = new URDecoder() - setUrDecoder(decoder) } load() }, []) @@ -93,23 +97,28 @@ export const ScanScreen = function ScanScreen({ route }: Props) { const onReadCode = async function(event: any) { + if (isScanned.current) { return } + const scanned = event.nativeEvent.codeStringValue - + + // The camera reads the same QR many times while it is on screen. Skipping the + // repeats keeps receivePart() and the progress re-render off the hot path. + if (scanned === lastPart.current) { return } + lastPart.current = scanned + if (scanned.toLowerCase().startsWith("ur:")) { - if(!urDecoder) { return } + urDecoder.current.receivePart(scanned) + setUrDecoderProgress(Math.floor(urDecoder.current.estimatedPercentComplete() * 100)) - urDecoder.receivePart(scanned) - setUrDecoderProgress(Math.floor(urDecoder.estimatedPercentComplete() * 100)) - - if (!urDecoder.isComplete()) { + if (!urDecoder.current.isComplete()) { return; } - if (urDecoder.isSuccess()) { - setIsScanned(true) + if (urDecoder.current.isSuccess()) { + isScanned.current = true setUrDecoderProgress(0) - const ur = urDecoder.resultUR() + const ur = urDecoder.current.resultUR() const decodedBuffer = ur.decodeCBOR() const decodedData = Buffer.from(decodedBuffer).toString('utf8') @@ -118,11 +127,11 @@ export const ScanScreen = function ScanScreen({ route }: Props) { return onIncomingData(decodedData) } else { - setError(new AppError(Err.SCAN_ERROR, urDecoder.resultError())) + setError(new AppError(Err.SCAN_ERROR, urDecoder.current.resultError())) } } - setIsScanned(true) + isScanned.current = true log.trace('Scanned', {scanned}) return onIncomingData(scanned) @@ -307,7 +316,8 @@ export const ScanScreen = function ScanScreen({ route }: Props) { (isScanned ? undefined : onReadCode(event))} + scanThrottleDelay={0} + onReadCode={onReadCode} style={{flex: 1}} /> diff --git a/src/screens/Wallet/QRCode.tsx b/src/screens/Wallet/QRCode.tsx index 59a2cb87..9f536e0d 100644 --- a/src/screens/Wallet/QRCode.tsx +++ b/src/screens/Wallet/QRCode.tsx @@ -27,6 +27,10 @@ import AppError, { Err } from '../../utils/AppError'; export type QRCodeBlockTypes = 'EncodedV3Token' | 'EncodedV4Token' | 'Bolt11Invoice' | 'URL' | 'NWC' | 'PUBKEY' | 'PaymentRequest' | 'BitcoinAddress' const ANIMATED_QR_FRAGMENT_LENGTH = 150 +// ScanScreen passes scanThrottleDelay={0}, so our own scanner reads every camera +// frame and the display rate is the only limit. 4 fps is kept deliberately: it is +// what other cashu wallets animate at, and it survives a low-end camera that cannot +// decode a frame in less than ~200ms. Do not raise it without device testing. const ANIMATED_QR_INTERVAL = 250 export const QRCodeBlock = function (props: {