From 8a16d712e417a14ea7f85ee39565f87b626c5d85 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Sun, 16 Aug 2026 16:35:25 -0700 Subject: [PATCH 1/2] Fix browser PSBT prevout attachment --- .../__tests__/utxo-tx-next-route.test.ts | 40 +++++++++++++++-- package.json | 1 + pages/api/utxo/tx/[txid].ts | 20 ++++++++- utils/psbt-prevouts.test.ts | 30 ++++++++++++- utils/psbt-prevouts.ts | 45 ++++++++++++++----- 5 files changed, 118 insertions(+), 18 deletions(-) diff --git a/api/routes/__tests__/utxo-tx-next-route.test.ts b/api/routes/__tests__/utxo-tx-next-route.test.ts index ea8879c..051bd95 100644 --- a/api/routes/__tests__/utxo-tx-next-route.test.ts +++ b/api/routes/__tests__/utxo-tx-next-route.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, jest } from "@jest/globals"; import type { NextApiRequest, NextApiResponse } from "next"; +import { utils as syscoinUtils } from "syscoinjs-lib"; import handler from "../../../pages/api/utxo/tx/[txid]"; type MockResponse = NextApiResponse & { @@ -51,6 +52,17 @@ const createRequest = (txid = "ab".repeat(32)) => const originalEnvironment = { ...process.env }; +const createTransaction = () => { + const transaction = new syscoinUtils.bitcoinjs.Transaction(); + transaction.addInput(Buffer.alloc(32), 0xffffffff); + transaction.addOutput( + Buffer.from("00140000000000000000000000000000000000000001", "hex"), + BigInt(5_000) + ); + transaction.ins[0].witness = [Buffer.alloc(64, 1)]; + return transaction; +}; + afterEach(() => { process.env = { ...originalEnvironment }; jest.restoreAllMocks(); @@ -59,11 +71,12 @@ afterEach(() => { describe("UTXO transaction proxy", () => { it("returns only immutable raw transaction data", async () => { process.env.UTXO_EXPLORER = "https://testnet-blockbook.example"; - const txid = "ab".repeat(32); + const transaction = createTransaction(); + const txid = transaction.getId(); const fetchMock = jest.spyOn(global, "fetch").mockResolvedValue({ ok: true, status: 200, - json: async () => ({ confirmations: 5, hex: "00a1" }), + json: async () => ({ confirmations: 5, hex: transaction.toHex() }), } as Response); const response = createResponse(); @@ -74,10 +87,30 @@ describe("UTXO transaction proxy", () => { { headers: { Accept: "application/json" } } ); expect(response.statusCode).toBe(200); - expect(response.body).toEqual({ hex: "00a1" }); + const nonWitnessTransaction = transaction.clone(); + nonWitnessTransaction.stripWitnesses(); + expect(response.body).toEqual({ hex: nonWitnessTransaction.toHex() }); expect(response.getHeader("Cache-Control")).toContain("immutable"); }); + it("rejects transaction data that does not match the requested ID", async () => { + process.env.UTXO_EXPLORER = "https://testnet-blockbook.example"; + const transaction = createTransaction(); + jest.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ hex: transaction.toHex() }), + } as Response); + const response = createResponse(); + + await handler(createRequest("ab".repeat(32)), response); + + expect(response.statusCode).toBe(502); + expect(response.body).toEqual({ + message: "Blockbook transaction does not match its ID", + }); + }); + it("rejects malformed transaction IDs without contacting Blockbook", async () => { process.env.UTXO_EXPLORER = "https://testnet-blockbook.example"; const fetchMock = jest.spyOn(global, "fetch"); @@ -107,4 +140,3 @@ describe("UTXO transaction proxy", () => { }); }); }); - diff --git a/package.json b/package.json index 0391055..3caf266 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@sentry/nextjs": "^7.57.0", "axios": "^1.13.2", "bitcoin-proof": "^2.0.0", + "buffer": "^6.0.3", "date-fns": "^2.30.0", "eslint-plugin-unused-imports": "^3.0.0", "iron-session": "^6.3.1", diff --git a/pages/api/utxo/tx/[txid].ts b/pages/api/utxo/tx/[txid].ts index 66ecb7d..390a8a3 100644 --- a/pages/api/utxo/tx/[txid].ts +++ b/pages/api/utxo/tx/[txid].ts @@ -1,4 +1,5 @@ import type { NextApiRequest, NextApiResponse } from "next"; +import { utils as syscoinUtils } from "syscoinjs-lib"; import { applyApiCors } from "utils/api/cors"; import { firstConfiguredUtxoBlockbookUrl } from "utils/syscoin-urls"; @@ -54,11 +55,28 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { .status(502) .json({ message: "Blockbook returned an invalid transaction" }); } + + let previousTransaction; + try { + previousTransaction = + syscoinUtils.bitcoinjs.Transaction.fromHex(transaction.hex); + } catch { + return res + .status(502) + .json({ message: "Blockbook returned an invalid transaction" }); + } + if (previousTransaction.getId().toLowerCase() !== txid.toLowerCase()) { + return res + .status(502) + .json({ message: "Blockbook transaction does not match its ID" }); + } + + previousTransaction.stripWitnesses(); res.setHeader( "Cache-Control", "public, max-age=300, s-maxage=31536000, immutable" ); - return res.status(200).json({ hex: transaction.hex }); + return res.status(200).json({ hex: previousTransaction.toHex() }); } catch { return res.status(502).json({ message: "UTXO Blockbook is unavailable" }); } diff --git a/utils/psbt-prevouts.test.ts b/utils/psbt-prevouts.test.ts index 1ae2084..406aac2 100644 --- a/utils/psbt-prevouts.test.ts +++ b/utils/psbt-prevouts.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, jest } from "@jest/globals"; +import { Buffer as BrowserBuffer } from "buffer/"; import { utils as syscoinUtils } from "syscoinjs-lib"; import { @@ -33,13 +34,19 @@ const createPsbt = (previousTransaction: any) => { return psbt; }; +const getNonWitnessHex = (transaction: any) => { + const nonWitnessTransaction = transaction.clone(); + nonWitnessTransaction.stripWitnesses(); + return nonWitnessTransaction.toHex(); +}; + describe("attachPsbtPrevouts", () => { it("deduplicates and attaches txid-bound parent transactions", async () => { const previousTransaction = createPreviousTransaction(); const psbt = createPsbt(previousTransaction); const fetchRawTransaction = jest .fn() - .mockResolvedValue({ hex: previousTransaction.toHex() }); + .mockResolvedValue({ hex: getNonWitnessHex(previousTransaction) }); await attachPsbtPrevouts(psbt, fetchRawTransaction); @@ -63,11 +70,30 @@ describe("attachPsbtPrevouts", () => { await expect( attachPsbtPrevouts(psbt, async () => ({ - hex: differentTransaction.toHex(), + hex: getNonWitnessHex(differentTransaction), })) ).rejects.toThrow("does not match its txid"); }); + it("accepts bitcoinjs Uint8Array hashes with the browser Buffer polyfill", async () => { + const previousTransaction = createPreviousTransaction(); + const psbt = createPsbt(previousTransaction); + const nonWitnessHex = getNonWitnessHex(previousTransaction); + const originalBuffer = global.Buffer; + global.Buffer = BrowserBuffer as typeof Buffer; + + try { + await expect( + attachPsbtPrevouts(psbt, async () => ({ + hex: nonWitnessHex, + })) + ).resolves.toBe(psbt); + expect(() => psbt.toBase64()).not.toThrow(); + } finally { + global.Buffer = originalBuffer; + } + }); + it("does not refetch an already attached parent transaction", async () => { const previousTransaction = createPreviousTransaction(); const psbt = createPsbt(previousTransaction); diff --git a/utils/psbt-prevouts.ts b/utils/psbt-prevouts.ts index 2172b62..2e1e2f2 100644 --- a/utils/psbt-prevouts.ts +++ b/utils/psbt-prevouts.ts @@ -12,6 +12,26 @@ type RawTransactionFetcher = ( const MAX_CONCURRENT_PREVOUT_FETCHES = 8; +const equalBytes = (left: Uint8Array, right: Uint8Array) => + left.length === right.length && + left.every((value, index) => value === right[index]); + +const bytesToReversedHex = (bytes: Uint8Array) => { + let hex = ""; + for (let index = bytes.length - 1; index >= 0; index -= 1) { + hex += bytes[index].toString(16).padStart(2, "0"); + } + return hex; +}; + +const hexToBytes = (hex: string) => { + const bytes = new Uint8Array(hex.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); + } + return bytes; +}; + const getRawTransactionHex = (response: RawTransactionResponse) => { if (typeof response === "string") return response; if (typeof response?.hex === "string") return response.hex; @@ -61,7 +81,11 @@ export const attachPsbtPrevouts = async ( psbt.txInputs.forEach((txInput: any, inputIndex: number) => { if (psbt.data.inputs[inputIndex]?.nonWitnessUtxo) return; - const txid = Buffer.from(txInput.hash).reverse().toString("hex"); + if (!(txInput.hash instanceof Uint8Array) || txInput.hash.length !== 32) { + throw new Error("Unable to prepare PSBT prevouts"); + } + + const txid = bytesToReversedHex(txInput.hash); const inputIndexes = inputIndexesByTxid.get(txid); if (inputIndexes) inputIndexes.push(inputIndex); else inputIndexesByTxid.set(txid, [inputIndex]); @@ -84,19 +108,18 @@ export const attachPsbtPrevouts = async ( throw new Error(`Unable to fetch PSBT prevout ${txid}`); } - const rawTransaction = Buffer.from(rawTransactionHex, "hex"); - const previousTransaction = - syscoinUtils.bitcoinjs.Transaction.fromBuffer(rawTransaction); - const nonWitnessTransaction = previousTransaction.clone(); - nonWitnessTransaction.ins.forEach((input: any) => { - input.witness = []; - }); - const nonWitnessUtxo = nonWitnessTransaction.toBuffer(); + // The proxy returns a validated, witness-stripped parent transaction. + // Keep transaction parsing on the server so this browser path only + // hashes and attaches authenticated bytes. + const nonWitnessUtxo = hexToBytes(rawTransactionHex); + const previousTransactionHash = + syscoinUtils.bitcoinjs.crypto.hash256(nonWitnessUtxo); for (const inputIndex of inputIndexes) { if ( - !Buffer.from(psbt.txInputs[inputIndex].hash).equals( - previousTransaction.getHash() + !equalBytes( + psbt.txInputs[inputIndex].hash, + previousTransactionHash ) ) { throw new Error(`PSBT prevout ${txid} does not match its txid`); From 7423bc6c2a185fab677b4430b0acc600c13ee406 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Sun, 16 Aug 2026 16:38:58 -0700 Subject: [PATCH 2/2] Preserve cached raw transaction responses --- api/routes/__tests__/utxo-tx-next-route.test.ts | 4 +--- pages/api/utxo/tx/[txid].ts | 3 +-- utils/psbt-prevouts.test.ts | 14 ++++---------- utils/psbt-prevouts.ts | 15 ++++++++------- 4 files changed, 14 insertions(+), 22 deletions(-) diff --git a/api/routes/__tests__/utxo-tx-next-route.test.ts b/api/routes/__tests__/utxo-tx-next-route.test.ts index 051bd95..949e2ca 100644 --- a/api/routes/__tests__/utxo-tx-next-route.test.ts +++ b/api/routes/__tests__/utxo-tx-next-route.test.ts @@ -87,9 +87,7 @@ describe("UTXO transaction proxy", () => { { headers: { Accept: "application/json" } } ); expect(response.statusCode).toBe(200); - const nonWitnessTransaction = transaction.clone(); - nonWitnessTransaction.stripWitnesses(); - expect(response.body).toEqual({ hex: nonWitnessTransaction.toHex() }); + expect(response.body).toEqual({ hex: transaction.toHex() }); expect(response.getHeader("Cache-Control")).toContain("immutable"); }); diff --git a/pages/api/utxo/tx/[txid].ts b/pages/api/utxo/tx/[txid].ts index 390a8a3..45ca311 100644 --- a/pages/api/utxo/tx/[txid].ts +++ b/pages/api/utxo/tx/[txid].ts @@ -71,12 +71,11 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { .json({ message: "Blockbook transaction does not match its ID" }); } - previousTransaction.stripWitnesses(); res.setHeader( "Cache-Control", "public, max-age=300, s-maxage=31536000, immutable" ); - return res.status(200).json({ hex: previousTransaction.toHex() }); + return res.status(200).json({ hex: transaction.hex }); } catch { return res.status(502).json({ message: "UTXO Blockbook is unavailable" }); } diff --git a/utils/psbt-prevouts.test.ts b/utils/psbt-prevouts.test.ts index 406aac2..a228cb3 100644 --- a/utils/psbt-prevouts.test.ts +++ b/utils/psbt-prevouts.test.ts @@ -34,19 +34,13 @@ const createPsbt = (previousTransaction: any) => { return psbt; }; -const getNonWitnessHex = (transaction: any) => { - const nonWitnessTransaction = transaction.clone(); - nonWitnessTransaction.stripWitnesses(); - return nonWitnessTransaction.toHex(); -}; - describe("attachPsbtPrevouts", () => { it("deduplicates and attaches txid-bound parent transactions", async () => { const previousTransaction = createPreviousTransaction(); const psbt = createPsbt(previousTransaction); const fetchRawTransaction = jest .fn() - .mockResolvedValue({ hex: getNonWitnessHex(previousTransaction) }); + .mockResolvedValue({ hex: previousTransaction.toHex() }); await attachPsbtPrevouts(psbt, fetchRawTransaction); @@ -70,7 +64,7 @@ describe("attachPsbtPrevouts", () => { await expect( attachPsbtPrevouts(psbt, async () => ({ - hex: getNonWitnessHex(differentTransaction), + hex: differentTransaction.toHex(), })) ).rejects.toThrow("does not match its txid"); }); @@ -78,14 +72,14 @@ describe("attachPsbtPrevouts", () => { it("accepts bitcoinjs Uint8Array hashes with the browser Buffer polyfill", async () => { const previousTransaction = createPreviousTransaction(); const psbt = createPsbt(previousTransaction); - const nonWitnessHex = getNonWitnessHex(previousTransaction); + const rawTransactionHex = previousTransaction.toHex(); const originalBuffer = global.Buffer; global.Buffer = BrowserBuffer as typeof Buffer; try { await expect( attachPsbtPrevouts(psbt, async () => ({ - hex: nonWitnessHex, + hex: rawTransactionHex, })) ).resolves.toBe(psbt); expect(() => psbt.toBase64()).not.toThrow(); diff --git a/utils/psbt-prevouts.ts b/utils/psbt-prevouts.ts index 2e1e2f2..5c93045 100644 --- a/utils/psbt-prevouts.ts +++ b/utils/psbt-prevouts.ts @@ -108,18 +108,19 @@ export const attachPsbtPrevouts = async ( throw new Error(`Unable to fetch PSBT prevout ${txid}`); } - // The proxy returns a validated, witness-stripped parent transaction. - // Keep transaction parsing on the server so this browser path only - // hashes and attaches authenticated bytes. - const nonWitnessUtxo = hexToBytes(rawTransactionHex); - const previousTransactionHash = - syscoinUtils.bitcoinjs.crypto.hash256(nonWitnessUtxo); + const previousTransaction = + syscoinUtils.bitcoinjs.Transaction.fromBuffer( + hexToBytes(rawTransactionHex) + ); + const nonWitnessTransaction = previousTransaction.clone(); + nonWitnessTransaction.stripWitnesses(); + const nonWitnessUtxo = nonWitnessTransaction.toBuffer(); for (const inputIndex of inputIndexes) { if ( !equalBytes( psbt.txInputs[inputIndex].hash, - previousTransactionHash + previousTransaction.getHash() ) ) { throw new Error(`PSBT prevout ${txid} does not match its txid`);