From 43961036327c12f6da34f10592c36f87d7e153c9 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Sun, 16 Aug 2026 16:11:42 -0700 Subject: [PATCH] Attach authenticated prevouts to bridge PSBTs --- .../__tests__/utxo-tx-next-route.test.ts | 110 +++++++++++++++ api/services/__tests__/sponsor-wallet.test.ts | 9 +- api/services/sponsor-wallet.ts | 7 +- components/Bridge/hooks/useMintSysx.ts | 3 +- contexts/Transfer/functions/burnSysToSysx.ts | 3 +- contexts/Transfer/functions/burnSysx.ts | 3 +- contexts/Transfer/functions/nevmToSys.ts | 3 +- pages/api/utxo/tx/[txid].ts | 67 +++++++++ utils/psbt-prevouts.test.ts | 104 ++++++++++++++ utils/psbt-prevouts.ts | 131 ++++++++++++++++++ 10 files changed, 431 insertions(+), 9 deletions(-) create mode 100644 api/routes/__tests__/utxo-tx-next-route.test.ts create mode 100644 pages/api/utxo/tx/[txid].ts create mode 100644 utils/psbt-prevouts.test.ts create mode 100644 utils/psbt-prevouts.ts diff --git a/api/routes/__tests__/utxo-tx-next-route.test.ts b/api/routes/__tests__/utxo-tx-next-route.test.ts new file mode 100644 index 0000000..ea8879c --- /dev/null +++ b/api/routes/__tests__/utxo-tx-next-route.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it, jest } from "@jest/globals"; +import type { NextApiRequest, NextApiResponse } from "next"; +import handler from "../../../pages/api/utxo/tx/[txid]"; + +type MockResponse = NextApiResponse & { + body?: unknown; + statusCode: number; +}; + +const createResponse = (): MockResponse => { + const headers = new Map(); + const response: { + body?: unknown; + statusCode: number; + [key: string]: unknown; + } = { + statusCode: 200, + status(code: number) { + this.statusCode = code; + return this; + }, + json(body: unknown) { + this.body = body; + return this; + }, + send(body: unknown) { + this.body = body; + return this; + }, + end() { + return this; + }, + setHeader(name: string, value: string | string[]) { + headers.set(name, value); + return this; + }, + getHeader(name: string) { + return headers.get(name); + }, + }; + return response as unknown as MockResponse; +}; + +const createRequest = (txid = "ab".repeat(32)) => + ({ + method: "GET", + headers: {}, + query: { txid }, + socket: {}, + } as unknown as NextApiRequest); + +const originalEnvironment = { ...process.env }; + +afterEach(() => { + process.env = { ...originalEnvironment }; + jest.restoreAllMocks(); +}); + +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 fetchMock = jest.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ confirmations: 5, hex: "00a1" }), + } as Response); + const response = createResponse(); + + await handler(createRequest(txid), response); + + expect(fetchMock).toHaveBeenCalledWith( + `https://testnet-blockbook.example/api/v2/tx/${txid}`, + { headers: { Accept: "application/json" } } + ); + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ hex: "00a1" }); + expect(response.getHeader("Cache-Control")).toContain("immutable"); + }); + + it("rejects malformed transaction IDs without contacting Blockbook", async () => { + process.env.UTXO_EXPLORER = "https://testnet-blockbook.example"; + const fetchMock = jest.spyOn(global, "fetch"); + const response = createResponse(); + + await handler(createRequest("not-a-txid"), response); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(response.statusCode).toBe(400); + expect(response.body).toEqual({ message: "Invalid transaction ID" }); + }); + + it("rejects a Blockbook response without valid transaction hex", async () => { + process.env.UTXO_EXPLORER = "https://testnet-blockbook.example"; + jest.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ hex: "not-hex" }), + } as Response); + const response = createResponse(); + + await handler(createRequest(), response); + + expect(response.statusCode).toBe(502); + expect(response.body).toEqual({ + message: "Blockbook returned an invalid transaction", + }); + }); +}); + diff --git a/api/services/__tests__/sponsor-wallet.test.ts b/api/services/__tests__/sponsor-wallet.test.ts index 397c06e..e986165 100644 --- a/api/services/__tests__/sponsor-wallet.test.ts +++ b/api/services/__tests__/sponsor-wallet.test.ts @@ -19,12 +19,17 @@ const mockWeb3 = { toHex: jest.fn((value) => value), }, }; +const mockExportPsbtWithPrevouts = jest.fn(); jest.mock("utils/get-web3", () => ({ __esModule: true, default: mockWeb3, })); +jest.mock("utils/psbt-prevouts", () => ({ + exportPsbtWithPrevouts: mockExportPsbtWithPrevouts, +})); + jest.mock("models/sponsor-wallet-transactions", () => { const Model: any = jest.fn(function (this: any, data: any) { Object.assign(this, data); @@ -1073,9 +1078,7 @@ describe("SponsorWalletService", () => { jest .spyOn(service, "getUserInputFingerprint") .mockReturnValue("fingerprint"); - (syscoinUtils.exportPsbtToJson as jest.Mock).mockReturnValue( - exported - ); + mockExportPsbtWithPrevouts.mockResolvedValue(exported); const store = jest .spyOn(service, "storePreparedUtxoBurn") .mockResolvedValue(undefined); diff --git a/api/services/sponsor-wallet.ts b/api/services/sponsor-wallet.ts index b799855..14fb3ce 100644 --- a/api/services/sponsor-wallet.ts +++ b/api/services/sponsor-wallet.ts @@ -9,6 +9,7 @@ import SponsorWalletTransactions, { } from "models/sponsor-wallet-transactions"; import { syscoin, UTXOTransaction, utils as syscoinUtils } from "syscoinjs-lib"; import web3 from "utils/get-web3"; +import { exportPsbtWithPrevouts } from "utils/psbt-prevouts"; import { toSyscoinBaseUnits } from "utils/syscoin-amount"; import { MAINNET_BLOCKBOOK_URL, @@ -406,9 +407,11 @@ export class SponsorWalletService { prepared.psbt, reservation.key ); - const exported = syscoinUtils.exportPsbtToJson( + const exported = await exportPsbtWithPrevouts( prepared.psbt, - prepared.assets + prepared.assets, + (txid) => + syscoinUtils.fetchBackendRawTx(getUtxoBlockbookUrl(), txid) ); await this.storePreparedUtxoBurn( placeholder, diff --git a/components/Bridge/hooks/useMintSysx.ts b/components/Bridge/hooks/useMintSysx.ts index 84e3040..569d608 100644 --- a/components/Bridge/hooks/useMintSysx.ts +++ b/components/Bridge/hooks/useMintSysx.ts @@ -6,6 +6,7 @@ import { usePaliWalletV2 } from "@contexts/PaliWallet/usePaliWallet"; import { useConstants } from "@contexts/useConstants"; import { useFeatureFlags } from "./useFeatureFlags"; import { requestSponsoredUtxo } from "./sponsored-utxo"; +import { exportPsbtWithPrevouts } from "utils/psbt-prevouts"; export const useMintSysx = (transfer: ITransfer) => { const syscoinInstance = useSyscoin(); @@ -46,7 +47,7 @@ export const useMintSysx = (transfer: ITransfer) => { throw new Error("Unable to mint SYSX: insufficient SYS for fees"); } - const psbt = utils.exportPsbtToJson(res.psbt, res.assets); + const psbt = await exportPsbtWithPrevouts(res.psbt, res.assets); const { tx, error } = await sendTransaction(psbt); if (error) { diff --git a/contexts/Transfer/functions/burnSysToSysx.ts b/contexts/Transfer/functions/burnSysToSysx.ts index 9bdf030..3aafd8f 100644 --- a/contexts/Transfer/functions/burnSysToSysx.ts +++ b/contexts/Transfer/functions/burnSysToSysx.ts @@ -1,5 +1,6 @@ import { syscoin, utils as syscoinUtils } from "syscoinjs-lib"; import { toSyscoinBaseUnits } from "utils/syscoin-amount"; +import { exportPsbtWithPrevouts } from "utils/psbt-prevouts"; import { SYSX_ASSET_GUID } from "../constants"; export const burnSysToSysx = async ( @@ -44,7 +45,7 @@ export const burnSysToSysx = async ( throw new Error("Unable to create the SYS burn transaction"); } console.log("burnSysToSysx", { res }); - return syscoinUtils.exportPsbtToJson(res.psbt, res.assets); + return exportPsbtWithPrevouts(res.psbt, res.assets); }; export default burnSysToSysx; diff --git a/contexts/Transfer/functions/burnSysx.ts b/contexts/Transfer/functions/burnSysx.ts index 81deabe..daf73bb 100644 --- a/contexts/Transfer/functions/burnSysx.ts +++ b/contexts/Transfer/functions/burnSysx.ts @@ -1,5 +1,6 @@ import { syscoin, utils as syscoinUtils } from "syscoinjs-lib"; import { toSyscoinBaseUnits } from "utils/syscoin-amount"; +import { exportPsbtWithPrevouts } from "utils/psbt-prevouts"; import { SYSX_ASSET_GUID } from "../constants"; export const burnSysx = async ( @@ -48,7 +49,7 @@ export const burnSysx = async ( cause: res, }); } - return syscoinUtils.exportPsbtToJson(res.psbt, res.assets); + return exportPsbtWithPrevouts(res.psbt, res.assets); }; export default burnSysx; diff --git a/contexts/Transfer/functions/nevmToSys.ts b/contexts/Transfer/functions/nevmToSys.ts index ede180e..5a1651a 100644 --- a/contexts/Transfer/functions/nevmToSys.ts +++ b/contexts/Transfer/functions/nevmToSys.ts @@ -8,6 +8,7 @@ import { COMMON_STATUS, ETH_TO_SYS_TRANSFER_STATUS, ITransfer } from "../types"; import { syscoin, utils } from "syscoinjs-lib"; import { SendUtxoTransaction } from "@contexts/ConnectedWallet/Provider"; import burnSysx from "./burnSysx"; +import { exportPsbtWithPrevouts } from "utils/psbt-prevouts"; import { toWei } from "web3-utils"; import { captureException } from "@sentry/nextjs"; import { useErc20ManagerContract } from "components/Bridge/hooks/useErc20ManagerContract"; @@ -173,7 +174,7 @@ const mintSysx = async ( console.log("assetAllocationMint received", { res, }); - const transaction = utils.exportPsbtToJson(res.psbt, res.assets); + const transaction = await exportPsbtWithPrevouts(res.psbt, res.assets); const mintSysxTransactionReceipt = await sendUtxoTransaction(transaction); dispatch( addLog( diff --git a/pages/api/utxo/tx/[txid].ts b/pages/api/utxo/tx/[txid].ts new file mode 100644 index 0000000..66ecb7d --- /dev/null +++ b/pages/api/utxo/tx/[txid].ts @@ -0,0 +1,67 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { applyApiCors } from "utils/api/cors"; +import { firstConfiguredUtxoBlockbookUrl } from "utils/syscoin-urls"; + +const firstQueryValue = (value: string | string[] | undefined) => + Array.isArray(value) ? value[0] : value; + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + if ( + applyApiCors(req, res, { + allowMethods: ["GET", "OPTIONS"], + allowWildcardOrigin: true, + }) + ) { + return; + } + + if (req.method !== "GET") { + return res.status(405).json({ message: "Method not allowed" }); + } + + const txid = firstQueryValue(req.query.txid); + if (!txid || !/^[0-9a-fA-F]{64}$/.test(txid)) { + return res.status(400).json({ message: "Invalid transaction ID" }); + } + + const blockbookUrl = firstConfiguredUtxoBlockbookUrl( + process.env.UTXO_EXPLORER, + process.env.UTXO_RPC_URL, + process.env.NEXT_PUBLIC_BLOCKBOOK_API_URL + ); + if (!blockbookUrl) { + return res + .status(503) + .json({ message: "UTXO Blockbook is not configured" }); + } + + try { + const response = await fetch(`${blockbookUrl}/api/v2/tx/${txid}`, { + headers: { Accept: "application/json" }, + }); + if (!response.ok) { + return res + .status(response.status) + .json({ message: "Unable to fetch transaction" }); + } + const transaction = (await response.json()) as { hex?: unknown }; + if ( + typeof transaction.hex !== "string" || + transaction.hex.length % 2 !== 0 || + !/^[0-9a-fA-F]+$/.test(transaction.hex) + ) { + return res + .status(502) + .json({ message: "Blockbook returned an invalid transaction" }); + } + res.setHeader( + "Cache-Control", + "public, max-age=300, s-maxage=31536000, immutable" + ); + return res.status(200).json({ hex: transaction.hex }); + } catch { + return res.status(502).json({ message: "UTXO Blockbook is unavailable" }); + } +}; + +export default handler; diff --git a/utils/psbt-prevouts.test.ts b/utils/psbt-prevouts.test.ts new file mode 100644 index 0000000..1ae2084 --- /dev/null +++ b/utils/psbt-prevouts.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { utils as syscoinUtils } from "syscoinjs-lib"; + +import { + attachPsbtPrevouts, + fetchBridgeRawTransaction, +} from "./psbt-prevouts"; + +const createPreviousTransaction = () => { + const transaction = new syscoinUtils.bitcoinjs.Transaction(); + transaction.addInput(Buffer.alloc(32), 0xffffffff); + transaction.addOutput(Buffer.from("00140000000000000000000000000000000000000001", "hex"), BigInt(5_000)); + transaction.addOutput(Buffer.from("00140000000000000000000000000000000000000002", "hex"), BigInt(7_000)); + transaction.ins[0].witness = [Buffer.alloc(64, 1)]; + return transaction; +}; + +const createPsbt = (previousTransaction: any) => { + const psbt = new syscoinUtils.bitcoinjs.Psbt({ + network: syscoinUtils.syscoinNetworks.testnet, + }); + previousTransaction.outs.forEach((output: any, index: number) => { + psbt.addInput({ + hash: previousTransaction.getHash(), + index, + witnessUtxo: output, + }); + }); + psbt.addOutput({ + script: previousTransaction.outs[0].script, + value: BigInt(11_000), + }); + return psbt; +}; + +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() }); + + await attachPsbtPrevouts(psbt, fetchRawTransaction); + + expect(fetchRawTransaction).toHaveBeenCalledTimes(1); + expect(fetchRawTransaction).toHaveBeenCalledWith( + previousTransaction.getId() + ); + expect(psbt.data.inputs).toHaveLength(2); + for (const input of psbt.data.inputs) { + const attachedParent = + syscoinUtils.bitcoinjs.Transaction.fromBuffer(input.nonWitnessUtxo); + expect(attachedParent.getId()).toBe(previousTransaction.getId()); + expect(attachedParent.hasWitnesses()).toBe(false); + } + }); + + it("rejects a parent transaction that does not match the input txid", async () => { + const psbt = createPsbt(createPreviousTransaction()); + const differentTransaction = createPreviousTransaction(); + differentTransaction.locktime = 1; + + await expect( + attachPsbtPrevouts(psbt, async () => ({ + hex: differentTransaction.toHex(), + })) + ).rejects.toThrow("does not match its txid"); + }); + + it("does not refetch an already attached parent transaction", async () => { + const previousTransaction = createPreviousTransaction(); + const psbt = createPsbt(previousTransaction); + for (let index = 0; index < psbt.data.inputs.length; index += 1) { + psbt.updateInput(index, { + nonWitnessUtxo: previousTransaction.toBuffer(), + }); + } + const fetchRawTransaction = jest.fn(); + + await attachPsbtPrevouts(psbt, fetchRawTransaction); + + expect(fetchRawTransaction).not.toHaveBeenCalled(); + }); +}); + +describe("fetchBridgeRawTransaction", () => { + it("uses the same-origin transaction proxy", async () => { + const txid = "ab".repeat(32); + const fetchMock = jest.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ hex: "00" }), + } as Response); + + await expect(fetchBridgeRawTransaction(txid)).resolves.toEqual({ + hex: "00", + }); + expect(fetchMock).toHaveBeenCalledWith(`/api/utxo/tx/${txid}`, { + headers: { Accept: "application/json" }, + }); + + fetchMock.mockRestore(); + }); +}); diff --git a/utils/psbt-prevouts.ts b/utils/psbt-prevouts.ts new file mode 100644 index 0000000..2172b62 --- /dev/null +++ b/utils/psbt-prevouts.ts @@ -0,0 +1,131 @@ +import { utils as syscoinUtils } from "syscoinjs-lib"; + +type RawTransactionResponse = + | string + | { hex?: unknown; result?: unknown } + | null + | undefined; + +type RawTransactionFetcher = ( + txid: string +) => Promise; + +const MAX_CONCURRENT_PREVOUT_FETCHES = 8; + +const getRawTransactionHex = (response: RawTransactionResponse) => { + if (typeof response === "string") return response; + if (typeof response?.hex === "string") return response.hex; + if (typeof response?.result === "string") return response.result; + if ( + response?.result && + typeof response.result === "object" && + "hex" in response.result && + typeof response.result.hex === "string" + ) { + return response.result.hex; + } + return null; +}; + +export const fetchBridgeRawTransaction: RawTransactionFetcher = async ( + txid +) => { + const response = await fetch( + `/api/utxo/tx/${encodeURIComponent(txid)}`, + { headers: { Accept: "application/json" } } + ); + if (!response.ok) { + throw new Error(`Unable to fetch PSBT prevout (${response.status})`); + } + return response.json(); +}; + +/** + * Make dapp-created PSBTs self-contained for wallets that authenticate every + * selected prevout. The parent is txid-bound before it is attached, so the + * bridge proxy is only a transport and cannot change what the wallet signs. + */ +export const attachPsbtPrevouts = async ( + psbt: any, + fetchRawTransaction: RawTransactionFetcher = fetchBridgeRawTransaction +) => { + if ( + !Array.isArray(psbt?.txInputs) || + !Array.isArray(psbt?.data?.inputs) || + psbt.txInputs.length !== psbt.data.inputs.length + ) { + throw new Error("Unable to prepare PSBT prevouts"); + } + + const inputIndexesByTxid = new Map(); + psbt.txInputs.forEach((txInput: any, inputIndex: number) => { + if (psbt.data.inputs[inputIndex]?.nonWitnessUtxo) return; + + const txid = Buffer.from(txInput.hash).reverse().toString("hex"); + const inputIndexes = inputIndexesByTxid.get(txid); + if (inputIndexes) inputIndexes.push(inputIndex); + else inputIndexesByTxid.set(txid, [inputIndex]); + }); + + const pendingPrevouts = Array.from(inputIndexesByTxid.entries()); + let nextPrevoutIndex = 0; + const attachNextPrevout = async () => { + while (true) { + const pendingPrevout = pendingPrevouts[nextPrevoutIndex++]; + if (!pendingPrevout) return; + const [txid, inputIndexes] = pendingPrevout; + const response = await fetchRawTransaction(txid); + const rawTransactionHex = getRawTransactionHex(response); + if ( + !rawTransactionHex || + rawTransactionHex.length % 2 !== 0 || + !/^[0-9a-fA-F]+$/.test(rawTransactionHex) + ) { + 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(); + + for (const inputIndex of inputIndexes) { + if ( + !Buffer.from(psbt.txInputs[inputIndex].hash).equals( + previousTransaction.getHash() + ) + ) { + throw new Error(`PSBT prevout ${txid} does not match its txid`); + } + psbt.updateInput(inputIndex, { nonWitnessUtxo }); + } + } + }; + + await Promise.all( + Array.from( + { + length: Math.min( + MAX_CONCURRENT_PREVOUT_FETCHES, + pendingPrevouts.length + ), + }, + attachNextPrevout + ) + ); + + return psbt; +}; + +export const exportPsbtWithPrevouts = async ( + psbt: any, + assets: any, + fetchRawTransaction?: RawTransactionFetcher +) => { + await attachPsbtPrevouts(psbt, fetchRawTransaction); + return syscoinUtils.exportPsbtToJson(psbt, assets); +};