Implement dynamic amount of tokens for change (#223)

With the recent update to NUT-08, we can ensure that the amount of blank
outputs is always enough to cover any overpaid lightning fees. This
change implements this functionality for both the wallet and the mint.
The mint updateis backwards-compatible with respect to old wallets.
This commit is contained in:
xphade
2023-05-23 17:40:48 +02:00
committed by GitHub
parent 5df0a9aa59
commit 21069fb61a
7 changed files with 130 additions and 23 deletions

View File

@@ -1,4 +1,5 @@
import asyncio
import math
from functools import partial, wraps
from typing import List
@@ -42,3 +43,13 @@ def fee_reserve(amount_msat: int, internal=False) -> int:
int(settings.lightning_reserve_fee_min),
int(amount_msat * settings.lightning_fee_percent / 100.0),
)
def calculate_number_of_blank_outputs(fee_reserve_sat: int):
"""Calculates the number of blank outputs used for returning overpaid fees.
The formula ensures that any overpaid fees can be represented by the blank outputs,
see NUT-08 for details.
"""
assert fee_reserve_sat > 0, "Fee reserve has to be positive."
return max(math.ceil(math.log2(fee_reserve_sat)), 1)

View File

@@ -515,7 +515,7 @@ class Ledger:
total_provided: int,
invoice_amount: int,
ln_fee_msat: int,
outputs: List[BlindedMessage],
outputs: Optional[List[BlindedMessage]],
keyset: Optional[MintKeyset] = None,
):
"""Generates a set of new promises (blinded signatures) from a set of blank outputs
@@ -523,15 +523,15 @@ class Ledger:
fee reserve provided by the wallet and the actual Lightning fee paid by the mint.
If there is a positive difference, produces maximum `n_return_outputs` new outputs
with values close or equal to the fee difference. We can't be sure that we hit the
fee perfectly because we can only work with a limited set of blanket outputs and
their values are limited to 2^n.
with values close or equal to the fee difference. If the given number of `outputs` matches
the equation defined in NUT-08, we can be sure to return the overpaid fee perfectly.
Otherwise, a smaller amount will be returned.
Args:
total_provided (int): Amount of the proofs provided by the wallet.
invoice_amount (int): Amount of the invoice to be paid.
ln_fee_msat (int): Actually paid Lightning network fees.
outputs (List[BlindedMessage]): Outputs to sign for returning the overpaid fees.
outputs (Optional[List[BlindedMessage]]): Outputs to sign for returning the overpaid fees.
Raises:
Exception: Output validation failed.
@@ -541,22 +541,24 @@ class Ledger:
"""
# we make sure that the fee is positive
ln_fee_msat = abs(ln_fee_msat)
# maximum number of change outputs (must be in consensus with wallet)
n_return_outputs = 4
ln_fee_sat = math.ceil(ln_fee_msat / 1000)
user_paid_fee_sat = total_provided - invoice_amount
overpaid_fee_sat = user_paid_fee_sat - ln_fee_sat
logger.debug(
f"Lightning fee was: {ln_fee_sat}. User paid: {user_paid_fee_sat}. Returning difference."
)
if user_paid_fee_sat - ln_fee_sat > 0 and outputs is not None:
# we will only accept at maximum n_return_outputs outputs
assert len(outputs) <= n_return_outputs, Exception(
"too many change outputs provided"
f"Lightning fee was: {ln_fee_sat}. User paid: {user_paid_fee_sat}. "
f"Returning difference: {overpaid_fee_sat}."
)
return_amounts = amount_split(user_paid_fee_sat - ln_fee_sat)
if overpaid_fee_sat > 0 and outputs is not None:
return_amounts = amount_split(overpaid_fee_sat)
# We return at most as many outputs as were provided or as many as are
# required to pay back the overpaid fee.
n_return_outputs = min(len(outputs), len(return_amounts))
# we only need as many outputs as we have change to return
outputs = outputs[: len(return_amounts)]
outputs = outputs[:n_return_outputs]
# we sort the return_amounts in descending order so we only
# take the largest values in the next step
return_amounts_sorted = sorted(return_amounts, reverse=True)

View File

@@ -77,7 +77,7 @@ async def pay(
assert total_amount > 0, "amount has to be larger than zero."
assert wallet.available_balance >= total_amount, "balance is too low."
_, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount)
await wallet.pay_lightning(send_proofs, invoice)
await wallet.pay_lightning(send_proofs, invoice, fee_reserve_sat)
await wallet.load_proofs()
return PayResponse(
amount=total_amount - fee_reserve_sat,

View File

@@ -115,7 +115,7 @@ async def pay(ctx: Context, invoice: str, yes: bool):
print("Error: Balance too low.")
return
_, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount)
await wallet.pay_lightning(send_proofs, invoice)
await wallet.pay_lightning(send_proofs, invoice, fee_reserve_sat)
await wallet.load_proofs()
wallet.status()

View File

@@ -38,7 +38,7 @@ from ..core.bolt11 import Invoice as InvoiceBolt11
from ..core.crypto import b_dhke
from ..core.crypto.secp import PrivateKey, PublicKey
from ..core.db import Database
from ..core.helpers import sum_proofs
from ..core.helpers import calculate_number_of_blank_outputs, sum_proofs
from ..core.script import (
step0_carol_checksig_redeemscrip,
step0_carol_privkey,
@@ -586,13 +586,13 @@ class Wallet(LedgerAPI):
await invalidate_proof(proof, db=self.db)
return frst_proofs, scnd_proofs
async def pay_lightning(self, proofs: List[Proof], invoice: str):
async def pay_lightning(self, proofs: List[Proof], invoice: str, fee_reserve: int):
"""Pays a lightning invoice"""
# generate outputs for the change for overpaid fees
# we will generate four blanked outputs that the mint will
# imprint with value depending on the fees we overpaid
n_return_outputs = 4
# Generate a number of blank outputs for any overpaid fees. As described in
# NUT-08, the mint will imprint these outputs with a value depending on the
# amount of fees we overpaid.
n_return_outputs = calculate_number_of_blank_outputs(fee_reserve)
secrets = [self._generate_secret() for _ in range(n_return_outputs)]
outputs, rs = self._construct_outputs(n_return_outputs * [1], secrets)

View File

@@ -1,4 +1,7 @@
import pytest
from cashu.core.base import TokenV3
from cashu.core.helpers import calculate_number_of_blank_outputs
from cashu.core.split import amount_split
@@ -22,3 +25,26 @@ def test_tokenv3_deserialize_serialize():
token_str = "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIkplaFpMVTZuQ3BSZCIsICJhbW91bnQiOiAyLCAic2VjcmV0IjogIjBFN2lDazRkVmxSZjVQRjFnNFpWMnciLCAiQyI6ICIwM2FiNTgwYWQ5NTc3OGVkNTI5NmY4YmVlNjU1ZGJkN2Q2NDJmNWQzMmRlOGUyNDg0NzdlMGI0ZDZhYTg2M2ZjZDUifSwgeyJpZCI6ICJKZWhaTFU2bkNwUmQiLCAiYW1vdW50IjogOCwgInNlY3JldCI6ICJzNklwZXh3SGNxcXVLZDZYbW9qTDJnIiwgIkMiOiAiMDIyZDAwNGY5ZWMxNmE1OGFkOTAxNGMyNTliNmQ2MTRlZDM2ODgyOWYwMmMzODc3M2M0NzIyMWY0OTYxY2UzZjIzIn1dLCAibWludCI6ICJodHRwOi8vbG9jYWxob3N0OjMzMzgifV19"
token = TokenV3.deserialize(token_str)
assert token.serialize() == token_str
def test_calculate_number_of_blank_outputs():
# Example from NUT-08 specification.
fee_reserve_sat = 1000
expected_n_blank_outputs = 10
n_blank_outputs = calculate_number_of_blank_outputs(fee_reserve_sat)
assert n_blank_outputs == expected_n_blank_outputs
def test_calculate_number_of_blank_outputs_for_small_fee_reserve():
# There should always be at least one blank output.
fee_reserve_sat = 1
expected_n_blank_outputs = 1
n_blank_outputs = calculate_number_of_blank_outputs(fee_reserve_sat)
assert n_blank_outputs == expected_n_blank_outputs
def test_calculate_number_of_blank_outputs_fails_for_negative_fee_reserve():
# Negative fee reserve is not supported.
fee_reserve_sat = 0
with pytest.raises(AssertionError):
_ = calculate_number_of_blank_outputs(fee_reserve_sat)

View File

@@ -3,6 +3,8 @@ from typing import List
import pytest
from cashu.core.base import BlindedMessage, Proof
from cashu.core.crypto.b_dhke import step1_alice
from cashu.core.helpers import calculate_number_of_blank_outputs
from cashu.core.migrations import migrate_databases
SERVER_ENDPOINT = "http://localhost:3338"
@@ -110,3 +112,69 @@ async def test_generate_promises(ledger: Ledger):
promises[0].C_
== "037074c4f53e326ee14ed67125f387d160e0e729351471b69ad41f7d5d21071e15"
)
@pytest.mark.asyncio
async def test_generate_change_promises(ledger: Ledger):
# Example slightly adapted from NUT-08 because we want to ensure the dynamic change
# token amount works: `n_blank_outputs != n_returned_promises != 4`.
invoice_amount = 100_000
fee_reserve = 2_000
total_provided = invoice_amount + fee_reserve
actual_fee_msat = 100_000
expected_returned_promises = 7 # Amounts = [4, 8, 32, 64, 256, 512, 1024]
expected_returned_fees = 1900
n_blank_outputs = calculate_number_of_blank_outputs(fee_reserve)
blinded_msgs = [step1_alice(str(n)) for n in range(n_blank_outputs)]
outputs = [
BlindedMessage(amount=1, B_=b.serialize().hex()) for b, _ in blinded_msgs
]
promises = await ledger._generate_change_promises(
total_provided, invoice_amount, actual_fee_msat, outputs
)
assert len(promises) == expected_returned_promises
assert sum([promise.amount for promise in promises]) == expected_returned_fees
@pytest.mark.asyncio
async def test_generate_change_promises_legacy_wallet(ledger: Ledger):
# Check if mint handles a legacy wallet implementation (always sends 4 blank
# outputs) as well.
invoice_amount = 100_000
fee_reserve = 2_000
total_provided = invoice_amount + fee_reserve
actual_fee_msat = 100_000
expected_returned_promises = 4 # Amounts = [64, 256, 512, 1024]
expected_returned_fees = 1856
n_blank_outputs = 4
blinded_msgs = [step1_alice(str(n)) for n in range(n_blank_outputs)]
outputs = [
BlindedMessage(amount=1, B_=b.serialize().hex()) for b, _ in blinded_msgs
]
promises = await ledger._generate_change_promises(
total_provided, invoice_amount, actual_fee_msat, outputs
)
assert len(promises) == expected_returned_promises
assert sum([promise.amount for promise in promises]) == expected_returned_fees
@pytest.mark.asyncio
async def test_generate_change_promises_returns_empty_if_no_outputs(ledger: Ledger):
invoice_amount = 100_000
fee_reserve = 1_000
total_provided = invoice_amount + fee_reserve
actual_fee_msat = 100_000
outputs = None
promises = await ledger._generate_change_promises(
total_provided, invoice_amount, actual_fee_msat, outputs
)
assert len(promises) == 0