Skip to content

Conversation

@d4rp4t
Copy link
Collaborator

@d4rp4t d4rp4t commented Jan 9, 2026

cashubtc/nuts#182

Summary by CodeRabbit

  • New Features

    • Keyset ID derivation accepts an optional input-fee field for more precise IDs.
  • Tests

    • Test vectors updated across NUT01/NUT02/NUT13/NUT14 to reflect new derivations and versions.
  • Refactor

    • Token encode/decode and short-ID mapping improved for robustness.
    • Derivation counters normalized to unsigned integers for consistent behavior.
  • Deprecated

    • Older v1 keyset-ID constructor marked obsolete.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 9, 2026

📝 Walkthrough

Walkthrough

Adds an optional inputFee/inputFeePpk parameter into Keyset ID derivation and verification (v0x01 preimage), updates CashuTokenHelper to map short IDs using KeysetId objects, changes NUT13 counter parameters from signed to unsigned, and updates tests and demo to match revised vectors and APIs.

Changes

Cohort / File(s) Summary
Tests
DotNut.Tests/UnitTest1.cs
Added ulong? inputFee parameter to Nut02Tests_KeysetIdMatch and updated test vectors across Nut01/Nut02/Nut13/Nut14 suites.
Keyset ID Derivation
DotNut/Keyset.cs
Extended GetKeysetId and VerifyKeysetId to accept optional ulong? inputFeePpk; v0x01 preimage now builds "amount:pubkey_hex" list and appends `
KeysetId Type
DotNut/KeysetId.cs
Marked KeysetId(Keyset keyset) constructor [Obsolete] (v1-only); removed unused using and reformatted validation.
Token Encoding/Decoding
DotNut/Encoding/CashuTokenHelper.cs
Switched decode mapping to List<KeysetId>? keysetIds; renamed MaybeShortIdMaybeShortenId; refactored MapShortKeysetIds to validate/map against KeysetId collection and throw when short IDs exist but keysetIds is null.
NUT13 Derivation
DotNut/NUT13/Nut13.cs
Changed counter parameters from int to uint for derivation APIs and adjusted internal counter handling to use unsigned/ulong where needed.
Demo
DotNut.Demo/Program.cs
Changed demo loop counter type from int to uint to match updated NUT13 API.

Sequence Diagram(s)

(omitted)

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • Kukks

Poem

🐰 I twitched my whiskers, found a fee,
I wove it into preimage glee,
Pipes and colons, bytes that bind,
Tests now hop with IDs aligned,
Secrets sprout — a carrot for me! 🥕

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'KeysetId V2 once again' vaguely references KeysetId V2 but lacks specificity about what was actually changed or implemented regarding this feature. Consider a more descriptive title such as 'Implement KeysetId V2 with inputFeePpk support' or 'Add inputFee parameter to KeysetId derivation' to clearly indicate the main changes being introduced.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In @DotNut/NUT13/Nut13.cs:
- Around line 51-57: The counter parameter must be unsigned to prevent negative
indices from wrapping when cast to ulong; change the parameter type from int to
uint in DeriveHmac and propagate that change through its callers
DeriveBlindingFactor and DeriveSecret (and any other call sites) so the
derivation index is enforced as non-negative at compile time, update all
signatures and call-site arguments accordingly, and ensure
BitConverter.GetBytes((ulong)counter) still receives the uint value.
🧹 Nitpick comments (1)
DotNut/Keyset.cs (1)

42-70: Consider using a more efficient byte array builder.

The repeated .Concat(...).ToArray() calls (lines 55, 62, 68-69) allocate intermediate arrays. For better efficiency, consider using a List<byte> or MemoryStream.

♻️ Optional refactor using List
 case 0x01:
 {
-    var sortedBytes = Encoding.UTF8.GetBytes(
+    var sortedBytes = new List<byte>(Encoding.UTF8.GetBytes(
         string.Join(
             ",",
             sortedKeys
             .Select(pair => $"{pair.Key}:{pair.Value.ToString().ToLowerInvariant()}")
         )
-    );
+    ));
     
     if (String.IsNullOrWhiteSpace(unit))
     { 
         throw new ArgumentNullException( nameof(unit), $"Unit parameter is required with version: {version}");
     }
-    sortedBytes = sortedBytes.Concat(Encoding.UTF8.GetBytes($"|unit:{unit.Trim().ToLowerInvariant()}")).ToArray();
+    sortedBytes.AddRange(Encoding.UTF8.GetBytes($"|unit:{unit.Trim().ToLowerInvariant()}"));
 
     if (inputFeePpk.HasValue && inputFeePpk.Value != 0)
     {
-        sortedBytes = sortedBytes.Concat(Encoding.UTF8.GetBytes($"|input_fee_ppk:{inputFeePpk.Value}")).ToArray();
+        sortedBytes.AddRange(Encoding.UTF8.GetBytes($"|input_fee_ppk:{inputFeePpk.Value}"));
     }
     
     if (!string.IsNullOrWhiteSpace(finalExpiration))
     {
-        sortedBytes = sortedBytes.Concat(Encoding.UTF8.GetBytes($"|final_expiry:{finalExpiration.Trim()}"))
-            .ToArray();
+        sortedBytes.AddRange(Encoding.UTF8.GetBytes($"|final_expiry:{finalExpiration.Trim()}"));
     }
     
-    var hash = sha256.ComputeHash(sortedBytes);
+    var hash = sha256.ComputeHash(sortedBytes.ToArray());
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5a25452 and 3580d17.

📒 Files selected for processing (5)
  • DotNut.Tests/UnitTest1.cs
  • DotNut/Encoding/CashuTokenHelper.cs
  • DotNut/Keyset.cs
  • DotNut/KeysetId.cs
  • DotNut/NUT13/Nut13.cs
🧰 Additional context used
🧬 Code graph analysis (4)
DotNut/NUT13/Nut13.cs (1)
DotNut/KeysetId.cs (1)
  • GetBytes (81-84)
DotNut/Keyset.cs (1)
DotNut/KeysetId.cs (4)
  • KeysetId (52-62)
  • GetBytes (81-84)
  • ToString (70-73)
  • GetVersion (75-79)
DotNut/Encoding/CashuTokenHelper.cs (4)
DotNut/Keyset.cs (1)
  • KeysetId (13-83)
DotNut/KeysetId.cs (3)
  • KeysetId (52-62)
  • GetVersion (75-79)
  • ToString (70-73)
DotNut/JsonConverters/KeysetIdJsonConverter.cs (1)
  • KeysetId (8-23)
DotNut/Proof.cs (1)
  • Proof (7-31)
DotNut.Tests/UnitTest1.cs (5)
DotNut/Encoding/CashuTokenHelper.cs (1)
  • KeysetId (86-91)
DotNut/Keyset.cs (1)
  • KeysetId (13-83)
DotNut/KeysetId.cs (1)
  • KeysetId (52-62)
DotNut/JsonConverters/KeysetJsonConverter.cs (1)
  • Keyset (8-54)
DotNut/NUT13/Nut13.cs (2)
  • DeriveBlindingFactor (10-11)
  • DeriveBlindingFactor (18-32)
🔇 Additional comments (9)
DotNut/KeysetId.cs (2)

64-68: LGTM - Appropriate deprecation for v2 compatibility.

The obsolete attribute correctly warns users that this constructor only supports v1 keyset ID derivation. The internal call to GetKeysetId() uses default parameters (version=0x00), which aligns with the warning message.


54-57: Improved readability with inline comments.

The reformatted validation with explanatory comments for each valid length (66, 16, 12) enhances code clarity without changing behavior.

DotNut/Keyset.cs (1)

85-93: LGTM - Short ID verification logic is correct.

The VerifyKeysetId method correctly handles both full-length and shortened keyset IDs:

  • If the presented ID is longer than derived, it fails (line 90)
  • Otherwise, it accepts exact matches or prefix matches (lines 91-92)

This allows v2 short IDs (16 chars) to verify against full-length derived IDs (66 chars).

DotNut.Tests/UnitTest1.cs (3)

203-208: LGTM - Test method signature updated correctly.

The Nut02Tests_KeysetIdMatch method now accepts the optional inputFee parameter and correctly passes it to GetKeysetId.


508-526: HMAC-KDF implementation for v0x01 keyset IDs is structurally correct, but test vector values cannot be independently verified.

The DeriveHmac method correctly implements HMAC-SHA256-based KDF with the message format "Cashu_KDF_HMAC_SHA256" || keysetId_bytes || counter (big-endian) || flag, and the test properly exercises the derivation for both secrets and blinding factors across counters 0-4. However, the specific expected test vector values lack verification against public Cashu NUT-13 documentation, which currently specifies only BIP32-based derivation (v0x00). These values should be validated against the official Cashu reference implementation or a formal specification update for v0x01 HMAC derivation.


184-202: Test vectors correctly cover v2 keyset ID derivation scenarios.

The test data properly validates the preimage construction rules:

  • Line 184-190: inputFee=100 (included when non-zero)
  • Line 191-197: inputFee=0 (omitted per spec requirement at line 59-60 of Keyset.cs)
  • Line 198-202: no inputFee parameter (omitted)

The implementation in Keyset.cs (lines 60-63) confirms the behavior: |input_fee_ppk is only added to the preimage when the value is non-zero. The test vectors align with this specification.

DotNut/Encoding/CashuTokenHelper.cs (3)

50-50: Good API change: Decode now accepts List<KeysetId> instead of List<Keyset>.

This reduces coupling - the caller only needs to provide keyset IDs for short ID mapping, not full keyset objects. This is a cleaner interface.


93-103: LGTM - Short keyset ID mapping logic is correct.

The condition on line 95 correctly identifies when mapping is needed:

  • Returns early if all proofs are either non-v0x01 OR have full-length IDs
  • Only proceeds to mapping when short v0x01 IDs (16 chars) exist

The null check with descriptive exception message (lines 99-103) provides clear feedback when keysets are required but not provided.


105-125: Clean proof mapping implementation.

The updated logic correctly:

  • Skips non-v0x01 proofs (line 107-108)
  • Matches short IDs using case-insensitive prefix matching (line 111)
  • Creates new Proof objects with the resolved full KeysetId (line 123)

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In @DotNut/NUT13/Nut13.cs:
- Around line 9-17: You changed counter parameters from int to uint on
DeriveBlindingFactor and DeriveSecret (e.g., the extension overloads that call
DeriveBlindingFactor(mnemonic.DeriveSeed(), keysetId, counter)), which is a
breaking API for callers using int loop variables; update all call sites
(notably the Demo loop that passes i) to use a uint counter or an explicit cast
to (uint)i when calling these methods, or change the loop variable to uint, so
the types match and intent is clear.
- Around line 68-71: GetNut13DerivationPath currently doesn't constrain the uint
counter to the BIP32 hardened index range; update it to mirror GetKeysetIdInt's
modulo constraint so counter is reduced to the 0..2^31-1 range before building
the path. Compute a mod value = (long)Math.Pow(2,31)-1 (or 1<<31 - 1), derive
constrainedCounter = (long)counter % mod, and use constrainedCounter in the
KeyPath.Parse string interpolation instead of the raw counter so hardened
derivation indices remain valid.
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3580d17 and 4b52cb7.

📒 Files selected for processing (1)
  • DotNut/NUT13/Nut13.cs
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-11-08T01:49:27.299Z
Learnt from: d4rp4t
Repo: Kukks/DotNut PR: 21
File: DotNut/P2PkBuilder.cs:100-107
Timestamp: 2025-11-08T01:49:27.299Z
Learning: In the DotNut codebase, the PrivKey class (DotNut/PrivKey.cs) defines an implicit operator that converts PrivKey to ECPrivKey by returning privKey.Key. This allows PrivKey instances to be passed directly to methods expecting ECPrivKey parameters without explicit conversion.

Applied to files:

  • DotNut/NUT13/Nut13.cs
🔇 Additional comments (1)
DotNut/NUT13/Nut13.cs (1)

50-65: LGTM!

The ulong cast at line 52 correctly produces an 8-byte counter buffer for the HMAC message, and the endianness handling is appropriate.

Comment on lines +68 to 71
public static KeyPath GetNut13DerivationPath(KeysetId keysetId, uint counter, bool secretOrr)
{
return (KeyPath) KeyPath.Parse($"m/{Purpose}/0'/{GetKeysetIdInt(keysetId)}'/{counter}'/{(secretOrr?0:1)}")!;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find the file and examine the context around the GetNut13DerivationPath method
find . -name "Nut13.cs" -type f

Repository: Kukks/DotNut

Length of output: 79


🏁 Script executed:

# Search for NBip32Fast imports and usage
rg -i "nbip32fast|keypath" --type cs -A 2 -B 2

Repository: Kukks/DotNut

Length of output: 1741


🏁 Script executed:

# Look for test files related to Nut13
find . -name "*Nut13*Test*" -o -name "*test*Nut13*" | head -20

Repository: Kukks/DotNut

Length of output: 38


🌐 Web query:

BIP32 specification hardened derivation index range limits

💡 Result:

Hardened child indexes are the 32-bit values from 2^31 through 2^32−1 (0x80000000 … 0xFFFFFFFF). They’re commonly written as i' (meaning i + 0x80000000). [1][2]

Sources:

  • BIP32 specification (ranges and hardened-index notation). [1]
  • HD-wallet documentation (explanatory summary of 0…2^31−1 for normal, 2^31…2^32−1 for hardened). [2]

🌐 Web query:

NBip32Fast C# library hardened derivation index validation

💡 Result:

Short answer: NBip32Fast follows standard BIP32 rules — a hardened child index is any index with the MSB set (i ≥ 2^31, i.e. 0x80000000). Produce/validate hardened indexes by adding/setting 2^31 (index | 0x80000000) and ensure non-hardened indexes are in 0..2^31-1 before use. NBip32Fast examples use KeyPathElement.Hard(i) / Harden-style helpers when deriving hardened children. (nuget.org)

Checklist you can use in code:

  • Require raw (unhardened) index in range [0, 2^31-1] for user inputs.
  • Hardened index = index | 0x80000000 (or index + 2^31).
  • When deriving with NBip32Fast, use the library’s Hard helper / KeyPathElement.Hard(i) or its HardenIndex helper to build the element. (nuget.org)

If you want, I can show a tiny C# snippet that validates an input index and produces the NBip32Fast hardened KeyPathElement.

Citations:


🏁 Script executed:

# Read the Nut13.cs file to see full context and check for validation
cat -n ./DotNut/NUT13/Nut13.cs | head -100

Repository: Kukks/DotNut

Length of output: 3524


🏁 Script executed:

# Check if KeyPath is defined in NBip32Fast or if it's custom
rg "class KeyPath|struct KeyPath" --type cs -A 5

Repository: Kukks/DotNut

Length of output: 38


🏁 Script executed:

# Search for test cases of GetNut13DerivationPath
rg "GetNut13DerivationPath" --type cs -B 2 -A 5

Repository: Kukks/DotNut

Length of output: 2485


🏁 Script executed:

# Check if counter values are validated anywhere before calling GetNut13DerivationPath
rg "counter" --type cs -B 2 -A 2 | head -80

Repository: Kukks/DotNut

Length of output: 4616


Add validation to ensure counter stays within BIP32 hardened index range.

GetKeysetIdInt already constrains the keysetId to [0, 2^31-1] using modulo, but counter lacks this validation. Since hardened BIP32 derivation requires indices < 2^31, a uint counter exceeding 2,147,483,647 would violate the spec. Apply the same modulo constraint to counter:

public static KeyPath GetNut13DerivationPath(KeysetId keysetId, uint counter, bool secretOrr)
{ 
    var mod = (long)Math.Pow(2, 31) - 1;
    var constrainedCounter = (long)counter % mod;
    return (KeyPath)KeyPath.Parse($"m/{Purpose}/0'/{GetKeysetIdInt(keysetId)}'/{constrainedCounter}'/{(secretOrr?0:1)}")!;
}
🤖 Prompt for AI Agents
In @DotNut/NUT13/Nut13.cs around lines 68 - 71, GetNut13DerivationPath currently
doesn't constrain the uint counter to the BIP32 hardened index range; update it
to mirror GetKeysetIdInt's modulo constraint so counter is reduced to the
0..2^31-1 range before building the path. Compute a mod value =
(long)Math.Pow(2,31)-1 (or 1<<31 - 1), derive constrainedCounter = (long)counter
% mod, and use constrainedCounter in the KeyPath.Parse string interpolation
instead of the raw counter so hardened derivation indices remain valid.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
DotNut/Keyset.cs (1)

36-83: Add using statement for MemoryStream disposal.

The MemoryStream should be properly disposed to follow best practices, even though it doesn't hold unmanaged resources.

♻️ Recommended refactor
         case 0x01:
         {
-            MemoryStream stream = new MemoryStream();
+            using MemoryStream stream = new MemoryStream();
             
             // 2 - concatenate each amount and its corresponding public key hex string (as "amount:publickey_hex")

Note: The preimage construction logic correctly implements the v0x01 specification, including proper handling of the optional inputFeePpk parameter (omitted when null or zero).

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4b52cb7 and 20c727d.

📒 Files selected for processing (3)
  • DotNut.Demo/Program.cs
  • DotNut.Tests/UnitTest1.cs
  • DotNut/Keyset.cs
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-11-29T15:42:55.222Z
Learnt from: d4rp4t
Repo: Kukks/DotNut PR: 23
File: DotNut/NUT11/SigAllHandler.cs:19-77
Timestamp: 2025-11-29T15:42:55.222Z
Learning: In DotNut, HTLCProofSecret inherits from P2PKProofSecret, forming a base-derived relationship where HTLC secrets are a specialized form of P2PK secrets. Type checks using `is P2PKProofSecret` will match both P2PK and HTLC secrets due to this inheritance.

Applied to files:

  • DotNut.Tests/UnitTest1.cs
🧬 Code graph analysis (1)
DotNut/Keyset.cs (2)
DotNut/KeysetId.cs (4)
  • KeysetId (52-62)
  • GetBytes (81-84)
  • ToString (70-73)
  • GetVersion (75-79)
DotNut/PubKey.cs (1)
  • ToString (26-29)
🔇 Additional comments (8)
DotNut.Demo/Program.cs (1)

439-439: LGTM! Counter type updated to align with NUT13 API.

The loop variable type change from int to uint correctly matches the updated NUT13 derivation methods that now expect unsigned counter parameters.

DotNut/Keyset.cs (3)

1-1: LGTM! API extended to support input fee in keyset derivation.

The addition of the optional inputFeePpk parameter maintains backward compatibility while enabling v0x01 keysets to include input fee information in the preimage.

Also applies to: 11-11


15-33: LGTM! Version 0x00 path refactored without semantic changes.

The refactoring maintains the original keyset ID derivation logic: sorting keys by amount, concatenating public key bytes, and hashing. The inputFeePpk parameter is correctly ignored for version 0x00.


90-98: LGTM! Verification logic correctly updated.

The VerifyKeysetId method properly threads the inputFeePpk parameter through to GetKeysetId and correctly handles both full and short keyset ID comparisons.

DotNut.Tests/UnitTest1.cs (4)

178-202: LGTM! Test coverage expanded for v2 keyset ID derivation.

The test data now includes v2 keyset scenarios with various combinations of inputFee and finalExpiration parameters, properly exercising the new API surface.


203-208: LGTM! Test method updated to match new API.

The test signature and implementation correctly thread the inputFee parameter through to the GetKeysetId call, validating the extended keyset derivation logic.


406-406: HTLC test vector updated.

The HTLC secret test data has been updated. Ensure this aligns with the latest test vectors from the Cashu specification.


510-526: LGTM! NUT13 test vectors updated for v2 keyset.

The test now uses a v2 keyset ID (matching the test case at lines 184-190 with inputFee: 100UL and finalExpiration: "2059210353"), and all derived secrets and blinding factors have been updated accordingly. This validates that deterministic derivation works correctly with the new keyset ID format.

@d4rp4t d4rp4t merged commit fd2c43a into Kukks:master Jan 11, 2026
1 check passed
@d4rp4t d4rp4t deleted the keyset-id-v2-v2 branch January 11, 2026 15:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant