From 19ca898fa56c833d7bdefb51d37d1871bc226587 Mon Sep 17 00:00:00 2001 From: Cryp Toon Date: Wed, 8 May 2024 00:12:02 +0200 Subject: [PATCH] Add bumpfee method to wallet --- bitcoinlib/db.py | 1 + bitcoinlib/transactions.py | 28 +++++++++--- bitcoinlib/wallets.py | 91 ++++++++++++++++++++++++++++++++++++-- tests/test_transactions.py | 2 +- tests/test_wallets.py | 65 ++++++++++++++++++++++++++- 5 files changed, 175 insertions(+), 12 deletions(-) diff --git a/bitcoinlib/db.py b/bitcoinlib/db.py index 01a1c9cf..d84a0e7c 100644 --- a/bitcoinlib/db.py +++ b/bitcoinlib/db.py @@ -496,6 +496,7 @@ class DbTransactionOutput(Base): spent = Column(Boolean, default=False, doc="Indicated if output is already spent in another transaction") spending_txid = Column(LargeBinary(33), doc="Transaction hash of input which spends this output") spending_index_n = Column(Integer, doc="Index number of transaction input which spends this output") + is_change = Column(Boolean, default=False, doc="Is this a change output / output to own wallet?") __table_args__ = (UniqueConstraint('transaction_id', 'output_n', name='constraint_transaction_output_unique'),) diff --git a/bitcoinlib/transactions.py b/bitcoinlib/transactions.py index 9ca4b491..cec139fc 100644 --- a/bitcoinlib/transactions.py +++ b/bitcoinlib/transactions.py @@ -158,7 +158,7 @@ def __init__(self, prev_txid, output_n, keys=None, signatures=None, public_hash= :param output_n: Output number in previous transaction. :type output_n: bytes, int :param keys: A list of Key objects or public / private key string in various formats. If no list is provided but a bytes or string variable, a list with one item will be created. Optional - :type keys: list (bytes, str, Key) + :type keys: list (bytes, str, Key, HDKey) :param signatures: Specify optional signatures :type signatures: list (bytes, str, Signature) :param public_hash: Public key hash or script hash. Specify if key is not available @@ -1822,15 +1822,15 @@ def add_input(self, prev_txid, output_n, keys=None, signatures=None, public_hash :param output_n: Output number in previous transaction. :type output_n: bytes, int :param keys: Public keys can be provided to construct an Unlocking script. Optional - :type keys: bytes, str + :type keys: list (bytes, str, Key, HDKey) :param signatures: Add signatures to input if already known :type signatures: bytes, str :param public_hash: Specify public hash from key or redeemscript if key is not available :type public_hash: bytes :param unlocking_script: Unlocking script (scriptSig) to prove ownership. Optional :type unlocking_script: bytes, hexstring - :param locking_script: TODO: find better name... - :type locking_script: bytes, str + :param locking_script: Locking script (scriptPubKey) of previous output if known + :type locking_script: bytes, hexstring :param script_type: Type of unlocking script used, i.e. p2pkh or p2sh_multisig. Default is p2pkh :type script_type: str :param address: Specify address of input if known, default is to derive from key or scripts @@ -2147,6 +2147,23 @@ def shuffle(self): self.shuffle_outputs() def bumpfee(self, fee=0, extra_fee=0): + """ + Increase fee for this transaction. If replace-by-fee is signaled in this transaction the fee can be + increased to speed up inclusion on the blockchain. + + If not fee or extra_fee is provided the extra fee will be increased by the formule you can find in the code + below using the BUMPFEE_DEFAULT_MULTIPLIER from the config settings. + + The extra fee will be deducted from change output. This method fails if there are not enough change outputs + to cover fees. + + :param fee: New fee for this transaction + :type fee: int + :param extra_fee: Extra fee to add to current transaction fee + :type extra_fee: int + + :return None: + """ if not self.fee: raise TransactionError("Current transaction fee is zero, cannot increase fee") if not self.vsize: @@ -2162,7 +2179,8 @@ def bumpfee(self, fee=0, extra_fee=0): raise TransactionError("Extra fee cannot be less than minimal required fee") fee = self.fee + extra_fee else: - fee = self.fee + (minimal_required_fee * BUMPFEE_DEFAULT_MULTIPLIER) + fee = int(self.fee * (1.03 ** BUMPFEE_DEFAULT_MULTIPLIER) + + (minimal_required_fee * BUMPFEE_DEFAULT_MULTIPLIER)) extra_fee = fee - self.fee remaining_fee = extra_fee diff --git a/bitcoinlib/wallets.py b/bitcoinlib/wallets.py index 00a30a5f..d758d655 100644 --- a/bitcoinlib/wallets.py +++ b/bitcoinlib/wallets.py @@ -30,7 +30,7 @@ from bitcoinlib.networks import Network from bitcoinlib.values import Value, value_to_satoshi from bitcoinlib.services.services import Service -from bitcoinlib.transactions import Input, Output, Transaction, get_unlocking_script_type +from bitcoinlib.transactions import Input, Output, Transaction, get_unlocking_script_type, TransactionError from bitcoinlib.scripts import Script from sqlalchemy import func, or_ @@ -753,7 +753,7 @@ def from_txid(cls, hdwallet, txid): public_key = key.key().public_hex outputs.append(Output(value=out.value, address=address, public_key=public_key, lock_script=out.script, spent=out.spent, output_n=out.output_n, - script_type=out.script_type, network=network)) + script_type=out.script_type, network=network, change=out.is_change)) return cls(hdwallet=hdwallet, inputs=inputs, outputs=outputs, locktime=db_tx.locktime, version=db_tx.version, network=network, fee=db_tx.fee, fee_per_kb=fee_per_kb, @@ -949,7 +949,7 @@ def store(self): if not tx_output: new_tx_item = DbTransactionOutput( transaction_id=txidn, output_n=to.output_n, key_id=key_id, address=to.address, value=to.value, - spent=spent, script=to.lock_script, script_type=to.script_type) + spent=spent, script=to.lock_script, script_type=to.script_type, is_change=to.change) sess.add(new_tx_item) elif key_id: tx_output.key_id = key_id @@ -1040,7 +1040,10 @@ def delete(self): filter(DbTransaction.txid == inp.prev_txid, DbTransactionOutput.output_n == inp.output_n, DbTransactionOutput.spent.is_(True), DbTransaction.wallet_id == self.hdwallet.wallet_id).all() for u in prev_utxos: - u.spent = False + # Check if output is spent in another transaction + if session.query(DbTransactionInput).filter(DbTransactionInput.transaction_id == + inp.transaction_id).first(): + u.spent = False session.query(DbTransactionInput).filter_by(transaction_id=tx.id).delete() qr = session.query(DbKey).filter_by(latest_txid=txid) qr.update({DbKey.latest_txid: None, DbKey.used: False}) @@ -1051,6 +1054,80 @@ def delete(self): self.hdwallet._commit() return res + def bumpfee(self, fee=0, extra_fee=0, broadcast=False): + """ + Increase fee for this transaction. If replace-by-fee is signaled in this transaction the fee can be + increased to speed up inclusion on the blockchain. + + If not fee or extra_fee is provided the extra fee will be increased by the formule you can find in the code + below using the BUMPFEE_DEFAULT_MULTIPLIER from the config settings. + + The extra fee will be deducted from change output. This method fails if there are not enough change outputs + to cover fees. + + If this transaction does not have enough inputs to cover extra fee, an extra wallet utxo will be aaded to + inputs if available. + + Previous broadcasted transaction will be removed from wallet with this replace-by-fee transaction and wallet + information updated. + + :param fee: New fee for this transaction + :type fee: int + :param extra_fee: Extra fee to add to current transaction fee + :type extra_fee: int + :param broadcast: Increase fee and directly broadcast transaction to the network + :type broadcast: bool + + :return None: + """ + fees_not_provided = not (fee or extra_fee) + old_txid = self.txid + try: + super(WalletTransaction, self).bumpfee(fee, extra_fee) + except TransactionError as e: + if str(e) != "Not enough unspent outputs to bump transaction fee": + raise TransactionError(str(e)) + else: + # Add extra input to cover fee + if fees_not_provided: + extra_fee = int(self.fee * (0.03 ** BUMPFEE_DEFAULT_MULTIPLIER) + + (self.vsize * BUMPFEE_DEFAULT_MULTIPLIER)) + new_inp = self.add_input_from_wallet(amount_min=extra_fee) + # Add value of extra input to change output + change_outputs = [o for o in self.outputs if o.change] + if change_outputs: + change_outputs[0].value += self.inputs[new_inp].value + else: + self.add_output(self.inputs[new_inp].value, self.hdwallet.get_key().address, change=True) + if fees_not_provided: + extra_fee += 25 * BUMPFEE_DEFAULT_MULTIPLIER + super(WalletTransaction, self).bumpfee(fee, extra_fee) + # remove previous transaction and update wallet + if self.pushed: + self.hdwallet.transaction_delete(old_txid) + if broadcast: + self.send() + + def add_input_from_wallet(self, amount_min=0, key_id=None, min_confirms=0): + if not amount_min: + amount_min = self.network.dust_amount + utxos = self.hdwallet.utxos(self.account_id, network=self.network.name, min_confirms=min_confirms, + key_id=key_id) + current_inputs = [(i.prev_txid.hex(), i.output_n_int) for i in self.inputs] + unused_inputs = [u for u in utxos + if (u['txid'], u['output_n']) not in current_inputs and u['value'] >= amount_min] + if not unused_inputs: + raise TransactionError("Not enough unspent inputs found for transaction %s" % + self.txid) + # take first input + utxo = unused_inputs[0] + inp_keys, key = self.hdwallet._objects_by_key_id(utxo['key_id']) + unlock_script_type = get_unlocking_script_type(utxo['script_type'], self.witness_type, + multisig=self.hdwallet.multisig) + return self.add_input(utxo['txid'], utxo['output_n'], keys=inp_keys, script_type=unlock_script_type, + sigs_required=self.hdwallet.multisig_n_required, sort=self.hdwallet.sort_keys, + compressed=key.compressed, value=utxo['value'], address=utxo['address'], + locking_script=utxo['script'], witness_type=key.witness_type) class Wallet(object): """ @@ -3559,6 +3636,12 @@ def transaction_spent(self, txid, output_n): def update_transactions_from_block(block, network=None): pass + def transaction_delete(self, txid): + wt = self.transaction(txid) + if wt: + wt.delete() + else: + raise WalletError("Transaction %s not found in this wallet" % txid) def _objects_by_key_id(self, key_id): key = self.session.query(DbKey).filter_by(id=key_id).scalar() diff --git a/tests/test_transactions.py b/tests/test_transactions.py index d2ad82cf..fb8f6c4f 100644 --- a/tests/test_transactions.py +++ b/tests/test_transactions.py @@ -1351,7 +1351,7 @@ def test_transaction_bumpfee(self): self.assertEqual(t.fee, 3333) txid_before = t.txid t.bumpfee() - self.assertEqual(t.fee, 4068) + self.assertEqual(t.fee, 4598) self.assertNotEqual(t.txid, txid_before) self.assertEqual(sum([i.value for i in t.inputs]) - sum([o.value for o in t.outputs]), t.fee) diff --git a/tests/test_wallets.py b/tests/test_wallets.py index a010b488..6540c6d1 100644 --- a/tests/test_wallets.py +++ b/tests/test_wallets.py @@ -2365,8 +2365,8 @@ def test_wallet_anti_fee_sniping(self): w = wallet_create_or_open('antifeesnipingtestwallet', network='testnet', db_uri=self.database_uri) w.utxo_add(w.get_key().address, 1234567, os.urandom(32).hex(), 1) t = w.send_to('tb1qrjtz22q59e76mhumy0p586cqukatw5vcd0xvvz', 123456) + # FIXME: Bitaps and Bitgo return incorrect blockcount for testnet block_height = Service(network='testnet', exclude_providers=['bitgo', 'bitaps'], cache_uri='').blockcount() - # Bitaps and Bitgo return incorrect blockcount for testnet, so set delta self.assertAlmostEqual(t.locktime, block_height+1, delta=3) w2 = wallet_create_or_open('antifeesnipingtestwallet2', network='testnet', anti_fee_sniping=True) @@ -2834,4 +2834,65 @@ def test_wallet_mixed_witness_types_passphrase(self): expected_addresslist = \ ['39h96ozh8F8W2sVrc2EhEbFwwdRoLHJAfB', '3LdJC6MSmFqKrn2WrxRfhd8DYkYYr8FNDr', 'MTSW4eC7xJiyp4YjwGZqpGmubsdm28Cdvc', 'bc1qgw8rg0057q9fmupx7ru6vtkxzy03gexc9ljycagj8z3hpzdfg7usvu56dp'] - self.assertListEqual(sorted(w.addresslist()), expected_addresslist) \ No newline at end of file + self.assertListEqual(sorted(w.addresslist()), expected_addresslist) + + def test_wallet_transactions_add_input_from_wallet(self): + w = wallet_create_or_open('add_input_from_wallet_test', network='bitcoinlib_test', + db_uri=self.database_uri) + w.utxos_update() + t = WalletTransaction(w, network='bitcoinlib_test') + t.add_input_from_wallet() + t.add_output(100000, 'blt1q68x6ghc7anelyzm4v7hwl2g245e07agee8yfag') + t.add_output(99000000, 'blt1qy0dlpnmfd8ldt5ns5kp0m4ery79wjaw5fz30t3', change=True) + t.sign_and_update() + self.assertTrue(t.verified) + self.assertEqual(t.fee, 900000) + self.assertEqual(len(t.outputs), 2) + self.assertEqual(t.inputs[0].witness_type, 'segwit') + + def test_wallet_transactions_bumpfee(self): + pkm = 'elephant dust deer company win final' + wallet_delete_if_exists('bumpfeetest01', force=True) + w = wallet_create_or_open('bumpfeetest01', keys=pkm, network='bitcoinlib_test', db_uri=self.database_uri) + w.utxos_update() + t = w.send_to('blt1qm89pcm4392vj93q9s2ft8saqzm4paruzj95a83', 99900000, fee=100000, + broadcast=True) + self.assertEqual(w.balance(), 100000000) + self.assertEqual(len(t.inputs), 1) + self.assertEqual(len(t.outputs), 1) + self.assertEqual(w.utxos()[0]['txid'], 'ea7bd8fe970ca6430cebbbf914ce2feeb369c3ae95edc117725dbe21519ccdab') + t.bumpfee(broadcast=True) + self.assertEqual(len(t.inputs), 2) + self.assertEqual(len(t.outputs), 2) + self.assertEqual(w.balance(), 99999325) + + w = wallet_create_or_open('bumpfeetest02', keys=pkm, network='bitcoinlib_test', db_uri=self.database_uri) + w.utxos_update() + t = w.send_to('blt1qm89pcm4392vj93q9s2ft8saqzm4paruzj95a83', 50000000, fee=100000, + broadcast=True) + self.assertEqual(w.balance(), 149900000) + self.assertEqual(len(t.inputs), 1) + self.assertEqual(len(t.outputs), 2) + t.bumpfee(fee=200000, broadcast=True) + self.assertEqual(len(t.inputs), 1) + self.assertEqual(len(t.outputs), 2) + self.assertEqual(w.balance(), 149800000) + + w = wallet_create_or_open('bumpfeetest03', keys=pkm, network='bitcoinlib_test', db_uri=self.database_uri) + w.utxos_update() + t = w.send_to('blt1qm89pcm4392vj93q9s2ft8saqzm4paruzj95a83', 99900000, fee=50000, + broadcast=True) + self.assertEqual(w.balance(), 100050000) + self.assertEqual(len(t.inputs), 1) + self.assertEqual(len(t.outputs), 2) + t.bumpfee(extra_fee=50000, broadcast=True) + self.assertEqual(len(t.inputs), 1) + self.assertEqual(len(t.outputs), 1) + self.assertEqual(w.balance(), 100000000) + self.assertEqual(len(w.utxos()), 1) + + w = wallet_create_or_open('bumpfeetest04', keys=pkm, network='bitcoinlib_test', db_uri=self.database_uri) + w.utxos_update() + t = w.send_to('blt1qm89pcm4392vj93q9s2ft8saqzm4paruzj95a83', 199900000, fee=100000, + broadcast=True) + self.assertRaisesRegex(TransactionError, "Not enough unspent inputs found for transaction", t.bumpfee)