From 8942d726c1e9a13d8c4cae3ac0393f1f7cdda8eb Mon Sep 17 00:00:00 2001 From: Imran Munir Date: Thu, 13 Aug 2026 07:54:08 +0100 Subject: [PATCH] feat(sdk): let a 402 declare txids the payer may omit from payment ancestry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An x-bsv-payment carries the payment transaction plus its ancestry, so the recipient can verify it without asking anyone. Any ancestor the recipient already holds is redundant weight, but the payer cannot know which those are, so it sends all of them. Chained payments therefore grow without bound: each spends the previous payment's unconfirmed change, so every payment re-ships the whole unconfirmed run until a block collapses it to a merkle path. BRC-96 already specifies the shorter encoding (Tx Data Format 02, version marker 0200BEEF), the SDK already implements it, and wallet-toolbox already honours createAction's knownTxids end to end. The only missing piece is that nothing tells the payer which txids the recipient has. Read an optional x-bsv-payment-known-txids off the 402 and thread it into createAction. Absent header omits the option entirely, so behaviour is byte-identical to before. Parsing is fail-soft — malformed entries are dropped rather than thrown — because a bad header should cost bytes, never a payment. The list is capped at 256 so a hostile server cannot inflate the createAction call. Only the recipient may populate this. Per BRC-96 a txid-only entry "is treated as implicitly valid", i.e. the recipient verifies nothing about it, so omitting an ancestor the recipient lacks makes the payment unverifiable. The list must come from the recipient's own records and must never be inferred by the payer. Both paths that build a payment forward the list, including the regeneration branch taken when a server adjusts its price mid-flight — a repriced retry is already the largest request in the exchange. --- packages/sdk/src/auth/clients/AuthFetch.ts | 66 ++++++- .../__tests__/AuthFetch.knownTxids.test.ts | 181 ++++++++++++++++++ 2 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 packages/sdk/src/auth/clients/__tests__/AuthFetch.knownTxids.test.ts diff --git a/packages/sdk/src/auth/clients/AuthFetch.ts b/packages/sdk/src/auth/clients/AuthFetch.ts index 41e588f7f..61927b740 100644 --- a/packages/sdk/src/auth/clients/AuthFetch.ts +++ b/packages/sdk/src/auth/clients/AuthFetch.ts @@ -76,6 +76,56 @@ const PAYMENT_VERSION = '1.0' const AUTH_RESPONSE_TIMEOUT_MS = 30000 const MAX_PENDING_AUTH_REQUESTS = 1000 +/** + * Optional 402 response header by which a server declares transactions it already holds. + * + * A payment carries its ancestry so the recipient can verify it without asking anyone. Any + * ancestor the recipient ALREADY has is redundant weight — but the payer cannot know which + * those are, so today it sends all of them. Chained payments therefore grow without bound: + * each spends the previous payment's unconfirmed change, so every payment re-ships the whole + * unconfirmed run until a block collapses it to a merkle path. Past ~32KB of total request + * headers a Cloudflare-fronted origin refuses the request outright, and the payer has already + * broadcast and paid by then. + * + * `knownTxids` is the existing wallet mechanism for exactly this: listed txids are emitted as + * txid-only instead of full transactions. It is unused here only because nothing tells the + * payer what the recipient has. + * + * Txid-only encoding is specified by BRC-96 "BEEF V2, Txid Only Extension" + * (https://github.com/bitcoin-sv/BRCs/blob/master/transactions/0096.md): Tx Data Format `02` + * carries just the 32-byte txid under version marker `0200BEEF`, for use "when parties + * exchanging BEEFs have already validated certain transactions". Note the spec's wording — + * such an entry "is treated as implicitly valid", i.e. the recipient verifies nothing about + * it, which is why only the recipient may declare one. + * + * Format: comma-separated 64-character hex txids. Absent header = no change in behaviour. + * + * SAFETY: only the recipient may populate this. Omitting an ancestor the recipient lacks makes + * the payment unverifiable, so the list must come from the recipient's own records — never + * inferred by the payer. + */ +const KNOWN_TXIDS_HEADER = 'x-bsv-payment-known-txids' +const TXID_REGEX = /^[0-9a-fA-F]{64}$/ +/** Bounded so a hostile or buggy server cannot inflate the createAction call. */ +const MAX_KNOWN_TXIDS = 256 + +/** + * Parse the known-txids header into a validated list. + * + * Deliberately lenient about the header being absent, empty or partly malformed: this is an + * optimisation, and a bad entry should cost bytes, never a failed payment. Anything that is not + * a well-formed txid is dropped rather than throwing. + */ +export function parseKnownTxidsHeader(headerValue: string | null): string[] | undefined { + if (headerValue == null) return undefined + const txids = headerValue + .split(',') + .map(t => t.trim().toLowerCase()) + .filter(t => TXID_REGEX.test(t)) + if (txids.length === 0) return undefined + return Array.from(new Set(txids)).slice(0, MAX_KNOWN_TXIDS) +} + /** * AuthFetch provides a lightweight fetch client for interacting with servers * over a simplified HTTP transport mechanism. It integrates session management, peer communication, @@ -589,6 +639,8 @@ export class AuthFetch { throw new Error('Missing x-bsv-payment-derivation-prefix response header.') } + const knownTxids = parseKnownTxidsHeader(originalResponse.headers.get(KNOWN_TXIDS_HEADER)) + let paymentContext = config.paymentContext if (paymentContext == null) { paymentContext = await this.createPaymentContext( @@ -596,7 +648,8 @@ export class AuthFetch { config, satoshisRequired, serverIdentityKey, - derivationPrefix + derivationPrefix, + knownTxids ) } else { const requirementsChanged = !this.isPaymentContextCompatible( @@ -616,7 +669,8 @@ export class AuthFetch { config, satoshisRequired, serverIdentityKey, - derivationPrefix + derivationPrefix, + knownTxids ) } } @@ -705,7 +759,8 @@ export class AuthFetch { config: SimplifiedFetchRequestOptions, satoshisRequired: number, serverIdentityKey: string, - derivationPrefix: string + derivationPrefix: string, + knownTxids?: string[] ): Promise { const derivationSuffix = await createNonce(this.wallet, undefined, this.originator) @@ -738,7 +793,10 @@ export class AuthFetch { } ], options: { - randomizeOutputs: false + randomizeOutputs: false, + // Ancestors the recipient already holds are emitted txid-only rather than in full. + // Undefined when the server did not declare any, which is the pre-existing behaviour. + ...(knownTxids != null ? { knownTxids } : {}) } }, this.originator diff --git a/packages/sdk/src/auth/clients/__tests__/AuthFetch.knownTxids.test.ts b/packages/sdk/src/auth/clients/__tests__/AuthFetch.knownTxids.test.ts new file mode 100644 index 000000000..fe582919c --- /dev/null +++ b/packages/sdk/src/auth/clients/__tests__/AuthFetch.knownTxids.test.ts @@ -0,0 +1,181 @@ +import { jest } from '@jest/globals' +import { parseKnownTxidsHeader, AuthFetch } from '../AuthFetch.js' +import { Utils, PrivateKey } from '../../../primitives/index.js' + +jest.mock('../../utils/createNonce.js', () => ({ + createNonce: jest.fn() +})) + +import { createNonce } from '../../utils/createNonce.js' + +const createNonceMock = createNonce as jest.MockedFunction + +/** + * The known-txids header is an optimisation: it lets a payer omit ancestry the recipient + * already holds. It must therefore fail SOFT. A malformed or hostile header should cost + * bytes on the wire, never a failed payment — so every invalid case must degrade to + * "send everything", which is exactly the behaviour that exists today. + */ +describe('parseKnownTxidsHeader', () => { + const A = 'a'.repeat(64) + const B = 'b'.repeat(64) + + it('returns undefined when the header is absent, so behaviour is unchanged', () => { + expect(parseKnownTxidsHeader(null)).toBeUndefined() + }) + + it('returns undefined for an empty or whitespace header rather than an empty list', () => { + // An empty array would still be passed to createAction; undefined omits the option entirely. + expect(parseKnownTxidsHeader('')).toBeUndefined() + expect(parseKnownTxidsHeader(' ')).toBeUndefined() + expect(parseKnownTxidsHeader(',,,')).toBeUndefined() + }) + + it('parses a single txid', () => { + expect(parseKnownTxidsHeader(A)).toEqual([A]) + }) + + it('parses a comma-separated list and tolerates surrounding whitespace', () => { + expect(parseKnownTxidsHeader(` ${A} , ${B} `)).toEqual([A, B]) + }) + + it('lowercases so callers can compare without normalising', () => { + expect(parseKnownTxidsHeader(A.toUpperCase())).toEqual([A]) + }) + + it('de-duplicates repeated txids', () => { + expect(parseKnownTxidsHeader(`${A},${A},${B}`)).toEqual([A, B]) + }) + + it('drops malformed entries but keeps the valid ones', () => { + // Wrong length, non-hex, and empty segments must not discard a usable txid. + expect(parseKnownTxidsHeader(`${A},nothex,${'c'.repeat(63)},,${B}`)).toEqual([A, B]) + }) + + it('returns undefined when every entry is malformed', () => { + expect(parseKnownTxidsHeader('nope,also-nope')).toBeUndefined() + }) + + it('caps the list so a hostile server cannot inflate the createAction call', () => { + const many = Array.from({ length: 400 }, (_, i) => i.toString(16).padStart(64, '0')) + const parsed = parseKnownTxidsHeader(many.join(',')) + expect(parsed).toHaveLength(256) + }) +}) + +// --------------------------------------------------------------------------- +// Wiring: the parsed list has to reach createAction on EVERY path that builds +// a payment, not just the first one. +// --------------------------------------------------------------------------- + +function buildWallet(): any { + const identityKey = new PrivateKey(10).toPublicKey().toString() + const derivedKey = new PrivateKey(11).toPublicKey().toString() + return { + getPublicKey: jest.fn(async (opts: any) => + opts?.identityKey === true ? { publicKey: identityKey } : { publicKey: derivedKey } + ), + createAction: jest.fn(async () => ({ + tx: Utils.toArray('mock-tx', 'utf8') + })), + createHmac: jest.fn(async () => ({ hmac: Array.from({ length: 32 }).fill(0) })) + } +} + +function make402Response(overrides: Record = {}): Response { + const headers: Record = { + 'x-bsv-payment-version': '1.0', + 'x-bsv-payment-satoshis-required': '10', + 'x-bsv-auth-identity-key': 'srv-key', + 'x-bsv-payment-derivation-prefix': 'pfx', + ...overrides + } + return new Response('', { status: 402, headers }) +} + +function existingContext(satoshisRequired: number): any { + return { + satoshisRequired, + transactionBase64: Utils.toBase64([1, 2, 3]), + derivationPrefix: 'pfx', + derivationSuffix: 'old-suffix', + serverIdentityKey: 'srv-key', + clientIdentityKey: 'client-key', + attempts: 0, + maxAttempts: 3, + errors: [], + requestSummary: { + url: 'https://example.com', + method: 'GET', + headers: {}, + bodyType: 'none', + bodyByteLength: 0 + } + } +} + +describe('AuthFetch.handlePaymentAndRetry – known-txids wiring', () => { + const A = 'a'.repeat(64) + const B = 'b'.repeat(64) + + function harness(): { authFetch: AuthFetch, wallet: any } { + const wallet = buildWallet() + const authFetch = new AuthFetch(wallet) + jest.spyOn(authFetch as any, 'logPaymentAttempt').mockImplementation(() => {}) + jest.spyOn(authFetch as any, 'wait').mockResolvedValue(undefined) + jest.spyOn(authFetch, 'fetch').mockResolvedValue(new Response('ok', { status: 200 })) + createNonceMock.mockResolvedValue('suffix') + return { authFetch, wallet } + } + + function optionsOfLastCreateAction(wallet: any): any { + const calls = wallet.createAction.mock.calls + return calls[calls.length - 1][0].options + } + + afterEach(() => { + jest.restoreAllMocks() + createNonceMock.mockReset() + }) + + it('forwards the declared txids to createAction when building a fresh payment', async () => { + const { authFetch, wallet } = harness() + + await (authFetch as any).handlePaymentAndRetry( + 'https://example.com', + {}, + make402Response({ 'x-bsv-payment-known-txids': `${A},${B}` }) + ) + + expect(optionsOfLastCreateAction(wallet).knownTxids).toEqual([A, B]) + }) + + it('omits the option entirely when the server declares nothing', async () => { + const { authFetch, wallet } = harness() + + await (authFetch as any).handlePaymentAndRetry('https://example.com', {}, make402Response()) + + // Not `[]` — the key must be absent so the createAction call is byte-identical + // to what the SDK sent before this feature existed. + expect(optionsOfLastCreateAction(wallet)).not.toHaveProperty('knownTxids') + }) + + it('forwards the declared txids when the server changes its price mid-flight', async () => { + // The regeneration branch builds a SECOND transaction. It is the path that matters most: + // a repriced retry is already the largest request in the exchange, so dropping the + // optimisation here would re-ship full ancestry at exactly the wrong moment. + const { authFetch, wallet } = harness() + + await (authFetch as any).handlePaymentAndRetry( + 'https://example.com', + { paymentContext: existingContext(5) }, // server now asks for 10 + make402Response({ + 'x-bsv-payment-satoshis-required': '10', + 'x-bsv-payment-known-txids': A + }) + ) + + expect(wallet.createAction).toHaveBeenCalledTimes(1) + expect(optionsOfLastCreateAction(wallet).knownTxids).toEqual([A]) + }) +})