Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion incoming.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
3 changes: 2 additions & 1 deletion incoming.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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'
Expand Down
6 changes: 3 additions & 3 deletions lightning.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
17 changes: 11 additions & 6 deletions payments.mjs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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()
Expand Down
41 changes: 29 additions & 12 deletions payments.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
}
})
Expand All @@ -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 = {
Expand All @@ -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])
})

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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'})
Expand Down
2 changes: 1 addition & 1 deletion test/fixture-sdk.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading