diff --git a/api/routes/__tests__/utxo-tx-next-route.test.ts b/api/routes/__tests__/utxo-tx-next-route.test.ts index ea8879c..949e2ca 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,28 @@ describe("UTXO transaction proxy", () => { { headers: { Accept: "application/json" } } ); expect(response.statusCode).toBe(200); - expect(response.body).toEqual({ hex: "00a1" }); + expect(response.body).toEqual({ hex: transaction.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 +138,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..45ca311 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,6 +55,22 @@ 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" }); + } + res.setHeader( "Cache-Control", "public, max-age=300, s-maxage=31536000, immutable" diff --git a/utils/psbt-prevouts.test.ts b/utils/psbt-prevouts.test.ts index 1ae2084..a228cb3 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 { @@ -68,6 +69,25 @@ describe("attachPsbtPrevouts", () => { ).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 rawTransactionHex = previousTransaction.toHex(); + const originalBuffer = global.Buffer; + global.Buffer = BrowserBuffer as typeof Buffer; + + try { + await expect( + attachPsbtPrevouts(psbt, async () => ({ + hex: rawTransactionHex, + })) + ).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..5c93045 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,18 +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); + syscoinUtils.bitcoinjs.Transaction.fromBuffer( + hexToBytes(rawTransactionHex) + ); const nonWitnessTransaction = previousTransaction.clone(); - nonWitnessTransaction.ins.forEach((input: any) => { - input.witness = []; - }); + nonWitnessTransaction.stripWitnesses(); const nonWitnessUtxo = nonWitnessTransaction.toBuffer(); for (const inputIndex of inputIndexes) { if ( - !Buffer.from(psbt.txInputs[inputIndex].hash).equals( + !equalBytes( + psbt.txInputs[inputIndex].hash, previousTransaction.getHash() ) ) {