From d2295249bdab87b7ad03c3c3bc992c092edfe258 Mon Sep 17 00:00:00 2001 From: refined-element Date: Sat, 21 Mar 2026 01:31:17 -0400 Subject: [PATCH 1/9] feat: Add MPP (Machine Payments Protocol) client-side support Parse WWW-Authenticate: Payment headers per IETF draft-ryan-httpauth-payment. MppChallenge, parse_mpp_challenge(), parse_payment_challenge(). Prefer L402 when both present; fall back to MPP. Optional macaroon in verify. 12 new tests, all 100 tests pass. Co-Authored-By: Claude Opus 4.6 --- src/le_agent_sdk/l402/__init__.py | 14 ++- src/le_agent_sdk/l402/client.py | 140 ++++++++++++++++++++++++++---- tests/test_l402_client.py | 94 +++++++++++++++++++- 3 files changed, 227 insertions(+), 21 deletions(-) diff --git a/src/le_agent_sdk/l402/__init__.py b/src/le_agent_sdk/l402/__init__.py index dad797c..dd28070 100644 --- a/src/le_agent_sdk/l402/__init__.py +++ b/src/le_agent_sdk/l402/__init__.py @@ -1,5 +1,15 @@ """L402 HTTP client for agent service settlement.""" -from le_agent_sdk.l402.client import L402Client +from le_agent_sdk.l402.client import ( + L402Client, + MppChallenge, + parse_mpp_challenge, + parse_payment_challenge, +) -__all__ = ["L402Client"] +__all__ = [ + "L402Client", + "MppChallenge", + "parse_mpp_challenge", + "parse_payment_challenge", +] diff --git a/src/le_agent_sdk/l402/client.py b/src/le_agent_sdk/l402/client.py index fe9f58e..b8f86e6 100644 --- a/src/le_agent_sdk/l402/client.py +++ b/src/le_agent_sdk/l402/client.py @@ -35,6 +35,15 @@ def authorization_header(self) -> str: return f"L402 {self.macaroon}" +@dataclass(frozen=True) +class MppChallenge: + """MPP challenge from Payment WWW-Authenticate header.""" + + invoice: str + amount: Optional[str] = None + realm: Optional[str] = None + + # Pattern for parsing L402/LSAT challenges _CHALLENGE_RE = re.compile( r'(?:L402|LSAT)\s+' @@ -43,6 +52,14 @@ def authorization_header(self) -> str: re.IGNORECASE, ) +# Patterns for parsing MPP (Machine Payments Protocol) challenges +_MPP_CHALLENGE_RE = re.compile( + r'Payment\s+.*?method="lightning".*?invoice="(?P[^"]+)"', + re.IGNORECASE, +) +_MPP_AMOUNT_RE = re.compile(r'amount="(?P[^"]+)"', re.IGNORECASE) +_MPP_REALM_RE = re.compile(r'realm="(?P[^"]+)"', re.IGNORECASE) + def parse_l402_challenge(headers: dict[str, str]) -> Optional[L402Challenge]: """Extract an L402 challenge from response headers. @@ -68,6 +85,68 @@ def parse_l402_challenge(headers: dict[str, str]) -> Optional[L402Challenge]: ) +def parse_mpp_challenge(header: str) -> MppChallenge: + """Parse a Payment (MPP) challenge from a WWW-Authenticate header value. + + Args: + header: The WWW-Authenticate header value string. + + Returns: + Parsed MppChallenge. + + Raises: + ValueError: If the header is not a valid MPP challenge. + """ + match = _MPP_CHALLENGE_RE.search(header) + if not match: + raise ValueError(f"Invalid MPP challenge: {header[:80]}") + + invoice = match.group("invoice") + amount_match = _MPP_AMOUNT_RE.search(header) + realm_match = _MPP_REALM_RE.search(header) + + return MppChallenge( + invoice=invoice, + amount=amount_match.group("amount") if amount_match else None, + realm=realm_match.group("realm") if realm_match else None, + ) + + +def parse_payment_challenge( + headers: dict[str, str], +) -> L402Challenge | MppChallenge: + """Parse WWW-Authenticate headers, trying L402 first then MPP. + + Prefers L402 when available; falls back to MPP (Machine Payments Protocol). + + Args: + headers: HTTP response headers dict. + + Returns: + Parsed L402Challenge or MppChallenge. + + Raises: + ValueError: If no valid L402 or MPP challenge is found. + """ + lower_headers = {k.lower(): v for k, v in headers.items()} + www_auth = lower_headers.get("www-authenticate", "") + if not www_auth: + raise ValueError("No WWW-Authenticate header found") + + # Try L402 first (preferred) + l402 = parse_l402_challenge(headers) + if l402 is not None: + return l402 + + # Try MPP fallback + try: + return parse_mpp_challenge(www_auth) + except ValueError: + pass + + raise ValueError(f"No valid L402 or MPP challenge: {www_auth[:80]}") + + class L402Client: """Async HTTP client with L402 payment support. @@ -181,8 +260,11 @@ async def access( if response.status_code != 402: return response - challenge = parse_l402_challenge(dict(response.headers)) - if challenge is None: + # Try L402 first, then MPP fallback + resp_headers = dict(response.headers) + try: + challenge = parse_payment_challenge(resp_headers) + except ValueError: return response if self._pay_callback is None: @@ -219,13 +301,21 @@ async def access( f"got length {len(preimage) if isinstance(preimage, str) else 'N/A'}" ) - self._cache[challenge.macaroon] = preimage - logger.info( - "L402 payment succeeded. Preimage: %s (save this for recovery)", preimage - ) + # Build the correct Authorization header based on challenge type + if isinstance(challenge, MppChallenge): + auth_header = f'Payment method="lightning", preimage="{preimage}"' + logger.info( + "MPP payment succeeded. Preimage: %s (save this for recovery)", preimage + ) + else: + self._cache[challenge.macaroon] = preimage + auth_header = f"L402 {challenge.macaroon}:{preimage}" + logger.info( + "L402 payment succeeded. Preimage: %s (save this for recovery)", preimage + ) - # Retry the request with L402 credentials, with retry+backoff - headers["Authorization"] = f"L402 {challenge.macaroon}:{preimage}" + # Retry the request with credentials, with retry+backoff + headers["Authorization"] = auth_header max_retries = 3 last_exc: Optional[Exception] = None @@ -282,14 +372,22 @@ async def pay_and_access( if response.status_code != 402: return response - challenge = parse_l402_challenge(dict(response.headers)) - if challenge is None: + # Try L402 first, then MPP fallback + resp_headers = dict(response.headers) + try: + challenge = parse_payment_challenge(resp_headers) + except ValueError: return response preimage = await pay_invoice_callback(challenge.invoice) - self._cache[challenge.macaroon] = preimage - headers["Authorization"] = f"L402 {challenge.macaroon}:{preimage}" + # Build the correct Authorization header based on challenge type + if isinstance(challenge, MppChallenge): + headers["Authorization"] = f'Payment method="lightning", preimage="{preimage}"' + else: + self._cache[challenge.macaroon] = preimage + headers["Authorization"] = f"L402 {challenge.macaroon}:{preimage}" + retry_response = await client.request(method, url, headers=headers, **kwargs) return retry_response @@ -427,27 +525,35 @@ async def create_challenge( async def verify_payment( self, - macaroon: str, preimage: str, + macaroon: Optional[str] = None, ) -> L402VerifyResponse: - """Verify an L402 token (macaroon + preimage) to confirm payment. + """Verify an L402 or MPP token to confirm payment. - The provider calls this after receiving an L402 token from the requester + For L402 verification, provide both macaroon and preimage. + For MPP verification, only the preimage is required (macaroon is None). + + The provider calls this after receiving a token from the requester to validate that the invoice has been paid before delivering the service. Args: - macaroon: Base64-encoded macaroon from the L402 token. preimage: Hex-encoded preimage (proof of payment). + macaroon: Base64-encoded macaroon from the L402 token. Optional for + MPP payments where only a preimage is provided. Returns: L402VerifyResponse indicating whether the payment is valid. """ client = self._ensure_client() + payload: dict[str, str] = {"preimage": preimage.strip()} + if macaroon: + payload["macaroon"] = macaroon.strip() + try: response = await client.post( f"{self._base_url}/api/l402/challenges/verify", - json={"macaroon": macaroon.strip(), "preimage": preimage.strip()}, + json=payload, ) if response.status_code != 200: diff --git a/tests/test_l402_client.py b/tests/test_l402_client.py index ba371ce..b3be061 100644 --- a/tests/test_l402_client.py +++ b/tests/test_l402_client.py @@ -1,8 +1,15 @@ -"""Tests for L402 client — challenge parsing and HTTP flow.""" +"""Tests for L402 client — challenge parsing, MPP support, and HTTP flow.""" import pytest -from le_agent_sdk.l402.client import L402Challenge, L402Client, parse_l402_challenge +from le_agent_sdk.l402.client import ( + L402Challenge, + L402Client, + MppChallenge, + parse_l402_challenge, + parse_mpp_challenge, + parse_payment_challenge, +) class TestParseL402Challenge: @@ -74,3 +81,86 @@ async def test_close_idempotent(self): client = L402Client() await client.close() await client.close() # Should not raise + + +class TestMppChallengeParsing: + def test_parse_valid_mpp_header(self): + header = 'Payment realm="api.example.com", method="lightning", invoice="lnbc100n1pjtest", amount="100", currency="sat"' + result = parse_mpp_challenge(header) + assert isinstance(result, MppChallenge) + assert result.invoice == "lnbc100n1pjtest" + assert result.amount == "100" + assert result.realm == "api.example.com" + + def test_parse_non_lightning_raises(self): + with pytest.raises(ValueError): + parse_mpp_challenge('Payment method="stripe", invoice="lnbc100n1pjtest"') + + def test_parse_missing_invoice_raises(self): + with pytest.raises(ValueError): + parse_mpp_challenge('Payment method="lightning", amount="100"') + + def test_parse_minimal_header(self): + result = parse_mpp_challenge( + 'Payment method="lightning", invoice="lnbc100n1pjtest"' + ) + assert result.invoice == "lnbc100n1pjtest" + assert result.amount is None + assert result.realm is None + + def test_parse_case_insensitive(self): + header = 'PAYMENT METHOD="LIGHTNING", INVOICE="lnbc100n1pjtest", AMOUNT="50"' + result = parse_mpp_challenge(header) + assert result.invoice == "lnbc100n1pjtest" + assert result.amount == "50" + + def test_mpp_challenge_frozen(self): + c = MppChallenge(invoice="inv1", amount="100", realm="example.com") + with pytest.raises(AttributeError): + c.invoice = "changed" + + +class TestParsePaymentChallenge: + def test_l402_preferred(self): + headers = { + "WWW-Authenticate": 'L402 macaroon="abc", invoice="lnbc100n1pjtest"' + } + result = parse_payment_challenge(headers) + assert isinstance(result, L402Challenge) + assert result.macaroon == "abc" + assert result.invoice == "lnbc100n1pjtest" + + def test_mpp_fallback(self): + headers = { + "WWW-Authenticate": 'Payment method="lightning", invoice="lnbc100n1pjtest"' + } + result = parse_payment_challenge(headers) + assert isinstance(result, MppChallenge) + assert result.invoice == "lnbc100n1pjtest" + + def test_invalid_raises(self): + headers = {"WWW-Authenticate": "Bearer token123"} + with pytest.raises(ValueError): + parse_payment_challenge(headers) + + def test_no_header_raises(self): + headers = {"Content-Type": "application/json"} + with pytest.raises(ValueError): + parse_payment_challenge(headers) + + def test_empty_header_raises(self): + headers = {"WWW-Authenticate": ""} + with pytest.raises(ValueError): + parse_payment_challenge(headers) + + def test_l402_with_both_present(self): + """When both L402 and MPP headers exist (combined), L402 is preferred.""" + headers = { + "WWW-Authenticate": ( + 'L402 macaroon="mac1", invoice="lnbc100n1pjl402" ' + 'Payment method="lightning", invoice="lnbc100n1pjmpp"' + ) + } + result = parse_payment_challenge(headers) + assert isinstance(result, L402Challenge) + assert result.macaroon == "mac1" From 9735ccf72376d9328f3c1c024fb7c1c4fb47cd3b Mon Sep 17 00:00:00 2001 From: refined-element Date: Sat, 21 Mar 2026 01:41:17 -0400 Subject: [PATCH 2/9] fix: Address Copilot review feedback on MPP client support - Restore backward-compatible parameter order for verify_payment() (macaroon first, preimage as keyword-only) to avoid breaking callers - Add preimage validation to pay_and_access() matching access() behavior - Scope MPP realm/amount parsing to Payment segment only to prevent cross-scheme attribute capture from multi-challenge headers - Add test for realm scoping with mixed Bearer + Payment headers Co-Authored-By: Claude Opus 4.6 --- src/le_agent_sdk/l402/client.py | 30 +++++++++++++++++++++++++----- tests/test_l402_client.py | 11 +++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/le_agent_sdk/l402/client.py b/src/le_agent_sdk/l402/client.py index b8f86e6..b17c275 100644 --- a/src/le_agent_sdk/l402/client.py +++ b/src/le_agent_sdk/l402/client.py @@ -53,8 +53,10 @@ class MppChallenge: ) # Patterns for parsing MPP (Machine Payments Protocol) challenges +# Match the full Payment challenge segment (up to end-of-string or next scheme) +# so that amount/realm extraction is scoped to this challenge only. _MPP_CHALLENGE_RE = re.compile( - r'Payment\s+.*?method="lightning".*?invoice="(?P[^"]+)"', + r'Payment\s+(?=(?:[^,]*,\s*)*method="lightning")(?P(?:[^,]*,\s*)*invoice="(?P[^"]+)"[^,]*(?:,\s*[^,]*)*)', re.IGNORECASE, ) _MPP_AMOUNT_RE = re.compile(r'amount="(?P[^"]+)"', re.IGNORECASE) @@ -101,9 +103,13 @@ def parse_mpp_challenge(header: str) -> MppChallenge: if not match: raise ValueError(f"Invalid MPP challenge: {header[:80]}") + # Extract the matched Payment segment only so that amount/realm from + # other schemes (e.g. "Bearer realm=...") are not accidentally captured. + payment_segment = match.group(0) + invoice = match.group("invoice") - amount_match = _MPP_AMOUNT_RE.search(header) - realm_match = _MPP_REALM_RE.search(header) + amount_match = _MPP_AMOUNT_RE.search(payment_segment) + realm_match = _MPP_REALM_RE.search(payment_segment) return MppChallenge( invoice=invoice, @@ -381,6 +387,19 @@ async def pay_and_access( preimage = await pay_invoice_callback(challenge.invoice) + # Validate preimage format before constructing credentials + if not self._validate_preimage(preimage): + logger.error( + "Invalid preimage returned from pay callback in pay_and_access: " + "expected 64-char hex, got %r (length=%d)", + preimage[:20] if isinstance(preimage, str) else type(preimage), + len(preimage) if isinstance(preimage, str) else 0, + ) + raise ValueError( + f"Invalid preimage from payment callback: expected 64-character hex string, " + f"got length {len(preimage) if isinstance(preimage, str) else 'N/A'}" + ) + # Build the correct Authorization header based on challenge type if isinstance(challenge, MppChallenge): headers["Authorization"] = f'Payment method="lightning", preimage="{preimage}"' @@ -525,8 +544,9 @@ async def create_challenge( async def verify_payment( self, - preimage: str, macaroon: Optional[str] = None, + *, + preimage: str, ) -> L402VerifyResponse: """Verify an L402 or MPP token to confirm payment. @@ -537,9 +557,9 @@ async def verify_payment( to validate that the invoice has been paid before delivering the service. Args: - preimage: Hex-encoded preimage (proof of payment). macaroon: Base64-encoded macaroon from the L402 token. Optional for MPP payments where only a preimage is provided. + preimage: Hex-encoded preimage (proof of payment). Keyword-only. Returns: L402VerifyResponse indicating whether the payment is valid. diff --git a/tests/test_l402_client.py b/tests/test_l402_client.py index b3be061..53fbd13 100644 --- a/tests/test_l402_client.py +++ b/tests/test_l402_client.py @@ -119,6 +119,17 @@ def test_mpp_challenge_frozen(self): with pytest.raises(AttributeError): c.invoice = "changed" + def test_realm_scoped_to_payment_segment(self): + """Realm from a different scheme (Bearer) must not leak into MPP.""" + header = ( + 'Bearer realm="other-service.com", ' + 'Payment method="lightning", invoice="lnbc100n1pjtest"' + ) + result = parse_mpp_challenge(header) + assert result.invoice == "lnbc100n1pjtest" + # The Bearer realm must NOT be captured + assert result.realm is None + class TestParsePaymentChallenge: def test_l402_preferred(self): From b82ff71e639794940c02a1ca59c3709f8cd34ab2 Mon Sep 17 00:00:00 2001 From: refined-element Date: Sat, 21 Mar 2026 01:53:02 -0400 Subject: [PATCH 3/9] fix: Address Copilot review round 2 - Properly delimit Payment challenge segment by splitting on auth-scheme boundaries, preventing realm/amount leakage from other schemes (e.g. trailing Bearer realm) - Restore verify_payment() backward compatibility: both macaroon and preimage are positional parameters again (no keyword-only restriction) - Add .strip() to all captured MPP challenge values for consistency with parse_l402_challenge() - Add test for trailing scheme not leaking into Payment challenge Co-Authored-By: Claude Opus 4.6 --- src/le_agent_sdk/l402/client.py | 52 +++++++++++++++++++++++---------- tests/test_l402_client.py | 11 +++++++ 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/src/le_agent_sdk/l402/client.py b/src/le_agent_sdk/l402/client.py index b17c275..1c73e95 100644 --- a/src/le_agent_sdk/l402/client.py +++ b/src/le_agent_sdk/l402/client.py @@ -53,12 +53,14 @@ class MppChallenge: ) # Patterns for parsing MPP (Machine Payments Protocol) challenges -# Match the full Payment challenge segment (up to end-of-string or next scheme) -# so that amount/realm extraction is scoped to this challenge only. -_MPP_CHALLENGE_RE = re.compile( - r'Payment\s+(?=(?:[^,]*,\s*)*method="lightning")(?P(?:[^,]*,\s*)*invoice="(?P[^"]+)"[^,]*(?:,\s*[^,]*)*)', - re.IGNORECASE, +# _AUTH_SCHEME_SPLIT splits a WWW-Authenticate value into individual challenges +# by detecting auth-scheme token boundaries (e.g. "Bearer ...", "Payment ..."). +_AUTH_SCHEME_SPLIT = re.compile( + r'(?:^|,\s*)(?=[A-Za-z][A-Za-z0-9!#$&\-^_`|~]*\s)', ) +# Match invoice inside a Payment challenge's parameter list +_MPP_INVOICE_RE = re.compile(r'invoice="(?P[^"]+)"', re.IGNORECASE) +_MPP_METHOD_RE = re.compile(r'method="lightning"', re.IGNORECASE) _MPP_AMOUNT_RE = re.compile(r'amount="(?P[^"]+)"', re.IGNORECASE) _MPP_REALM_RE = re.compile(r'realm="(?P[^"]+)"', re.IGNORECASE) @@ -87,6 +89,21 @@ def parse_l402_challenge(headers: dict[str, str]) -> Optional[L402Challenge]: ) +def _extract_payment_segment(header: str) -> Optional[str]: + """Extract only the Payment challenge segment from a WWW-Authenticate value. + + Splits the header at auth-scheme boundaries so that parameters from + other schemes (e.g. Bearer realm=...) are never included. + """ + # Split into individual challenge segments at auth-scheme boundaries + segments = _AUTH_SCHEME_SPLIT.split(header) + for segment in segments: + stripped = segment.strip().rstrip(",").strip() + if stripped.upper().startswith("PAYMENT "): + return stripped + return None + + def parse_mpp_challenge(header: str) -> MppChallenge: """Parse a Payment (MPP) challenge from a WWW-Authenticate header value. @@ -99,22 +116,26 @@ def parse_mpp_challenge(header: str) -> MppChallenge: Raises: ValueError: If the header is not a valid MPP challenge. """ - match = _MPP_CHALLENGE_RE.search(header) - if not match: + payment_segment = _extract_payment_segment(header) + if payment_segment is None: + raise ValueError(f"Invalid MPP challenge: {header[:80]}") + + # Verify method="lightning" within the Payment segment + if not _MPP_METHOD_RE.search(payment_segment): raise ValueError(f"Invalid MPP challenge: {header[:80]}") - # Extract the matched Payment segment only so that amount/realm from - # other schemes (e.g. "Bearer realm=...") are not accidentally captured. - payment_segment = match.group(0) + invoice_match = _MPP_INVOICE_RE.search(payment_segment) + if not invoice_match: + raise ValueError(f"Invalid MPP challenge: {header[:80]}") - invoice = match.group("invoice") + invoice = invoice_match.group("invoice").strip() amount_match = _MPP_AMOUNT_RE.search(payment_segment) realm_match = _MPP_REALM_RE.search(payment_segment) return MppChallenge( invoice=invoice, - amount=amount_match.group("amount") if amount_match else None, - realm=realm_match.group("realm") if realm_match else None, + amount=amount_match.group("amount").strip() if amount_match else None, + realm=realm_match.group("realm").strip() if realm_match else None, ) @@ -545,8 +566,7 @@ async def create_challenge( async def verify_payment( self, macaroon: Optional[str] = None, - *, - preimage: str, + preimage: str = "", ) -> L402VerifyResponse: """Verify an L402 or MPP token to confirm payment. @@ -559,7 +579,7 @@ async def verify_payment( Args: macaroon: Base64-encoded macaroon from the L402 token. Optional for MPP payments where only a preimage is provided. - preimage: Hex-encoded preimage (proof of payment). Keyword-only. + preimage: Hex-encoded preimage (proof of payment). Returns: L402VerifyResponse indicating whether the payment is valid. diff --git a/tests/test_l402_client.py b/tests/test_l402_client.py index 53fbd13..9700a74 100644 --- a/tests/test_l402_client.py +++ b/tests/test_l402_client.py @@ -130,6 +130,17 @@ def test_realm_scoped_to_payment_segment(self): # The Bearer realm must NOT be captured assert result.realm is None + def test_realm_scoped_with_trailing_scheme(self): + """Realm from a trailing scheme must not leak into a Payment challenge.""" + header = ( + 'Payment method="lightning", invoice="lnbc100n1pjtest", ' + 'Bearer realm="other-service.com"' + ) + result = parse_mpp_challenge(header) + assert result.invoice == "lnbc100n1pjtest" + # The trailing Bearer realm must NOT be captured + assert result.realm is None + class TestParsePaymentChallenge: def test_l402_preferred(self): From 68a03e5e04023580c57c56ab293ba61076dfc909 Mon Sep 17 00:00:00 2001 From: refined-element Date: Sat, 21 Mar 2026 02:02:28 -0400 Subject: [PATCH 4/9] fix: Address Copilot review round 3 - Make preimage required (keyword-only) in verify_payment() to prevent accidental calls with empty preimage - Distinguish missing vs empty WWW-Authenticate header in parse_payment_challenge() with specific error messages - Add try/except wrapping around pay_invoice_callback in pay_and_access() for consistent error handling matching access() - Add 7 unit tests for verify_payment() covering L402 and MPP payload formation, TypeError on missing args, API errors, and whitespace stripping Co-Authored-By: Claude Opus 4.6 --- src/le_agent_sdk/l402/client.py | 22 +++++-- tests/test_l402_client.py | 112 +++++++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 5 deletions(-) diff --git a/src/le_agent_sdk/l402/client.py b/src/le_agent_sdk/l402/client.py index 1c73e95..ab85052 100644 --- a/src/le_agent_sdk/l402/client.py +++ b/src/le_agent_sdk/l402/client.py @@ -156,9 +156,11 @@ def parse_payment_challenge( ValueError: If no valid L402 or MPP challenge is found. """ lower_headers = {k.lower(): v for k, v in headers.items()} - www_auth = lower_headers.get("www-authenticate", "") - if not www_auth: + if "www-authenticate" not in lower_headers: raise ValueError("No WWW-Authenticate header found") + www_auth = lower_headers["www-authenticate"] + if not www_auth: + raise ValueError("Empty WWW-Authenticate header") # Try L402 first (preferred) l402 = parse_l402_challenge(headers) @@ -406,7 +408,18 @@ async def pay_and_access( except ValueError: return response - preimage = await pay_invoice_callback(challenge.invoice) + try: + preimage = await pay_invoice_callback(challenge.invoice) + except Exception as exc: + logger.error( + "Error in pay_invoice_callback during pay_and_access for URL %r: %s", + url, + exc, + exc_info=True, + ) + raise RuntimeError( + "Payment callback failed during pay_and_access; see logs for details" + ) from exc # Validate preimage format before constructing credentials if not self._validate_preimage(preimage): @@ -566,7 +579,8 @@ async def create_challenge( async def verify_payment( self, macaroon: Optional[str] = None, - preimage: str = "", + *, + preimage: str, ) -> L402VerifyResponse: """Verify an L402 or MPP token to confirm payment. diff --git a/tests/test_l402_client.py b/tests/test_l402_client.py index 9700a74..01acf7c 100644 --- a/tests/test_l402_client.py +++ b/tests/test_l402_client.py @@ -1,10 +1,14 @@ """Tests for L402 client — challenge parsing, MPP support, and HTTP flow.""" import pytest +import httpx +from unittest.mock import AsyncMock, patch from le_agent_sdk.l402.client import ( L402Challenge, L402Client, + L402ProducerClient, + L402VerifyResponse, MppChallenge, parse_l402_challenge, parse_mpp_challenge, @@ -172,7 +176,7 @@ def test_no_header_raises(self): def test_empty_header_raises(self): headers = {"WWW-Authenticate": ""} - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Empty WWW-Authenticate header"): parse_payment_challenge(headers) def test_l402_with_both_present(self): @@ -186,3 +190,109 @@ def test_l402_with_both_present(self): result = parse_payment_challenge(headers) assert isinstance(result, L402Challenge) assert result.macaroon == "mac1" + + +class TestL402ProducerClientVerifyPayment: + """Tests for L402ProducerClient.verify_payment() covering both L402 and MPP flows.""" + + @pytest.mark.asyncio + async def test_verify_with_macaroon_sends_both_fields(self): + """When macaroon is provided, payload should include both macaroon and preimage.""" + mock_response = httpx.Response( + 200, + json={"valid": True, "resource": "/api/data"}, + request=httpx.Request("POST", "https://api.lightningenable.com/api/l402/challenges/verify"), + ) + with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response) as mock_post: + async with L402ProducerClient(le_api_key="test-key") as client: + result = await client.verify_payment("mac123", preimage="aa" * 32) + + assert result.success is True + assert result.valid is True + assert result.resource == "/api/data" + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + assert payload["macaroon"] == "mac123" + assert payload["preimage"] == "aa" * 32 + + @pytest.mark.asyncio + async def test_verify_without_macaroon_sends_preimage_only(self): + """MPP flow: when macaroon is omitted, payload should only contain preimage.""" + mock_response = httpx.Response( + 200, + json={"valid": True, "resource": "/api/mpp-data"}, + request=httpx.Request("POST", "https://api.lightningenable.com/api/l402/challenges/verify"), + ) + with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response) as mock_post: + async with L402ProducerClient(le_api_key="test-key") as client: + result = await client.verify_payment(preimage="bb" * 32) + + assert result.success is True + assert result.valid is True + assert result.resource == "/api/mpp-data" + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + assert "macaroon" not in payload + assert payload["preimage"] == "bb" * 32 + + @pytest.mark.asyncio + async def test_verify_without_macaroon_none_explicit(self): + """Explicitly passing macaroon=None should omit it from payload.""" + mock_response = httpx.Response( + 200, + json={"valid": False}, + request=httpx.Request("POST", "https://api.lightningenable.com/api/l402/challenges/verify"), + ) + with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response) as mock_post: + async with L402ProducerClient(le_api_key="test-key") as client: + result = await client.verify_payment(None, preimage="cc" * 32) + + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + assert "macaroon" not in payload + + @pytest.mark.asyncio + async def test_verify_missing_preimage_raises_type_error(self): + """Calling verify_payment() without preimage keyword arg should raise TypeError.""" + async with L402ProducerClient(le_api_key="test-key") as client: + with pytest.raises(TypeError): + await client.verify_payment("mac123") + + @pytest.mark.asyncio + async def test_verify_no_args_raises_type_error(self): + """Calling verify_payment() with no arguments should raise TypeError.""" + async with L402ProducerClient(le_api_key="test-key") as client: + with pytest.raises(TypeError): + await client.verify_payment() + + @pytest.mark.asyncio + async def test_verify_api_error_returns_failure(self): + """Non-200 response should return a failure result.""" + mock_response = httpx.Response( + 401, + json={"error": "Invalid API key"}, + request=httpx.Request("POST", "https://api.lightningenable.com/api/l402/challenges/verify"), + ) + with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response): + async with L402ProducerClient(le_api_key="bad-key") as client: + result = await client.verify_payment(preimage="dd" * 32) + + assert result.success is False + assert "Invalid API key" in result.error + + @pytest.mark.asyncio + async def test_verify_strips_whitespace(self): + """Macaroon and preimage values should be stripped of whitespace.""" + mock_response = httpx.Response( + 200, + json={"valid": True}, + request=httpx.Request("POST", "https://api.lightningenable.com/api/l402/challenges/verify"), + ) + with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response) as mock_post: + async with L402ProducerClient(le_api_key="test-key") as client: + result = await client.verify_payment(" mac123 ", preimage=" " + "ee" * 32 + " ") + + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + assert payload["macaroon"] == "mac123" + assert payload["preimage"] == "ee" * 32 From 8da4ecc2134dc987b81f3726ca7265b8a9d74700 Mon Sep 17 00:00:00 2001 From: refined-element Date: Sat, 21 Mar 2026 02:14:31 -0400 Subject: [PATCH 5/9] fix: Address Copilot review round 4 - Restore backward-compatible positional args in verify_payment(macaroon, preimage) - Distinguish macaroon=None (MPP) from empty/whitespace macaroon (ValueError) - Add negative lookbehind to MPP regexes to prevent substring parameter matches - Add async tests for 402->pay->retry flow in access() for both L402 and MPP - Add async tests for 402->pay->retry flow in pay_and_access() for both L402 and MPP - Add tests for empty/whitespace macaroon validation and positional backward compat Co-Authored-By: Claude Opus 4.6 --- src/le_agent_sdk/l402/client.py | 53 +++++-- tests/test_l402_client.py | 249 +++++++++++++++++++++++++++++++- 2 files changed, 285 insertions(+), 17 deletions(-) diff --git a/src/le_agent_sdk/l402/client.py b/src/le_agent_sdk/l402/client.py index ab85052..c0fc503 100644 --- a/src/le_agent_sdk/l402/client.py +++ b/src/le_agent_sdk/l402/client.py @@ -58,11 +58,24 @@ class MppChallenge: _AUTH_SCHEME_SPLIT = re.compile( r'(?:^|,\s*)(?=[A-Za-z][A-Za-z0-9!#$&\-^_`|~]*\s)', ) -# Match invoice inside a Payment challenge's parameter list -_MPP_INVOICE_RE = re.compile(r'invoice="(?P[^"]+)"', re.IGNORECASE) -_MPP_METHOD_RE = re.compile(r'method="lightning"', re.IGNORECASE) -_MPP_AMOUNT_RE = re.compile(r'amount="(?P[^"]+)"', re.IGNORECASE) -_MPP_REALM_RE = re.compile(r'realm="(?P[^"]+)"', re.IGNORECASE) +# Match parameters inside a Payment challenge's parameter list, ensuring we +# only match full parameter names (not substrings of longer names). +_MPP_INVOICE_RE = re.compile( + r'(?[^"]+)"', + re.IGNORECASE, +) +_MPP_METHOD_RE = re.compile( + r'(?[^"]+)"', + re.IGNORECASE, +) +_MPP_REALM_RE = re.compile( + r'(?[^"]+)"', + re.IGNORECASE, +) def parse_l402_challenge(headers: dict[str, str]) -> Optional[L402Challenge]: @@ -579,8 +592,7 @@ async def create_challenge( async def verify_payment( self, macaroon: Optional[str] = None, - *, - preimage: str, + preimage: Optional[str] = None, ) -> L402VerifyResponse: """Verify an L402 or MPP token to confirm payment. @@ -592,17 +604,36 @@ async def verify_payment( Args: macaroon: Base64-encoded macaroon from the L402 token. Optional for - MPP payments where only a preimage is provided. - preimage: Hex-encoded preimage (proof of payment). + MPP payments where only a preimage is provided. Pass None to use + MPP verification without a macaroon. + preimage: Hex-encoded preimage (proof of payment). Required. Returns: L402VerifyResponse indicating whether the payment is valid. + + Raises: + ValueError: If preimage is not provided, or if macaroon is provided + but empty/whitespace. """ + if preimage is None: + raise ValueError( + "preimage is required; pass a hex-encoded preimage string" + ) + client = self._ensure_client() payload: dict[str, str] = {"preimage": preimage.strip()} - if macaroon: - payload["macaroon"] = macaroon.strip() + + # Distinguish MPP (macaroon is None) from an explicitly provided but + # empty/whitespace macaroon, which should be treated as an error. + if macaroon is not None: + macaroon_stripped = macaroon.strip() + if not macaroon_stripped: + raise ValueError( + "macaroon must be a non-empty string when provided; " + "use None to request MPP verification without a macaroon." + ) + payload["macaroon"] = macaroon_stripped try: response = await client.post( diff --git a/tests/test_l402_client.py b/tests/test_l402_client.py index 01acf7c..a82fd09 100644 --- a/tests/test_l402_client.py +++ b/tests/test_l402_client.py @@ -87,6 +87,210 @@ async def test_close_idempotent(self): await client.close() # Should not raise +class TestL402ClientAccessFlow: + """Tests for the 402 -> pay -> retry flow in L402Client.access().""" + + @pytest.mark.asyncio + async def test_access_l402_402_pay_retry(self): + """L402 challenge: 402 response, pay invoice, retry with L402 Authorization header.""" + fake_preimage = "ab" * 32 + + # First call returns 402 with L402 challenge + response_402 = httpx.Response( + 402, + headers={"WWW-Authenticate": 'L402 macaroon="mac_test", invoice="lnbc100n1pjtest"'}, + request=httpx.Request("GET", "https://api.example.com/resource"), + ) + # Second call returns 200 (after auth) + response_200 = httpx.Response( + 200, + json={"data": "success"}, + request=httpx.Request("GET", "https://api.example.com/resource"), + ) + + pay_callback = AsyncMock(return_value=fake_preimage) + + with patch("httpx.AsyncClient.request", new_callable=AsyncMock, side_effect=[response_402, response_200]) as mock_request: + async with L402Client(pay_invoice_callback=pay_callback) as client: + result = await client.access("https://api.example.com/resource") + + assert result.status_code == 200 + pay_callback.assert_awaited_once_with("lnbc100n1pjtest") + # Verify the retry request used the correct L402 Authorization header + retry_call = mock_request.call_args_list[1] + retry_headers = retry_call.kwargs.get("headers") or retry_call[1].get("headers", {}) + assert retry_headers["Authorization"] == f"L402 mac_test:{fake_preimage}" + + @pytest.mark.asyncio + async def test_access_mpp_402_pay_retry(self): + """MPP challenge: 402 response, pay invoice, retry with Payment Authorization header.""" + fake_preimage = "cd" * 32 + + # First call returns 402 with MPP challenge + response_402 = httpx.Response( + 402, + headers={"WWW-Authenticate": 'Payment method="lightning", invoice="lnbc200n1pjmpptest"'}, + request=httpx.Request("GET", "https://api.example.com/mpp-resource"), + ) + # Second call returns 200 (after auth) + response_200 = httpx.Response( + 200, + json={"data": "mpp-success"}, + request=httpx.Request("GET", "https://api.example.com/mpp-resource"), + ) + + pay_callback = AsyncMock(return_value=fake_preimage) + + with patch("httpx.AsyncClient.request", new_callable=AsyncMock, side_effect=[response_402, response_200]) as mock_request: + async with L402Client(pay_invoice_callback=pay_callback) as client: + result = await client.access("https://api.example.com/mpp-resource") + + assert result.status_code == 200 + pay_callback.assert_awaited_once_with("lnbc200n1pjmpptest") + # Verify the retry request used the correct Payment Authorization header + retry_call = mock_request.call_args_list[1] + retry_headers = retry_call.kwargs.get("headers") or retry_call[1].get("headers", {}) + assert retry_headers["Authorization"] == f'Payment method="lightning", preimage="{fake_preimage}"' + + @pytest.mark.asyncio + async def test_access_no_callback_returns_402(self): + """Without a pay callback, 402 responses are returned as-is.""" + response_402 = httpx.Response( + 402, + headers={"WWW-Authenticate": 'L402 macaroon="mac_test", invoice="lnbc100n1pjtest"'}, + request=httpx.Request("GET", "https://api.example.com/resource"), + ) + + with patch("httpx.AsyncClient.request", new_callable=AsyncMock, return_value=response_402): + async with L402Client() as client: + result = await client.access("https://api.example.com/resource") + + assert result.status_code == 402 + + +class TestL402ClientPayAndAccessFlow: + """Tests for the 402 -> pay -> retry flow in L402Client.pay_and_access().""" + + @pytest.mark.asyncio + async def test_pay_and_access_l402_flow(self): + """L402: pay_and_access should pay invoice and retry with L402 Authorization.""" + fake_preimage = "ef" * 32 + + response_402 = httpx.Response( + 402, + headers={"WWW-Authenticate": 'L402 macaroon="mac_paa", invoice="lnbc300n1pjpaatest"'}, + request=httpx.Request("GET", "https://api.example.com/l402-resource"), + ) + response_200 = httpx.Response( + 200, + json={"data": "l402-paid"}, + request=httpx.Request("GET", "https://api.example.com/l402-resource"), + ) + + pay_callback = AsyncMock(return_value=fake_preimage) + + with patch("httpx.AsyncClient.request", new_callable=AsyncMock, side_effect=[response_402, response_200]) as mock_request: + async with L402Client() as client: + result = await client.pay_and_access( + "https://api.example.com/l402-resource", + pay_invoice_callback=pay_callback, + ) + + assert result.status_code == 200 + pay_callback.assert_awaited_once_with("lnbc300n1pjpaatest") + retry_call = mock_request.call_args_list[1] + retry_headers = retry_call.kwargs.get("headers") or retry_call[1].get("headers", {}) + assert retry_headers["Authorization"] == f"L402 mac_paa:{fake_preimage}" + + @pytest.mark.asyncio + async def test_pay_and_access_mpp_flow(self): + """MPP: pay_and_access should pay invoice and retry with Payment Authorization.""" + fake_preimage = "01" * 32 + + response_402 = httpx.Response( + 402, + headers={"WWW-Authenticate": 'Payment method="lightning", invoice="lnbc400n1pjmpppaa"'}, + request=httpx.Request("GET", "https://api.example.com/mpp-resource"), + ) + response_200 = httpx.Response( + 200, + json={"data": "mpp-paid"}, + request=httpx.Request("GET", "https://api.example.com/mpp-resource"), + ) + + pay_callback = AsyncMock(return_value=fake_preimage) + + with patch("httpx.AsyncClient.request", new_callable=AsyncMock, side_effect=[response_402, response_200]) as mock_request: + async with L402Client() as client: + result = await client.pay_and_access( + "https://api.example.com/mpp-resource", + pay_invoice_callback=pay_callback, + ) + + assert result.status_code == 200 + pay_callback.assert_awaited_once_with("lnbc400n1pjmpppaa") + retry_call = mock_request.call_args_list[1] + retry_headers = retry_call.kwargs.get("headers") or retry_call[1].get("headers", {}) + assert retry_headers["Authorization"] == f'Payment method="lightning", preimage="{fake_preimage}"' + + @pytest.mark.asyncio + async def test_pay_and_access_callback_failure_raises(self): + """Payment callback failure should raise RuntimeError with context.""" + response_402 = httpx.Response( + 402, + headers={"WWW-Authenticate": 'L402 macaroon="mac_fail", invoice="lnbc100n1pjfail"'}, + request=httpx.Request("GET", "https://api.example.com/resource"), + ) + + pay_callback = AsyncMock(side_effect=ConnectionError("wallet offline")) + + with patch("httpx.AsyncClient.request", new_callable=AsyncMock, return_value=response_402): + async with L402Client() as client: + with pytest.raises(RuntimeError, match="Payment callback failed"): + await client.pay_and_access( + "https://api.example.com/resource", + pay_invoice_callback=pay_callback, + ) + + @pytest.mark.asyncio + async def test_pay_and_access_non_402_returns_directly(self): + """Non-402 responses should be returned without attempting payment.""" + response_200 = httpx.Response( + 200, + json={"data": "free"}, + request=httpx.Request("GET", "https://api.example.com/free"), + ) + + pay_callback = AsyncMock() + + with patch("httpx.AsyncClient.request", new_callable=AsyncMock, return_value=response_200): + async with L402Client() as client: + result = await client.pay_and_access( + "https://api.example.com/free", + pay_invoice_callback=pay_callback, + ) + + assert result.status_code == 200 + pay_callback.assert_not_awaited() + + +class TestMppRegexBoundary: + """Tests for MPP regex parameter-boundary matching.""" + + def test_no_false_positive_on_substring_method(self): + """A parameter like some_method='lightning' should not be matched as method='lightning'.""" + header = 'Payment some_method="lightning", invoice="lnbc100n1pjtest", method="lightning"' + result = parse_mpp_challenge(header) + # Should still parse correctly because real method="lightning" is present + assert result.invoice == "lnbc100n1pjtest" + + def test_reject_only_prefixed_method(self): + """If only a prefixed 'method' exists (no real 'method'), it should fail.""" + header = 'Payment xmethod="lightning", invoice="lnbc100n1pjtest"' + with pytest.raises(ValueError): + parse_mpp_challenge(header) + + class TestMppChallengeParsing: def test_parse_valid_mpp_header(self): header = 'Payment realm="api.example.com", method="lightning", invoice="lnbc100n1pjtest", amount="100", currency="sat"' @@ -252,19 +456,52 @@ async def test_verify_without_macaroon_none_explicit(self): assert "macaroon" not in payload @pytest.mark.asyncio - async def test_verify_missing_preimage_raises_type_error(self): - """Calling verify_payment() without preimage keyword arg should raise TypeError.""" + async def test_verify_missing_preimage_raises_value_error(self): + """Calling verify_payment() without preimage should raise ValueError.""" async with L402ProducerClient(le_api_key="test-key") as client: - with pytest.raises(TypeError): + with pytest.raises(ValueError, match="preimage is required"): await client.verify_payment("mac123") @pytest.mark.asyncio - async def test_verify_no_args_raises_type_error(self): - """Calling verify_payment() with no arguments should raise TypeError.""" + async def test_verify_no_args_raises_value_error(self): + """Calling verify_payment() with no arguments should raise ValueError.""" async with L402ProducerClient(le_api_key="test-key") as client: - with pytest.raises(TypeError): + with pytest.raises(ValueError, match="preimage is required"): await client.verify_payment() + @pytest.mark.asyncio + async def test_verify_empty_macaroon_raises_value_error(self): + """Passing an empty string as macaroon should raise ValueError, not silently switch to MPP.""" + async with L402ProducerClient(le_api_key="test-key") as client: + with pytest.raises(ValueError, match="macaroon must be a non-empty string"): + await client.verify_payment("", preimage="aa" * 32) + + @pytest.mark.asyncio + async def test_verify_whitespace_macaroon_raises_value_error(self): + """Passing whitespace-only macaroon should raise ValueError.""" + async with L402ProducerClient(le_api_key="test-key") as client: + with pytest.raises(ValueError, match="macaroon must be a non-empty string"): + await client.verify_payment(" ", preimage="aa" * 32) + + @pytest.mark.asyncio + async def test_verify_positional_backward_compat(self): + """Calling verify_payment(macaroon, preimage) positionally should still work.""" + mock_response = httpx.Response( + 200, + json={"valid": True, "resource": "/api/data"}, + request=httpx.Request("POST", "https://api.lightningenable.com/api/l402/challenges/verify"), + ) + with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response) as mock_post: + async with L402ProducerClient(le_api_key="test-key") as client: + result = await client.verify_payment("mac123", "aa" * 32) + + assert result.success is True + assert result.valid is True + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + assert payload["macaroon"] == "mac123" + assert payload["preimage"] == "aa" * 32 + @pytest.mark.asyncio async def test_verify_api_error_returns_failure(self): """Non-200 response should return a failure result.""" From b4ea505a6b9ddc9d0ab3b44fa097626cbc3631d7 Mon Sep 17 00:00:00 2001 From: refined-element Date: Sat, 21 Mar 2026 02:28:22 -0400 Subject: [PATCH 6/9] fix: Address Copilot review round 5 - Use RFC-compliant tchar boundary matching (^|[\s,]) for MPP regexes instead of incomplete negative lookbehind - Redact preimage from info-level logs to prevent credential leakage; full value only at debug level - Remove unused L402VerifyResponse import from test module - Support both quoted and unquoted (bare token) auth-param values in MPP challenge parsing per HTTP auth header grammar Co-Authored-By: Claude Opus 4.6 --- src/le_agent_sdk/l402/client.py | 34 ++++++++++++++++++--------------- tests/test_l402_client.py | 9 ++++++++- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/le_agent_sdk/l402/client.py b/src/le_agent_sdk/l402/client.py index c0fc503..514e1e7 100644 --- a/src/le_agent_sdk/l402/client.py +++ b/src/le_agent_sdk/l402/client.py @@ -59,21 +59,24 @@ class MppChallenge: r'(?:^|,\s*)(?=[A-Za-z][A-Za-z0-9!#$&\-^_`|~]*\s)', ) # Match parameters inside a Payment challenge's parameter list, ensuring we -# only match full parameter names (not substrings of longer names). +# only match full parameter names at proper boundaries (start-of-string, +# whitespace, or comma — covers the full RFC 7230 tchar set). +# Also supports both quoted and unquoted (bare token) auth-param values +# per HTTP auth header grammar. _MPP_INVOICE_RE = re.compile( - r'(?[^"]+)"', + r'(?:^|[\s,])invoice="?(?P[^",\s]+)"?', re.IGNORECASE, ) _MPP_METHOD_RE = re.compile( - r'(?[^"]+)"', + r'(?:^|[\s,])amount="?(?P[^",\s]+)"?', re.IGNORECASE, ) _MPP_REALM_RE = re.compile( - r'(?[^"]+)"', + r'(?:^|[\s,])realm="?(?P[^",\s]+)"?', re.IGNORECASE, ) @@ -346,15 +349,13 @@ async def access( # Build the correct Authorization header based on challenge type if isinstance(challenge, MppChallenge): auth_header = f'Payment method="lightning", preimage="{preimage}"' - logger.info( - "MPP payment succeeded. Preimage: %s (save this for recovery)", preimage - ) + logger.info("MPP payment succeeded for %s", url) + logger.debug("MPP preimage (first 8 chars): %.8s...", preimage) else: self._cache[challenge.macaroon] = preimage auth_header = f"L402 {challenge.macaroon}:{preimage}" - logger.info( - "L402 payment succeeded. Preimage: %s (save this for recovery)", preimage - ) + logger.info("L402 payment succeeded for %s", url) + logger.debug("L402 preimage (first 8 chars): %.8s...", preimage) # Retry the request with credentials, with retry+backoff headers["Authorization"] = auth_header @@ -369,20 +370,23 @@ async def access( last_exc = exc logger.warning( "Authenticated retry attempt %d/%d failed: %s. " - "Preimage for recovery: %s", + "Preimage prefix for recovery: %.8s...", attempt + 1, max_retries, exc, preimage, ) if attempt < max_retries - 1: await asyncio.sleep(0.5 * (2 ** attempt)) - # All retries exhausted — log preimage for recovery + # All retries exhausted — log preimage prefix for recovery identification logger.error( "All %d authenticated retries failed after payment. " - "IMPORTANT — save this preimage for manual recovery: %s", + "Preimage prefix for recovery: %.8s... (enable DEBUG logging for full value)", max_retries, preimage, ) + logger.debug( + "Full preimage for manual recovery: %s", preimage, + ) raise RuntimeError( - f"Payment succeeded (preimage: {preimage}) but all {max_retries} " + f"Payment succeeded (preimage prefix: {preimage[:8]}...) but all {max_retries} " f"authenticated retries failed: {last_exc}" ) diff --git a/tests/test_l402_client.py b/tests/test_l402_client.py index a82fd09..b78be7d 100644 --- a/tests/test_l402_client.py +++ b/tests/test_l402_client.py @@ -8,7 +8,6 @@ L402Challenge, L402Client, L402ProducerClient, - L402VerifyResponse, MppChallenge, parse_l402_challenge, parse_mpp_challenge, @@ -338,6 +337,14 @@ def test_realm_scoped_to_payment_segment(self): # The Bearer realm must NOT be captured assert result.realm is None + def test_parse_unquoted_values(self): + """Bare token (unquoted) auth-param values should parse correctly.""" + header = 'Payment method=lightning, invoice=lnbc100n1pjtest, amount=100, realm=api.example.com' + result = parse_mpp_challenge(header) + assert result.invoice == "lnbc100n1pjtest" + assert result.amount == "100" + assert result.realm == "api.example.com" + def test_realm_scoped_with_trailing_scheme(self): """Realm from a trailing scheme must not leak into a Payment challenge.""" header = ( From 58abbc1c751772a36da1a06d382adb999ba8aee6 Mon Sep 17 00:00:00 2001 From: refined-element Date: Sat, 21 Mar 2026 02:37:14 -0400 Subject: [PATCH 7/9] fix: Address Copilot review round 6 Tighten _MPP_METHOD_RE regex to enforce exact "lightning" value boundary, preventing false positives like method="lightning2" or method=lightningXYZ. Add tests verifying both quoted and unquoted suffixed variants are rejected. Co-Authored-By: Claude Opus 4.6 --- src/le_agent_sdk/l402/client.py | 2 +- tests/test_l402_client.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/le_agent_sdk/l402/client.py b/src/le_agent_sdk/l402/client.py index 514e1e7..cb5f17a 100644 --- a/src/le_agent_sdk/l402/client.py +++ b/src/le_agent_sdk/l402/client.py @@ -68,7 +68,7 @@ class MppChallenge: re.IGNORECASE, ) _MPP_METHOD_RE = re.compile( - r'(?:^|[\s,])method="?lightning"?', + r'(?:^|[\s,])method="?lightning"?(?=$|[\s,])', re.IGNORECASE, ) _MPP_AMOUNT_RE = re.compile( diff --git a/tests/test_l402_client.py b/tests/test_l402_client.py index b78be7d..07d13b5 100644 --- a/tests/test_l402_client.py +++ b/tests/test_l402_client.py @@ -289,6 +289,18 @@ def test_reject_only_prefixed_method(self): with pytest.raises(ValueError): parse_mpp_challenge(header) + def test_reject_lightning_suffix_in_method(self): + """method='lightning2' or method='lightningXYZ' must not be accepted.""" + header = 'Payment method="lightning2", invoice="lnbc100n1pjtest"' + with pytest.raises(ValueError): + parse_mpp_challenge(header) + + def test_reject_unquoted_lightning_suffix(self): + """Bare token method=lightningXYZ must not be accepted.""" + header = "Payment method=lightningXYZ, invoice=lnbc100n1pjtest" + with pytest.raises(ValueError): + parse_mpp_challenge(header) + class TestMppChallengeParsing: def test_parse_valid_mpp_header(self): From 7f37b03070d314ba5bfb2d5557f89468d38deaa4 Mon Sep 17 00:00:00 2001 From: refined-element Date: Sat, 21 Mar 2026 02:47:39 -0400 Subject: [PATCH 8/9] fix: Address Copilot review round 7 - Add missing '=' delimiter in _CHALLENGE_RE for L402 auth-param parsing - Add missing '=' delimiter in MPP regex patterns (_MPP_INVOICE_RE, _MPP_METHOD_RE, _MPP_AMOUNT_RE, _MPP_REALM_RE) - Stop logging full preimage at DEBUG level; log only 8-char prefix for security Co-Authored-By: Claude Opus 4.6 --- src/le_agent_sdk/l402/client.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/le_agent_sdk/l402/client.py b/src/le_agent_sdk/l402/client.py index cb5f17a..643100d 100644 --- a/src/le_agent_sdk/l402/client.py +++ b/src/le_agent_sdk/l402/client.py @@ -47,8 +47,8 @@ class MppChallenge: # Pattern for parsing L402/LSAT challenges _CHALLENGE_RE = re.compile( r'(?:L402|LSAT)\s+' - r'macaroon="?(?P[^",\s]+)"?\s*,\s*' - r'invoice="?(?P[^",\s]+)"?', + r'macaroon\s*=\s*"?(?P[^",\s]+)"?\s*,\s*' + r'invoice\s*=\s*"?(?P[^",\s]+)"?', re.IGNORECASE, ) @@ -64,19 +64,19 @@ class MppChallenge: # Also supports both quoted and unquoted (bare token) auth-param values # per HTTP auth header grammar. _MPP_INVOICE_RE = re.compile( - r'(?:^|[\s,])invoice="?(?P[^",\s]+)"?', + r'(?:^|[\s,])invoice\s*=\s*"?(?P[^",\s]+)"?', re.IGNORECASE, ) _MPP_METHOD_RE = re.compile( - r'(?:^|[\s,])method="?lightning"?(?=$|[\s,])', + r'(?:^|[\s,])method\s*=\s*"?lightning"?(?=$|[\s,])', re.IGNORECASE, ) _MPP_AMOUNT_RE = re.compile( - r'(?:^|[\s,])amount="?(?P[^",\s]+)"?', + r'(?:^|[\s,])amount\s*=\s*"?(?P[^",\s]+)"?', re.IGNORECASE, ) _MPP_REALM_RE = re.compile( - r'(?:^|[\s,])realm="?(?P[^",\s]+)"?', + r'(?:^|[\s,])realm\s*=\s*"?(?P[^",\s]+)"?', re.IGNORECASE, ) @@ -383,7 +383,8 @@ async def access( max_retries, preimage, ) logger.debug( - "Full preimage for manual recovery: %s", preimage, + "Preimage prefix for manual recovery (full value never logged for security): %.8s...", + preimage, ) raise RuntimeError( f"Payment succeeded (preimage prefix: {preimage[:8]}...) but all {max_retries} " From f069d2ec83b8ee40130918113e2c2cdb86749114 Mon Sep 17 00:00:00 2001 From: refined-element Date: Sat, 21 Mar 2026 02:59:19 -0400 Subject: [PATCH 9/9] fix: Address 4 Copilot review comments (PR #1 round 8) - Use call_args.kwargs["json"] instead of positional tuple access in verify_payment tests (threads PRRT_kwDORnWTtc513k28, PRRT_kwDORnWTtc513k3Q) - Remove contradictory log message about DEBUG logging showing full preimage when it only shows the prefix (thread PRRT_kwDORnWTtc513k3E) - Add proper preimage validation in verify_payment: reject empty strings, whitespace-only, and non-string values (thread PRRT_kwDORnWTtc513k3J) - Use .kwargs access pattern consistently for retry_call headers in tests Co-Authored-By: Claude Opus 4.6 --- src/le_agent_sdk/l402/client.py | 10 +++------- tests/test_l402_client.py | 32 +++++++++++++++++++++++--------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/le_agent_sdk/l402/client.py b/src/le_agent_sdk/l402/client.py index 643100d..a131dd8 100644 --- a/src/le_agent_sdk/l402/client.py +++ b/src/le_agent_sdk/l402/client.py @@ -379,13 +379,9 @@ async def access( # All retries exhausted — log preimage prefix for recovery identification logger.error( "All %d authenticated retries failed after payment. " - "Preimage prefix for recovery: %.8s... (enable DEBUG logging for full value)", + "Preimage prefix for recovery: %.8s... (full value never logged for security)", max_retries, preimage, ) - logger.debug( - "Preimage prefix for manual recovery (full value never logged for security): %.8s...", - preimage, - ) raise RuntimeError( f"Payment succeeded (preimage prefix: {preimage[:8]}...) but all {max_retries} " f"authenticated retries failed: {last_exc}" @@ -620,9 +616,9 @@ async def verify_payment( ValueError: If preimage is not provided, or if macaroon is provided but empty/whitespace. """ - if preimage is None: + if not preimage or not isinstance(preimage, str) or not preimage.strip(): raise ValueError( - "preimage is required; pass a hex-encoded preimage string" + "preimage is required; pass a non-empty hex-encoded preimage string" ) client = self._ensure_client() diff --git a/tests/test_l402_client.py b/tests/test_l402_client.py index 07d13b5..4fb6605 100644 --- a/tests/test_l402_client.py +++ b/tests/test_l402_client.py @@ -117,7 +117,7 @@ async def test_access_l402_402_pay_retry(self): pay_callback.assert_awaited_once_with("lnbc100n1pjtest") # Verify the retry request used the correct L402 Authorization header retry_call = mock_request.call_args_list[1] - retry_headers = retry_call.kwargs.get("headers") or retry_call[1].get("headers", {}) + retry_headers = retry_call.kwargs.get("headers", {}) assert retry_headers["Authorization"] == f"L402 mac_test:{fake_preimage}" @pytest.mark.asyncio @@ -148,7 +148,7 @@ async def test_access_mpp_402_pay_retry(self): pay_callback.assert_awaited_once_with("lnbc200n1pjmpptest") # Verify the retry request used the correct Payment Authorization header retry_call = mock_request.call_args_list[1] - retry_headers = retry_call.kwargs.get("headers") or retry_call[1].get("headers", {}) + retry_headers = retry_call.kwargs.get("headers", {}) assert retry_headers["Authorization"] == f'Payment method="lightning", preimage="{fake_preimage}"' @pytest.mark.asyncio @@ -198,7 +198,7 @@ async def test_pay_and_access_l402_flow(self): assert result.status_code == 200 pay_callback.assert_awaited_once_with("lnbc300n1pjpaatest") retry_call = mock_request.call_args_list[1] - retry_headers = retry_call.kwargs.get("headers") or retry_call[1].get("headers", {}) + retry_headers = retry_call.kwargs.get("headers", {}) assert retry_headers["Authorization"] == f"L402 mac_paa:{fake_preimage}" @pytest.mark.asyncio @@ -229,7 +229,7 @@ async def test_pay_and_access_mpp_flow(self): assert result.status_code == 200 pay_callback.assert_awaited_once_with("lnbc400n1pjmpppaa") retry_call = mock_request.call_args_list[1] - retry_headers = retry_call.kwargs.get("headers") or retry_call[1].get("headers", {}) + retry_headers = retry_call.kwargs.get("headers", {}) assert retry_headers["Authorization"] == f'Payment method="lightning", preimage="{fake_preimage}"' @pytest.mark.asyncio @@ -434,7 +434,7 @@ async def test_verify_with_macaroon_sends_both_fields(self): assert result.valid is True assert result.resource == "/api/data" call_kwargs = mock_post.call_args - payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + payload = call_kwargs.kwargs["json"] assert payload["macaroon"] == "mac123" assert payload["preimage"] == "aa" * 32 @@ -454,7 +454,7 @@ async def test_verify_without_macaroon_sends_preimage_only(self): assert result.valid is True assert result.resource == "/api/mpp-data" call_kwargs = mock_post.call_args - payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + payload = call_kwargs.kwargs["json"] assert "macaroon" not in payload assert payload["preimage"] == "bb" * 32 @@ -471,7 +471,7 @@ async def test_verify_without_macaroon_none_explicit(self): result = await client.verify_payment(None, preimage="cc" * 32) call_kwargs = mock_post.call_args - payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + payload = call_kwargs.kwargs["json"] assert "macaroon" not in payload @pytest.mark.asyncio @@ -488,6 +488,20 @@ async def test_verify_no_args_raises_value_error(self): with pytest.raises(ValueError, match="preimage is required"): await client.verify_payment() + @pytest.mark.asyncio + async def test_verify_empty_preimage_raises_value_error(self): + """Passing an empty string as preimage should raise ValueError.""" + async with L402ProducerClient(le_api_key="test-key") as client: + with pytest.raises(ValueError, match="preimage is required"): + await client.verify_payment("mac123", preimage="") + + @pytest.mark.asyncio + async def test_verify_whitespace_preimage_raises_value_error(self): + """Passing whitespace-only preimage should raise ValueError.""" + async with L402ProducerClient(le_api_key="test-key") as client: + with pytest.raises(ValueError, match="preimage is required"): + await client.verify_payment("mac123", preimage=" ") + @pytest.mark.asyncio async def test_verify_empty_macaroon_raises_value_error(self): """Passing an empty string as macaroon should raise ValueError, not silently switch to MPP.""" @@ -517,7 +531,7 @@ async def test_verify_positional_backward_compat(self): assert result.success is True assert result.valid is True call_kwargs = mock_post.call_args - payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + payload = call_kwargs.kwargs["json"] assert payload["macaroon"] == "mac123" assert payload["preimage"] == "aa" * 32 @@ -549,6 +563,6 @@ async def test_verify_strips_whitespace(self): result = await client.verify_payment(" mac123 ", preimage=" " + "ee" * 32 + " ") call_kwargs = mock_post.call_args - payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + payload = call_kwargs.kwargs["json"] assert payload["macaroon"] == "mac123" assert payload["preimage"] == "ee" * 32