diff --git a/packages/sdk/src/auth/clients/AuthFetch.ts b/packages/sdk/src/auth/clients/AuthFetch.ts index 44ecab310..c8b96fd1a 100644 --- a/packages/sdk/src/auth/clients/AuthFetch.ts +++ b/packages/sdk/src/auth/clients/AuthFetch.ts @@ -77,6 +77,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, @@ -590,6 +640,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( @@ -597,7 +649,8 @@ export class AuthFetch { config, satoshisRequired, serverIdentityKey, - derivationPrefix + derivationPrefix, + knownTxids ) } else { const requirementsChanged = !this.isPaymentContextCompatible( @@ -617,7 +670,8 @@ export class AuthFetch { config, satoshisRequired, serverIdentityKey, - derivationPrefix + derivationPrefix, + knownTxids ) } } @@ -706,7 +760,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) @@ -739,7 +794,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]) + }) +})