Uograde cashu-ts to 4.11

This commit is contained in:
minibits-cash
2026-09-28 21:29:47 +02:00
parent d10d3893e3
commit 641e904be6
7 changed files with 266 additions and 57 deletions
@@ -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<CloseInit> = {}) {
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)
})
})
+2 -2
View File
@@ -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",
@@ -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);
@@ -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()
}
}
@@ -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()
}
}
@@ -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()
}
}
+30 -30
View File
@@ -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