Skip to content

NUT-20/29: length-prefixed quote signature message - #38

Open
d4rp4t wants to merge 4 commits into
Kukks:masterfrom
d4rp4t:fix/nut20-message-aggregation
Open

NUT-20/29: length-prefixed quote signature message#38
d4rp4t wants to merge 4 commits into
Kukks:masterfrom
d4rp4t:fix/nut20-message-aggregation

Conversation

@d4rp4t

@d4rp4t d4rp4t commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Aligns the mint quote signature with cashubtc/nuts#b969c4aa, which replaced the concatenated quote id and hex outputs with a domain-separated, length-prefixed commitment over raw bytes:

msg_to_sign = b"Cashu_MintQuoteSig_v1"
              || len32(quote) || quote
              || for each output i: len32(amount_i) || amount_i || len32(B_i) || B_i

Amounts are canonical minimal big-endian (0 → empty, 2560x0100) and B_ is the decoded point, not its hex string. The old test vector was signed over the old message, so it is replaced with the one from tests/20-test.md; the test asserts msg_to_sign and its hash directly, so a mistake in the length prefixes shows up on its own rather than as a failed signature.

Legacy fallback

No released cdk-mintd understands the new message. The change is on main (56fb4d7b) but in no tag up to v0.17.3, so signing only the new way makes NUT-20 minting fail against every mint deployed today — including the one CI starts.

So the handlers sign the current message first and, when the mint answers error 20008, sign the same outputs the old way and retry once. Nothing is issued on a rejected signature, so the retry cannot double-spend. Verification accepts either, with allowLegacy: false to refuse.

This mirrors what the reference implementations did: cdk added sign_legacy for wallets plus a legacy branch in verify_signature, and nutshell has verified both since 0.20.2. Removable once mints without the current format are gone.

Verified against live mints:

mint result
cdk-mintd 0.15.1 (what CI runs, latest-amd64) passes
cdk-mintd 0.17.3 (latest release) passes

NUT-29

NUT-29 defines no message of its own — signatures[i] is a NUT-20 signature over quotes[i] and the batch's whole outputs array, since the outputs are one consolidated set rather than partitioned per quote. The models were already there but nothing filled Signatures, so this adds PostBatchedMintRequest.Sign(keysByQuoteId): one entry per quote, null for unlocked ones, field omitted when nothing is locked. Covered by the vector from tests/29-tests.md.

Also

Error codes moved off MintQuoteSigner into CashuErrorCodes, carrying the full table from error_codes.md — including 12003 and NUT-29's 11016/11017, which upstream added recently.


Unit tests: 126 passing.

d4rp4t added 4 commits August 2, 2026 20:33
cashubtc/nuts@b969c4aa replaced the concatenated quote id and hex outputs
with a domain-separated, length-prefixed commitment over raw bytes:

  "Cashu_MintQuoteSig_v1" || len32(quote) || quote
    || (len32(amount) || amount || len32(B_) || B_) per output

Amounts are canonical minimal big-endian, so zero is empty, and B_ is the
decoded point rather than its hex string. The old vector in the tests was
signed over the old message and is replaced by the one from
tests/20-test.md, asserting msg_to_sign and its hash directly so a mistake
in the length prefixes is visible on its own.

This is breaking against mints still on the old message: cdk-mintd 0.15.1
rejects these signatures, and NUT-20 advertises no version to negotiate on.
No released cdk-mintd understands the domain-separated message yet — it is
on main but in no tag up to v0.17.3 — so signing only the current way makes
NUT-20 minting fail against every mint deployed today, including the one CI
starts.

Mints sign the current message first and, when the mint answers 20008,
sign the same outputs again the old way and retry once. Nothing is issued
on a rejected signature, so the retry cannot double-spend. Verification
accepts either, with allowLegacy to refuse. This mirrors cdk, which added
sign_legacy for wallets and a legacy branch in verify_signature, and
nutshell, which has verified both since 0.20.2.

Removable once mints without the current format are gone; nutshell already
marks its legacy path deprecated.
The signature error code sat on MintQuoteSigner, which is not where callers
would look for it. Lifts it next to CashuProtocolError, along with the rest
of the codes from the spec's error_codes.md.
NUT-29 does not define a message of its own: signatures[i] is a NUT-20
signature over quotes[i] and the batch's whole outputs array, since the
outputs are one consolidated set rather than partitioned per quote. So the
message fix carries over, and what was missing was assembling the array —
one entry per quote, null for the unlocked ones, and the field left out
when nothing is locked.

Covered by the vector from tests/29-tests.md.
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR implements domain-separated NUT-20 mint quote signatures with legacy compatibility, adds batched NUT-29 signing, defines Cashu error codes, and retries mint requests with legacy signatures when required.

Changes

Mint signature flows

Layer / File(s) Summary
NUT-20 signing and compatibility
DotNut/NUT20/MintQuoteSigner.cs, DotNut.Tests/Nut20Tests.cs
Mint quote messages now use domain-separated binary serialization, length prefixes, and minimal big-endian amounts. Signing and verification support the legacy format. Tests cover vectors, ordering, round trips, fallback control, and amount encoding.
Legacy signature retry wiring
DotNut/Abstractions/Handlers/MintHandlerBolt11.cs, DotNut/Abstractions/Handlers/MintHandlerBolt12.cs, DotNut/Api/CashuErrorCodes.cs
Bolt11 and Bolt12 handlers retain signing keys and retry with legacy signatures after MintRequestSignatureInvalid. Cashu protocol error constants are defined.
NUT-29 batch signing
DotNut/NUT29/BatchedMintQuoteSigner.cs, DotNut.Tests/Unit/Nut29Tests.cs
The batch signer signs locked quotes against all outputs, keeps signatures aligned with quote entries, and leaves signatures unset when no quote is locked. Tests validate serialization, signatures, missing keys, and output coverage.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • Kukks/DotNut#31: Adds the NUT-29 batch request and API models used by the batch signer.
  • Kukks/DotNut#32: Introduces the NUT-20 signing and verification functionality extended by this PR.

Suggested reviewers: kukks

Sequence Diagram(s)

sequenceDiagram
  participant MintHandler
  participant MintQuoteSigner
  participant Mint
  MintHandler->>MintQuoteSigner: SignMintQuote
  MintHandler->>Mint: Submit mint request
  Mint-->>MintHandler: MintRequestSignatureInvalid
  MintHandler->>MintQuoteSigner: SignMintQuoteLegacy
  MintHandler->>Mint: Retry mint request
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the NUT-20 and NUT-29 quote signature message changes, which are central to the pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
DotNut/Abstractions/Handlers/MintHandlerBolt11.cs (1)

59-77: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Duplicated retry-with-legacy-signature logic in both handlers; shared safety assumption unverified.

Both handlers implement the identical catch-and-retry pattern for CashuErrorCodes.MintRequestSignatureInvalid, re-signing the same outputs with SignMintQuoteLegacy. The root cause is one piece of logic copy-pasted into two files, and both copies rely on the same unverified external contract: that the mint does not issue promises before rejecting a request for an invalid NUT-20 signature.

  • DotNut/Abstractions/Handlers/MintHandlerBolt11.cs#L59-L77: confirm this ordering guarantee against the NUT-04/NUT-20 spec (or the target mints), then extract the try/catch-and-retry block into a shared helper (for example, a static method taking the mint client, endpoint name, request, and signing key) that both handlers call.
  • DotNut/Abstractions/Handlers/MintHandlerBolt12.cs#L60-L75: replace this near-identical block with a call to the same shared helper once extracted, and rely on the same verification performed for the Bolt11 handler.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@DotNut/Abstractions/Handlers/MintHandlerBolt11.cs` around lines 59 - 77, The
Bolt11 and Bolt12 handlers duplicate legacy-signature retry logic and rely on an
unverified mint ordering guarantee. Verify the NUT-04/NUT-20 or target-mint
guarantee that invalid NUT-20 signatures issue no promises, then extract the
shared try/catch-and-retry behavior into a helper accepting the mint client,
endpoint, request, and signing key; update MintHandlerBolt11.cs lines 59-77 and
MintHandlerBolt12.cs lines 60-75 to call that helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@DotNut/NUT20/MintQuoteSigner.cs`:
- Around line 113-136: Update VerifySignature to catch FormatException from
Convert.FromHexString when parsing quote.Signature, returning false for
malformed odd-length or non-hex input before invoking
SecpSchnorrSignature.TryCreate. Preserve the existing signature verification and
allowLegacy behavior for valid input.

---

Nitpick comments:
In `@DotNut/Abstractions/Handlers/MintHandlerBolt11.cs`:
- Around line 59-77: The Bolt11 and Bolt12 handlers duplicate legacy-signature
retry logic and rely on an unverified mint ordering guarantee. Verify the
NUT-04/NUT-20 or target-mint guarantee that invalid NUT-20 signatures issue no
promises, then extract the shared try/catch-and-retry behavior into a helper
accepting the mint client, endpoint, request, and signing key; update
MintHandlerBolt11.cs lines 59-77 and MintHandlerBolt12.cs lines 60-75 to call
that helper.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0587c09e-3777-4dc0-9bae-d5ae2dff629e

📥 Commits

Reviewing files that changed from the base of the PR and between d6b4d36 and 773ad08.

📒 Files selected for processing (7)
  • DotNut.Tests/Nut20Tests.cs
  • DotNut.Tests/Unit/Nut29Tests.cs
  • DotNut/Abstractions/Handlers/MintHandlerBolt11.cs
  • DotNut/Abstractions/Handlers/MintHandlerBolt12.cs
  • DotNut/Api/CashuErrorCodes.cs
  • DotNut/NUT20/MintQuoteSigner.cs
  • DotNut/NUT29/BatchedMintQuoteSigner.cs

Comment on lines +113 to 136
/// <param name="allowLegacy">
/// Also accept a signature over the superseded message, the way cdk and nutshell do, so
/// that wallets which have not upgraded can still mint.
/// </param>
public static bool VerifySignature(this PostMintRequest quote, PubKey pk, bool allowLegacy = true)
{
ArgumentNullException.ThrowIfNull(quote.Signature, nameof(quote.Signature));
if (!SecpSchnorrSignature.TryCreate(Convert.FromHexString(quote.Signature), out var sig))
{
return false;
}
return xonly.SigVerifyBIP340(sig, hash);

var xonly = pk.Key.ToXOnlyPubKey();
if (xonly.SigVerifyBIP340(sig, SHA256.HashData(GetMessageToSign(quote.Quote, quote.Outputs))))
{
return true;
}

return allowLegacy
&& xonly.SigVerifyBIP340(
sig,
SHA256.HashData(GetLegacyMessageToSign(quote.Quote, quote.Outputs))
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== find file =="
fd -a 'MintQuoteSigner.cs' . || true

echo "== file outline =="
ast-grep outline DotNut/NUT20/MintQuoteSigner.cs --view expanded || true

echo "== relevant lines =="
nl -ba DotNut/NUT20/MintQuoteSigner.cs | sed -n '1,180p'

echo "== project target/framework/package hints =="
find . -maxdepth 3 \( -name '*.csproj' -o -name 'Directory.Build.props' -o -name 'Directory.Packages.props' \) -print | sort | xargs -r sed -n '1,160p'

Repository: Kukks/DotNut

Length of output: 1031


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== relevant lines =="
awk '{printf "%6d\t%s\n", NR, $0}' DotNut/NUT20/MintQuoteSigner.cs | sed -n '1,180p'

echo "== signature-related API docs/usages =="
rg -n "VerifySignature|FromHexString|TryCreate|SigVerifyBIP340|FormatException" DotNut -S

echo "== project sdk/package context =="
find . -maxdepth 3 \( -name '*.csproj' -o -name 'Directory.Build.props' -o -name 'Directory.Packages.props' \) -print | sort | while read -r f; do
  echo "--- $f"
  sed -n '1,160p' "$f"
done

echo "== deterministic probe if SDK present =="
cat > /tmp/FromHexStringProbe.cs <<'CS'
using System;
class P {
  static void Main() {
    foreach (var s in new string[] { "97c6b9...", "97c6b9", "zz", null }) {
      try {
        Convert.FromHexString(s);
        Console.WriteLine(s ?? "null: ok");
      }
      catch (Exception ex) {
        Console.WriteLine(s ?? "null: " + ex.GetType().Name + ": " + ex.Message);
      }
    }
  }
}
CS
dotnet --version 2>/dev/null && mkdir -p /tmp/probe && cp /tmp/FromHexStringProbe.cs /tmp/probe/Probe.csproj <<'EOF'
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
  </PropertyGroup>
</Project>
EOF
cd /tmp/probe && dotnet run --no-restore 2>&1 || true

Repository: Kukks/DotNut

Length of output: 16599


🌐 Web query:

.NET Convert.FromHexString throws FormatException odd length documentation

💡 Result:

In.NET, the Convert.FromHexString method requires the input string (or span) to have a length that is a multiple of two because each byte in a hexadecimal-encoded string is represented by exactly two characters. If you provide an input with an odd length, the method explicitly throws a System.FormatException [1][2][3]. This behavior is documented in the official.NET API reference for all relevant versions (including.NET 8.0, 9.0, and 10.0), which states that a FormatException is thrown if the length of the source input is not zero or a multiple of two [1][2][3]. If you need to process a hexadecimal string that might be of an odd length, you must validate or pad the input before passing it to Convert.FromHexString. A common approach is to check if the length is odd and, if so, prepend a leading '0' (or handle it according to your specific data requirements) before performing the conversion.

Citations:


Return false for malformed quote.Signature hex strings.

Convert.FromHexString(quote.Signature) can throw FormatException for odd-length or non-hex input, so VerifySignature does not return false for malformed signature strings. Catch FormatException around the parse step before calling SecpSchnorrSignature.TryCreate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@DotNut/NUT20/MintQuoteSigner.cs` around lines 113 - 136, Update
VerifySignature to catch FormatException from Convert.FromHexString when parsing
quote.Signature, returning false for malformed odd-length or non-hex input
before invoking SecpSchnorrSignature.TryCreate. Preserve the existing signature
verification and allowLegacy behavior for valid input.

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