Skip to content
Merged
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
38 changes: 34 additions & 4 deletions api/routes/__tests__/utxo-tx-next-route.test.ts
Original file line number Diff line number Diff line change
@@ -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 & {
Expand Down Expand Up @@ -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();
Expand All @@ -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();

Expand All @@ -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");
Expand Down Expand Up @@ -107,4 +138,3 @@ describe("UTXO transaction proxy", () => {
});
});
});

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions pages/api/utxo/tx/[txid].ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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"
Expand Down
20 changes: 20 additions & 0 deletions utils/psbt-prevouts.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
38 changes: 31 additions & 7 deletions utils/psbt-prevouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]);
Expand All @@ -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()
)
) {
Expand Down
Loading