[WIP] [NUTs] NUT-06 update: deprecate amount field in /split (#263)

* mint upgraded and still backwards compatible

* cleanup

* fix tests

* fix things

* add deprecated message to new struct

* fix test

* fix typo

* readd endpoint that got lost during merge

* version bump in pyproject.toml

* remove wallet backwards compatibility because it makes no sense

* comment for backwards compat

* fix comment
This commit is contained in:
callebtc
2023-07-25 11:13:20 +02:00
committed by GitHub
parent 27bc2bda06
commit b196c34427
7 changed files with 117 additions and 101 deletions

View File

@@ -271,11 +271,6 @@ class PostMintRequest(BaseModel):
outputs: List[BlindedMessage]
class PostMintResponseLegacy(BaseModel):
# NOTE: Backwards compability for < 0.8.0 where we used a simple list and not a key-value dictionary
__root__: List[BlindedSignature] = []
class PostMintResponse(BaseModel):
promises: List[BlindedSignature] = []
@@ -305,7 +300,7 @@ class GetMeltResponse(BaseModel):
class PostSplitRequest(BaseModel):
proofs: List[Proof]
amount: int
amount: Optional[int] = None # deprecated since 0.13.0
outputs: List[BlindedMessage]
# signature: Optional[str] = None
@@ -323,8 +318,14 @@ class PostSplitRequest(BaseModel):
class PostSplitResponse(BaseModel):
fst: List[BlindedSignature]
snd: List[BlindedSignature]
promises: List[BlindedSignature]
# deprecated since 0.13.0
class PostSplitResponse_Deprecated(BaseModel):
fst: List[BlindedSignature] = []
snd: List[BlindedSignature] = []
deprecated: str = "The amount field is deprecated since 0.13.0"
# ------- API: CHECK -------

View File

@@ -444,14 +444,6 @@ class Ledger:
return False
return True
def _verify_split_amount(self, amount: int) -> None:
"""Split amount like output amount can't be negative or too big."""
try:
self._verify_amount(amount)
except:
# For better error message
raise Exception("invalid split amount: " + str(amount))
def _verify_amount(self, amount: int) -> int:
"""Any amount used should be a positive integer not larger than 2^MAX_ORDER."""
valid = (
@@ -463,12 +455,18 @@ class Ledger:
return amount
def _verify_equation_balanced(
self, proofs: List[Proof], outs: List[BlindedSignature]
self,
proofs: List[Proof],
outs: Union[List[BlindedSignature], List[BlindedMessage]],
) -> None:
"""Verify that Σoutputs - Σinputs = 0."""
"""Verify that Σinputs - Σoutputs = 0.
Outputs can be BlindedSignature or BlindedMessage.
"""
sum_inputs = sum(self._verify_amount(p.amount) for p in proofs)
sum_outputs = sum(self._verify_amount(p.amount) for p in outs)
assert sum_outputs - sum_inputs == 0
assert (
sum_outputs - sum_inputs == 0
), "inputs do not have same amount as outputs"
async def _request_lightning_invoice(self, amount: int):
"""Generate a Lightning invoice using the funding source backend.
@@ -1007,10 +1005,11 @@ class Ledger:
async def split(
self,
*,
proofs: List[Proof],
amount: int,
outputs: List[BlindedMessage],
keyset: Optional[MintKeyset] = None,
amount: Optional[int] = None, # backwards compatibility < 0.13.0
):
"""Consumes proofs and prepares new promises based on the amount split. Used for splitting tokens
Before sending or for redeeming tokens for new ones that have been received by another wallet.
@@ -1031,15 +1030,15 @@ class Ledger:
await self._set_proofs_pending(proofs)
total = sum_proofs(proofs)
total_amount = sum_proofs(proofs)
try:
logger.trace(f"verifying _verify_split_amount")
# verify that amount is kosher
self._verify_split_amount(amount)
self._verify_amount(total_amount)
# verify overspending attempt
if amount > total:
raise Exception("split amount is higher than the total sum.")
self._verify_equation_balanced(proofs, outputs)
logger.trace("verifying proofs: _verify_proofs_and_outputs")
await self._verify_proofs_and_outputs(proofs, outputs)
@@ -1055,20 +1054,32 @@ class Ledger:
# delete proofs from pending list
await self._unset_proofs_pending(proofs)
# split outputs according to amount
outs_fst = amount_split(total - amount)
B_fst = [od for od in outputs[: len(outs_fst)]]
B_snd = [od for od in outputs[len(outs_fst) :]]
# BEGIN backwards compatibility < 0.13.0
if amount is not None:
logger.debug(
"Split: Client provided `amount` - backwards compatibility response pre 0.13.0"
)
# split outputs according to amount
total = sum_proofs(proofs)
if amount > total:
raise Exception("split amount is higher than the total sum.")
outs_fst = amount_split(total - amount)
B_fst = [od for od in outputs[: len(outs_fst)]]
B_snd = [od for od in outputs[len(outs_fst) :]]
# generate promises
prom_fst, prom_snd = await self._generate_promises(
B_fst, keyset
), await self._generate_promises(B_snd, keyset)
# generate promises
prom_fst = await self._generate_promises(B_fst, keyset)
prom_snd = await self._generate_promises(B_snd, keyset)
promises = prom_fst + prom_snd
# END backwards compatibility < 0.13.0
else:
promises = await self._generate_promises(outputs, keyset)
# verify amounts in produced proofs
self._verify_equation_balanced(proofs, prom_fst + prom_snd)
# verify amounts in produced promises
self._verify_equation_balanced(proofs, promises)
logger.trace(f"split successful")
return promises
return prom_fst, prom_snd
async def restore(

View File

@@ -22,6 +22,7 @@ from ..core.base import (
PostRestoreResponse,
PostSplitRequest,
PostSplitResponse,
PostSplitResponse_Deprecated,
)
from ..core.errors import CashuError
from ..core.settings import settings
@@ -208,7 +209,7 @@ async def check_fees(payload: CheckFeesRequest) -> CheckFeesResponse:
@router.post("/split", name="Split", summary="Split proofs at a specified amount")
async def split(
payload: PostSplitRequest,
) -> Union[CashuError, PostSplitResponse]:
) -> Union[CashuError, PostSplitResponse, PostSplitResponse_Deprecated]:
"""
Requetst a set of tokens with amount "total" to be split into two
newly minted sets with amount "split" and "total-split".
@@ -219,17 +220,36 @@ async def split(
logger.trace(f"> POST /split: {payload}")
assert payload.outputs, Exception("no outputs provided.")
try:
split_return = await ledger.split(
payload.proofs, payload.amount, payload.outputs
promises = await ledger.split(
proofs=payload.proofs, outputs=payload.outputs, amount=payload.amount
)
except Exception as exc:
return CashuError(code=0, error=str(exc))
if not split_return:
if not promises:
return CashuError(code=0, error="there was an error with the split")
frst_promises, scnd_promises = split_return
resp = PostSplitResponse(fst=frst_promises, snd=scnd_promises)
logger.trace(f"< POST /split: {resp}")
return resp
if payload.amount:
# BEGIN backwards compatibility < 0.13
# old clients expect two lists of promises where the second one's amounts
# sum up to `amount`. The first one is the rest.
# The returned value `promises` has the form [keep1, keep2, ..., send1, send2, ...]
# The sum of the sendx is `amount`. We need to split this into two lists and keep the order of the elements.
frst_promises: List[BlindedSignature] = []
scnd_promises: List[BlindedSignature] = []
scnd_amount = 0
for promise in promises[::-1]: # we iterate backwards
if scnd_amount < payload.amount:
scnd_promises.insert(0, promise) # and insert at the beginning
scnd_amount += promise.amount
else:
frst_promises.insert(0, promise) # and insert at the beginning
logger.trace(
f"Split into keep: {len(frst_promises)}: {sum([p.amount for p in frst_promises])} sat and send: {len(scnd_promises)}: {sum([p.amount for p in scnd_promises])} sat"
)
return PostSplitResponse_Deprecated(fst=frst_promises, snd=scnd_promises)
# END backwards compatibility < 0.13
else:
return PostSplitResponse(promises=promises)
@router.post(

View File

@@ -32,9 +32,9 @@ from ..core.base import (
PostMeltRequest,
PostMintRequest,
PostMintResponse,
PostMintResponseLegacy,
PostRestoreResponse,
PostSplitRequest,
PostSplitResponse_Deprecated,
Proof,
Secret,
SecretKind,
@@ -487,11 +487,7 @@ class LedgerAPI(object):
reponse_dict = resp.json()
self.raise_on_error(reponse_dict)
logger.trace("Lightning invoice checked. POST /mint")
try:
# backwards compatibility: parse promises < 0.8.0 with no "promises" field
promises = PostMintResponseLegacy.parse_obj(reponse_dict).__root__
except:
promises = PostMintResponse.parse_obj(reponse_dict).promises
promises = PostMintResponse.parse_obj(reponse_dict).promises
# bump secret counter in database
await bump_secret_derivation(
@@ -504,27 +500,16 @@ class LedgerAPI(object):
self,
proofs: List[Proof],
outputs: List[BlindedMessage],
secrets: List[str],
rs: List[PrivateKey],
amount: int,
secret_lock: Optional[Secret] = None,
) -> Tuple[List[BlindedSignature], List[BlindedSignature]]:
"""Consume proofs and create new promises based on amount split.
If secret_lock is None, random secrets will be generated for the tokens to keep (frst_outputs)
and the promises to send (scnd_outputs).
If secret_lock is provided, the wallet will create blinded secrets with those to attach a
predefined spending condition to the tokens they want to send."""
) -> List[BlindedSignature]:
"""Consume proofs and create new promises based on amount split."""
logger.debug("Calling split. POST /split")
split_payload = PostSplitRequest(proofs=proofs, amount=amount, outputs=outputs)
split_payload = PostSplitRequest(proofs=proofs, outputs=outputs)
# construct payload
def _splitrequest_include_fields(proofs: List[Proof]):
"""strips away fields from the model that aren't necessary for the /split"""
proofs_include = {"id", "amount", "secret", "C", "p2shscript", "p2pksigs"}
return {
"amount": ...,
"outputs": ...,
"proofs": {i: proofs_include for i in range(len(proofs))},
}
@@ -537,13 +522,13 @@ class LedgerAPI(object):
promises_dict = resp.json()
self.raise_on_error(promises_dict)
promises_fst = [BlindedSignature(**p) for p in promises_dict["fst"]]
promises_snd = [BlindedSignature(**p) for p in promises_dict["snd"]]
mint_response = PostMintResponse.parse_obj(promises_dict)
promises = [BlindedSignature(**p.dict()) for p in mint_response.promises]
if len(promises_fst) == 0 and len(promises_snd) == 0:
if len(promises) == 0:
raise Exception("received no splits.")
return promises_fst, promises_snd
return promises
@async_set_requests
async def check_proof_state(self, proofs: List[Proof]):
@@ -1065,21 +1050,23 @@ class Wallet(LedgerAPI):
amount: int,
secret_lock: Optional[Secret] = None,
) -> Tuple[List[Proof], List[Proof]]:
"""Split proofs into two sets of proofs at a given amount.
"""If secret_lock is None, random secrets will be generated for the tokens to keep (frst_outputs)
and the promises to send (scnd_outputs).
If secret_lock is provided, the wallet will create blinded secrets with those to attach a
predefined spending condition to the tokens they want to send.
Args:
proofs (List[Proof]): Input proofs to split.
amount (int): Amount to split at.
secret_lock (Optional[Secret], optional): Secret to lock outputs to. Defaults to None.
Raises:
Exception: Raises exception if no proofs have been provided
Exception: Raises exception if no proofs are returned after splitting
proofs (List[Proof]): _description_
amount (int): _description_
secret_lock (Optional[Secret], optional): _description_. Defaults to None.
Returns:
Tuple[List[Proof], List[Proof]]: Two sets of proofs after splitting.
_type_: _description_
"""
assert len(proofs) > 0, ValueError("no proofs provided.")
assert len(proofs) > 0, "no proofs provided."
assert sum_proofs(proofs) >= amount, "amount too large."
assert amount > 0, "amount must be positive."
# potentially add witnesses to unlock provided proofs (if they indicate one)
proofs = await self.add_witnesses_to_proofs(proofs)
@@ -1125,36 +1112,25 @@ class Wallet(LedgerAPI):
outputs = await self.add_witnesses_to_outputs(proofs, outputs)
# Call /split API
promises_fst, promises_snd = await super().split(
proofs, outputs, secrets, rs, amount, secret_lock
)
promises = await super().split(proofs, outputs)
# Construct proofs from returned promises (i.e., unblind the signatures)
frst_proofs = self._construct_proofs(
promises_fst,
secrets[: len(promises_fst)],
rs[: len(promises_fst)],
derivation_paths,
)
scnd_proofs = self._construct_proofs(
promises_snd,
secrets[len(promises_fst) :],
rs[len(promises_fst) :],
derivation_paths,
)
new_proofs = self._construct_proofs(promises, secrets, rs, derivation_paths)
# remove used proofs from wallet and add new ones
used_secrets = [p.secret for p in proofs]
self.proofs = list(filter(lambda p: p.secret not in used_secrets, self.proofs))
# add new proofs to wallet
self.proofs += frst_proofs + scnd_proofs
self.proofs += new_proofs
# store new proofs in database
await self._store_proofs(frst_proofs + scnd_proofs)
# invalidate used proofs
async with self.db.connect() as conn:
for proof in proofs:
await invalidate_proof(proof, db=self.db, conn=conn)
return frst_proofs, scnd_proofs
await self._store_proofs(new_proofs)
# invalidate used proofs in database
for proof in proofs:
await invalidate_proof(proof, db=self.db)
keep_proofs = new_proofs[: len(frst_outputs)]
send_proofs = new_proofs[len(frst_outputs) :]
return keep_proofs, send_proofs
async def pay_lightning(
self, proofs: List[Proof], invoice: str, fee_reserve_sat: int

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "cashu"
version = "0.12.3"
version = "0.13.0"
description = "Ecash wallet and mint"
authors = ["calle <callebtc@protonmail.com>"]
license = "MIT"

View File

@@ -27,6 +27,13 @@ def test_tokenv3_deserialize_serialize():
assert token.serialize() == token_str
def test_tokenv3_deserialize_with_memo():
token_str = "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIkplaFpMVTZuQ3BSZCIsICJhbW91bnQiOiAyLCAic2VjcmV0IjogIjBFN2lDazRkVmxSZjVQRjFnNFpWMnciLCAiQyI6ICIwM2FiNTgwYWQ5NTc3OGVkNTI5NmY4YmVlNjU1ZGJkN2Q2NDJmNWQzMmRlOGUyNDg0NzdlMGI0ZDZhYTg2M2ZjZDUifSwgeyJpZCI6ICJKZWhaTFU2bkNwUmQiLCAiYW1vdW50IjogOCwgInNlY3JldCI6ICJzNklwZXh3SGNxcXVLZDZYbW9qTDJnIiwgIkMiOiAiMDIyZDAwNGY5ZWMxNmE1OGFkOTAxNGMyNTliNmQ2MTRlZDM2ODgyOWYwMmMzODc3M2M0NzIyMWY0OTYxY2UzZjIzIn1dLCAibWludCI6ICJodHRwOi8vbG9jYWxob3N0OjMzMzgifV0sICJtZW1vIjogIlRlc3QgbWVtbyJ9"
token = TokenV3.deserialize(token_str)
assert token.serialize() == token_str
assert token.memo == "Test memo"
def test_calculate_number_of_blank_outputs():
# Example from NUT-08 specification.
fee_reserve_sat = 1000

View File

@@ -198,7 +198,8 @@ async def test_split_more_than_balance(wallet1: Wallet):
await wallet1.mint(64)
await assert_err(
wallet1.split(wallet1.proofs, 128),
"Mint Error: split amount is higher than the total sum.",
# "Mint Error: inputs do not have same amount as outputs",
"amount too large.",
)
assert wallet1.balance == 64
@@ -274,7 +275,7 @@ async def test_split_invalid_amount(wallet1: Wallet):
await wallet1.mint(64)
await assert_err(
wallet1.split(wallet1.proofs, -1),
"Mint Error: invalid split amount: -1",
"amount must be positive.",
)