diff --git a/flake.nix b/flake.nix index a70fa1a..bf32159 100644 --- a/flake.nix +++ b/flake.nix @@ -16,7 +16,7 @@ package = pkgs.buildNpmPackage { inherit pname version; src = ./.; - npmDepsHash = "sha256-6TdR6dCmC2Oe/B520L4PNy/Y0KChJEDZa0rPo4HHvx4="; + npmDepsHash = "sha256-LQ9rqHf/Gd7Cu5iME+wXdz7IOKFwKFlMBOaQq9ZOI1g="; dontNpmBuild = true; dontBuild = true; diff --git a/incoming.mjs b/incoming.mjs index 8f2bac1..a78c09c 100644 --- a/incoming.mjs +++ b/incoming.mjs @@ -40,7 +40,7 @@ export async function receivedFundsAvailable(wallet, request) { // proves that this receipt cleared before the leaf was subsequently spent. // Restrict this inference to single-receiver transfers; other receiver legs // do not establish this wallet's ownership history. - // Pinned SDK 0.9.0 maps protobuf receivers to WalletTransfer.receivers; + // Pinned SDK 0.13.0 maps protobuf receivers to WalletTransfer.receivers; // undefined means the protobuf list was empty (legacy transfer). return ( (transfer.receivers?.length || 0) <= 1 && diff --git a/incoming.test.mjs b/incoming.test.mjs index 2ec4764..b9e2fa9 100644 --- a/incoming.test.mjs +++ b/incoming.test.mjs @@ -211,7 +211,7 @@ test('missing cache entries and unrelated balance alone do not prove clearing', assert.equal(await receivedFundsAvailable(wallet, invoice), false) }) -test('SDK 0.9.0 getTransfer preserves receiver legs and blocks multi-receiver ownership inference', async () => { +test('SDK 0.13.0 getTransfer preserves receiver legs and blocks multi-receiver ownership inference', async () => { const ownKey = Buffer.from(`02${'1'.repeat(64)}`, 'hex') const otherKey = Buffer.from(`03${'2'.repeat(64)}`, 'hex') const laterOwner = Buffer.from(`02${'3'.repeat(64)}`, 'hex') @@ -253,6 +253,7 @@ test('SDK 0.9.0 getTransfer preserves receiver legs and blocks multi-receiver ow const single = await wallet.getTransfer('transfer') assert.deepEqual(single.receivers, [ { + id: 'own', identityPublicKey: ownKey.toString('hex'), amountSats: 100, status: 'TRANSFER_RECEIVER_STATUS_COMPLETED' diff --git a/lightning.mjs b/lightning.mjs index 5b9456c..850667c 100644 --- a/lightning.mjs +++ b/lightning.mjs @@ -13,7 +13,7 @@ export const terminalPaymentStatuses = new Set([ 'USER_SWAP_RETURN_FAILED' ]) -// These validations occur before payment dispatch in SDK 0.9.0. Do not +// These validations occur before payment dispatch in SDK 0.13.0. Do not // classify all SparkValidationErrors as failed: some occur after sending. function rejectedBeforeSend(error) { if (!(error instanceof SparkValidationError)) return false @@ -35,7 +35,7 @@ export function decodePayment(invoice) { } } -// SDK 0.9.0 LightningSendRequest contains encodedInvoice. The nested invoice +// SDK 0.13.0 LightningSendRequest contains encodedInvoice. The nested invoice // object belongs to LightningReceiveRequest and is absent on outgoing requests. export function lightningSendPaymentHash(request) { const invoice = request?.encodedInvoice @@ -140,7 +140,7 @@ export async function prepareLightningPayment( let fee try { fee = await wallet.getLightningSendFeeEstimate({ - // SDK 0.9.0 payLightningInvoice normalizes before requesting its quote. + // SDK 0.13.0 payLightningInvoice normalizes before requesting its quote. // The standalone quote method does not; preserve that behavior here too. encodedInvoice: params.invoice.toLowerCase(), amountSats: params.amountSatsToSend diff --git a/package-lock.json b/package-lock.json index 971da17..ad44d43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "lnbits-spark-sidecar", "dependencies": { - "@buildonspark/spark-sdk": "0.9.0", + "@buildonspark/spark-sdk": "0.13.0", "light-bolt11-decoder": "3.2.0" }, "devDependencies": { @@ -21,9 +21,9 @@ "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@buildonspark/spark-sdk": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@buildonspark/spark-sdk/-/spark-sdk-0.9.0.tgz", - "integrity": "sha512-lG6Bh5wB4OFmm/AhvXyBMp84mw9BOPwCrOp/iJjP6vq2MYlCuWhAQERyKE+oPcPdAzNYKiqu455k5XI4zABBBw==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@buildonspark/spark-sdk/-/spark-sdk-0.13.0.tgz", + "integrity": "sha512-YivAlt1iAvEKK00Vo8ywFnO9h7mr2IPmsp12wbsThTB5q3DMAlRFtcXLGi+NVYHDnd7L3oTVQCmzDkXOBajDAA==", "license": "Apache-2.0", "dependencies": { "@bufbuild/protobuf": "^2.2.5", @@ -71,9 +71,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.14.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", - "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", + "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.8.0", diff --git a/package.json b/package.json index 313926e..34334c7 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "dependencies": { - "@buildonspark/spark-sdk": "0.9.0", + "@buildonspark/spark-sdk": "0.13.0", "light-bolt11-decoder": "3.2.0" }, "devDependencies": { diff --git a/payments.mjs b/payments.mjs index ce5faf9..08a39b1 100644 --- a/payments.mjs +++ b/payments.mjs @@ -1,5 +1,6 @@ import {OperationQueue} from './operation-queue.mjs' import {createHash, timingSafeEqual} from 'node:crypto' +import {UUID} from '@buildonspark/spark-sdk' import { prepareLightningPayment, sendLightningPayment, @@ -100,18 +101,22 @@ export class PaymentService { if (data.amount_sats !== undefined) integer(data.amount_sats, 1) if (decodePayment(data.bolt11).hash !== checkingId) throw new Error('Invoice payment hash mismatch') + // A deterministic UUIDv8 keeps retries stable across process restarts. + const transferBytes = createHash('sha256') + .update(`spark-sidecar:lightning:${this.network}:${checkingId}`) + .digest() + .subarray(0, 16) + transferBytes[6] = (transferBytes[6] & 0x0f) | 0x80 + transferBytes[8] = (transferBytes[8] & 0x3f) | 0x80 let wallet const params = { invoice: data.bolt11, maxFeeSats: data.max_fee_sats, amountSatsToSend: data.amount_sats, preferSpark: false, - // SDK 0.9.0 forwards this to initiate_preimage_swap_v3. Spark stores - // the deduplicated response, scoped to the authenticated wallet identity. - // Never vary this key with invoice case, fee allowance, or process lifetime. - idempotencyKey: createHash('sha256') - .update(`spark-sidecar:lightning:${this.network}:${checkingId}`) - .digest('hex') + // SDK 0.13.0 uses this ID for the transfer, preimage swap and SSP request. + // Never vary it with invoice case, fee allowance, or process lifetime. + transferId: UUID.ofInner(transferBytes) } try { wallet = await this.getWallet() diff --git a/payments.test.mjs b/payments.test.mjs index decb1d7..a67cbe0 100644 --- a/payments.test.mjs +++ b/payments.test.mjs @@ -5,7 +5,8 @@ import {bech32} from '@scure/base' import { SparkValidationError, SparkWallet, - Network + Network, + UUID } from '@buildonspark/spark-sdk' import {getLightningSendRequestQuery} from '@buildonspark/spark-sdk/types' import {PaymentService, createPaymentHandler} from './payments.mjs' @@ -103,7 +104,7 @@ test('the pinned SDK passes the sidecar idempotency key to its preimage-swap ser feeEstimate: {originalUnit: 'SATOSHI', originalValue: 1} }), requestLightningSend: async ({userOutboundTransferExternalId}) => { - assert.equal(userOutboundTransferExternalId, 'provider-transfer') + assert.equal(userOutboundTransferExternalId, keys.at(-1)) return request() } }) @@ -113,11 +114,19 @@ test('the pinned SDK passes the sidecar idempotency key to its preimage-swap ser selectLeavesAndExecute: async (_, callback) => callback([[]]), handleTransferEvent: async () => {} } - sdk.transferService = {prepareTransferForLightning: async () => ({})} + sdk.transferService = { + prepareTransferForLightning: async (_, __, ___, transferId) => ({ + transferId + }) + } sdk.lightningService = { swapNodesForPreimage: async params => { + assert.equal( + params.startTransferRequest.transferId, + params.idempotencyKey + ) keys.push(params.idempotencyKey) - return {transfer: {id: 'provider-transfer'}} + return {transfer: {id: keys.at(-1), leaves: []}} } } const options = { @@ -134,7 +143,10 @@ test('the pinned SDK passes the sidecar idempotency key to its preimage-swap ser ) } assert.equal(keys.length, 2) - assert.match(keys[0], /^[0-9a-f]{64}$/) + assert.match( + keys[0], + /^[0-9a-f]{8}-[0-9a-f]{4}-8[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ) assert.equal(keys[0], keys[1]) }) @@ -175,9 +187,14 @@ test('Spark receives a stable idempotency key across restarts, casing and fee ch // Simulate provider history lag: deduplication must not depend on local data. payLightningInvoice: async params => { assert.equal(params.preferSpark, false) - assert.match(params.idempotencyKey, /^[0-9a-f]{64}$/) - if (!keys.has(params.idempotencyKey)) { - keys.add(params.idempotencyKey) + assert.ok(params.transferId instanceof UUID) + assert.equal(params.idempotencyKey, undefined) + assert.match( + params.transferId.toString(), + /^[0-9a-f]{8}-[0-9a-f]{4}-8[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ) + if (!keys.has(params.transferId.toString())) { + keys.add(params.transferId.toString()) transfers++ } return request() @@ -224,7 +241,7 @@ test('lost send response recovers through Spark history after replacement', asyn assert.equal(calls, 1) }) -test('hash recovery matches outgoing invoices parsed by SDK 0.9.0', async t => { +test('hash recovery matches outgoing invoices parsed by SDK 0.13.0', async t => { const providerRequest = getLightningSendRequestQuery( 'spark-request' ).constructObject({ @@ -568,13 +585,13 @@ test('concurrent invoices and duplicate POSTs stay separate with provider dedupl peak = Math.max(peak, activeHashes.size) await new Promise(resolve => setTimeout(resolve, 5)) activeHashes.delete(hash) - if (!sent.has(params.idempotencyKey)) - sent.set(params.idempotencyKey, { + if (!sent.has(params.transferId.toString())) + sent.set(params.transferId.toString(), { ...request(), id: `spark-${hash}`, encodedInvoice: params.invoice }) - return sent.get(params.idempotencyKey) + return sent.get(params.transferId.toString()) } }) const handler = await createPaymentHandler({...options, apiKey: 'test'}) diff --git a/test/fixture-sdk.mjs b/test/fixture-sdk.mjs index 0a1b00b..84382a2 100644 --- a/test/fixture-sdk.mjs +++ b/test/fixture-sdk.mjs @@ -51,7 +51,7 @@ export const SparkWallet = { encodedInvoice: params.invoice, fee: {originalUnit: 'SATOSHI', originalValue: 1} } - data.idempotencyKey = params.idempotencyKey + data.transferId = params.transferId.toString() data.submissions = (data.submissions || 0) + 1 // This file represents the external Spark service in the test. It is // outside the sidecar working directory and is never a sidecar journal.