Skip to content

Commit 9a213c7

Browse files
committed
feat: add keysets v2 client side implementation
Keysets v2: align with NUT-13 HMAC derivation (4-byte counter, single HMAC split); implement wallet-side short ID manager and token helpers; apply default final_expiry on rotation; keep mint agnostic of s_id alignment NUT-02/13 alignment: - Keys API: include final_expiry in /v1/keys and /v1/keys/{id} - Derivation: keyset ID v2 per spec (unit + optional final_expiry; SHA256; v=01) - NUT-13 v2 secrets: switch to HMAC-SHA256 with 8-byte counter and type byte (00/01) - Tests: update wallet v2 tests to HMAC-SHA256; derive seed from BIP-39 mnemonic; fix TokenV4 short ID expansion test to use KeysetManager directly; update API expectations for final_expiry Remove mint_use_keysets_v2 flag; always generate v2 keyset IDs for new keysets. Keep optional default final_expiry via settings. Update keyset rotation/activation to use default expiry unconditionally. Update docs. wallet: log debug when resolving short keyset id to full id (cache hit). wallet: integrate v2 short keyset IDs directly in TokenV4 creation; deprecate WalletTokensV2 helper; tests: remove tokens_v2 dependency, assert short-ID serialization; secrets: NUT-13 v2 docstring to HMAC-SHA256; proofs: fix helper method refactor regression; cleanup remove deprecated cashu/wallet/tokens_v2.py; short-ID handling now in WalletProofs._make_tokenv4 Summary of changes on keysets-v2 - Removed deprecated helper: - cashu/wallet/tokens_v2.py deleted. - Integrated short-ID handling into wallet TokenV4 creation: - cashu/wallet/proofs.py: _make_tokenv4 now converts v2 full keyset IDs to short IDs during token creation via KeysetManager; ensured _get_proofs_mint_unit remains correct. - Updated tests to remove tokens_v2 dependency: - tests/wallet/test_wallet_keysets_v2.py: test_token_serialization_with_short_ids now uses WalletProofs to assert short-ID serialization. - Docstring correction: - cashu/wallet/secrets.py: NUT-13 v2 derivation is HMAC-SHA256 (not SHA512). Remote - Branch keysets-v2 is now ahead by 2 commits and pushed to origin. format move tests to v2 keyset id fixed test_mint_api more fixes bump nutshell version to 0.18 => derive ID v1/v2 based on nutshell version of the keyset: if not specified the nutshell version is 0.18 and the keyset ID is v2 more fixes Fix keyset version tests: update assertions for v1/v2 ID expectations - Change test_keyset_0_15_0 and test_keyset_0_15_0_encrypted to expect V1_KEYSET_ID (version 0.15.0 uses v1 IDs) - Update test_keyset_short_id to properly assert V1_KEYSET_ID for legacy keysets - Simplify test_keyset_v2_deterministic to use version 0.18.0 which uses v2 IDs - Remove redundant test_keyset_v1_v2_compatibility test (covered by other tests) - Update test section header to 'KEYSET IDs NUT-02 TEST VECTORS' Add comprehensive keyset ID test vectors and version behavior tests New tests added: 1. test_keyset_versions_produce_correct_id_format: Tests that different nutshell versions (0.11, 0.14, 0.15, 0.17, 0.18, 0.19) produce the correct ID format (base64 for <0.15, v1 hex for 0.15-0.17, v2 hex for >=0.18) 2. test_keyset_id_v1_test_vectors: Implements NUT-02 test vectors for v1 keyset ID derivation with two test vectors (small 4-key keyset and large 64-key keyset) 3. test_keyset_id_v2_test_vectors: Implements NUT-02 test vectors for v2 keyset ID derivation with three test vectors testing different combinations of keyset sizes, units, and final_expiry values These tests ensure compatibility with the NUT specifications and verify that the keyset ID derivation algorithms produce stable, interoperable results across different implementations. Test vectors source: https://github.com/cashubtc/nuts/pull/182/files Fix test_keyset_version_detection for v1 vs v2 ID distinction Corrected the test to properly distinguish between v1 and v2 keyset IDs: - V1 IDs (version 0.15-0.17) start with '00' and are NOT detected as v2 - V2 IDs (version 0.18+) start with '01' and ARE detected as v2 This test was incorrectly expecting a v1 keyset (version 0.15.0) to be identified as v2. more fixes Add v2 short keyset ID expansion support for token redemption Fix for TokenV3 and TokenV4 redemption with v2 short keyset IDs: - Added _expand_short_keyset_ids() method to WalletProofs class - Expands v2 short IDs (16 chars, '01' + 7 bytes) to full IDs (66 chars) - Uses KeysetManager to map short->full IDs from loaded keysets - Handles expansion failures gracefully with warning logging - Updated redeem_TokenV3() and redeem_TokenV4() in helpers.py - Calls _expand_short_keyset_ids() after load_mint() to ensure all keyset IDs in proofs are expanded before redemption - Added comprehensive test: test_proof_short_id_expansion() - Verifies short ID expansion works correctly - Tests that proofs with v2 short IDs get expanded to full IDs - Fixed existing tests in test_wallet_keysets_v2.py - Removed incorrect 'await' calls on synchronous KeysetManager methods - Fixed test_token_v4_short_keyset_expansion to use proper v2 short ID - Fixed test_nut13_spec_compliance to use actual V2_KEYSET_ID constant - Updated test_wallet_cli.py token to use v2 short keyset IDs This ensures wallets can properly handle TokenV3/V4 tokens containing v2 short keyset IDs, which are used to reduce token size. feat: improve v2 keyset ID handling and fee calculation - Add short-to-full keyset ID expansion in wallet helpers - Enhance KeysetManager with better documentation and type hints - Fix fee calculation to handle short keyset IDs gracefully - Add keyset ID expansion before proof redemption in CLI - Update tests to reflect new keyset ID handling behavior - Add fallback logic for missing keysets in fee calculations format fixes base64 keysets detection
1 parent a5f950a commit 9a213c7

23 files changed

+2231
-1005
lines changed

cashu/core/base.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
derive_keys_deprecated_pre_0_15,
2525
derive_keyset_id,
2626
derive_keyset_id_deprecated,
27+
derive_keyset_id_v2,
2728
derive_pubkeys,
2829
)
2930
from .crypto.secp import PrivateKey, PublicKey
@@ -805,6 +806,7 @@ class MintKeyset:
805806
version: Optional[str] = None
806807
amounts: List[int]
807808
balance: int
809+
final_expiry: Optional[int] = None # NEW: Final expiry timestamp for keyset v2
808810

809811
duplicate_keyset_id: Optional[str] = None # BACKWARDS COMPATIBILITY < 0.15.0
810812

@@ -826,6 +828,7 @@ def __init__(
826828
id: str = "",
827829
balance: int = 0,
828830
fees_paid: int = 0,
831+
final_expiry: Optional[int] = None,
829832
):
830833
DEFAULT_SEED = "supersecretprivatekey"
831834
if seed == DEFAULT_SEED:
@@ -861,6 +864,7 @@ def __init__(
861864
self.balance = balance
862865
self.fees_paid = fees_paid
863866
self.input_fee_ppk = input_fee_ppk or 0
867+
self.final_expiry = final_expiry
864868

865869
if self.input_fee_ppk < 0:
866870
raise Exception("Input fee must be non-negative.")
@@ -915,6 +919,7 @@ def from_row(cls, row: Row):
915919
amounts=json.loads(row["amounts"]),
916920
balance=row["balance"],
917921
fees_paid=row["fees_paid"],
922+
final_expiry=row["final_expiry"],
918923
)
919924

920925
@property
@@ -958,12 +963,33 @@ def generate_keys(self):
958963
)
959964
self.public_keys = derive_pubkeys(self.private_keys, self.amounts) # type: ignore
960965
self.id = id_in_db or derive_keyset_id_deprecated(self.public_keys) # type: ignore
966+
elif self.version_tuple < (0, 18):
967+
self.private_keys = derive_keys(
968+
self.seed, self.derivation_path, self.amounts
969+
)
970+
self.public_keys = derive_pubkeys(self.private_keys, self.amounts) # type: ignore
971+
972+
if id_in_db:
973+
# If loading from DB, preserve existing ID
974+
self.id = id_in_db
975+
else:
976+
assert self.public_keys is not None
977+
self.id = derive_keyset_id(self.public_keys)
978+
logger.info(f"Generated keyset v1 ID: {self.id}")
961979
else:
962980
self.private_keys = derive_keys(
963981
self.seed, self.derivation_path, self.amounts
964982
)
965983
self.public_keys = derive_pubkeys(self.private_keys, self.amounts) # type: ignore
966-
self.id = id_in_db or derive_keyset_id(self.public_keys) # type: ignore
984+
985+
# KEYSETS V2: Use new keyset ID derivation
986+
if id_in_db:
987+
# If loading from DB, preserve existing ID
988+
self.id = id_in_db
989+
else:
990+
assert self.public_keys is not None
991+
self.id = derive_keyset_id_v2(self.public_keys, self.unit, self.final_expiry)
992+
logger.info(f"Generated keyset v2 ID: {self.id}")
967993

968994

969995
# ------- TOKEN -------

cashu/core/crypto/keys.py

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import base64
22
import hashlib
33
import random
4-
from typing import Dict, List
4+
from typing import TYPE_CHECKING, Dict, List, Optional
55

66
from bip32 import BIP32
77

88
from .secp import PrivateKey, PublicKey
99

10+
if TYPE_CHECKING:
11+
from ..base import Unit
12+
1013

1114
def derive_keys(mnemonic: str, derivation_path: str, amounts: List[int]):
1215
"""
@@ -54,13 +57,121 @@ def derive_pubkeys(keys: Dict[int, PrivateKey], amounts: List[int]):
5457

5558

5659
def derive_keyset_id(keys: Dict[int, PublicKey]):
57-
"""Deterministic derivation keyset_id from set of public keys."""
60+
"""Deterministic derivation keyset_id from set of public keys (version 00)."""
5861
# sort public keys by amount
5962
sorted_keys = dict(sorted(keys.items()))
6063
pubkeys_concat = b"".join([p.serialize() for _, p in sorted_keys.items()])
6164
return f"00{hashlib.sha256(pubkeys_concat).hexdigest()[:14]}"
6265

6366

67+
def derive_keyset_id_v2(
68+
keys: Dict[int, PublicKey],
69+
unit: "Unit",
70+
final_expiry: Optional[int] = None
71+
) -> str:
72+
"""
73+
Deterministic derivation keyset_id v2 from set of public keys (version 01).
74+
75+
Args:
76+
keys: Dictionary mapping amounts to public keys
77+
unit: The unit of the keyset (e.g., Unit.sat, Unit.usd)
78+
final_expiry: Optional unix epoch timestamp for keyset expiration
79+
80+
Returns:
81+
Full 33-byte keyset ID (version byte + 32-byte hash) as hex string
82+
"""
83+
# sort public keys by amount in ascending order
84+
sorted_keys = dict(sorted(keys.items()))
85+
86+
# concatenate all public keys to one byte array with fixed separator between keys
87+
keyset_id_bytes = b"".join([p.serialize() for p in sorted_keys.values()])
88+
89+
# add the lowercase unit string to the byte array (no separator necessary since we hash)
90+
keyset_id_bytes += f"unit:{unit.name}".encode("utf-8")
91+
92+
# only include final_expiry if provided (per spec discussion)
93+
if final_expiry is not None:
94+
keyset_id_bytes += f"final_expiry:{final_expiry}".encode("utf-8")
95+
96+
# SHA256 hash the concatenated byte array
97+
hash_digest = hashlib.sha256(keyset_id_bytes).hexdigest()
98+
99+
# prefix with version byte 01
100+
return f"01{hash_digest}"
101+
102+
103+
def derive_keyset_short_id(keyset_id: str) -> str:
104+
"""
105+
Derive the short keyset ID (8 bytes) from a full keyset ID.
106+
107+
Args:
108+
keyset_id: Full keyset ID (either version 00 or 01)
109+
110+
Returns:
111+
Short keyset ID (version byte + first 7 bytes of hash)
112+
"""
113+
# For version 00, keep existing behavior (already short)
114+
if keyset_id.startswith("00"):
115+
return keyset_id
116+
117+
# For version 01, return first 16 chars (8 bytes in hex)
118+
if keyset_id.startswith("01"):
119+
return keyset_id[:16]
120+
121+
raise ValueError(f"Unsupported keyset version in ID: {keyset_id}")
122+
123+
124+
def is_base64_keyset_id(keyset_id: str) -> bool:
125+
"""
126+
Check if a keyset ID is a legacy base64 format (pre-0.15.0).
127+
128+
Base64 keyset IDs:
129+
- Don't start with "00" or "01" version prefix
130+
- Are typically 12 characters long
131+
- Are valid base64 strings
132+
133+
Args:
134+
keyset_id: The keyset ID to check
135+
136+
Returns:
137+
True if the keyset ID is base64 format, False otherwise
138+
"""
139+
# If it starts with a known version prefix, it's not base64
140+
if keyset_id.startswith("00") or keyset_id.startswith("01"):
141+
return False
142+
143+
# Try to decode as base64 to confirm
144+
try:
145+
base64.b64decode(keyset_id)
146+
return True
147+
except Exception:
148+
return False
149+
150+
151+
def get_keyset_id_version(keyset_id: str) -> str:
152+
"""
153+
Extract the version from a keyset ID.
154+
155+
Returns:
156+
- "00" for version 0 (hex keyset IDs with 00 prefix)
157+
- "01" for version 1 (v2 keyset IDs with 01 prefix)
158+
- "base64" for legacy base64 keyset IDs (pre-0.15.0)
159+
"""
160+
if len(keyset_id) < 2:
161+
raise ValueError("Invalid keyset ID: too short")
162+
163+
# Check if it's a legacy base64 keyset ID
164+
if is_base64_keyset_id(keyset_id):
165+
return "base64"
166+
167+
return keyset_id[:2]
168+
169+
170+
def is_keyset_id_v2(keyset_id: str) -> bool:
171+
"""Check if a keyset ID is version 2 (starts with '01')."""
172+
return keyset_id.startswith("01")
173+
174+
64175
def derive_keyset_id_deprecated(keys: Dict[int, PublicKey]):
65176
"""DEPRECATED 0.15.0: Deterministic derivation keyset_id from set of public keys.
66177
DEPRECATION: This method produces base64 keyset ids. Use `derive_keyset_id` instead.

cashu/core/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ class KeysResponseKeyset(BaseModel):
104104
id: str
105105
unit: str
106106
keys: Dict[int, str]
107+
final_expiry: Optional[int] = None
107108

108109

109110
class KeysResponse(BaseModel):
@@ -115,6 +116,7 @@ class KeysetsResponseKeyset(BaseModel):
115116
unit: str
116117
active: bool
117118
input_fee_ppk: Optional[int] = None
119+
final_expiry: Optional[int] = None
118120

119121

120122
class KeysetsResponse(BaseModel):

cashu/core/settings.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
env = Env()
1010

11-
VERSION = "0.17.0"
11+
VERSION = "0.18.0"
1212

1313

1414
def find_env_file():
@@ -91,7 +91,6 @@ class MintWatchdogSettings(MintSettings):
9191
class MintDeprecationFlags(MintSettings):
9292
mint_inactivate_base64_keysets: bool = Field(default=False)
9393

94-
9594
class MintBackends(MintSettings):
9695
mint_lightning_backend: str = Field(default="") # deprecated
9796
mint_backend_bolt11_sat: str = Field(default="")

cashu/mint/crud.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -688,8 +688,8 @@ async def store_keyset(
688688
await (conn or db).execute(
689689
f"""
690690
INSERT INTO {db.table_with_schema('keysets')}
691-
(id, seed, encrypted_seed, seed_encryption_method, derivation_path, valid_from, valid_to, first_seen, active, version, unit, input_fee_ppk, amounts, balance)
692-
VALUES (:id, :seed, :encrypted_seed, :seed_encryption_method, :derivation_path, :valid_from, :valid_to, :first_seen, :active, :version, :unit, :input_fee_ppk, :amounts, :balance)
691+
(id, seed, encrypted_seed, seed_encryption_method, derivation_path, valid_from, valid_to, first_seen, active, version, unit, input_fee_ppk, amounts, balance, final_expiry)
692+
VALUES (:id, :seed, :encrypted_seed, :seed_encryption_method, :derivation_path, :valid_from, :valid_to, :first_seen, :active, :version, :unit, :input_fee_ppk, :amounts, :balance, :final_expiry)
693693
""",
694694
{
695695
"id": keyset.id,
@@ -710,6 +710,7 @@ async def store_keyset(
710710
"input_fee_ppk": keyset.input_fee_ppk,
711711
"amounts": json.dumps(keyset.amounts),
712712
"balance": keyset.balance,
713+
"final_expiry": keyset.final_expiry, # NEW: Store final expiry
713714
},
714715
)
715716

@@ -822,7 +823,7 @@ async def update_keyset(
822823
await (conn or db).execute(
823824
f"""
824825
UPDATE {db.table_with_schema('keysets')}
825-
SET seed = :seed, encrypted_seed = :encrypted_seed, seed_encryption_method = :seed_encryption_method, derivation_path = :derivation_path, valid_from = :valid_from, valid_to = :valid_to, first_seen = :first_seen, active = :active, version = :version, unit = :unit, input_fee_ppk = :input_fee_ppk
826+
SET seed = :seed, encrypted_seed = :encrypted_seed, seed_encryption_method = :seed_encryption_method, derivation_path = :derivation_path, valid_from = :valid_from, valid_to = :valid_to, first_seen = :first_seen, active = :active, version = :version, unit = :unit, input_fee_ppk = :input_fee_ppk, final_expiry = :final_expiry
826827
WHERE id = :id
827828
""",
828829
{
@@ -843,6 +844,7 @@ async def update_keyset(
843844
"unit": keyset.unit.name,
844845
"input_fee_ppk": keyset.input_fee_ppk,
845846
"balance": keyset.balance,
847+
"final_expiry": keyset.final_expiry, # NEW: Update final expiry
846848
},
847849
)
848850

cashu/mint/keysets.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ async def rotate_next_keyset(
9797
amounts=amounts,
9898
input_fee_ppk=input_fee_ppk,
9999
active=True,
100+
final_expiry=None
100101
)
101102

102103
logger.debug(f"New keyset was generated with Id {new_keyset.id}. Saving...")
@@ -166,6 +167,7 @@ async def activate_keyset(
166167
amounts=self.amounts,
167168
version=version,
168169
input_fee_ppk=settings.mint_input_fee_ppk,
170+
final_expiry=None,
169171
)
170172
logger.debug(f"Generated new keyset with ID '{keyset.id}'.")
171173

cashu/mint/migrations.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -968,3 +968,16 @@ async def m027_add_balance_to_keysets_and_log_table(db: Database):
968968
);
969969
"""
970970
)
971+
972+
973+
async def m028_add_final_expiry_to_keysets(db: Database):
974+
"""
975+
Add final_expiry column to keysets table for keysets v2 support.
976+
"""
977+
async with db.connect() as conn:
978+
await conn.execute(
979+
f"""
980+
ALTER TABLE {db.table_with_schema('keysets')}
981+
ADD COLUMN final_expiry INTEGER NULL
982+
"""
983+
)

cashu/mint/router.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ async def keys():
8181
id=keyset.id,
8282
unit=keyset.unit.name,
8383
keys={k: v for k, v in keyset.public_keys_hex.items()},
84+
final_expiry=keyset.final_expiry, # NEW: Include final expiry to align with NUT-02 PR #182
8485
)
8586
)
8687
return KeysResponse(keysets=keyset_for_response)
@@ -117,6 +118,7 @@ async def keyset_keys(keyset_id: str) -> KeysResponse:
117118
id=keyset.id,
118119
unit=keyset.unit.name,
119120
keys={k: v for k, v in keyset.public_keys_hex.items()},
121+
final_expiry=keyset.final_expiry,
120122
)
121123
return KeysResponse(keysets=[keyset_for_response])
122124

@@ -139,6 +141,7 @@ async def keysets() -> KeysetsResponse:
139141
unit=keyset.unit.name,
140142
active=keyset.active,
141143
input_fee_ppk=keyset.input_fee_ppk,
144+
final_expiry=keyset.final_expiry,
142145
)
143146
)
144147
return KeysetsResponse(keysets=keysets)

cashu/wallet/helpers.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,14 @@ async def redeem_TokenV3(wallet: Wallet, token: TokenV3) -> Wallet:
4242
"""
4343
if not token.unit:
4444
# load unit from wallet keyset db
45-
keysets = await get_keysets(id=token.token[0].proofs[0].id, db=wallet.db)
45+
# Note: if the keyset ID is a v2 short ID, we might not find it in the db
46+
# In that case, we'll just skip setting the unit here and let it be set later
47+
proof_keyset_id = token.token[0].proofs[0].id
48+
keysets = await get_keysets(id=proof_keyset_id, db=wallet.db)
49+
if not keysets and proof_keyset_id.startswith("01") and len(proof_keyset_id) == 16:
50+
# This might be a v2 short ID, try to find a matching full ID
51+
all_keysets = await get_keysets(db=wallet.db)
52+
keysets = [k for k in all_keysets if k.id.startswith(proof_keyset_id)]
4653
if keysets:
4754
token.unit = keysets[0].unit.name
4855

@@ -58,6 +65,11 @@ async def redeem_TokenV3(wallet: Wallet, token: TokenV3) -> Wallet:
5865
keyset_ids = mint_wallet._get_proofs_keyset_ids(t.proofs)
5966
logger.trace(f"Keysets in tokens: {' '.join(set(keyset_ids))}")
6067
await mint_wallet.load_mint()
68+
69+
# Expand v2 short keyset IDs to full IDs
70+
# V2 short IDs are 16 chars (version '01' + 7 bytes), full IDs are 66 chars
71+
await mint_wallet._expand_short_keyset_ids(t.proofs)
72+
6173
proofs_to_keep, _ = await mint_wallet.redeem(t.proofs)
6274
print(f"Received {mint_wallet.unit.str(sum_proofs(proofs_to_keep))}")
6375

@@ -70,7 +82,15 @@ async def redeem_TokenV4(wallet: Wallet, token: TokenV4) -> Wallet:
7082
Redeem a token with a single mint.
7183
"""
7284
await wallet.load_mint()
73-
proofs_to_keep, _ = await wallet.redeem(token.proofs)
85+
86+
# Get proofs from token (these will have short keyset IDs)
87+
proofs = token.proofs
88+
89+
# Expand v2 short keyset IDs to full IDs in-place
90+
await wallet._expand_short_keyset_ids(proofs)
91+
92+
# Use the expanded proofs for redemption
93+
proofs_to_keep, _ = await wallet.redeem(proofs)
7494
print(f"Received {wallet.unit.str(sum_proofs(proofs_to_keep))}")
7595
return wallet
7696

0 commit comments

Comments
 (0)