diff --git a/class/hd-segwit-bech32-transaction.js b/class/hd-segwit-bech32-transaction.js new file mode 100644 index 000000000..54fa405c1 --- /dev/null +++ b/class/hd-segwit-bech32-transaction.js @@ -0,0 +1,210 @@ +import { HDSegwitBech32Wallet, SegwitBech32Wallet } from './'; +const bitcoin = require('bitcoinjs5'); +const BlueElectrum = require('../BlueElectrum'); +const reverse = require('buffer-reverse'); +const BigNumber = require('bignumber.js'); + +/** + * Represents transaction of a BIP84 wallet. + * Helpers for RBF, CPFP etc. + */ +export class HDSegwitBech32Transaction { + /** + * @param txhex string Object initialized with txhex + * @param wallet {HDSegwitBech32Wallet} If set - a wallet object to which transacton belongs + */ + constructor(txhex, wallet) { + this._txhex = txhex; + + if (wallet) { + if (wallet.type === HDSegwitBech32Wallet.type) { + /** @type {HDSegwitBech32Wallet} */ + this._wallet = wallet; + } else { + throw new Error('Only HD Bech32 wallets supported'); + } + } + + this._txDecoded = bitcoin.Transaction.fromHex(this._txhex); + this._remoteTx = null; + } + + /** + * Returns max used sequence for this transaction. Next RBF transaction + * should have this sequence + 1 + * + * @returns {number} + */ + getMaxUsedSequence() { + let max = 0; + for (let inp of this._txDecoded.ins) { + max = Math.max(inp.sequence, max); + } + + return max; + } + + /** + * Basic check that Sequence num for this TX is replaceable + * + * @returns {boolean} + */ + isSequenceReplaceable() { + return this.getMaxUsedSequence() < bitcoin.Transaction.DEFAULT_SEQUENCE; + } + + /** + * If internal extended tx data not set - this is a method + * to fetch and set this data from electrum + * + * @returns {Promise} + * @private + */ + async _fetchRemoteTx() { + let result = await BlueElectrum.multiGetTransactionByTxid([this._txDecoded.getId()]); + this._remoteTx = Object.values(result)[0]; + } + + /** + * Fetches from electrum actual confirmations number for this tx + * + * @returns {Promise} + */ + async getRemoteConfirmationsNum() { + if (!this._remoteTx) await this._fetchRemoteTx(); + return this._remoteTx.confirmations; + } + + /** + * Checks that tx belongs to a wallet and also + * tx value is < 0, which means its a spending transaction + * definately initiated by us. + * + * @returns {Promise} + */ + async isOurTransaction() { + if (!this._wallet) throw new Error('Wallet required for this method'); + let found = false; + for (let tx of this._wallet.getTransactions()) { + if (tx.txid === this._txDecoded.getId()) { + // its our transaction, and its spending transaction, which means we initiated it + if (tx.value < 0) found = true; + } + } + return found; + } + + async getInfo() { + if (!this._wallet) throw new Error('Wallet required for this method'); + if (!this._remoteTx) await this._fetchRemoteTx(); + + let prevInputs = []; + for (let inp of this._txDecoded.ins) { + let reversedHash = Buffer.from(reverse(inp.hash)); + reversedHash = reversedHash.toString('hex'); + prevInputs.push(reversedHash); + } + + let prevTransactions = await BlueElectrum.multiGetTransactionByTxid(prevInputs); + + // fetched, now lets count how much satoshis went in + let wentIn = 0; + let utxos = []; + for (let inp of this._txDecoded.ins) { + let reversedHash = Buffer.from(reverse(inp.hash)); + reversedHash = reversedHash.toString('hex'); + if (prevTransactions[reversedHash] && prevTransactions[reversedHash].vout && prevTransactions[reversedHash].vout[inp.index]) { + let value = prevTransactions[reversedHash].vout[inp.index].value; + value = new BigNumber(value).multipliedBy(100000000).toNumber(); + wentIn += value; + let address = SegwitBech32Wallet.witnessToAddress(inp.witness[inp.witness.length - 1]); + utxos.push({ vout: inp.index, value: value, txId: reversedHash, address: address }); + } + } + + // counting how much went into actual outputs + + let wasSpent = 0; + for (let outp of this._txDecoded.outs) { + wasSpent += +outp.value; + } + + let fee = wentIn - wasSpent; + let feeRate = Math.floor(fee / (this._txhex.length / 2)); + + // lets take a look at change + let changeAmount = 0; + let targets = []; + for (let outp of this._remoteTx.vout) { + let address = outp.scriptPubKey.addresses[0]; + let value = new BigNumber(outp.value).multipliedBy(100000000).toNumber(); + if (this._wallet.weOwnAddress(address)) { + changeAmount += value; + } else { + // this is target + targets.push({ value: value, address: address }); + } + } + + return { fee, feeRate, targets, changeAmount, utxos }; + + // this means... + // let maxPossibleFee = fee + changeAmount; + // let maxPossibleFeeRate = Math.floor(maxPossibleFee / (this._txhex.length / 2)); + // console.warn({maxPossibleFeeRate}); + } + + /** + * Checks if tx has single output and that output belongs to us - that + * means we already canceled this tx and we can only bump fees. Or plain all outputs belong to us. + * @returns {boolean} + */ + canCancelTx() { + if (!this._wallet) throw new Error('Wallet required for this method'); + + // if theres at least one output we dont own - we can cancel this transaction! + for (let outp of this._txDecoded.outs) { + if (!this._wallet.weOwnAddress(SegwitBech32Wallet.scriptPubKeyToAddress(outp.script))) return true; + } + + return false; + } + + /** + * @param newFeerate + * @returns {Promise<{outputs: Array, tx: HDSegwitBech32Transaction, inputs: Array, fee: Number}>} + */ + async createRBFcancelTx(newFeerate) { + if (!this._wallet) throw new Error('Wallet required for this method'); + if (!this._remoteTx) await this._fetchRemoteTx(); + + let { feeRate, utxos } = await this.getInfo(); + + if (newFeerate <= feeRate) throw new Error('New feerate should be bigger than the old one'); + let myAddress = await this._wallet.getChangeAddressAsync(); + + return this._wallet.createTransaction( + utxos, + [{ address: myAddress }], + newFeerate, + /* meaningless in this context */ myAddress, + this.getMaxUsedSequence() + 1, + ); + } + + /** + * @param newFeerate + * @returns {Promise<{outputs: Array, tx: HDSegwitBech32Transaction, inputs: Array, fee: Number}>} + */ + async createRBFbumpFee(newFeerate) { + if (!this._wallet) throw new Error('Wallet required for this method'); + if (!this._remoteTx) await this._fetchRemoteTx(); + + let { feeRate, targets, utxos } = await this.getInfo(); + + if (newFeerate <= feeRate) throw new Error('New feerate should be bigger than the old one'); + let myAddress = await this._wallet.getChangeAddressAsync(); + + return this._wallet.createTransaction(utxos, targets, newFeerate, myAddress, this.getMaxUsedSequence() + 1); + } +} diff --git a/class/hd-segwit-bech32-wallet.js b/class/hd-segwit-bech32-wallet.js index f5cf201fb..bccc4ec81 100644 --- a/class/hd-segwit-bech32-wallet.js +++ b/class/hd-segwit-bech32-wallet.js @@ -646,6 +646,9 @@ export class HDSegwitBech32Wallet extends AbstractHDWallet { return false; } + /** + * @deprecated + */ createTx(utxos, amount, fee, address) { throw new Error('Deprecated'); } diff --git a/class/index.js b/class/index.js index db989a176..c6add4c07 100644 --- a/class/index.js +++ b/class/index.js @@ -11,3 +11,4 @@ export * from './watch-only-wallet'; export * from './lightning-custodian-wallet'; export * from './abstract-hd-wallet'; export * from './hd-segwit-bech32-wallet'; +export * from './hd-segwit-bech32-transaction'; diff --git a/tests/integration/hd-segwit-bech32-transaction.test.js b/tests/integration/hd-segwit-bech32-transaction.test.js new file mode 100644 index 000000000..f63107e33 --- /dev/null +++ b/tests/integration/hd-segwit-bech32-transaction.test.js @@ -0,0 +1,182 @@ +/* global it, describe, jasmine, afterAll, beforeAll */ +import { HDSegwitBech32Wallet, HDSegwitBech32Transaction, SegwitBech32Wallet } from '../../class'; +const bitcoin = require('bitcoinjs5'); +global.crypto = require('crypto'); // shall be used by tests under nodejs CLI, but not in RN environment +let assert = require('assert'); +global.net = require('net'); // needed by Electrum client. For RN it is proviced in shim.js +let BlueElectrum = require('../../BlueElectrum'); + +afterAll(async () => { + // after all tests we close socket so the test suite can actually terminate + BlueElectrum.forceDisconnect(); + return new Promise(resolve => setTimeout(resolve, 10000)); // simple sleep to wait for all timeouts termination +}); + +beforeAll(async () => { + // awaiting for Electrum to be connected. For RN Electrum would naturally connect + // while app starts up, but for tests we need to wait for it + await BlueElectrum.waitTillConnected(); +}); + +describe('HDSegwitBech32Transaction', () => { + it('can decode & check sequence', async function() { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 30 * 1000; + let T = new HDSegwitBech32Transaction( + '020000000001035b6bd5f35a4ae9fb97da83929d042ee1609aa37e763e1428f31578d2308b5afb0000000000ffffffff66595fc361ad1d5954f7c06e38482d0abf1b981feee7353d854cd25c7f6516e80000000000ffffffffc33a839294313ae2674ce4c574364ff3837d31c8e9c40b030409bff318ddcda10100000000ffffffff02888a01000000000017a914e286d58e53f9247a4710e51232cce0686f16873c87d2880000000000001600140c75ccd91c55c4f54788931d48e6f23909d3a21f024730440220078c59543244d8b642f9ef7a7ea2747ddf34c2937a89fe9641038832f5baebf00220146b116e9a915a6fd9ab855c63b7549d44b8abf1607639aaf67321d045596046012103e73f0f28b3007440783b188facc81e835df195687a6dc5e9fbd775022d27eebc02483045022100e2f3a3064517226dbde0d87733b0179e0bb0cd94968f27961ba66847f4553d3602200ab8a858b7967cdb656816861a8cab46cf86db69be8a285292157b1fe1546872012103d8a20ce5c997b78ebeb0611f12bcf6c6bd797e485ff99253fee0e9794ac73dfd0247304402207dd7833521682e01c399195d37965b84ca6085f5ff62a34e09446e3244760d2702207672b60ec9d307ed502dc023b30d00415c061fd68a7f503738c57ab81419c788012102b8faed0d077eb0523af21e14fe51bd6dcc5a14ba6e800071bac2a3583a9d3a6a00000000', + ); + assert.strictEqual(T.getMaxUsedSequence(), 0xffffffff); + assert.strictEqual(T.isSequenceReplaceable(), false); + + T = new HDSegwitBech32Transaction( + '02000000000102f1155666b534f7cb476a0523a45dc8731d38d56b5b08e877c968812423fbd7f3010000000000000000d8a2882a692ee759b43e6af48ac152dd3410cc4b7d25031e83b3396c16ffbc8900000000000000000002400d03000000000017a914e286d58e53f9247a4710e51232cce0686f16873c870695010000000000160014d3e2ecbf4d91321794e0297e0284c47527cf878b02483045022100d18dc865fb4d087004d021d480b983b8afb177a1934ce4cd11cf97b03e17944f02206d7310687a84aab5d4696d535bca69c2db4449b48feb55fff028aa004f2d1744012103af4b208608c75f38e78f6e5abfbcad9c360fb60d3e035193b2cd0cdc8fc0155c0247304402207556e859845df41d897fe442f59b6106c8fa39c74ba5b7b8e3268ab0aebf186f0220048a9f3742339c44a1e5c78b491822b96070bcfda3f64db9dc6434f8e8068475012102456e5223ed3884dc6b0e152067fd836e3eb1485422eda45558bf83f59c6ad09f00000000', + ); + assert.strictEqual(T.getMaxUsedSequence(), 0); + assert.strictEqual(T.isSequenceReplaceable(), true); + + assert.ok((await T.getRemoteConfirmationsNum()) >= 292); + }); + + it('can tell if its our transaction', async function() { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 30 * 1000; + if (!process.env.HD_MNEMONIC_BIP84) { + console.error('process.env.HD_MNEMONIC_BIP84 not set, skipped'); + return; + } + + let hd = new HDSegwitBech32Wallet(); + hd.setSecret(process.env.HD_MNEMONIC_BIP84); + assert.ok(hd.validateMnemonic()); + await hd.fetchTransactions(); + + let tt = new HDSegwitBech32Transaction( + '02000000000102f1155666b534f7cb476a0523a45dc8731d38d56b5b08e877c968812423fbd7f3010000000000000000d8a2882a692ee759b43e6af48ac152dd3410cc4b7d25031e83b3396c16ffbc8900000000000000000002400d03000000000017a914e286d58e53f9247a4710e51232cce0686f16873c870695010000000000160014d3e2ecbf4d91321794e0297e0284c47527cf878b02483045022100d18dc865fb4d087004d021d480b983b8afb177a1934ce4cd11cf97b03e17944f02206d7310687a84aab5d4696d535bca69c2db4449b48feb55fff028aa004f2d1744012103af4b208608c75f38e78f6e5abfbcad9c360fb60d3e035193b2cd0cdc8fc0155c0247304402207556e859845df41d897fe442f59b6106c8fa39c74ba5b7b8e3268ab0aebf186f0220048a9f3742339c44a1e5c78b491822b96070bcfda3f64db9dc6434f8e8068475012102456e5223ed3884dc6b0e152067fd836e3eb1485422eda45558bf83f59c6ad09f00000000', + hd, + ); + + assert.ok(await tt.isOurTransaction()); + + tt = new HDSegwitBech32Transaction( + '01000000000101e141f756746932f869c7323d941f26e6a1a6817143b97250a51f8c08510547a901000000171600148ba6d02e74c0a6e000e8b174eb2ed44e5ea211a60000000002400d03000000000016001465eb5c39aa6785f69f292fdb41c282ea7799721ff85c09000000000017a914e286d58e53f9247a4710e51232cce0686f16873c8702473044022009a072e3a920708a63bac6452f5ff74a0e918057bb79f9f0fce72494c7edd5c9022000e430179e9051fe37b6ea8ba538b4af94e120dd70fc30aed4e54d5054bc9f9d0121039a421d5eb7c9de6590ae2a471cb556b60de8c6b056beb907dbdc1f5e6092f58800000000', + hd, + ); + + assert.ok(!(await tt.isOurTransaction())); + }); + + it('can tell tx info', async function() { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 30 * 1000; + if (!process.env.HD_MNEMONIC_BIP84) { + console.error('process.env.HD_MNEMONIC_BIP84 not set, skipped'); + return; + } + + let hd = new HDSegwitBech32Wallet(); + hd.setSecret(process.env.HD_MNEMONIC_BIP84); + await hd.fetchBalance(); + await hd.fetchTransactions(); + + // 881c54edd95cbdd1583d6b9148eb35128a47b64a2e67a5368a649d6be960f08e + let tt = new HDSegwitBech32Transaction( + '02000000000102f1155666b534f7cb476a0523a45dc8731d38d56b5b08e877c968812423fbd7f3010000000000000000d8a2882a692ee759b43e6af48ac152dd3410cc4b7d25031e83b3396c16ffbc8900000000000000000002400d03000000000017a914e286d58e53f9247a4710e51232cce0686f16873c870695010000000000160014d3e2ecbf4d91321794e0297e0284c47527cf878b02483045022100d18dc865fb4d087004d021d480b983b8afb177a1934ce4cd11cf97b03e17944f02206d7310687a84aab5d4696d535bca69c2db4449b48feb55fff028aa004f2d1744012103af4b208608c75f38e78f6e5abfbcad9c360fb60d3e035193b2cd0cdc8fc0155c0247304402207556e859845df41d897fe442f59b6106c8fa39c74ba5b7b8e3268ab0aebf186f0220048a9f3742339c44a1e5c78b491822b96070bcfda3f64db9dc6434f8e8068475012102456e5223ed3884dc6b0e152067fd836e3eb1485422eda45558bf83f59c6ad09f00000000', + hd, + ); + + let { fee, feeRate, targets, changeAmount, utxos } = await tt.getInfo(); + assert.strictEqual(fee, 4464); + assert.strictEqual(changeAmount, 103686); + assert.strictEqual(feeRate, 12); + assert.strictEqual(targets.length, 1); + assert.strictEqual(targets[0].value, 200000); + assert.strictEqual(targets[0].address, '3NLnALo49CFEF4tCRhCvz45ySSfz3UktZC'); + assert.strictEqual( + JSON.stringify(utxos), + JSON.stringify([ + { + vout: 1, + value: 108150, + txId: 'f3d7fb23248168c977e8085b6bd5381d73c85da423056a47cbf734b5665615f1', + address: 'bc1qahhgjtxexjx9t0e5pjzqwtjnxexzl6f5an38hq', + }, + { + vout: 0, + value: 200000, + txId: '89bcff166c39b3831e03257d4bcc1034dd52c18af46a3eb459e72e692a88a2d8', + address: 'bc1qvh44cwd2v7zld8ef9ld5rs5zafmejuslp6yd73', + }, + ]), + ); + }); + + it('can do RBF - cancel tx', async function() { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 30 * 1000; + if (!process.env.HD_MNEMONIC_BIP84) { + console.error('process.env.HD_MNEMONIC_BIP84 not set, skipped'); + return; + } + + let hd = new HDSegwitBech32Wallet(); + hd.setSecret(process.env.HD_MNEMONIC_BIP84); + await hd.fetchBalance(); + await hd.fetchTransactions(); + + // 881c54edd95cbdd1583d6b9148eb35128a47b64a2e67a5368a649d6be960f08e + let tt = new HDSegwitBech32Transaction( + '02000000000102f1155666b534f7cb476a0523a45dc8731d38d56b5b08e877c968812423fbd7f3010000000000000000d8a2882a692ee759b43e6af48ac152dd3410cc4b7d25031e83b3396c16ffbc8900000000000000000002400d03000000000017a914e286d58e53f9247a4710e51232cce0686f16873c870695010000000000160014d3e2ecbf4d91321794e0297e0284c47527cf878b02483045022100d18dc865fb4d087004d021d480b983b8afb177a1934ce4cd11cf97b03e17944f02206d7310687a84aab5d4696d535bca69c2db4449b48feb55fff028aa004f2d1744012103af4b208608c75f38e78f6e5abfbcad9c360fb60d3e035193b2cd0cdc8fc0155c0247304402207556e859845df41d897fe442f59b6106c8fa39c74ba5b7b8e3268ab0aebf186f0220048a9f3742339c44a1e5c78b491822b96070bcfda3f64db9dc6434f8e8068475012102456e5223ed3884dc6b0e152067fd836e3eb1485422eda45558bf83f59c6ad09f00000000', + hd, + ); + + assert.strictEqual(tt.canCancelTx(), true); + + let { tx } = await tt.createRBFcancelTx(15); + + let createdTx = bitcoin.Transaction.fromHex(tx.toHex()); + assert.strictEqual(createdTx.ins.length, 2); + assert.strictEqual(createdTx.outs.length, 1); + let addr = SegwitBech32Wallet.scriptPubKeyToAddress(createdTx.outs[0].script); + assert.ok(hd.weOwnAddress(addr)); + + let actualFeerate = (108150 + 200000 - createdTx.outs[0].value) / (tx.toHex().length / 2); + assert.strictEqual(Math.round(actualFeerate), 15); + + let tt2 = new HDSegwitBech32Transaction(tx.toHex(), hd); + assert.strictEqual(tt2.canCancelTx(), false); // newly created cancel tx is not cancellable anymore + }); + + it('can do RBF - bumpfees tx', async function() { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 30 * 1000; + if (!process.env.HD_MNEMONIC_BIP84) { + console.error('process.env.HD_MNEMONIC_BIP84 not set, skipped'); + return; + } + + let hd = new HDSegwitBech32Wallet(); + hd.setSecret(process.env.HD_MNEMONIC_BIP84); + await hd.fetchBalance(); + await hd.fetchTransactions(); + + // 881c54edd95cbdd1583d6b9148eb35128a47b64a2e67a5368a649d6be960f08e + let tt = new HDSegwitBech32Transaction( + '02000000000102f1155666b534f7cb476a0523a45dc8731d38d56b5b08e877c968812423fbd7f3010000000000000000d8a2882a692ee759b43e6af48ac152dd3410cc4b7d25031e83b3396c16ffbc8900000000000000000002400d03000000000017a914e286d58e53f9247a4710e51232cce0686f16873c870695010000000000160014d3e2ecbf4d91321794e0297e0284c47527cf878b02483045022100d18dc865fb4d087004d021d480b983b8afb177a1934ce4cd11cf97b03e17944f02206d7310687a84aab5d4696d535bca69c2db4449b48feb55fff028aa004f2d1744012103af4b208608c75f38e78f6e5abfbcad9c360fb60d3e035193b2cd0cdc8fc0155c0247304402207556e859845df41d897fe442f59b6106c8fa39c74ba5b7b8e3268ab0aebf186f0220048a9f3742339c44a1e5c78b491822b96070bcfda3f64db9dc6434f8e8068475012102456e5223ed3884dc6b0e152067fd836e3eb1485422eda45558bf83f59c6ad09f00000000', + hd, + ); + + assert.strictEqual(tt.canCancelTx(), true); + + let { tx } = await tt.createRBFbumpFee(17); + + let createdTx = bitcoin.Transaction.fromHex(tx.toHex()); + assert.strictEqual(createdTx.ins.length, 2); + assert.strictEqual(createdTx.outs.length, 2); + let addr0 = SegwitBech32Wallet.scriptPubKeyToAddress(createdTx.outs[0].script); + assert.ok(!hd.weOwnAddress(addr0)); + assert.strictEqual(addr0, '3NLnALo49CFEF4tCRhCvz45ySSfz3UktZC'); // dest address + let addr1 = SegwitBech32Wallet.scriptPubKeyToAddress(createdTx.outs[1].script); + assert.ok(hd.weOwnAddress(addr1)); + + let actualFeerate = (108150 + 200000 - (createdTx.outs[0].value + createdTx.outs[1].value)) / (tx.toHex().length / 2); + assert.strictEqual(Math.round(actualFeerate), 17); + + let tt2 = new HDSegwitBech32Transaction(tx.toHex(), hd); + assert.strictEqual(tt2.canCancelTx(), true); // new tx is still cancellable since we only bumped fees + }); +}); diff --git a/tests/integration/HDBech32Wallet.test.js b/tests/integration/hd-segwit-bech32-wallet.test.js similarity index 98% rename from tests/integration/HDBech32Wallet.test.js rename to tests/integration/hd-segwit-bech32-wallet.test.js index 8eb470e5c..498001eab 100644 --- a/tests/integration/HDBech32Wallet.test.js +++ b/tests/integration/hd-segwit-bech32-wallet.test.js @@ -227,6 +227,10 @@ describe('Bech32 Segwit HD (BIP84)', () => { let hd = new HDSegwitBech32Wallet(); hd.setSecret(process.env.HD_MNEMONIC_BIP84); assert.ok(hd.validateMnemonic()); + assert.strictEqual( + hd.getXpub(), + 'zpub6qoWjSiZRHzSYPGYJ6EzxEXJXP1b2Rj9syWwJZFNCmupMwkbSAWSBk3UvSkJyQLEhQpaBAwvhmNj3HPKpwCJiTBB9Tutt46FtEmjL2DoU3J', + ); let start = +new Date(); await hd.fetchBalance(); @@ -284,7 +288,7 @@ describe('Bech32 Segwit HD (BIP84)', () => { let { tx, inputs, outputs, fee } = hd.createTransaction( hd.getUtxo(), - [{ address: 'bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu', value: 101000 }], + [{ address: 'bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu', value: 51000 }], 13, changeAddress, ); @@ -296,6 +300,7 @@ describe('Bech32 Segwit HD (BIP84)', () => { totalInput += inp.value; } + assert.strictEqual(outputs.length, 2); let totalOutput = 0; for (let outp of outputs) { totalOutput += outp.value;