diff --git a/app/lightning/impl/cln_grpc.py b/app/lightning/impl/cln_grpc.py index bb9fed5..1a44f78 100644 --- a/app/lightning/impl/cln_grpc.py +++ b/app/lightning/impl/cln_grpc.py @@ -49,7 +49,7 @@ async def _make_local_call(*args: str): # in the CLN grpc interface yet. # Pass the command as a discrete argv list (create_subprocess_exec, not - # _shell) so user-controlled arguments such as the bolt11 in decodepay + # _shell) so user-controlled arguments such as the string in decode # can never be interpreted as shell syntax. testnet = config("BAPI_NETWORK") == "testnet" argv = ["lightning-cli", "-k", *(["--testnet"] if testnet else []), *args] @@ -551,7 +551,7 @@ class LnNodeCLNgRPC(LightningNodeBase): async def decode_pay_request(self, pay_req: str) -> PaymentRequest: logger.trace(f"decode_pay_request(pay_req={pay_req})") - res = await _make_local_call("decodepay", f"bolt11={pay_req}") + res = await _make_local_call("decode", f"string={pay_req}") if not res: raise HTTPException( @@ -569,7 +569,21 @@ class LnNodeCLNgRPC(LightningNodeBase): raise_for_pay_req_decode_error(decoded) - return PaymentRequest.from_cln_json(json.loads(decoded)) + data = json.loads(decoded) + if not data.get("valid", True): + # unlike decodepay, decode reports recognized-but-invalid strings + # as a normal result with valid=false + warning_* fields instead + # of an RPC error + m = "; ".join( + str(data[k]) for k in sorted(data) if k.startswith("warning") + ) or "invalid payment request" + logger.error(m) + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Could not decode the payment request: {m}", + ) + + return PaymentRequest.from_cln_json(data) @logger.catch(exclude=(HTTPException,)) async def get_fee_revenue(self) -> FeeRevenue: diff --git a/app/lightning/impl/cln_jrpc.py b/app/lightning/impl/cln_jrpc.py index 8a37de5..98aa36e 100644 --- a/app/lightning/impl/cln_jrpc.py +++ b/app/lightning/impl/cln_jrpc.py @@ -467,10 +467,22 @@ class LnNodeCLNjRPC(LightningNodeBase): return self._bolt11_cache[pay_req] params = [pay_req] - res = await self._send_request("decodepay", params) + res = await self._send_request("decode", params) if "error" not in res: res = res["result"] + if not res.get("valid", True): + # unlike decodepay, decode reports recognized-but-invalid + # strings as a normal result with valid=false + warning_* + # fields instead of an RPC error + m = "; ".join( + str(res[k]) for k in sorted(res) if k.startswith("warning") + ) or "invalid payment request" + logger.error(m) + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Could not decode the payment request: {m}", + ) req = PaymentRequest.from_cln_json(res) self._bolt11_cache[pay_req] = req return req diff --git a/tests/test_cln_shell_safety.py b/tests/test_cln_shell_safety.py index e4f4972..1f568d5 100644 --- a/tests/test_cln_shell_safety.py +++ b/tests/test_cln_shell_safety.py @@ -45,7 +45,7 @@ async def test_make_local_call_passes_args_without_a_shell(capture_exec): from app.lightning.impl import cln_grpc payload = "lnbc1pdummy; touch /tmp/pwned" - await cln_grpc._make_local_call("decodepay", f"bolt11={payload}") + await cln_grpc._make_local_call("decode", f"string={payload}") assert capture_exec["shell_used"] is False, ( "must not run user input through a shell" @@ -54,7 +54,7 @@ async def test_make_local_call_passes_args_without_a_shell(capture_exec): assert argv is not None, "create_subprocess_exec was not called" # the whole bolt11 value, metacharacters and all, must arrive as one # discrete argv token so the shell never sees it - assert f"bolt11={payload}" in argv + assert f"string={payload}" in argv assert argv[0] == "lightning-cli" diff --git a/tests/test_decode_pay_request_errors.py b/tests/test_decode_pay_request_errors.py index 130b9dd..b455ac2 100644 --- a/tests/test_decode_pay_request_errors.py +++ b/tests/test_decode_pay_request_errors.py @@ -125,3 +125,87 @@ async def test_cln_grpc_wrong_network_returns_400(monkeypatch): await node.decode_pay_request("lnbcrt500u1pjvvyly-grpc") assert exc.value.status_code == 400 + + +# --- CLN decodepay removal ---------------------------------------------------- +# CLN deprecated `decodepay` in v24.11, disables it by default in v25.12 +# (JSON-RPC -32601 'Command "decodepay" is deprecated') and removes it in +# v26.04. Calling it turned every Transactions load into a 500; both CLN +# backends must use `decode` instead. + +_DECODE_RESULT = { + "type": "bolt11 invoice", + "valid": True, + "currency": "bc", + "created_at": 1759640918, + "expiry": 3599, + "payee": "027cd974e47086291bb8a5b0160a889c738f2712a703b8ea939985fd16f3aae67e", + "amount_msat": 100000, + "description": "test", + "min_final_cltv_expiry": 80, + "payment_secret": ( + "097795ffadc7849ab82737572eb3dd36c5dc5d17277effcdf2de709c07cd4a9a" + ), + "payment_hash": ( + "4620a5027133dd4f049df5e6c2024368e7ed804e9ad759feb349af2c2f60cde6" + ), +} + + +async def test_cln_jrpc_uses_decode_not_decodepay(): + node = LnNodeCLNjRPC() + seen = {} + + async def fake_send(method, params=None): + seen["method"] = method + return {"result": dict(_DECODE_RESULT)} + + node._send_request = fake_send + + req = await node.decode_pay_request("lnbc100u1p-jrpc-decode") + + assert seen["method"] == "decode" + assert req.payment_hash == _DECODE_RESULT["payment_hash"] + + +async def test_cln_jrpc_decode_valid_false_returns_400(): + # unlike decodepay, decode reports recognized-but-invalid strings as a + # normal result with valid=false + warning_* fields instead of an error + node = LnNodeCLNjRPC() + + async def fake_send(method, params=None): + return { + "result": { + "type": "bolt12 invoice", + "valid": False, + "warning_invoice_missing_amount": "invoice without an amount", + } + } + + node._send_request = fake_send + + with pytest.raises(HTTPException) as exc: + await node.decode_pay_request("lni1-invalid") + + assert exc.value.status_code == 400 + assert "invoice without an amount" in str(exc.value.detail) + + +async def test_cln_grpc_uses_decode_not_decodepay(monkeypatch): + import json + + from app.lightning.impl import cln_grpc + + node = LnNodeCLNgRPC() + seen = {} + + async def fake_call(*args): + seen["args"] = args + return (json.dumps(_DECODE_RESULT).encode(), b"") + + monkeypatch.setattr(cln_grpc, "_make_local_call", fake_call) + + req = await node.decode_pay_request("lnbc100u1p-grpc-decode") + + assert seen["args"] == ("decode", "string=lnbc100u1p-grpc-decode") + assert req.payment_hash == _DECODE_RESULT["payment_hash"]