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
66 changes: 62 additions & 4 deletions packages/sdk/src/auth/clients/AuthFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -590,14 +640,17 @@ 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(
url,
config,
satoshisRequired,
serverIdentityKey,
derivationPrefix
derivationPrefix,
knownTxids
)
} else {
const requirementsChanged = !this.isPaymentContextCompatible(
Expand All @@ -617,7 +670,8 @@ export class AuthFetch {
config,
satoshisRequired,
serverIdentityKey,
derivationPrefix
derivationPrefix,
knownTxids
)
}
}
Expand Down Expand Up @@ -706,7 +760,8 @@ export class AuthFetch {
config: SimplifiedFetchRequestOptions,
satoshisRequired: number,
serverIdentityKey: string,
derivationPrefix: string
derivationPrefix: string,
knownTxids?: string[]
): Promise<PaymentRetryContext> {
const derivationSuffix = await createNonce(this.wallet, undefined, this.originator)

Expand Down Expand Up @@ -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
Expand Down
181 changes: 181 additions & 0 deletions packages/sdk/src/auth/clients/__tests__/AuthFetch.knownTxids.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createNonce>

/**
* 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<string, string> = {}): Response {
const headers: Record<string, string> = {
'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])
})
})