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

Discreet Log Contracts #576

Draft
wants to merge 84 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 78 commits
Commits
Show all changes
84 commits
Select commit Hold shift + click to select a range
cc4aeb5
Models + Merkle function + Test draft
lollerfirst Jul 10, 2024
97edb79
Merge remote-tracking branch 'origin/main' into dlc
lollerfirst Jul 10, 2024
383e032
remove `test_mint_dlc`
lollerfirst Jul 10, 2024
3f44d81
fix errors
lollerfirst Jul 10, 2024
619c778
fix more errors
lollerfirst Jul 10, 2024
c22b800
merkle functions tests
lollerfirst Jul 10, 2024
d1fd1d7
making mypy happy
lollerfirst Jul 10, 2024
043556b
SCT spending conditions
lollerfirst Jul 11, 2024
7d22656
formatting errors
lollerfirst Jul 11, 2024
5e11a99
Update cashu/core/crypto/dlc.py
lollerfirst Jul 11, 2024
a14707b
fix description `merkle_root`
lollerfirst Jul 11, 2024
344cbff
Merge branch 'cashubtc:main' into dlc
lollerfirst Jul 12, 2024
2dfdafe
secret generation
lollerfirst Jul 14, 2024
f45e5e5
fix broken import
lollerfirst Jul 14, 2024
258d4ae
db add dlc_root and spending_conditions to proofs tables + related e…
lollerfirst Jul 15, 2024
77b2631
fix error
lollerfirst Jul 15, 2024
aa1af77
move dlc from core to wallet
lollerfirst Jul 15, 2024
8b134dd
move `add_witnessess_to_proofs` up to `wallet.py` for common use.
lollerfirst Jul 15, 2024
4c67c3d
Merge remote-tracking branch 'origin/main' into dlc
lollerfirst Jul 15, 2024
e709af8
tests: swapping for locked, unlocked.
lollerfirst Jul 16, 2024
08529b5
fix naive mistake
lollerfirst Jul 16, 2024
32cc283
Better tests for dlc locked proofs spending validation
lollerfirst Jul 16, 2024
b0bfc0e
dlc funding token
lollerfirst Jul 17, 2024
41ee5e1
fix selfpay
lollerfirst Jul 18, 2024
ea56b57
Merge remote-tracking branch 'origin/main' into dlc
lollerfirst Jul 18, 2024
6a3e8d3
* mint DB add dlc table migration
lollerfirst Jul 18, 2024
9a741cb
fix migration
lollerfirst Jul 18, 2024
0b89fb7
verify threshold, verify funding amount and fees coverage.
lollerfirst Jul 19, 2024
1bbca5a
embarassing error fix
lollerfirst Jul 19, 2024
fd12aa3
report index of proofs that failed verification
lollerfirst Jul 25, 2024
ffa6858
DlcFundingProof signature
lollerfirst Jul 25, 2024
30ebc3f
Merge remote-tracking branch 'origin/main' into dlc
lollerfirst Jul 29, 2024
ff125d6
refactor: move dlc verification functions into `verification.py`, reg…
lollerfirst Jul 29, 2024
4ce0b7b
funding proof signature fix
lollerfirst Jul 29, 2024
a5b147b
Database shenanigans
lollerfirst Jul 30, 2024
1dd7abf
error fix
lollerfirst Jul 30, 2024
a01a77f
Removed `is_atomic`, `sign_dlc` with the first key of the active keys…
lollerfirst Jul 31, 2024
8dc0c71
tests on `register_dlc` working
lollerfirst Jul 31, 2024
932fa7a
Merge remote-tracking branch 'origin/main' into dlc
lollerfirst Jul 31, 2024
10b6e9f
rename `DiscreteLogContract` to `DiscreetLogContract`, added `status_…
lollerfirst Aug 1, 2024
0da2683
add test for `status_dlc`
lollerfirst Aug 1, 2024
cb7eea2
better threshold check + test
lollerfirst Aug 2, 2024
c6e06e7
settlement
lollerfirst Aug 2, 2024
2ba5f5b
secret attestation verification
lollerfirst Aug 2, 2024
6b1d04c
settlement database
lollerfirst Aug 3, 2024
c58cf9b
whitespace formatting
conduition Aug 5, 2024
97684a6
rename DLCWitness -> SCTWitness
conduition Aug 5, 2024
42964e7
raise errors instead of returning false in _verify_sct_spending_condi…
conduition Aug 5, 2024
0b5c191
refactor handling of DLC input validation code
conduition Aug 5, 2024
1f287d3
Fix linter errors
conduition Aug 5, 2024
e4463ac
Merge pull request #1 from conduition/dlc
lollerfirst Aug 5, 2024
0c810fa
fix PostgreSQL's tantrum over BIT datatype
lollerfirst Aug 5, 2024
162cd47
avoid re-settling already settled DLC
conduition Aug 23, 2024
2a3049e
separate error types for registration/settlement responses
conduition Aug 23, 2024
971b43e
update registration response to include funding proof keyset id
conduition Aug 23, 2024
47ace2f
fix funding proof signature to match spec
conduition Aug 23, 2024
c38b2ed
Merge pull request #2 from conduition/dlc
lollerfirst Aug 23, 2024
9213daf
Merge pull request #4 from conduition/dlc3
lollerfirst Aug 23, 2024
18c5a34
test for dlc settlement and relative error fixes
lollerfirst Aug 24, 2024
a9fb148
Merge remote-tracking branch 'loller/dlc' into dlc2
conduition Aug 26, 2024
be81d78
update tests to reflect new response types
conduition Aug 26, 2024
9c5bf34
Merge pull request #3 from conduition/dlc2
lollerfirst Aug 26, 2024
62145e2
add leading slashes and `POST /v1/dlc/settle`
gudnuf Sep 7, 2024
a5f7590
Merge pull request #5 from gudnuf/dlc-fix-router
lollerfirst Sep 13, 2024
3ed21e4
Merge remote-tracking branch 'upstream/main' into dlc
lollerfirst Sep 13, 2024
0ad83a9
initial support for payouts
lollerfirst Sep 13, 2024
9f10d34
dlc payouts: part 2
lollerfirst Sep 14, 2024
0e35fbf
error fixes
lollerfirst Sep 14, 2024
ad65326
definition of `DlcPayoutWitness` before `DlcPayout`
lollerfirst Sep 14, 2024
52729b2
fix more errors
lollerfirst Sep 14, 2024
231eb25
fix for out of spec debts map
lollerfirst Sep 14, 2024
d561dbe
dlc payout tests + bug fixes
lollerfirst Sep 15, 2024
e35df9b
make format
lollerfirst Sep 16, 2024
2b8fa8b
test fix
lollerfirst Sep 17, 2024
39aa849
fix order of `sorted_merkle_hash`
lollerfirst Sep 21, 2024
cd2bf24
fix tests
lollerfirst Sep 21, 2024
c58947f
Merge remote-tracking branch 'origin/main' into dlc
lollerfirst Sep 27, 2024
58aaee9
Merge remote-tracking branch 'origin' into dlc
lollerfirst Oct 21, 2024
a290313
Merge remote-tracking branch 'upstream/main' into dlc
lollerfirst Oct 30, 2024
5b35eb7
corrections
lollerfirst Oct 30, 2024
897141b
make format + remove weird SCTWitness clone
lollerfirst Oct 30, 2024
aa7e38e
remove more weird duplicates
lollerfirst Oct 30, 2024
6e204bc
unused `proofs` in tests
lollerfirst Oct 30, 2024
a4084aa
fix some minor typos
lollerfirst Nov 30, 2024
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
126 changes: 126 additions & 0 deletions cashu/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,15 @@ class P2PKWitness(BaseModel):
def from_witness(cls, witness: str):
return cls(**json.loads(witness))

class SCTWitness(BaseModel):
leaf_secret: str
merkle_proof: List[str]
witness: Optional[str] = None

@classmethod
def from_witness(cls, witness: str):
return cls(**json.loads(witness))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed this conflicts with the spec (the witness in the spec was an object, not a string). I see Proof.witness is strictly typed to be an Optional[str] in nutshell, so i changed the spec to match.

cashubtc/nuts@d1ffefa



class Proof(BaseModel):
"""
Expand Down Expand Up @@ -154,6 +163,8 @@ class Proof(BaseModel):
melt_id: Union[
None, str
] = None # holds the id of the melt operation that destroyed this proof
all_spending_conditions: Optional[List[str]] = None # holds all eventual SCT spending conditions
dlc_root: Optional[str] = None # holds the root hash of a DLC contract

def __init__(self, **data):
super().__init__(**data)
Expand All @@ -169,6 +180,12 @@ def from_dict(cls, proof_dict: dict):
else:
# overwrite the empty string with None
proof_dict["dleq"] = None

if (proof_dict.get("all_spending_conditions")
and isinstance(proof_dict["all_spending_conditions"], str)):
proof_dict["all_spending_conditions"] = json.loads(proof_dict["all_spending_conditions"])
else:
proof_dict["all_spending_conditions"] = None
c = cls(**proof_dict)
return c

Expand Down Expand Up @@ -205,6 +222,16 @@ def p2pksigs(self) -> List[str]:
assert self.witness, "Witness is missing for p2pk signature"
return P2PKWitness.from_witness(self.witness).signatures

@property
def dlc_leaf_secret(self) -> str:
assert self.witness, "Witness is missing for dlc leaf secret"
return SCTWitness.from_witness(self.witness).leaf_secret

@property
def dlc_merkle_proof(self) -> List[str]:
assert self.witness, "Witness is missing for dlc merkle proof"
return SCTWitness.from_witness(self.witness).merkle_proof

@property
def htlcpreimage(self) -> Union[str, None]:
assert self.witness, "Witness is missing for htlc preimage"
Expand Down Expand Up @@ -1023,6 +1050,8 @@ class TokenV4(Token):
t: List[TokenV4Token]
# memo
d: Optional[str] = None
# dlc root
r: Optional[str] = None

@property
def mint(self) -> str:
Expand Down Expand Up @@ -1078,6 +1107,10 @@ def proofs(self) -> List[Proof]:
for p in token.p
]

@property
def dlc_root(self) -> Optional[str]:
return self.r

@property
def keysets(self) -> List[str]:
return list({p.i.hex() for p in self.t})
Expand Down Expand Up @@ -1142,6 +1175,9 @@ def serialize_to_dict(self, include_dleq=False):
# optional memo
if self.d:
return_dict.update(dict(d=self.d))
# optional dlc root
if self.r:
return_dict.update(dict(r=self.r))
# mint
return_dict.update(dict(m=self.m))
# unit
Expand Down Expand Up @@ -1214,4 +1250,94 @@ def parse_obj(cls, token_dict: dict):
u=token_dict["u"],
t=[TokenV4Token(**t) for t in token_dict["t"]],
d=token_dict.get("d", None),
r=token_dict.get("r", None),
)

# -------- DLC STUFF --------

class DiscreetLogContract(BaseModel):
"""
A discrete log contract
"""
settled: Optional[bool] = False
dlc_root: str
funding_amount: int
unit: str
inputs: Optional[List[Proof]] = None # Need to verify these are indeed SCT proofs
debts: Optional[Dict[str, int]] = None # We save who we owe money to here

@classmethod
def from_row(cls, row: Row):
return cls(
dlc_root=row["dlc_root"],
settled=bool(row["settled"]),
funding_amount=int(row["funding_amount"]),
unit=row["unit"],
debts=json.loads(row["debts"]) if row["debts"] else None,
)

class DlcBadInput(BaseModel):
index: int
detail: str

class DlcFundingProof(BaseModel):
"""
A dlc merkle root with its signature
or a dlc merkle root with bad inputs.
"""
keyset: str
signature: str

class DlcFundingAck(BaseModel):
dlc_root: str
funding_proof: DlcFundingProof

class DlcFundingError(BaseModel):
dlc_root: str
bad_inputs: Optional[List[DlcBadInput]] # Used to specify potential errors

class DlcOutcome(BaseModel):
"""
Describes a DLC outcome
"""
k: Optional[str] # The blinded attestation secret
t: Optional[int] # The timeout (claim when time is over)
P: str # The payout structure associated with this outcome

class DlcSettlement(BaseModel):
"""
Data used to settle an outcome of a DLC
"""
dlc_root: str
outcome: DlcOutcome
merkle_proof: List[str]

class DlcSettlementAck(BaseModel):
"""
Used by the mint to indicate the success of a DLC's funding, settlement, etc.
"""
dlc_root: str

class DlcSettlementError(BaseModel):
"""
Indicates to the client that a DLC operation (funding, settlement, etc) failed.
"""
dlc_root: str
details: str

class DlcPayoutWitness(BaseModel):
# a BIP-340 signature on the root of the contract
signature: Optional[str] = None
# the discrete log of the public key (the private key)
secret: Optional[str] = None

class DlcPayoutForm(BaseModel):
dlc_root: str
pubkey: str
outputs: List[BlindedMessage]
witness: DlcPayoutWitness

class DlcPayout(BaseModel):
dlc_root: str
outputs: Optional[List[BlindedSignature]] = None
lollerfirst marked this conversation as resolved.
Show resolved Hide resolved
detail: Optional[str] = None # error details
100 changes: 100 additions & 0 deletions cashu/core/crypto/dlc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
from hashlib import sha256
from typing import List, Optional, Tuple

from secp256k1 import PrivateKey, PublicKey


def sorted_merkle_hash(left: bytes, right: bytes) -> bytes:
'''Sorts `left` and `right` in non-ascending order and
computes the hash of their concatenation
'''
if right < left:
left, right = right, left
return sha256(left+right).digest()


def merkle_root(
leaf_hashes: List[bytes],
track_branch: Optional[int] = None
) -> Tuple[bytes, Optional[List[bytes]]]:
'''Computes the root of a list of leaf hashes
if `track_branch` is set, extracts the hashes for the branch that leads
to `leaf_hashes[track_branch]`
'''
if track_branch is not None:
if len(leaf_hashes) == 0:
return b"", []
elif len(leaf_hashes) == 1:
return leaf_hashes[0], []
else:
split = len(leaf_hashes) // 2
left, left_branch_hashes = merkle_root(leaf_hashes[:split],
track_branch if track_branch < split else None)
right, right_branch_hashes = merkle_root(leaf_hashes[split:],
track_branch-split if track_branch >= split else None)
branch_hashes = (left_branch_hashes if
track_branch < split else right_branch_hashes)
hashh = sorted_merkle_hash(left, right)
# Needed to pass mypy checks
assert branch_hashes is not None, "merkle_root fail: branch_hashes == None"
branch_hashes.append(right if track_branch < split else left)
return hashh, branch_hashes
else:
if len(leaf_hashes) == 0:
return b"", None
elif len(leaf_hashes) == 1:
return leaf_hashes[0], None
else:
split = len(leaf_hashes) // 2
left, _ = merkle_root(leaf_hashes[:split], None)
right, _ = merkle_root(leaf_hashes[split:], None)
hashh = sorted_merkle_hash(left, right)
return hashh, None

def merkle_verify(root: bytes, leaf_hash: bytes, proof: List[bytes]) -> bool:
'''Verifies that `leaf_hash` belongs to a merkle tree
that has `root` as root
'''
h = leaf_hash
for branch_hash in proof:
h = sorted_merkle_hash(h, branch_hash)
return h == root

def list_hash(leaves: List[str]) -> List[bytes]:
return [sha256(leaf.encode()).digest() for leaf in leaves]

def sign_dlc(
dlc_root: str,
funding_amount: int,
privkey: PrivateKey,
) -> bytes:
message = (
bytes.fromhex(dlc_root)
+funding_amount.to_bytes(8, "big")
)
return privkey.schnorr_sign(message, None, raw=True)

def verify_dlc_signature(
dlc_root: str,
funding_amount: int,
signature: bytes,
pubkey: PublicKey,
) -> bool:
message = (
bytes.fromhex(dlc_root)
+funding_amount.to_bytes(8, "big")
)
return pubkey.schnorr_verify(message, signature, None, raw=True)

def verify_payout_signature(
dlc_root: bytes,
signature: bytes,
pubkey: PublicKey,
) -> bool:
return pubkey.schnorr_verify(dlc_root, signature, None, raw=True)

def verify_payout_secret(
secret: bytes,
pubkey: PublicKey,
) -> bool:
return pubkey == PrivateKey(secret, raw=True).pubkey
45 changes: 45 additions & 0 deletions cashu/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ def __init__(self, detail, code=0):
self.code = code
self.detail = detail

def __str__(self):
return self.detail


class NotAllowedError(CashuError):
detail = "not allowed"
Expand Down Expand Up @@ -96,3 +99,45 @@ class QuoteNotPaidError(CashuError):

def __init__(self):
super().__init__(self.detail, code=2001)


class DlcVerificationFail(CashuError):
detail = "dlc verification fail"
code = 30000
bad_inputs = None

def __init__(self, **kwargs):
super().__init__(self.detail, self.code)
self.bad_inputs = kwargs.get("bad_inputs", "")

class DlcAlreadyRegisteredError(CashuError):
detail = "dlc already registered"
code = 30001

def __init__(self, **kwargs):
super().__init__(self.detail, self.code)
self.detail += kwargs.get("detail", "")

class DlcNotFoundError(CashuError):
detail = "dlc not found"
code = 30002

def __init__(self, **kwargs):
super().__init__(self.detail, self.code)
self.detail += kwargs.get('detail', '')

class DlcSettlementFail(CashuError):
detail = "settlement verification failed: "
code = 30003

def __init__(self, **kwargs):
super().__init__(self.detail, self.code)
self.detail += kwargs.get("detail", "")

class DlcPayoutFail(CashuError):
detail = "payout verification failed: "
code = 30004

def __init__(self, **kwargs):
super().__init__(self.detail, self.code)
self.detail += kwargs.get("detail", "")
43 changes: 43 additions & 0 deletions cashu/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@
BlindedMessage,
BlindedMessage_Deprecated,
BlindedSignature,
DiscreetLogContract,
DlcFundingAck,
DlcFundingError,
DlcPayout,
DlcPayoutForm,
DlcSettlement,
DlcSettlementAck,
DlcSettlementError,
MeltQuote,
MintQuote,
Proof,
Expand Down Expand Up @@ -339,3 +347,38 @@ class PostRestoreResponse(BaseModel):
def __init__(self, **data):
super().__init__(**data)
self.promises = self.signatures


# ------- API: DLC REGISTRATION -------

class PostDlcRegistrationRequest(BaseModel):
registrations: List[DiscreetLogContract]

class PostDlcRegistrationResponse(BaseModel):
funded: List[DlcFundingAck] = []
errors: Optional[List[DlcFundingError]] = None

# ------- API: DLC SETTLEMENT -------

class PostDlcSettleRequest(BaseModel):
settlements: List[DlcSettlement]

class PostDlcSettleResponse(BaseModel):
settled: List[DlcSettlementAck] = []
errors: Optional[List[DlcSettlementError]] = None

# ------- API: DLC PAYOUT -------
class PostDlcPayoutRequest(BaseModel):
payouts: List[DlcPayoutForm]

class PostDlcPayoutResponse(BaseModel):
paid: List[DlcPayout]
errors: Optional[List[DlcPayout]]

# ------- API: DLC STATUS -------

class GetDlcStatusResponse(BaseModel):
settled: bool
unit: Optional[str] = None
Copy link
Contributor

@gudnuf gudnuf Oct 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is inconsistent from the spec. There is no unit field defined in the responses. I think including the keyset ID in the status response would make more sense than unit if anything.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weird mistake. I'll be sure to fix that.

funding_amount: Optional[int] = None
debts: Optional[Dict[str, int]] = None
1 change: 1 addition & 0 deletions cashu/core/nuts.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@
DETERMINSTIC_SECRETS_NUT = 13
MPP_NUT = 15
WEBSOCKETS_NUT = 17
DLC_NUT = 99
Loading