12

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)
}


Ramadoka
  • 352
  • 3
  • 25

2 Answers2

3

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)
}

Ramadoka
  • 352
  • 3
  • 25
  • You can use signP2SHTransaction to sign any transaction (not restricted to P2SH or P2WSH). The redeemScript you have to put in the inputs must be the redeemScript of each particular input. Also, for some reason, you have to use the redeemScript of p2pkh for any type of segwit input. If you have mixed inputs (segwit and legacy) you'll have to run serializeTransactionOutputs & signP2SHTransaction twice using segwit = true/false. Signatures must Buffer.concat(01) in all legacy inputs. More here: https://github.com/bitcoinjs/bitcoinjs-lib/issues/1517#issuecomment-1064367562 – landabaso Mar 10 '22 at 18:34
  • @ramadoka Do you have the complete code to send the transaction? I'm trying to send a transaction using the ledger for many days. This is the only thing I found. Thanks in advance. Email : tan.sandbox@gmail.com – nithinTa Jul 17 '22 at 07:10
-1

My guess is you have a space in the string passed to toByteArray function. This function doesn't trim input. Also doesn't check if input's length is even.

Xaqron
  • 29,931
  • 42
  • 140
  • 205
  • Hmm... i dont think that's the issue, I've tried using the standard `Buffer.from`, but the code above is just me copypasting the code from the reference I mentioned above since it seems the original code is supposed to be running in browser. – Ramadoka Nov 30 '19 at 12:46