Skip to content
14 changes: 12 additions & 2 deletions src/le_agent_sdk/l402/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
182 changes: 164 additions & 18 deletions src/le_agent_sdk/l402/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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+'
Expand All @@ -43,6 +52,18 @@ def authorization_header(self) -> str:
re.IGNORECASE,
Comment thread
refined-element marked this conversation as resolved.
)

# Patterns for parsing MPP (Machine Payments Protocol) challenges
# _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<invoice>[^"]+)"', re.IGNORECASE)
_MPP_METHOD_RE = re.compile(r'method="lightning"', re.IGNORECASE)
_MPP_AMOUNT_RE = re.compile(r'amount="(?P<amount>[^"]+)"', re.IGNORECASE)
_MPP_REALM_RE = re.compile(r'realm="(?P<realm>[^"]+)"', re.IGNORECASE)
Comment thread
refined-element marked this conversation as resolved.
Outdated


def parse_l402_challenge(headers: dict[str, str]) -> Optional[L402Challenge]:
"""Extract an L402 challenge from response headers.
Expand All @@ -68,6 +89,91 @@ 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.

Args:
header: The WWW-Authenticate header value string.

Returns:
Parsed MppChallenge.

Raises:
ValueError: If the header is not a valid MPP challenge.
"""
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]}")

invoice_match = _MPP_INVOICE_RE.search(payment_segment)
if not invoice_match:
raise ValueError(f"Invalid MPP challenge: {header[:80]}")

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").strip() if amount_match else None,
realm=realm_match.group("realm").strip() 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")
Comment thread
refined-element marked this conversation as resolved.
Outdated

# 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.

Expand Down Expand Up @@ -181,8 +287,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:
Expand Down Expand Up @@ -219,13 +328,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
)
Comment thread
refined-element marked this conversation as resolved.
Outdated

# 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
Comment thread
refined-element marked this conversation as resolved.
max_retries = 3
last_exc: Optional[Exception] = None

Expand Down Expand Up @@ -282,14 +399,35 @@ 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)
Comment thread
refined-element marked this conversation as resolved.
Outdated
self._cache[challenge.macaroon] = preimage

headers["Authorization"] = f"L402 {challenge.macaroon}:{preimage}"
# 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}"'
else:
self._cache[challenge.macaroon] = preimage
headers["Authorization"] = f"L402 {challenge.macaroon}:{preimage}"
Comment thread
refined-element marked this conversation as resolved.

retry_response = await client.request(method, url, headers=headers, **kwargs)
return retry_response
Comment thread
refined-element marked this conversation as resolved.

Expand Down Expand Up @@ -427,27 +565,35 @@ async def create_challenge(

async def verify_payment(
self,
macaroon: str,
preimage: str,
macaroon: Optional[str] = None,
preimage: str = "",
Comment thread
refined-element marked this conversation as resolved.
Outdated
) -> L402VerifyResponse:
Comment thread
refined-element marked this conversation as resolved.
Comment thread
refined-element marked this conversation as resolved.
"""Verify an L402 token (macaroon + preimage) to confirm payment.
"""Verify an L402 or MPP token to confirm payment.

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 an L402 token from the requester
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.
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).

Returns:
L402VerifyResponse indicating whether the payment is valid.
"""
client = self._ensure_client()

payload: dict[str, str] = {"preimage": preimage.strip()}
Comment thread
refined-element marked this conversation as resolved.
if macaroon:
payload["macaroon"] = macaroon.strip()

Comment thread
refined-element marked this conversation as resolved.
Outdated
try:
response = await client.post(
f"{self._base_url}/api/l402/challenges/verify",
json={"macaroon": macaroon.strip(), "preimage": preimage.strip()},
json=payload,
)
Comment thread
refined-element marked this conversation as resolved.

if response.status_code != 200:
Expand Down
116 changes: 114 additions & 2 deletions tests/test_l402_client.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -74,3 +81,108 @@ 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"

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

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


Comment thread
refined-element marked this conversation as resolved.
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"
Loading