javascripttypescriptblockchainbitcoinbitcoinlib

how to sign bitcoin psbt with ledger?


I'm trying to sign a Psbt transaction from bitcoinjs-lib following what I found here:

https://github.com/helperbit/helperbit-wallet/blob/master/app/components/dashboard.wallet/bitcoin.service/ledger.ts

I've checked that the compressed publicKey both from ledger, and the one from bitcoinjsLib returned the same value.

I could sign it with the bitcoinjs-lib ECPair, but when I tries to sign it using ledger, it is always invalid.

Can someone helps me point out where did I made a mistake?

These variables is already mentioned in the code below, but for clarity purpose:

- mnemonics: 
abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about

- previousTx:
02000000000101869362410c61a69ab9390b2167d08219662196e869626e8b0350f1a8e4075efb0100000017160014ef3fdddccdb6b53e6dd1f5a97299a6ba2e1c11c3ffffffff0240420f000000000017a914f748afee815f78f97672be5a9840056d8ed77f4887df9de6050000000017a9142ff4aa6ffa987335c7bdba58ef4cbfecbe9e49938702473044022061a01bf0fbac4650a9b3d035b3d9282255a5c6040aa1d04fd9b6b52ed9f4d20a022064e8e2739ef532e6b2cb461321dd20f5a5d63cf34da3056c428475d42c9aff870121025fb5240daab4cee5fa097eef475f3f2e004f7be702c421b6607d8afea1affa9b00000000

- paths:
["0'/0/0"]

- redeemScript: (non-multisig segwit)
00144328adace54072cd069abf108f97cf80420b212b

This is my minimum reproducible code I've got.

/* tslint:disable */
// @ts-check
require('regenerator-runtime');
const bip39 = require('bip39');
const { default: Transport } = require('@ledgerhq/hw-transport-node-hid');
const { default: AppBtc } = require('@ledgerhq/hw-app-btc');
const bitcoin = require('bitcoinjs-lib');
const mnemonics = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const NETWORK = bitcoin.networks.regtest;

/**
 * @param {string} pk 
 * @returns {string}
 */
function compressPublicKey(pk) {
  const { publicKey } = bitcoin.ECPair.fromPublicKey(Buffer.from(pk, 'hex'));
  return publicKey.toString('hex');
}

/** @returns {Promise<any>} */
async function appBtc() {
  const transport = await Transport.create();
  const btc = new AppBtc(transport);
  return btc;
}

const signTransaction = async() => {
  const ledger = await appBtc();
  const paths = ["0'/0/0"];
  const [ path ] = paths;
  const previousTx = "02000000000101869362410c61a69ab9390b2167d08219662196e869626e8b0350f1a8e4075efb0100000017160014ef3fdddccdb6b53e6dd1f5a97299a6ba2e1c11c3ffffffff0240420f000000000017a914f748afee815f78f97672be5a9840056d8ed77f4887df9de6050000000017a9142ff4aa6ffa987335c7bdba58ef4cbfecbe9e49938702473044022061a01bf0fbac4650a9b3d035b3d9282255a5c6040aa1d04fd9b6b52ed9f4d20a022064e8e2739ef532e6b2cb461321dd20f5a5d63cf34da3056c428475d42c9aff870121025fb5240daab4cee5fa097eef475f3f2e004f7be702c421b6607d8afea1affa9b00000000"
  const utxo = bitcoin.Transaction.fromHex(previousTx);
  const segwit = utxo.hasWitnesses();
  const txIndex = 0;

  // ecpairs things.
  const seed = await bip39.mnemonicToSeed(mnemonics);
  const node = bitcoin.bip32.fromSeed(seed, NETWORK);

  const ecPrivate = node.derivePath(path);
  const ecPublic = bitcoin.ECPair.fromPublicKey(ecPrivate.publicKey, { network: NETWORK });
  const p2wpkh = bitcoin.payments.p2wpkh({ pubkey: ecPublic.publicKey, network: NETWORK });
  const p2sh = bitcoin.payments.p2sh({ redeem: p2wpkh, network: NETWORK });
  const redeemScript = p2sh.redeem.output;
  const fromLedger = await ledger.getWalletPublicKey(path, { format: 'p2sh' });
  const ledgerPublicKey = compressPublicKey(fromLedger.publicKey);
  const bitcoinJsPublicKey = ecPublic.publicKey.toString('hex');
  console.log({ ledgerPublicKey, bitcoinJsPublicKey, address: p2sh.address, segwit, fromLedger, redeemScript: redeemScript.toString('hex') });

  var tx1 = ledger.splitTransaction(previousTx, true);
  const psbt = new bitcoin.Psbt({ network: NETWORK });
  psbt.addInput({
    hash: utxo.getId(),
    index: txIndex,
    nonWitnessUtxo: Buffer.from(previousTx, 'hex'),
    redeemScript,
  });
  psbt.addOutput({
    address: 'mgWUuj1J1N882jmqFxtDepEC73Rr22E9GU',
    value: 5000,
  });
  psbt.setMaximumFeeRate(1000 * 1000 * 1000); // ignore maxFeeRate we're testnet anyway.
  psbt.setVersion(2);
  /** @type {string} */
  // @ts-ignore
  const newTx = psbt.__CACHE.__TX.toHex();
  console.log({ newTx });

  const splitNewTx = await ledger.splitTransaction(newTx, true);
  const outputScriptHex = await ledger.serializeTransactionOutputs(splitNewTx).toString("hex");
  const expectedOutscriptHex = '0188130000000000001976a9140ae1441568d0d293764a347b191025c51556cecd88ac';
  // stolen from: https://github.com/LedgerHQ/ledgerjs/blob/master/packages/hw-app-btc/tests/Btc.test.js
  console.log({ outputScriptHex, expectedOutscriptHex, eq: expectedOutscriptHex === outputScriptHex });

  const inputs = [ [tx1, 0, p2sh.redeem.output.toString('hex') /** ??? */] ];
  const ledgerSignatures = await ledger.signP2SHTransaction(
    inputs,
    paths,
    outputScriptHex,
    0, // lockTime,
    undefined, // sigHashType = SIGHASH_ALL ???
    utxo.hasWitnesses(),
    2, // version??,
  );

  const signer = {
    network: NETWORK,
    publicKey: ecPrivate.publicKey,
    /** @param {Buffer} $hash */
    sign: ($hash) => {
      const expectedSignature = ecPrivate.sign($hash); // just for comparison.
      const [ ledgerSignature0 ] = ledgerSignatures;
      const decodedLedgerSignature = bitcoin.script.signature.decode(Buffer.from(ledgerSignature0, 'hex'));
      console.log({
        $hash: $hash.toString('hex'),
        expectedSignature: expectedSignature.toString('hex'),
        actualSignature: decodedLedgerSignature.signature.toString('hex'),
      });
      // return signature;
      return decodedLedgerSignature.signature;
    },
  };
  psbt.signInput(0, signer);
  const validated = psbt.validateSignaturesOfInput(0);
  psbt.finalizeAllInputs();
  const hex = psbt.extractTransaction().toHex();
  console.log({ validated, hex });
};

if (process.argv[1] === __filename) {
  signTransaction().catch(console.error)
}



Solution

  • Ooof, finally got it working.

    My mistake was I was trying to sign a p2sh-p2ms, By following a reference on how to sign a p2sh-p2wsh-p2ms.

    And, also, that missing last 2 bit (01), which I think represent SIGHASH_ALL caused an error when I try to decode the signature.

    this is my finalized working example.

    // @ts-check
    require('regenerator-runtime');
    const bip39 = require('bip39');
    const { default: Transport } = require('@ledgerhq/hw-transport-node-hid');
    const { default: AppBtc } = require('@ledgerhq/hw-app-btc');
    const serializer = require('@ledgerhq/hw-app-btc/lib/serializeTransaction');
    const bitcoin = require('bitcoinjs-lib');
    const mnemonics = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
    const NETWORK = bitcoin.networks.regtest;
    const DEFAULT_LOCK_TIME = 0;
    const SIGHASH_ALL = 1;
    const PATHS = ["m/49'/1'/0'/0/0", "m/49'/1'/0'/0/1"]; 
    
    async function appBtc() {
      const transport = await Transport.create();
      const btc = new AppBtc(transport);
      return btc;
    }
    
    /**
     * @param {string} pk 
     * @returns {string}
     */
    function compressPublicKey(pk) {
      const {
        publicKey
      } = bitcoin.ECPair.fromPublicKey(Buffer.from(pk, 'hex'));
      return publicKey.toString('hex');
    }
    
    /**
     * @param {AppBtc} ledger
     * @param {bitcoin.Transaction} tx
     */
    function splitTransaction(ledger, tx) {
      return ledger.splitTransaction(tx.toHex(), tx.hasWitnesses());
    }
    
    const signTransaction = async() => {
      const seed = await bip39.mnemonicToSeed(mnemonics);
      const node = bitcoin.bip32.fromSeed(seed, NETWORK);
      const signers = PATHS.map((p) => node.derivePath(p));
      const publicKeys = signers.map((s) => s.publicKey);
      const p2ms = bitcoin.payments.p2ms({ pubkeys: publicKeys, network: NETWORK, m: 1 });
      const p2shP2ms = bitcoin.payments.p2sh({ redeem: p2ms, network: NETWORK });
      const previousTx = '02000000000101588e8fc89afea9adb79de2650f0cdba762f7d0880c29a1f20e7b468f97da9f850100000017160014345766130a8f8e83aef8621122ca14fff88e6d51ffffffff0240420f000000000017a914a0546d83e5f8876045d7025a230d87bf69db893287df9de6050000000017a9142ff4aa6ffa987335c7bdba58ef4cbfecbe9e49938702483045022100c654271a891af98e46ca4d82ede8cccb0503a430e50745f959274294c98030750220331b455fed13ff4286f6db699eca06aa0c1c37c45c9f3aed3a77a3b0187ff4ac0121037ebcf3cf122678b9dc89b339017c5b76bee9fedd068c7401f4a8eb1d7e841c3a00000000';
      const utxo = bitcoin.Transaction.fromHex(previousTx);
      const txIndex = 0;
      const destination = p2shP2ms;
      const redeemScript = destination.redeem.output;
      // const witnessScript = destination.redeem.redeem.output;
      const ledgerRedeemScript = redeemScript;
      // use witness script if the outgoing transaction was from a p2sh-p2wsh-p2ms instead of p2sh-p2ms
      const fee = 1000;
      /** @type {number} */
      // @ts-ignore
      const amount = utxo.outs[txIndex].value;
      const withdrawAmount = amount - fee;
      const psbt = new bitcoin.Psbt({ network: NETWORK });
      const version = 1;
      psbt.addInput({
        hash: utxo.getId(),
        index: txIndex,
        nonWitnessUtxo: utxo.toBuffer(),
        redeemScript,
      });
      psbt.addOutput({
        address: '2MsK2NdiVEPCjBMFWbjFvQ39mxWPMopp5vp',
        value: withdrawAmount
      });
      psbt.setVersion(version);
      /** @type {bitcoin.Transaction}  */
      // @ts-ignore
      const newTx = psbt.__CACHE.__TX;
    
      const ledger = await appBtc();
      const inLedgerTx = splitTransaction(ledger, utxo);
      const outLedgerTx = splitTransaction(ledger, newTx);
      const outputScriptHex = await serializer.serializeTransactionOutputs(outLedgerTx).toString('hex');
    
      /** @param {string} path */
      const signer = (path) => {
        const ecPrivate = node.derivePath(path);
        // actually only publicKey is needed, albeit ledger give an uncompressed one.
        // const { publicKey: uncompressedPublicKey } = await ledger.getWalletPublicKey(path);
        // const publicKey = compressPublicKey(publicKey);
        return {
          network: NETWORK,
          publicKey: ecPrivate.publicKey,
          /** @param {Buffer} $hash */
          sign: async ($hash) => {
            const ledgerTxSignatures = await ledger.signP2SHTransaction({
              inputs: [[inLedgerTx, txIndex, ledgerRedeemScript.toString('hex')]],
              associatedKeysets: [ path ],
              outputScriptHex,
              lockTime: DEFAULT_LOCK_TIME,
              segwit: newTx.hasWitnesses(),
              transactionVersion: version,
              sigHashType: SIGHASH_ALL,
            });
            const [ ledgerSignature ] = ledgerTxSignatures;
            const expectedSignature = ecPrivate.sign($hash);
            const finalSignature = (() => {
              if (newTx.hasWitnesses()) {
                return Buffer.from(ledgerSignature, 'hex');
              };
              return Buffer.concat([
                ledgerSignature,
                Buffer.from('01', 'hex'), // SIGHASH_ALL
              ]);
            })();
            console.log({
              expectedSignature: expectedSignature.toString('hex'),
              finalSignature: finalSignature.toString('hex'),
            });
            const { signature } = bitcoin.script.signature.decode(finalSignature);
            return signature;
          },
        };
      }
      await psbt.signInputAsync(0, signer(PATHS[0]));
      const validate = await psbt.validateSignaturesOfAllInputs();
      await psbt.finalizeAllInputs();
      const hex = psbt.extractTransaction().toHex();
      console.log({ validate, hex });
    };
    
    if (process.argv[1] === __filename) {
      signTransaction().catch(console.error)
    }