Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensure 2nd pass through transaction create loop will generate change > DUST. #3719

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 20 additions & 17 deletions lbry/wallet/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -808,25 +808,34 @@ async def create(cls, inputs: Iterable[Input], outputs: Iterable[Output],
tx.get_base_fee(ledger) +
tx.get_total_output_sum(ledger)
)
cost_of_change = (
tx.get_base_fee(ledger) +
Output.pay_pubkey_hash(COIN, NULL_HASH32).get_fee(ledger)
)
# value of the inputs less the cost to spend those inputs
payment = tx.get_effective_input_sum(ledger)

try:

for _ in range(5):
for i in range(2):

if payment < cost:
if payment < cost or (i > 0 and not tx._outputs):
deficit = cost - payment
# this condition and the outer range(2) loop cover an edge case
# whereby a single input is just enough to cover the fee and
# has some change left over, but the change left over is less
# than the cost_of_change: thus the input is completely
# consumed and no output is added, which is an invalid tx.
# to be able to spend this input we must increase the cost
# in order to make a change output > DUST.
if i > 0 and not tx._outputs:
deficit += (cost_of_change + DUST + 1)
spendables = await ledger.get_spendable_utxos(deficit, funding_accounts)
if not spendables:
raise InsufficientFundsError()
payment += sum(s.effective_amount for s in spendables)
tx.add_inputs(s.txi for s in spendables)

cost_of_change = (
tx.get_base_fee(ledger) +
Output.pay_pubkey_hash(COIN, NULL_HASH32).get_fee(ledger)
)
if payment > cost:
change = payment - cost
change_amount = change - cost_of_change
Expand All @@ -839,17 +848,11 @@ async def create(cls, inputs: Iterable[Input], outputs: Iterable[Output],

if tx._outputs:
break
# this condition and the outer range(5) loop cover an edge case
# whereby a single input is just enough to cover the fee and
# has some change left over, but the change left over is less
# than the cost_of_change: thus the input is completely
# consumed and no output is added, which is an invalid tx.
# to be able to spend this input we must increase the cost
# of the TX and run through the balance algorithm a second time
# adding an extra input and change output, making tx valid.
# we do this 5 times in case the other UTXOs added are also
# less than the fee, after 5 attempts we give up and go home
cost += cost_of_change + 1
# We need to run through the balance algorithm a second time
# adding extra inputs and change output, making tx valid.

if not tx._outputs:
raise InsufficientFundsError()

if sign:
await tx.sign(funding_accounts)
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/claims/test_claim_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -2119,7 +2119,7 @@ async def test_abandoning_stream_at_loss(self):
tx = await self.stream_create(bid='0.0001')
await self.assertBalance(self.account, '9.979793')
await self.stream_abandon(self.get_claim_id(tx))
await self.assertBalance(self.account, '9.97968399')
await self.assertBalance(self.account, '9.979712')

async def test_publish(self):

Expand Down
53 changes: 47 additions & 6 deletions tests/unit/wallet/test_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
from binascii import hexlify, unhexlify
from itertools import cycle

from lbry.error import InsufficientFundsError
from lbry.testcase import AsyncioTestCase
from lbry.wallet.constants import CENT, COIN, NULL_HASH32
from lbry.wallet.constants import CENT, COIN, DUST, NULL_HASH32
from lbry.wallet import Wallet, Account, Ledger, Database, Headers, Transaction, Output, Input


NULL_HASH = b'\x00'*32
FEE_PER_BYTE = 50
FEE_PER_CHAR = 200000
Expand Down Expand Up @@ -395,12 +395,12 @@ async def create_utxos(self, amounts):
return utxos

@staticmethod
def inputs(tx):
return [round(i.amount/COIN, 2) for i in tx.inputs]
def inputs(tx, precision=2):
return [round(i.amount/COIN, precision) for i in tx.inputs]

@staticmethod
def outputs(tx):
return [round(o.amount/COIN, 2) for o in tx.outputs]
def outputs(tx, precision=2):
return [round(o.amount/COIN, precision) for o in tx.outputs]

async def test_basic_use_cases(self):
self.ledger.fee_per_byte = int(.01*CENT)
Expand Down Expand Up @@ -532,3 +532,44 @@ async def test_basic_use_cases_sqlite(self):
self.assertListEqual([0.01, 1], self.inputs(tx))
# change is now needed to consume extra input
self.assertListEqual([0.97], self.outputs(tx))

async def test_liquidate_at_loss(self):
#self.ledger.coin_selection_strategy = 'sqlite'
self.ledger.fee_per_byte = int(0.01*CENT)

# Create UTXOs with values large enough that they can be spent.
utxos = await self.create_utxos([a/COIN for a in range(1490*DUST, 1510*DUST, int(DUST/10))])

tx = await self.tx(
[self.txi(self.txo(0.01))], # inputs
[] # outputs
)

# A very tiny amount of change is generated as the only output.
self.assertListEqual([1100], [o.amount for o in tx.outputs])
# A large number of additional UTXOs are added to cover fees.
self.assertListEqual([
1000000, 1509900, 1509800, 1509700, 1509600, 1509500, 1509400, 1509300, 1509200, 1509100,
1509000, 1508900, 1508800, 1508700, 1508600, 1508500, 1508400, 1508300, 1508200, 1508100,
1494600, 1494400, 1508000, 1507900, 1507800, 1507700, 1507600, 1507500, 1507400, 1507300,
1507200, 1507100, 1507000, 1506900, 1506800, 1506700, 1506600, 1506500, 1506400, 1506300,
1506200, 1505200, 1501000],
[i.amount for i in tx.inputs])
self.assertIn(tx.size, range(6350, 6430))
self.assertEqual(64300000, tx.fee)

await self.ledger.release_outputs(utxos)

async def test_liquidate_at_loss_tiny_utxos(self):
#self.ledger.coin_selection_strategy = 'sqlite'
self.ledger.fee_per_byte = int(0.01*CENT)

# Create UTXOs with values so tiny that they cannot be spent.
utxos = await self.create_utxos([a/COIN for a in range(1460*DUST, 1490*DUST, int(DUST/10))])

with self.assertRaises(InsufficientFundsError):
tx = await self.tx(
[self.txi(self.txo(0.01))], # inputs
[] # outputs
)
self.assertFalse([i.amount for i in tx.inputs])