Skip to content

Fix: Standardize error codes - #1129

Merged
callebtc merged 6 commits into
cashubtc:mainfrom
KvngMikey:fix/nut-error-code-alignment
Sep 2, 2026
Merged

Fix: Standardize error codes#1129
callebtc merged 6 commits into
cashubtc:mainfrom
KvngMikey:fix/nut-error-code-alignment

Conversation

@KvngMikey

@KvngMikey KvngMikey commented Aug 20, 2026

Copy link
Copy Markdown
Member

Closes #264 and #769

Summary

  • Brings cashu/core/errors.py and its mint-side call sites into agreement with [cashubtc/nuts/error_codes.md] (https://github.com/cashubtc/nuts/blob/main/error_codes.md).

  • Every change is codes-only — conditions and error messages are untouched, so callers matching on type or detail are unaffected.

The clear and blind auth errors shipped codes in the 80000/81000 range,
but NUT-21 and NUT-22 specify 30001/30002 and 31001-31004 respectively.
Clients dispatching on the specified codes never matched.

Remap all six classes to their specified codes. Error messages are
unchanged, so callers matching on `detail` are unaffected.

Add a parametrized test pinning each class to its specified code, and
assert the code on the four raise paths that already had coverage.

Closes cashubtc#769
TransactionUnitError duplicated TransactionMultipleUnitsError on 11009;
remove it and raise the latter at its single call site.

SecretTooLongError and WitnessTooLongError sat on 11003 and 11004, which
the NUTs assign to "outputs already signed" and "outputs are pending".
No NUT code covers input length limits, so both fall back to the generic
11000. Classes and messages are unchanged.
Minting disabled, quote pending, invoice already paid and quote expired
all surfaced as generic TransactionError (11000) or NotAllowedError
(10000), neither of which appears in the NUT error code table.

Add MintingDisabledError (20003), QuotePendingError (20005),
InvoiceAlreadyPaidError (20006) and QuoteExpiredError (20007), and raise
them at the eight sites that already detect these conditions. Each site
keeps its existing message, so only the code changes.

Melt-disabled keeps NotAllowedError: 20003 is mint-only per NUT-04.
Amountless invoices, amount mismatches and unsupported units surfaced as
generic TransactionError (11000) or NotAllowedError (10000), neither of
which appears in the NUT error code table.

Add AmountlessInvoiceNotSupportedError (11011), AmountMismatchError
(11012) and UnitNotSupportedError (11013), and raise them at the five
sites that already detect these conditions. Each site keeps its existing
message, so only the code changes.

The method/unit backend check in _verify_and_get_unit_method keeps
NotAllowedError: 11013 covers the unit, not the method.
Copilot AI lite review requested due to automatic review settings August 20, 2026 18:42
@github-project-automation github-project-automation Bot moved this to Backlog in nutshell Aug 20, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR standardizes and expands error-code usage across the mint by aligning authentication errors with NUT-21/NUT-22 and by introducing more specific error types for quote lifecycle and transaction validation paths.

Changes:

  • Update authentication error codes to match NUT-21/NUT-22 (300xx / 310xx).
  • Introduce/replace several mint/transaction errors with more specific exception classes and codes (e.g., quote lifecycle, unit support, invoice/amount checks).
  • Update mint tests to assert specific error codes and exception types.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
cashu/core/errors.py Adds new error classes and adjusts multiple error codes, including auth codes and new quote/transaction errors.
cashu/mint/ledger.py Switches several mint/melt code paths to raise the new standardized errors.
cashu/mint/verification.py Replaces unit-related exceptions with standardized transaction errors.
cashu/mint/db/write.py Replaces generic transaction errors with quote-lifecycle errors when setting/storing quote pending states.
tests/mint/test_mint_verification.py Adds assertions for standardized transaction error codes and updates expected exception types.
tests/mint/test_mint_operations.py Adds coverage asserting quote lifecycle error codes and behavior for disabled/pending cases.
tests/mint/test_mint_melt.py Updates expected exception type/code for duplicate checking-id pending behavior.
tests/mint/test_mint_auth_server_unit.py Adds tests ensuring auth error codes match NUT-21/NUT-22 and asserts raised codes in auth flows.
Suppressed comments (2)

cashu/core/errors.py:88

  • WitnessTooLongError also sets code = 11000 (same as the base TransactionError), which removes any ability for clients to reliably branch on this specific failure and is inconsistent with most other TransactionError subclasses having their own codes. Consider giving this error a dedicated unused code and updating the relevant test assertions accordingly.
class WitnessTooLongError(TransactionError):
    code = 11000

    def __init__(self, detail="witness too long"):
        super().__init__(detail, code=self.code)

cashu/mint/db/write.py:455

  • Same as above: _store_melt_quote raises QuotePendingError even when the conflicting quote is already paid. If InvoiceAlreadyPaidError (20006) is the intended “already paid” signal elsewhere, consider using it here for the paid case and reserving QuotePendingError (20005) for pending-only conflicts.
                    quote.state in [MeltQuoteState.pending, MeltQuoteState.paid]
                    for quote in quotes_db
                ]
            ):
                raise QuotePendingError("Melt quote already paid or pending.")

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread cashu/mint/db/write.py Outdated
Comment thread cashu/core/errors.py
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.04878% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.90%. Comparing base (3282be2) to head (9583d96).
⚠️ Report is 6 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
cashu/mint/ledger.py 38.88% 11 Missing ⚠️
cashu/mint/db/write.py 60.00% 6 Missing ⚠️
cashu/core/errors.py 97.87% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1129      +/-   ##
==========================================
+ Coverage   74.85%   74.90%   +0.05%     
==========================================
  Files         112      112              
  Lines       12589    12631      +42     
==========================================
+ Hits         9423     9461      +38     
- Misses       3166     3170       +4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

DbWriteHelper re-checks quote state under the row lock, and that check is
the authoritative one -- the matching check in ledger.py is an optimistic
pre-check. Both raised the same conditions, but the locked path returned
generic TransactionError (11000), so a client losing a race got 11000
where the winner got the spec code.

Raise QuotePendingError (20005), QuoteNotPaidError (20001),
QuoteAlreadyIssuedError (20002) and InvoiceAlreadyPaidError (20006) at
the guards. The two melt guards tested for paid and pending together, so
they are split to report the state the client actually hit; the same set
of states raises, with messages unchanged.

QuoteNotPaidError and QuoteAlreadyIssuedError take an optional detail so
the guards keep their quote_id in the message.

@callebtc callebtc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review findings:

  • High: A repeat melt request reaches Ledger._prepare_melt before the new locked guard. Its if not melt_quote.unpaid returns generic 11000 for both PAID and PENDING, rather than the documented 20006 and 20005. Please split that pre-check too.

The two inline comments cover the remaining issued-quote paths.

Comment thread cashu/mint/db/write.py
Comment thread cashu/mint/ledger.py
Split the melt pre-check in _prepare_melt so a repeat melt reports 20006
or 20005 instead of a generic 11000, add the missing issued branch to
_set_mint_quote_pending, and convert the two internal-melt issued paths.
@KvngMikey
KvngMikey force-pushed the fix/nut-error-code-alignment branch from be1bc5f to 9583d96 Compare September 2, 2026 05:21
@callebtc
callebtc merged commit 8849620 into cashubtc:main Sep 2, 2026
40 of 42 checks passed
@a1denvalu3

Copy link
Copy Markdown
Collaborator

title: "NPC wallet marks unpaid/unissued quotes as ISSUED because it dispatches on the wrong mint error code (11000 instead of 20002)"
slug: npc-mint-error-code-misdispatch
date: 2026-09-02
status: confirmed
severity: medium
finding: true
relation: revealed
target: [cashubtc/nutshell]
nuts: [NUT-04]

Summary

PR #1129 ("fix/nut-error-code-alignment") is a mint-side
refactor that aligns error classes/codes with the NUT error-code registry.
Reviewing exactly which code the mint now returns for each mint failure reveals
a latent wallet-side bug in cashu/wallet/npc.py (untouched by the PR): the
npub.cash NPC minting loop detects "quote already issued" by matching the
hardcoded string "Code: 11000", but 11000 is the generic TransactionError
code, while the mint returns 20002 (QuoteAlreadyIssuedError) for the real
already-issued case. The check therefore (a) never matches the condition it was
written for (false negative), and (b) matches unrelated mint failures that
carry code 11000 (false positive), after which the NPC permanently marks the
local quote as ISSUED and never retries minting it — stranding a paid Lightning
invoice with no ecash issued.

Root Cause

cashu/wallet/npc.py:189-199:

except Exception as e:
    # If the mint returns an error that the quote is already issued, we assume it is issued
    if "Code: 11000" in str(e) or (isinstance(e, CashuError) and e.code == 11000):
        print(f"Quote {quote_id} already issued (mint). Updating local state.")
        await update_bolt11_mint_quote(
            db=self.wallet.db,
            quote=quote_id,
            state=MintQuoteState.issued,
            paid_time=int(time.time()),
        )
        continue

The mint surfaces errors as {"detail": ..., "code": ...}
(cashu/mint/app.py exception middleware, ~lines 97-105), and the wallet raises
Exception(f"Mint Error: {detail} (Code: {code})")
(cashu/wallet/v1_api.py:137-140), so str(e) contains the mint's numeric
code.

The PR's error-code tables (cashu/core/errors.py) show why the dispatch is
wrong:

  • TransactionError (the generic fallback) has code 11000
    (cashu/core/errors.py:30-35). Mint failures that are not "already issued"
    but return 11000 include "amount to mint does not match quote amount" and
    "quote unit does not match output unit" (cashu/mint/ledger.py:528-531).
    Pre-PR this list additionally included "Mint quote already pending." and
    "quote expired" — the PR re-mapped those to 20005/20007, which narrows but
    does not eliminate the false-positive surface.
  • QuoteAlreadyIssuedError has code 20002 (cashu/core/errors.py:220-225)
    and is what ledger.mint() actually raises for an issued quote
    (cashu/mint/ledger.py:520-521). The npc check never matches it.

So the "already issued" self-healing path in npc.py is dead code for its
intended purpose and a trap for unrelated 11000 failures.

Insight gained from this PR (relation: revealed)

cashu/wallet/npc.py is not modified by this PR. The bug was discovered
directly from the PR's changes: the PR enumerates and remaps the exact NUT
error codes the mint returns on every mint-failure branch (11000/20001/20002/
20005/20007/20008/11003/11004). Auditing "which code does each ledger.mint()
failure now produce" against every consumer of those codes surfaced that the
only code-based consumer in the codebase, npc.py:191, dispatches on the
generic 11000 while the condition it wants ("already issued") is 20002 —
before and after the PR. The PR's alignment work makes this mis-dispatch
explicit (it even re-maps two of the false-positive triggers), but the
underlying wallet bug is pre-existing and remains exploitable post-PR.

Attack Steps / Reproduction

  1. Set up the npub.cash NPC flow (NpubCash.mint_quotes() polls paid quotes
    and mints them via wallet.mint()).
  2. Cause any mint attempt for a paid, un-issued quote to fail with a
    11000-coded error (post-PR: e.g. amount/unit mismatch; pre-PR also "quote
    expired" / "Mint quote already pending.").
  3. npc.py matches "Code: 11000", marks the quote issued in the local
    wallet DB, and continues.
  4. On every subsequent poll the local quote is issued, so NPC prints
    "already minted" and skips it forever. The invoice was paid, but no ecash
    was ever issued to the user and the automated flow has no recovery path.

PoC executes the real NpubCash.mint_quotes() code path with a stubbed wallet:

  • Case 1 (false positive): wallet.mint raises
    Exception("Mint Error: amount to mint does not match quote amount (Code: 11000)")
    → npc calls update_bolt11_mint_quote(..., state=MintQuoteState.issued)
    even though nothing was issued.
  • Case 2 (false negative): wallet.mint raises
    Exception("Mint Error: quote already issued (Code: 20002)")
    (the genuine QuoteAlreadyIssuedError response) → npc does NOT update the
    local state and will keep retrying.

Impact

Wallet-side loss-of-funds availability in the automated NPC/npub.cash flow: a
paid quote is irreversibly (from the NPC's perspective) marked as issued when
minting failed, so the user pays a Lightning invoice but never receives the
corresponding ecash, with no automatic retry or recovery. Additionally, the
intended idempotency handling for genuinely-issued quotes never fires, causing
endless failed retries. The mint side is unaffected.

Test Results

Executed against the PR branch (Poetry env):

$ poetry run python poc_npc_error_code.py
Quote quote-1 already issued (mint). Updating local state.
[false-positive / unrelated 11000 error] exception: Mint Error: amount to mint does not match quote amount (Code: 11000)
    quote marked ISSUED locally: True
Failed to mint quote quote-1: Mint Error: quote already issued (Code: 20002)
[false-negative / real already-issued 20002] exception: Mint Error: quote already issued (Code: 20002)
    quote marked ISSUED locally: False

PoC SUCCESS:
 - an unrelated 11000-coded mint failure falsely marks the
   local quote as ISSUED (strands the paid invoice)
 - the genuine 'already issued' response (20002) is NOT detected

Proposed Fix

In cashu/wallet/npc.py, dispatch on the correct NUT code for
"quote already issued" instead of the generic transaction error code, and
prefer structured error handling over string matching:

except Exception as e:
    code = e.code if isinstance(e, CashuError) else None
    if code is None:
        # parse "Mint Error: <detail> (Code: <n>)" raised by v1_api
        m = re.search(r"\(Code: (\d+)\)", str(e))
        code = int(m.group(1)) if m else None
    if code == 20002:  # QuoteAlreadyIssuedError
        await update_bolt11_mint_quote(
            db=self.wallet.db,
            quote=quote_id,
            state=MintQuoteState.issued,
            paid_time=int(time.time()),
        )
        continue
    print(f"Failed to mint quote {quote_id}: {e}")

Even better: make cashu/wallet/v1_api.py::raise_on_error_request raise a
CashuError subclass carrying the mint's numeric code (it already parses
resp_dict["code"]) so consumers never need to regex the message string, and
audit other places (e.g. CLI TODO at cashu/wallet/cli/cli.py:626) to consume
that structured code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Update authentication error codes to match NUT-21/NUT-22 specifications Standardize errors

4 participants