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

Stamps #295

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
Draft
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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ mypy:
poetry run mypy cashu --ignore-missing

flake8:
poetry run flake8 cashu
poetry run flake8 cashu tests

format: isort black

Expand Down
82 changes: 70 additions & 12 deletions cashu/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,22 @@ class P2SHScript(BaseModel):
address: Union[str, None] = None


class ProofY(BaseModel):
"""
ProofY is a proof that is used to stamp a token.
"""

id: str
amount: int
C: str
Y: str


class StampSignature(BaseModel):
e: str
s: str


class Proof(BaseModel):
"""
Value token
Expand All @@ -165,6 +181,7 @@ class Proof(BaseModel):
amount: int = 0
secret: str = "" # secret or message to be blinded and signed
C: str = "" # signature on secret, unblinded by wallet
stamp: Union[StampSignature, None] = None # stamp signature
p2pksigs: Union[List[str], None] = [] # P2PK signature
p2shscript: Union[P2SHScript, None] = None # P2SH spending condition
reserved: Union[
Expand All @@ -177,13 +194,39 @@ class Proof(BaseModel):
time_reserved: Union[None, str] = ""
derivation_path: Union[None, str] = "" # derivation path of the proof

def to_dict(self):
# dictionary without the fields that don't need to be send to Carol
return dict(id=self.id, amount=self.amount, secret=self.secret, C=self.C)
def to_dict(self, include_stamps=False):
# dictionary without the fields that don't need to be sent to Carol
d: Dict[str, Any] = dict(
id=self.id, amount=self.amount, secret=self.secret, C=self.C
)
if include_stamps:
assert self.stamp, "Stamp signature is missing"
d["stamp"] = self.stamp.dict()
return d

def to_dict_no_secret(self):
# dictionary but without the secret itself
return dict(id=self.id, amount=self.amount, C=self.C)
@classmethod
def from_row(cls, rowObj: Row):
row = dict(rowObj)
return cls(
id=row["id"],
amount=row["amount"],
secret=row["secret"],
C=row["C"],
stamp=StampSignature(e=row["stamp_e"], s=row["stamp_s"])
if row["stamp_e"] and row["stamp_s"]
else None,
# p2pksigs=json.loads(row["p2pksigs"]) if row["p2pksigs"] else None,
# p2shscript=P2SHScript(
# script=row["script"], signature=row["signature"]
# ) # type: ignore
# if row["script"]
# else None,
reserved=row["reserved"],
send_id=row["send_id"],
time_created=row["time_created"],
time_reserved=row["time_reserved"],
derivation_path=row["derivation_path"],
)

def __getitem__(self, key):
return self.__getattribute__(key)
Expand Down Expand Up @@ -360,6 +403,17 @@ class PostRestoreResponse(BaseModel):
promises: List[BlindedSignature] = []


# ------- API: STAMP -------


class PostStampRequest(BaseModel):
proofys: List[ProofY]


class PostStampResponse(BaseModel):
stamps: List[StampSignature]


# ------- KEYSETS -------


Expand Down Expand Up @@ -551,8 +605,10 @@ class TokenV3Token(BaseModel):
mint: Optional[str] = None
proofs: List[Proof]

def to_dict(self):
return_dict = dict(proofs=[p.to_dict() for p in self.proofs])
def to_dict(self, include_stamps=False):
return_dict = dict(
proofs=[p.to_dict(include_stamps=include_stamps) for p in self.proofs]
)
if self.mint:
return_dict.update(dict(mint=self.mint)) # type: ignore
return return_dict
Expand All @@ -566,8 +622,10 @@ class TokenV3(BaseModel):
token: List[TokenV3Token] = []
memo: Optional[str] = None

def to_dict(self):
return_dict = dict(token=[t.to_dict() for t in self.token])
def to_dict(self, include_stamps=False):
return_dict = dict(
token=[t.to_dict(include_stamps=include_stamps) for t in self.token]
)
if self.memo:
return_dict.update(dict(memo=self.memo)) # type: ignore
return return_dict
Expand All @@ -594,14 +652,14 @@ def deserialize(cls, tokenv3_serialized: str) -> "TokenV3":
token = json.loads(base64.urlsafe_b64decode(token_base64))
return cls.parse_obj(token)

def serialize(self) -> str:
def serialize(self, include_stamps=False) -> str:
"""
Takes a TokenV3 and serializes it as "cashuA<json_urlsafe_base64>.
"""
prefix = "cashuA"
tokenv3_serialized = prefix
# encode the token as a base64 string
tokenv3_serialized += base64.urlsafe_b64encode(
json.dumps(self.to_dict()).encode()
json.dumps(self.to_dict(include_stamps=include_stamps)).encode()
).decode()
return tokenv3_serialized
64 changes: 63 additions & 1 deletion cashu/core/crypto/b_dhke.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"""

import hashlib
from typing import Optional
from typing import Optional, Tuple

from secp256k1 import PrivateKey, PublicKey

Expand Down Expand Up @@ -74,6 +74,68 @@ def verify(a: PrivateKey, C: PublicKey, secret_msg: str) -> bool:
return C == Y.mult(a) # type: ignore


# stamps
"""
Proves that a in A = a*G is the same as a in C = a*Y

Bob:
R1 = rG
R2 = rY
e = hash(R1, R2, Y, C)
s = r + e*a

Alice/Carol:
Y = hash_to_curve(x)
R1 = sG - eA
R2 = sY - eC
(eaY = eC, since C' - rA = aY + arG - arG = aY = C)

e == hash(R1, R2, Y, C) (verification)

If true, C must have originated from Bob with private key a
"""


def hash_e(*args) -> bytes:
"""Hashes a list of public keys to a 32 byte value"""
e_ = ""
for pk in args:
assert isinstance(pk, PublicKey), "object is not of type PublicKey"
e_ += pk.serialize(compressed=False).hex()
e = hashlib.sha256(e_.encode("utf-8")).digest()
return e


def stamp_step1_bob(
Y: PublicKey, C: PublicKey, a: PrivateKey, p_bytes: bytes = b""
) -> Tuple[PrivateKey, PrivateKey]:
if p_bytes:
# deterministic p for testing
p = PrivateKey(privkey=p_bytes, raw=True)
else:
# normally, we generate a random p
p = PrivateKey()
assert p.pubkey
R1: PublicKey = p.pubkey # R1 = pG
R2: PublicKey = Y.mult(p) # type: ignore # R2 = pY
print(R1.serialize().hex(), R2.serialize().hex())
e = hash_e(R1, R2, Y, C)
s = p.tweak_add(a.tweak_mul(e)) # s = p + ea
spk = PrivateKey(s, raw=True)
epk = PrivateKey(e, raw=True)
return epk, spk


def stamp_step2_alice_verify(
Y: PublicKey, C: PublicKey, s: PrivateKey, e: PrivateKey, A: PublicKey
) -> bool:
assert s.pubkey
R1: PublicKey = s.pubkey - A.mult(e) # type: ignore # R1 = sG - eA
R2: PublicKey = Y.mult(s) - C.mult(e) # type: ignore # R2 = sY - eC
e_bytes = e.private_key
return e_bytes == hash_e(R1, R2, Y, C)


# Below is a test of a simple positive and negative case

# # Alice's keys
Expand Down
17 changes: 17 additions & 0 deletions cashu/mint/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
MintKeyset,
MintKeysets,
Proof,
ProofY,
Secret,
SecretKind,
SigFlags,
StampSignature,
)
from ..core.crypto import b_dhke
from ..core.crypto.keys import derive_pubkey, random_hash
Expand Down Expand Up @@ -1118,3 +1120,18 @@ async def restore(
return_outputs.append(output)
logger.trace(f"promise found: {promise}")
return return_outputs, promises

async def stamp(self, proofys: List[ProofY]) -> List[StampSignature]:
signatures: List[StampSignature] = []
for proofy in proofys:
assert proofy.id
private_key_amount = self.keysets.keysets[proofy.id].private_keys[
proofy.amount
]
e, s = b_dhke.stamp_step1_bob(
Y=PublicKey(bytes.fromhex(proofy.Y), raw=True),
C=PublicKey(bytes.fromhex(proofy.C), raw=True),
a=private_key_amount,
)
signatures.append(StampSignature(e=e.serialize(), s=s.serialize()))
return signatures
15 changes: 15 additions & 0 deletions cashu/mint/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
PostSplitRequest,
PostSplitResponse,
PostSplitResponse_Deprecated,
PostStampRequest,
PostStampResponse,
)
from ..core.errors import CashuError
from ..core.settings import settings
Expand Down Expand Up @@ -282,3 +284,16 @@ async def restore(payload: PostMintRequest) -> PostRestoreResponse:
assert payload.outputs, Exception("no outputs provided.")
outputs, promises = await ledger.restore(payload.outputs)
return PostRestoreResponse(outputs=outputs, promises=promises)


@router.post(
"/stamp",
name="Stamp",
summary="Request signatures on proofs",
response_model=PostStampResponse,
response_description=("List of signatures on proofs."),
)
async def stamp(payload: PostStampRequest) -> PostStampResponse:
assert payload.proofys, Exception("no proofs provided")
signatures = await ledger.stamp(payload.proofys)
return PostStampResponse(stamps=signatures)
36 changes: 36 additions & 0 deletions cashu/wallet/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -761,3 +761,39 @@ async def restore(ctx: Context, to: int, batch: int):
await wallet.restore_wallet_from_mnemonic(mnemonic, to=to, batch=batch)
await wallet.load_proofs()
wallet.status()


@cli.command("stamp", help="Stamp tokens in wallet.")
@click.option(
"--max-amount",
"-m",
default=None,
help="Maximum amount to stamp.",
type=int,
)
@click.pass_context
@coro
async def stamp(ctx: Context, max_amount: int):
wallet: Wallet = ctx.obj["WALLET"]
await wallet.load_mint()
await wallet.load_proofs()
proofs_without_stamp = [p for p in wallet.proofs if not p.stamp]
if len(proofs_without_stamp) == 0:
print("All tokens in wallet are stamped.")
return

if max_amount:
# sort proofs by amount and remove largest proofs until max_amount is reached
proofs_without_stamp = sorted(
proofs_without_stamp, key=lambda x: x.amount, reverse=True
)
while sum_proofs(proofs_without_stamp) > max_amount:
proofs_without_stamp.pop(0)
if len(proofs_without_stamp) == 0:
print(f"No tokens smaller than {max_amount} sat in wallet.")
return

print(
f"Requesting {len(proofs_without_stamp)} stamps for tokens worth {sum_proofs(proofs_without_stamp)} sat."
)
await wallet.get_proofs_stamps(proofs_without_stamp)
16 changes: 12 additions & 4 deletions cashu/wallet/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ async def get_proofs(
SELECT * from proofs
"""
)
return [Proof(**dict(r)) for r in rows]
return [Proof.from_row(r) for r in rows]


async def get_reserved_proofs(
Expand Down Expand Up @@ -82,10 +82,13 @@ async def invalidate_proof(
)


async def update_proof_reserved(
async def update_proof(
proof: Proof,
reserved: bool,
send_id: str = "",
*,
reserved: Optional[bool] = None,
send_id: Optional[str] = None,
stamp_e: Optional[str] = None,
stamp_s: Optional[str] = None,
db: Optional[Database] = None,
conn: Optional[Connection] = None,
):
Expand All @@ -103,6 +106,11 @@ async def update_proof_reserved(
clauses.append("time_reserved = ?")
values.append(int(time.time()))

if stamp_e and stamp_s:
clauses.append("stamp_e = ?")
values.append(stamp_e)
clauses.append("stamp_s = ?")
values.append(stamp_s)
await (conn or db).execute( # type: ignore
f"UPDATE proofs SET {', '.join(clauses)} WHERE secret = ?",
(*values, str(proof.secret)),
Expand Down
7 changes: 7 additions & 0 deletions cashu/wallet/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,10 @@ async def m009_privatekey_and_determinstic_key_derivation(db: Database):
"""
)
# await db.execute("INSERT INTO secret_derivation (counter) VALUES (0)")


async def m010_proofs_add_stamps(db: Database):
await db.execute("ALTER TABLE proofs ADD COLUMN stamp_e TEXT")
await db.execute("ALTER TABLE proofs ADD COLUMN stamp_s TEXT")
await db.execute("ALTER TABLE proofs_used ADD COLUMN stamp_e TEXT")
await db.execute("ALTER TABLE proofs_used ADD COLUMN stamp_s TEXT")
Loading
Loading