LedgerHQ / ledgerjs

⛔️ MOVED to monorepo "ledger-live"
https://github.com/LedgerHQ/ledger-live
Apache License 2.0
575 stars 377 forks source link

cannot sign bitcoin P2SH transaction (0x6985 error) #521

Open hoonsubin opened 4 years ago

hoonsubin commented 4 years ago

What I'm trying to make

Sign a transaction that unlocks a time-locked token from a react-typescript front-end application on the browser with bitcoinjs-lib. The application will ask the user to choose an address path, timelock duration in days (encoded to bip68 for CSV lock), and request to export their public key from the ledger device. This will create a P2SH address with a QR code that the user can send the funds they wish to lock for the given duration. The UI will have a list of lock transactions. The user can click the 'create unlock TX' button to create and sign a redeem transaction to recover their funds after the lock period has ended.

What should happen

The user should be able to sign a new transaction that unlocks the transaction and output a raw signed transaction in hex, ready for propagation.

What happens

I've created multiple transaction methods for testing, but all of them will return the Condition of use not satisfied (denied by the user?) (0x6985) error. One test involves using the createPaymentTransactionNew() method from the apps-btc package, this will return the 0x6985 error without any feedback from the device. The other method is using signP2SHTransaction() and this will give the Unverified inputs update third party wallet software message from Ledger Nano S and continuing the sign this transaction will result in the 0x6985 error.

Transport

For Ledger device transport, I used WebUSB or U2F if the WebUSB instance does not connect.

Lock UTXO data

This is the full lock transaction, recorded in Bitcoin testnet

{
    "txid": "01cec976192e2f39ff57fdba5cba5d03094a7cf696f3f5ab89379e389ef77412",
    "version": 1,
    "locktime": 0,
    "vin": [
        {
            "txid": "3d9cfa57382330c50af5cbc729d26962bd2954d51c80b15f8d3a11f32689c819",
            "vout": 0,
            "prevout": {
                "scriptpubkey": "001494a518b249157104a9c2adcb33b0ee4c6be43332",
                "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 94a518b249157104a9c2adcb33b0ee4c6be43332",
                "scriptpubkey_type": "v0_p2wpkh",
                "scriptpubkey_address": "tb1qjjj33vjfz4csf2wz4h9n8v8wf347gvejtx7ym0",
                "value": 10000
            },
            "scriptsig": "",
            "scriptsig_asm": "",
            "witness": [
                "3045022100f6dbf811dc959f6f626da873ce54193efa5b28d81b14c6c0cf7c9237728993fb02206ff5d41e10b000867d345ec183e1dc9cd50cbdf7cd10efeb5366dd96dc47474701",
                "02b845db7300f3208891ea36eebdb1742b846783cefb0978d72d8e5d9b827022be"
            ],
            "is_coinbase": false,
            "sequence": 4294967295
        },
        {
            "txid": "b4156ffc10872933cc2cb0addc391e466ed519d82938b9bbe55c1213f95cce7f",
            "vout": 1,
            "prevout": {
                "scriptpubkey": "001494a518b249157104a9c2adcb33b0ee4c6be43332",
                "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 94a518b249157104a9c2adcb33b0ee4c6be43332",
                "scriptpubkey_type": "v0_p2wpkh",
                "scriptpubkey_address": "tb1qjjj33vjfz4csf2wz4h9n8v8wf347gvejtx7ym0",
                "value": 1000000
            },
            "scriptsig": "",
            "scriptsig_asm": "",
            "witness": [
                "30440220619094ab5daa0000db9d9905059e9a61951f61764ce6f96ec45026dde2e27f9c02205f63448a55b9587395897430e98452fd5a07a074ad8747501c86f5fda764001001",
                "02b845db7300f3208891ea36eebdb1742b846783cefb0978d72d8e5d9b827022be"
            ],
            "is_coinbase": false,
            "sequence": 4294967295
        }
    ],
    "vout": [
        {
            "scriptpubkey": "a914d550b302301e25bf8f2c5115a31f8511bdbfdd9487",
            "scriptpubkey_asm": "OP_HASH160 OP_PUSHBYTES_20 d550b302301e25bf8f2c5115a31f8511bdbfdd94 OP_EQUAL",
            "scriptpubkey_type": "p2sh",
            "scriptpubkey_address": "2NCh8bE55urpQeZu5NxrXDj9jDK1a9jY7rZ",
            "value": 630482
        },
        {
            "scriptpubkey": "0014aeda84ee9434c2259966b95298323b989ec48095",
            "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 aeda84ee9434c2259966b95298323b989ec48095",
            "scriptpubkey_type": "v0_p2wpkh",
            "scriptpubkey_address": "tb1q4mdgfm55xnpztxtxh9ffsv3mnz0vfqy4s6d7cc",
            "value": 377378
        }
    ],
    "size": 372,
    "weight": 837,
    "fee": 2140,
    "status": {
        "confirmed": true,
        "block_height": 1781804,
        "block_hash": "000000000ad0ada80f0dd0bb98e2a0b0ff0dd16a6ea055c0739fa0efc51b2734",
        "block_time": 1595940590
    },
    "raw_tx_hex": "0100000000010219c88926f3113a8d5fb1801cd55429bd6269d229c7cbf50ac530233857fa9c3d0000000000ffffffff7fce5cf913125ce5bbb93829d819d56e461e39dcadb02ccc33298710fc6f15b40100000000ffffffff02d29e09000000000017a914d550b302301e25bf8f2c5115a31f8511bdbfdd948722c2050000000000160014aeda84ee9434c2259966b95298323b989ec4809502483045022100f6dbf811dc959f6f626da873ce54193efa5b28d81b14c6c0cf7c9237728993fb02206ff5d41e10b000867d345ec183e1dc9cd50cbdf7cd10efeb5366dd96dc474747012102b845db7300f3208891ea36eebdb1742b846783cefb0978d72d8e5d9b827022be024730440220619094ab5daa0000db9d9905059e9a61951f61764ce6f96ec45026dde2e27f9c02205f63448a55b9587395897430e98452fd5a07a074ad8747501c86f5fda7640010012102b845db7300f3208891ea36eebdb1742b846783cefb0978d72d8e5d9b827022be00000000"
}

Code that shows the issue

This is where the transaction signature request is invoked

// get ledger API
const btc = await ledgerApiInstance();

const rawTxHex = JSONData.raw_tx_hex;

/// method 1 ==============================
const isSegWit = bitcoinjs.Transaction.fromHex(rawTxHex).hasWitnesses();
const txIndex = 0; //temp value

// transaction that locks the tokens
const utxo = btc.splitTransaction(rawTxHex);

const newTx = await btc.createPaymentTransactionNew({
    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
    inputs: [[utxo, txIndex, lockScript.redeem!.output!.toString('hex'), null]],
    associatedKeysets: [addressPath],
    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
    outputScriptHex: lockScript.output!.toString('hex'),
    segwit: isSegWit,
    sigHashType: bitcoinjs.Transaction.SIGHASH_ALL,
    lockTime: 0,
    useTrustedInputForSegwit: isSegWit,
});

console.log(newTx);

// method 2 ==============================
const ledgerSigner = await btcLock.generateSigner(btc, addressPath, networkType, rawTxHex, lockScript, publicKey);

// this is used for the random output address
const randomPublicKey = bitcoinjs.ECPair.makeRandom({ network: networkType, compressed: true }).publicKey;
const randomAddress = bitcoinjs.payments.p2pkh({ pubkey: randomPublicKey, network: networkType }).address;
const FEE = 1000;
// create the redeem UTXO
const unlockTx = await btcLock.btcUnlockTx(
    ledgerSigner,
    networkType,
    bitcoinjs.Transaction.fromHex(rawTxHex),
    lockScript.redeem!.output!,
    btcLock.daysToBlockSequence(lockDuration.value),
    randomAddress!,
    FEE,
);

const signedTxHex = unlockTx.toHex();
console.log(signedTxHex);

This is the functions used for the second method

/**
 * creates a lock redeem UTXO
 * @param signer the signer for signing the transaction hash
 * @param network network type (bitcoinjs-lib)
 * @param lockTx the transaction that locks the value to P2SH address
 * @param lockScript the lock script (P2SH)
 * @param blockSequence block sequence to lock the funds, should be the same value used in the lock script
 * @param recipient recipient for the transaction output
 * @param fee transaction fee for the lock transaction
 */
export async function btcUnlockTx(
    signer: HwSigner,
    network: Network,
    lockTx: bitcoinjs.Transaction,
    lockScript: Buffer,
    blockSequence: number,
    recipientAddress: string,
    fee: number, // satoshis
) {
    function idToHash(txid: string): Buffer {
        return Buffer.from(txid, 'hex').reverse();
    }
    function toOutputScript(address: string): Buffer {
        return bitcoinjs.address.toOutputScript(address, network);
    }

    if (blockSequence < 0) {
        throw new Error('Block sequence cannot be less than zeo');
    }
    if (fee < 0) {
        throw new Error('Transaction fee cannot be less than zero');
    }

    if (!Number.isInteger(blockSequence) || !Number.isFinite(blockSequence)) {
        throw new Error('Block sequence must be a valid integer, but received: ' + blockSequence);
    }
    if (!Number.isInteger(fee) || !Number.isFinite(fee)) {
        throw new Error('Fee must be a valid integer, but received: ' + fee);
    }

    const txIndex = 0;

    //const sequence = bip68.encode({ blocks: lockBlocks });
    const tx = new bitcoinjs.Transaction();
    tx.version = 2;
    tx.addInput(idToHash(lockTx.getId()), txIndex, blockSequence);
    tx.addOutput(toOutputScript(recipientAddress), lockTx.outs[txIndex].value - fee);

    const hashType = bitcoinjs.Transaction.SIGHASH_ALL;
    const signatureHash = tx.hashForSignature(0, lockScript, hashType);
    const signature = bitcoinjs.script.signature.encode(await signer.sign(signatureHash), hashType);

    const redeemScriptSig = bitcoinjs.payments.p2sh({
        network,
        redeem: {
            network,
            output: lockScript,
            input: bitcoinjs.script.compile([signature]),
        },
    }).input;
    if (redeemScriptSig instanceof Buffer) {
        tx.setInputScript(0, redeemScriptSig);
    } else {
        throw new Error('Transaction is invalid');
    }

    return tx;
}

This is the signer method that is compatible with bitcoinjs-lib

/**
 * Creates a signer instance for signing transactions made with bitcoinjs-lib
 * from Ledger BTC App.
 * @param ledgerApi
 * @param path HD address path
 * @param network bitcoin network the transaction will belong
 * @param lockTxHex raw lock UTXO in hex string
 * @param lockScript lock script used to generate the P2SH
 * @param publicKey compressed public key in string format
 */
export const generateSigner = async (
    ledgerApi: AppBtc,
    path: string,
    network: bitcoinjs.Network,
    lockTxHex: string,
    lockScript: bitcoinjs.payments.Payment,
    publicKey: string,
) => {
    const isSegWit = bitcoinjs.Transaction.fromHex(lockTxHex).hasWitnesses();
    const ledgerTx = ledgerApi.splitTransaction(lockTxHex, isSegWit);
    const txIndex = 0; //temp value

    return {
        network,
        publicKey: Buffer.from(publicKey, 'hex'),

        sign: async (hash: Buffer, lowR?: boolean) => {
            console.log('signing with ledger\n' + hash.toString('hex'));

            const ledgerTxSignatures = await ledgerApi.signP2SHTransaction({
                // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                inputs: [[ledgerTx, txIndex, lockScript.redeem!.output!.toString('hex'), null]],
                associatedKeysets: [path],
                // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                outputScriptHex: lockScript.output!.toString('hex'),
                segwit: isSegWit,
                transactionVersion: 2,
                sigHashType: bitcoinjs.Transaction.SIGHASH_ALL,
            });

            console.log(ledgerTxSignatures);
            console.log(hash.toString('hex') + lowR);
            const [ledgerSignature] = ledgerTxSignatures;
            const encodedSignature = (() => {
                if (isSegWit) {
                    return Buffer.from(ledgerSignature, 'hex');
                }
                return Buffer.concat([
                    Buffer.from(ledgerSignature, 'hex'),
                    Buffer.from('01', 'hex'), // SIGHASH_ALL
                ]);
            })();
            const decoded = bitcoinjs.script.signature.decode(encodedSignature);
            return decoded.signature;
        },
    } as HwSigner;
};

Comnment

I assume the issue comes from improper input or output data for the unlock transaction, but Ledger isn't giving me a useful error message, I've tried all options and I'm feeling hopeless. At this point, I just wish it was either my device's fault or some simple data misplacement. If you need more information, please tell me.

junderw commented 4 years ago

What is the lockScript: Buffer? It could just be your locking script is bugged and unspendable.

hoonsubin commented 4 years ago

@junderw thank you for the comment, here's the full lock script that's used in the code.

/**
 * create a bitcoin lock script buffer with the given public key.
 * this will lock the token for the given number of block sequence.
 * if the given public key is not compressed, this function will compress it.
 * @param publicKeyHex compressed BTC public key in hex string
 * @param blockSequence bip68 encoded block sequence
 * @param network bitcoin network the public key belongs to
 */
export function btcLockScript(publicKeyHex: string, blockSequence: number, network: bitcoinjs.Network): Buffer {
    // verify block sequence value
    if (blockSequence < 0) {
        throw new Error('Block sequence cannot be a negative number');
    }
    if (!Number.isInteger(blockSequence) || !Number.isFinite(blockSequence)) {
        throw new Error('Block sequence must be a valid integer, but received: ' + blockSequence);
    }
    if (blockSequence > 65535) {
        // maximum lock time https://en.bitcoin.it/wiki/Timelock
        throw new Error('Block sequence cannot be more than 65535');
    }
    // verify public key by converting to an address
    if (!validatePublicKey(publicKeyHex, network)) {
        throw new Error('Invalid public key');
    }

    const pubKeyBuffer = Buffer.from(compressPubKey(publicKeyHex, network), 'hex');

    return bitcoinjs.script.fromASM(
        `
        ${bitcoinjs.script.number.encode(blockSequence).toString('hex')}
        OP_CHECKSEQUENCEVERIFY
        OP_DROP
        ${pubKeyBuffer.toString('hex')}
        OP_CHECKSIG
        `
            .trim()
            .replace(/\s+/g, ' '),
    );
}

and the P2SH that was used to lock the funds are generated here

/**
 * creates a P2SH instance that locks the sent token for the given duration.
 * the locked tokens can only be claimed by the provided public key
 * @param lockDays the lock duration in days
 * @param publicKey public key of the locker. This can be both compressed or uncompressed
 * @param network bitcoin network the script will generate for
 */
export function getLockP2SH(lockDays: number, publicKey: string, network: bitcoinjs.Network) {
    // only check lock duration boundaries for main net
    if (network === bitcoinjs.networks.bitcoin) {
        if (lockDays > 300 || lockDays < 30) {
            throw new Error('Lock duration must be between 30 days to 300 days');
        }
    }

    return bitcoinjs.payments.p2sh({
        network: network,
        redeem: {
            output: btcLockScript(publicKey, daysToBlockSequence(lockDays), network),
        },
    });
}

I don't think the script is the issue here because it passes unit tests done with a randomly generated EC pair on regtest