From e4b26dd27b967af668bc7fd657ee8efb2080d137 Mon Sep 17 00:00:00 2001 From: Jeroen Ubbink Date: Tue, 23 Jun 2026 09:38:04 +0200 Subject: [PATCH] fix: require shortfall text for generic 11000 melt errors 11000 is nutshell's generic, unregistered TransactionError covering many unrelated failures, so trusting the code alone made any 11000 retryable. Gate the generic code on the 'not enough inputs' detail text; keep trusting the registered 11005 (TransactionUnbalanced) on the code alone. Behavior is unchanged for every existing case; a bare non-shortfall 11000 now surfaces immediately instead of burning the retry budget. Co-Authored-By: Claude Opus 4.8 --- routstr/wallet.py | 26 ++++++++++++++++++-------- tests/unit/test_wallet.py | 4 ++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/routstr/wallet.py b/routstr/wallet.py index 71b6cab7..3f789d20 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -127,10 +127,12 @@ _MAX_SWAP_ATTEMPTS = 3 _MINT_ERROR_CODE_RE = re.compile(r"\(Code: (\d+)\)") _MELT_SHORTFALL_RE = re.compile(r"Provided: (\d+), needed: (\d+)") -# Insufficient melt inputs across implementations: 11005 is the registered -# "Transaction is not balanced" code (used by cdk); 11000 is nutshell's -# generic TransactionError, which is unregistered but what nutshell sends. -_RETRYABLE_MELT_CODES = frozenset({"11000", "11005"}) +# Insufficient-melt-inputs failures differ across mint implementations. 11005 is +# the registered "Transaction is not balanced" code (cdk), specific enough to +# trust on the code alone. 11000 is nutshell's generic, unregistered +# TransactionError covering many unrelated failures, so it only counts as a fee +# shortfall alongside the "not enough inputs" detail text. With no code suffix at +# all, that same text is the only signal. def _net_minted_amount(amount_msat: int, token_unit: str, fees: int) -> int: @@ -160,11 +162,19 @@ def _melt_insufficient_shortfall(error: Exception) -> int | None: """ message = str(error) code_match = _MINT_ERROR_CODE_RE.search(message) - if code_match is not None: - if code_match.group(1) not in _RETRYABLE_MELT_CODES: + code = code_match.group(1) if code_match is not None else None + has_shortfall_text = "not enough inputs" in message.lower() + + match code: + case "11005": # registered TransactionUnbalanced: trust the code + pass + case "11000" if has_shortfall_text: # generic nutshell error: needs the text + pass + case None if has_shortfall_text: # no code suffix: text is the only signal + pass + case _: # other codes, a bare 11000, or no signal: must not retry return None - elif "not enough inputs" not in message.lower(): - return None + amounts = _MELT_SHORTFALL_RE.search(message) if amounts is not None: provided, needed = int(amounts.group(1)), int(amounts.group(2)) diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index a6f9cef7..afaf05b0 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -573,6 +573,10 @@ async def test_net_minted_amount_unit_conversions( "Provided: 180, needed: 179 (Code: 11000)", 1, ), + # a generic 11000 without the shortfall text is not a fee shortfall: + # 11000 is nutshell's catch-all TransactionError, so retrying (shrinking + # the invoice) would never help and only masks the real error + ("Mint Error: Duplicate inputs provided. (Code: 11000)", None), # spent proofs must never be retried: the funds are gone ("Mint Error: Token already spent. (Code: 11001)", None), # Lightning failures must never be retried: a smaller invoice won't help