diff --git a/__tests__/websocketSubscriptionClose.test.ts b/__tests__/websocketSubscriptionClose.test.ts new file mode 100644 index 00000000..ca58a098 --- /dev/null +++ b/__tests__/websocketSubscriptionClose.test.ts @@ -0,0 +1,152 @@ +/** + * The cashu-ts contract that the websocket->poller fallbacks rest on. + * + * `_monitorSentProofs` (sendOperationApi), `_monitorAsyncMeltQuote` + * (transferOperationApi) and the mint-quote watcher (topupOperationApi) all + * subscribe over NUT-17 and fall back to a poller when the websocket cannot + * carry the update. The subscribe call failing is the easy half, and a plain + * try/catch covers it. The other half is an ESTABLISHED subscription dying + * later: mints and reverse proxies idle out long-lived sockets, and a sent + * token or a pending melt quote routinely outlives one. + * + * Up to cashu-ts 4.10 that case was UNREPORTABLE. `createSubscription` passed + * the error callback only to `addRpcListener`, which covers the subscribe RPC + * itself; `addSubListener(subId, callback)` took no error callback at all, so + * once the subscription was live nothing held a way to signal it. A socket + * close ran `stopMessageHandling()`, which drained the queue and told nobody. + * The subscription simply went quiet and the transaction hung. + * + * 4.11 (#1252) stores the error callback per subscription and fans it out from + * `stopMessageHandling(err)`. That is what lets the fallback start, and it is + * the entire reason the pollers in those three functions are hoisted out of + * the setup `catch` into a shared `startPoller()` reachable from both paths. + * + * If a future cashu-ts stops invoking the error callback on close, those three + * fallbacks go silent again with no other test failing. This pins it at the + * WSConnection level — the layer the `wallet.on.*` helpers are built on — so + * the assertion is about the library contract rather than our wiring. + * + * Deterministic and offline: the WebSocket implementation is injected. + * + * @jest-environment node + */ +import {WSConnection, injectWebSocketImpl} from '@cashu/cashu-ts' + +type CloseInit = {code: number; reason: string; wasClean: boolean} + +/** Minimal WebSocket double: opens next tick, acks `subscribe`, closes on demand. */ +class FakeWebSocket { + static instances: FakeWebSocket[] = [] + static OPEN = 1 + + onopen?: () => void + onclose?: (e: CloseInit) => void + onmessage?: (e: {data: string}) => void + onerror?: (e: unknown) => void + readyState = 0 + sent: string[] = [] + + constructor(public url: string) { + FakeWebSocket.instances.push(this) + setTimeout(() => { + this.readyState = FakeWebSocket.OPEN + this.onopen?.() + }, 0) + } + + send(raw: string) { + this.sent.push(raw) + const msg = JSON.parse(raw) + if (msg.method === 'subscribe') { + // The sub listener is only registered once this ack lands. + setTimeout( + () => + this.onmessage?.({ + data: JSON.stringify({ + jsonrpc: '2.0', + result: {status: 'OK', subId: msg.params.subId}, + id: msg.id, + }), + }), + 0, + ) + } + } + + close() { + this.readyState = 3 + } + + /** What a mint or proxy does to an idle connection. */ + dropRemote(init: Partial = {}) { + this.readyState = 3 + this.onclose?.({code: 1006, reason: 'idle timeout', wasClean: false, ...init}) + } +} + +const tick = () => new Promise(resolve => setTimeout(resolve, 5)) + +async function establishedSubscription() { + const ws = new WSConnection('wss://mint.test/v1/ws') + await ws.connect() + + const updates: unknown[] = [] + const errors: Error[] = [] + ws.createSubscription( + {kind: 'proof_state', filters: ['deadbeef']} as never, + payload => updates.push(payload), + error => errors.push(error), + ) + await tick() + + const socket = FakeWebSocket.instances[FakeWebSocket.instances.length - 1] + return {ws, socket, updates, errors} +} + +describe('an established NUT-17 subscription reports a socket close', () => { + beforeEach(() => { + FakeWebSocket.instances = [] + injectWebSocketImpl(FakeWebSocket as never) + }) + + it('PRECONDITION: the subscription is live before the socket drops', async () => { + const {ws, errors} = await establishedSubscription() + + expect(ws.activeSubscriptions).toHaveLength(1) + expect(errors).toHaveLength(0) + }) + + it('invokes the error callback when the mint drops the connection', async () => { + const {socket, errors} = await establishedSubscription() + + socket.dropRemote() + await tick() + + // This is the signal the three operation-api fallbacks start their + // poller from. Before 4.11 it never arrived and they hung instead. + expect(errors).toHaveLength(1) + expect(errors[0]).toBeInstanceOf(Error) + expect(errors[0].message).toMatch(/closed/i) + }) + + it('drops the subscription so a later close cannot fire it twice', async () => { + const {ws, socket, errors} = await establishedSubscription() + + socket.dropRemote() + await tick() + socket.dropRemote() + await tick() + + expect(errors).toHaveLength(1) + expect(ws.activeSubscriptions).toHaveLength(0) + }) + + it('reports a CLEAN close too — idle teardown is not a success signal', async () => { + const {socket, errors} = await establishedSubscription() + + socket.dropRemote({code: 1000, reason: '', wasClean: true}) + await tick() + + expect(errors).toHaveLength(1) + }) +}) diff --git a/package.json b/package.json index f2770c64..8fbdd24b 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "machine-translate": "npx @inlang/cli machine translate --project minibits.inlang" }, "dependencies": { - "@cashu/cashu-ts": "^4.10.0", + "@cashu/cashu-ts": "^4.11.0", "@fortawesome/fontawesome-svg-core": "^7.1.0", "@fortawesome/free-brands-svg-icons": "^7.1.0", "@fortawesome/free-regular-svg-icons": "^7.1.0", @@ -43,7 +43,7 @@ "@react-navigation/native": "^7.1.18", "@react-navigation/native-stack": "^7.3.27", "@react-navigation/stack": "^7.4.9", - "@scure/bip32": "^2.2.0", + "@scure/bip32": "^2.4.0", "@scure/bip39": "2.0.1", "@sentry/react-native": "^7.7.0", "date-fns": "^4.1.0", diff --git a/patches/@scure+bip32+2.2.0.patch b/patches/@scure+bip32+2.4.0.patch similarity index 83% rename from patches/@scure+bip32+2.2.0.patch rename to patches/@scure+bip32+2.4.0.patch index 5e637a9f..bfc44a0a 100644 --- a/patches/@scure+bip32+2.2.0.patch +++ b/patches/@scure+bip32+2.4.0.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@scure/bip32/index.js b/node_modules/@scure/bip32/index.js -index 8e49612..d54da85 100644 +index 49b54b8..a66540e 100644 --- a/node_modules/@scure/bip32/index.js +++ b/node_modules/@scure/bip32/index.js @@ -28,6 +28,7 @@ import { ripemd160 } from '@noble/hashes/legacy.js'; @@ -10,7 +10,7 @@ index 8e49612..d54da85 100644 const Point = /* @__PURE__ */ (() => secp.Point)(); const Fn = /* @__PURE__ */ (() => Point.Fn)(); const base58check = /* @__PURE__ */ createBase58check(sha256); -@@ -102,7 +103,8 @@ export class HDKey { +@@ -112,7 +113,8 @@ export class HDKey { throw new RangeError('HDKey: seed length must be between 128 and 512 bits; 256 bits is advised, got ' + seed.length); } @@ -20,13 +20,13 @@ index 8e49612..d54da85 100644 const privateKey = I.slice(0, 32); const chainCode = I.slice(32); return new HDKey({ versions, chainCode, privateKey }); -@@ -225,7 +227,8 @@ export class HDKey { +@@ -248,7 +250,8 @@ export class HDKey { // Normal child: serP(point(kpar)) || ser32(index) data = concatBytes(this._publicKey, data); } -- const out = _I || hmac(sha512, this.chainCode, data); -+ //const out = _I || hmac(sha512, this.chainCode, data); -+ const out = _I || new Uint8Array(quickCrypto.createHmac('sha512', this.chainCode).update(data).digest()); +- const out = _I || hmac(sha512, this._chainCode, data); ++ //const out = _I || hmac(sha512, this._chainCode, data); ++ const out = _I || new Uint8Array(quickCrypto.createHmac('sha512', this._chainCode).update(data).digest()); abytes(out, 64); const childTweak = out.slice(0, 32); const chainCode = out.slice(32); diff --git a/src/services/wallet/operations/sendOperationApi.ts b/src/services/wallet/operations/sendOperationApi.ts index 2e004e49..fa6994d2 100644 --- a/src/services/wallet/operations/sendOperationApi.ts +++ b/src/services/wallet/operations/sendOperationApi.ts @@ -739,6 +739,25 @@ async function _monitorSentProofs(params: {mintUrl: string; proofsToSend: CashuP const wsMint = new CashuMint(mintUrl) const wsWallet = new CashuWallet(wsMint) + // The websocket is the fast path; the poller is the fallback that has to run + // whenever it is not available. Two things must reach it: the subscribe failing + // outright, and an ESTABLISHED socket closing later — mints and proxies idle out + // long-lived subscriptions. cashu-ts reports the second case through the error + // callback (>= 4.11; before that an established subscription just went silent), + // so the fallback cannot live in the catch below — that catch has long since + // been left by then. + let settled = false + const startPoller = () => { + if (settled) return + settled = true + poller( + `syncStateWithMintPoller-${mintUrl}`, + WalletTask.syncStateWithMintQueueAwaitable, + {interval: 10 * 1000, maxPolls: 3, maxErrors: 1}, + {proofsToSync, mintUrl, proofState: 'PENDING' as const}, + ).then(() => log.trace('[SendOperationApi]', 'polling completed', {mintUrl})) + } + try { log.trace('[SendOperationApi]', 'Subscribing to proofStateUpdates', {secret: proofsToSend[0]?.secret}) const unsub = await wsWallet.on.proofStateUpdates( @@ -747,21 +766,21 @@ async function _monitorSentProofs(params: {mintUrl: string; proofsToSend: CashuP log.trace(`[SendOperationApi] Websocket: proof state updated: ${proofState.state} with secret: ${proofsToSend[0].secret}`) if (proofState.state === CheckStateEnum.SPENT) { WalletTask.syncStateWithMintQueueAwaitable({proofsToSync, mintUrl, proofState: 'PENDING'}) + settled = true unsub() } }, async (error: any) => { - throw error + log.warn( + '[SendOperationApi] Proof state subscription closed, falling back to poller', + {mintUrl, error: error?.message}, + ) + startPoller() }, ) } catch (error: any) { log.error(Err.NETWORK_ERROR, 'WebSocket subscription failed. Starting poller.', error.message) - poller( - `syncStateWithMintPoller-${mintUrl}`, - WalletTask.syncStateWithMintQueueAwaitable, - {interval: 10 * 1000, maxPolls: 3, maxErrors: 1}, - {proofsToSync, mintUrl, proofState: 'PENDING' as const}, - ).then(() => log.trace('[SendOperationApi]', 'polling completed', {mintUrl})) + startPoller() } } diff --git a/src/services/wallet/operations/topupOperationApi.ts b/src/services/wallet/operations/topupOperationApi.ts index bd633db7..cc289a61 100644 --- a/src/services/wallet/operations/topupOperationApi.ts +++ b/src/services/wallet/operations/topupOperationApi.ts @@ -585,6 +585,24 @@ async function _monitorMintQuote(params: { MintOperationService.enqueuePendingTopupCheck(tx) } + // The websocket is the fast path; the poller is the fallback that has to run + // whenever it is not available. Two things must reach it: the subscribe failing + // outright, and an ESTABLISHED socket closing later — mints and proxies idle out + // long-lived subscriptions. cashu-ts reports the second case through the error + // callback (>= 4.11; before that an established subscription just went silent), + // so the fallback cannot live in the catch below — that catch has long since + // been left by then. + let settled = false + const startPoller = () => { + if (settled) return + settled = true + poller( + `handlePendingTopupPoller-${paymentHash}`, + enqueueRefresh, + {interval: 10 * 1000, maxPolls: 6, maxErrors: 2}, + ).then(() => log.trace('[handlePendingTopupPoller] polling completed', {quote})) + } + try { log.trace('[TopupOperationApi]', 'Subscribing to mintQuotePaid', {quote}) const unsub = await wsWallet.on.mintQuotePaid( @@ -598,10 +616,15 @@ async function _monitorMintQuote(params: { {transactionId, error: e.message}, ) } + settled = true unsub() }, async (error: any) => { - throw error + log.warn( + '[TopupOperationApi] Mint quote subscription closed, falling back to poller', + {quote, transactionId, error: error?.message}, + ) + startPoller() }, ) } catch (error: any) { @@ -610,11 +633,7 @@ async function _monitorMintQuote(params: { '[TopupOperationApi] WebSocket error for mint quote, starting poller.', error.message, ) - poller( - `handlePendingTopupPoller-${paymentHash}`, - enqueueRefresh, - {interval: 10 * 1000, maxPolls: 6, maxErrors: 2}, - ).then(() => log.trace('[handlePendingTopupPoller] polling completed', {quote})) + startPoller() } } diff --git a/src/services/wallet/operations/transferOperationApi.ts b/src/services/wallet/operations/transferOperationApi.ts index 81629d14..d3476774 100644 --- a/src/services/wallet/operations/transferOperationApi.ts +++ b/src/services/wallet/operations/transferOperationApi.ts @@ -1340,6 +1340,24 @@ async function _monitorAsyncMeltQuote(params: { const wsMint = new CashuMint(mintUrl) const wsWallet = new CashuWallet(wsMint) + // The websocket is the fast path; the poller is what actually resolves the tx + // when it is not available. Two things must reach it: the subscribe failing + // outright, and an ESTABLISHED socket closing later — mints and proxies idle out + // long-lived subscriptions, and a melt quote can stay pending for minutes. + // cashu-ts reports the second case through the error callback (>= 4.11; before + // that an established subscription just went silent), so the fallback cannot + // live in the catch below — that catch has long since been left by then. + let settled = false + const startPoller = () => { + if (settled) return + settled = true + poller( + `meltQuotePoller-${quoteId}`, + () => refresh(transactionId), + {interval: 15 * 1000, maxPolls: 8, maxErrors: 2}, + ).then(() => log.trace('[meltQuotePoller] polling completed', {quoteId})) + } + try { log.trace('[TransferOperationApi]', 'Subscribing to meltQuoteUpdates', {quoteId}) const unsub = await wsWallet.on.meltQuoteUpdates( @@ -1357,11 +1375,16 @@ async function _monitorAsyncMeltQuote(params: { {transactionId, error: refreshError.message}, ) } + settled = true unsub() } }, async (error: any) => { - throw error + log.warn( + '[TransferOperationApi] Melt quote subscription closed, falling back to poller', + {quoteId, transactionId, error: error?.message}, + ) + startPoller() }, ) } catch (error: any) { @@ -1370,11 +1393,7 @@ async function _monitorAsyncMeltQuote(params: { '[TransferOperationApi] WebSocket error for async melt, starting poller.', error.message, ) - poller( - `meltQuotePoller-${quoteId}`, - () => refresh(transactionId), - {interval: 15 * 1000, maxPolls: 8, maxErrors: 2}, - ).then(() => log.trace('[meltQuotePoller] polling completed', {quoteId})) + startPoller() } } diff --git a/yarn.lock b/yarn.lock index 56773579..df3df780 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2869,15 +2869,15 @@ __metadata: languageName: node linkType: hard -"@cashu/cashu-ts@npm:^4.10.0": - version: 4.10.0 - resolution: "@cashu/cashu-ts@npm:4.10.0" +"@cashu/cashu-ts@npm:^4.11.0": + version: 4.11.0 + resolution: "@cashu/cashu-ts@npm:4.11.0" dependencies: - "@noble/curves": ^2.2.0 - "@noble/hashes": ^2.2.0 - "@scure/base": ^2.2.0 - "@scure/bip32": ^2.2.0 - checksum: 55994116585f64acf6078e000ca747e5d082d307779165c9a1aece0803a6d2f1d1c3d9c0c5616cb1b3df8ac12ca2175d03c42f59d08ef3df21ab13c79ecc6b79 + "@noble/curves": ^2.4.0 + "@noble/hashes": ^2.4.0 + "@scure/base": ^2.4.0 + "@scure/bip32": ^2.4.0 + checksum: 1adfa648fdb36bd6308e600535f6d1a1d258fbaeada371709e42a7aade425dd5ff0eb844bec09a29da79aad226ad7c4377fe3abcab6f8614e0371144ae4a2709 languageName: node linkType: hard @@ -5449,12 +5449,12 @@ __metadata: languageName: node linkType: hard -"@noble/curves@npm:2.2.0, @noble/curves@npm:^2.2.0": - version: 2.2.0 - resolution: "@noble/curves@npm:2.2.0" +"@noble/curves@npm:2.4.0, @noble/curves@npm:^2.4.0": + version: 2.4.0 + resolution: "@noble/curves@npm:2.4.0" dependencies: - "@noble/hashes": 2.2.0 - checksum: 2b6f02c18918578f528791644886f54333c67323f5ccefe10bf250f08760f63786b807390d8d5f8562c661e70dd3559ce8e51e1fe360118c8fd47071c2bd6991 + "@noble/hashes": 2.4.0 + checksum: 3e1d2a57efea01bd76cd2e0d711446b67ac4124988f4abe8b4b849f04a9d52b7fa1e7fa7d8ed94151ca31cb09c0bc7e00b77a057a7d99f114a0d45f8ead6d71e languageName: node linkType: hard @@ -5465,10 +5465,10 @@ __metadata: languageName: node linkType: hard -"@noble/hashes@npm:2.2.0, @noble/hashes@npm:^2.2.0": - version: 2.2.0 - resolution: "@noble/hashes@npm:2.2.0" - checksum: a8745fb5da57a73ddd3dd6d5ad45d156ea664be51ead5344ca1528f8e56ad0ba0720ec8adff3bda37c50c72a86a33a57332ec02c3fb8d2a2612f9c3e5e7079ab +"@noble/hashes@npm:2.4.0, @noble/hashes@npm:^2.4.0": + version: 2.4.0 + resolution: "@noble/hashes@npm:2.4.0" + checksum: 0eaf273e341c53ebf7de3e6dcc27755c2124da04152549a61580a525929844e05a7799aeeb9a6a46943cde59ff297ebe6d61968669b162577db188c62b948efe languageName: node linkType: hard @@ -6463,10 +6463,10 @@ __metadata: languageName: node linkType: hard -"@scure/base@npm:2.2.0, @scure/base@npm:^2.2.0": - version: 2.2.0 - resolution: "@scure/base@npm:2.2.0" - checksum: 2dd91f310765366e2dcb5e37709eb35ccd57d3f2f112d36232ca03fb8819c8764708f333ac40935fa719ff2e943bd50e1912ab43d212e44e807100d21f835f13 +"@scure/base@npm:2.4.0, @scure/base@npm:^2.4.0": + version: 2.4.0 + resolution: "@scure/base@npm:2.4.0" + checksum: 95014a31c1c0fc8d70913031535060deac181aef10cd84cacbc0bd394db58df22fd35ce7b14a73d34e158b472094fd92e614613598aa03378ffeb8967b5b007c languageName: node linkType: hard @@ -6481,14 +6481,14 @@ __metadata: languageName: node linkType: hard -"@scure/bip32@npm:^2.2.0": - version: 2.2.0 - resolution: "@scure/bip32@npm:2.2.0" +"@scure/bip32@npm:^2.4.0": + version: 2.4.0 + resolution: "@scure/bip32@npm:2.4.0" dependencies: - "@noble/curves": 2.2.0 - "@noble/hashes": 2.2.0 - "@scure/base": 2.2.0 - checksum: 6cd0ddf603b739d3c24a13618fc246d409bbf0dffec22bc2c3c8e3e64271bb58c7078bea0b6ab9601fcfe881a969ee31e3074d478affe13a439d7527a0b68ecf + "@noble/curves": 2.4.0 + "@noble/hashes": 2.4.0 + "@scure/base": 2.4.0 + checksum: fabadcab4673e17c3702bca8037f847f0c6e90b7bc751424de740c26f5de432bac957716dbd16fb775005db1701fd6e220cf8adc17a46d5e355b7086969faf39 languageName: node linkType: hard @@ -14762,7 +14762,7 @@ __metadata: "@babel/plugin-proposal-export-namespace-from": ^7.18.9 "@babel/preset-env": ^7.25.3 "@babel/runtime": ^7.25.0 - "@cashu/cashu-ts": ^4.10.0 + "@cashu/cashu-ts": ^4.11.0 "@expo/fingerprint": ^0.13.4 "@fortawesome/fontawesome-svg-core": ^7.1.0 "@fortawesome/free-brands-svg-icons": ^7.1.0 @@ -14794,7 +14794,7 @@ __metadata: "@react-navigation/native": ^7.1.18 "@react-navigation/native-stack": ^7.3.27 "@react-navigation/stack": ^7.4.9 - "@scure/bip32": ^2.2.0 + "@scure/bip32": ^2.4.0 "@scure/bip39": 2.0.1 "@sentry/cli": ^2.56.0 "@sentry/react-native": ^7.7.0