Skip to content

feat(utxo-core): implement buildToSignPsbt for BIP322 message signing #6611

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 1, 2025
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
1 change: 1 addition & 0 deletions modules/utxo-core/src/bip322/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './toSpend';
export * from './toSign';
49 changes: 49 additions & 0 deletions modules/utxo-core/src/bip322/toSign.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Psbt, Transaction } from '@bitgo/utxo-lib';

export type AddressDetails = {
redeemScript?: Buffer;
witnessScript?: Buffer;
};

/**
* Construct the toSign PSBT for a BIP322 verification.
* Source implementation:
* https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki#full
*
* @param {string} toSpendTxHex - The hex representation of the `toSpend` transaction.
* @param {AddressDetails} addressDetails - The details of the address, including redeemScript and/or witnessScript.
* @returns {string} - The hex representation of the constructed PSBT.
*/
export function buildToSignPsbt(toSpendTx: Transaction<bigint>, addressDetails: AddressDetails): Psbt {
if (!addressDetails.redeemScript && !addressDetails.witnessScript) {
throw new Error('redeemScript and/or witnessScript must be provided');
}

// Create PSBT object for constructing the transaction
const psbt = new Psbt();
// Set default value for nVersion and nLockTime
psbt.setVersion(0); // nVersion = 0
psbt.setLocktime(0); // nLockTime = 0
// Set the input
psbt.addInput({
hash: toSpendTx.getId(), // vin[0].prevout.hash = to_spend.txid
index: 0, // vin[0].prevout.n = 0
sequence: 0, // vin[0].nSequence = 0
nonWitnessUtxo: toSpendTx.toBuffer(), // previous transaction for us to rebuild later to verify
});
if (addressDetails.redeemScript) {
psbt.updateInput(0, { redeemScript: addressDetails.redeemScript });
}
if (addressDetails.witnessScript) {
psbt.updateInput(0, {
witnessUtxo: { value: BigInt(0), script: addressDetails.witnessScript },
});
}

// Set the output
psbt.addOutput({
value: BigInt(0), // vout[0].nValue = 0
script: Buffer.from([0x6a]), // vout[0].scriptPubKey = OP_RETURN
});
return psbt;
}
12 changes: 8 additions & 4 deletions modules/utxo-core/src/bip322/toSpend.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Hash } from 'fast-sha256';
import { Psbt } from '@bitgo/utxo-lib';
import { Psbt, Transaction } from '@bitgo/utxo-lib';

export const BIP322_TAG = 'BIP0322-signed-message';

Expand Down Expand Up @@ -31,9 +31,13 @@ export function hashMessageWithTag(message: string | Buffer, tag = BIP322_TAG):
* @param {Buffer} scriptPubKey - The scriptPubKey to use for the output
* @param {string | Buffer} message - The message to include in the transaction
* @param {Buffer} [tag=BIP322_TAG] - The tag to use for hashing, defaults to BIP322_TAG.
* @returns {string} - The hex representation of the constructed transaction
* @returns {Transaction} - The constructed transaction
*/
export function buildToSpendTransaction(scriptPubKey: Buffer, message: string | Buffer, tag = BIP322_TAG): string {
export function buildToSpendTransaction(
scriptPubKey: Buffer,
message: string | Buffer,
tag = BIP322_TAG
): Transaction<bigint> {
// Create PSBT object for constructing the transaction
const psbt = new Psbt();
// Set default value for nVersion and nLockTime
Expand All @@ -60,5 +64,5 @@ export function buildToSpendTransaction(scriptPubKey: Buffer, message: string |
script: scriptPubKey, // vout[0].scriptPubKey = message_challenge
});
// Return transaction
return psbt.extractTransaction().toHex();
return psbt.extractTransaction();
}
43 changes: 43 additions & 0 deletions modules/utxo-core/test/bip322/toSign.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import assert from 'assert';

import { payments, ECPair, Transaction } from '@bitgo/utxo-lib';

import * as bip322 from '../../src/bip322';

describe('BIP322 toSign', function () {
describe('buildToSignPsbt', function () {
const WIF = 'L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k';
const prv = ECPair.fromWIF(WIF);
const scriptPubKey = payments.p2wpkh({
address: 'bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l',
}).output as Buffer;

// Source: https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki#transaction-hashes
const fixtures = [
{
message: '',
txid: '1e9654e951a5ba44c8604c4de6c67fd78a27e81dcadcfe1edf638ba3aaebaed6',
},
{
message: 'Hello World',
txid: '88737ae86f2077145f93cc4b153ae9a1cb8d56afa511988c149c5c8c9d93bddf',
},
];

fixtures.forEach(({ message, txid }) => {
it(`should build a to_sign PSBT for message "${message}"`, function () {
const toSpendTx = bip322.buildToSpendTransaction(scriptPubKey, Buffer.from(message));
const addressDetails = {
witnessScript: scriptPubKey,
};
const result = bip322.buildToSignPsbt(toSpendTx, addressDetails);
const computedTxid = result
.signAllInputs(prv, [Transaction.SIGHASH_ALL])
.finalizeAllInputs()
.extractTransaction()
.getId();
assert.strictEqual(computedTxid, txid, `Transaction ID for message "${message}" does not match expected value`);
});
});
});
});
4 changes: 2 additions & 2 deletions modules/utxo-core/test/bip322/toSpend.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from 'assert';

import { payments, Transaction } from '@bitgo/utxo-lib';
import { payments } from '@bitgo/utxo-lib';

import { buildToSpendTransaction, hashMessageWithTag } from '../../src/bip322';

Expand Down Expand Up @@ -50,7 +50,7 @@ describe('to_spend', function () {
fixtures.forEach(({ message, txid }) => {
it(`should build a to_spend transaction for message "${message}"`, function () {
const result = buildToSpendTransaction(scriptPubKey, Buffer.from(message));
const computedTxid = Transaction.fromHex(result).getId();
const computedTxid = result.getId();
assert.strictEqual(computedTxid, txid, `Transaction ID for message "${message}" does not match expected value`);
});
});
Expand Down