Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,59 @@ core conversions. It also preserves the payment method when converting an MCP
receipt back to a core receipt. Until that fix is included in the version of
`pympp` you install, do not rely on its MCP receipt objects to retain those fields.

## x402 models and payment identifiers

Install `inflowpay[x402]` to use `inflowpay.x402`. Its `PaymentRequirements`,
`PaymentRequired`, `PaymentPayload`, verification, settlement, and capability
models are the actual classes from the upstream Python x402 SDK, not alternative
models that need converting before passing them to upstream code. They accept
InFlow's `balance` scheme and `inflow:1` network as well as blockchain schemes
and networks. Model availability does not imply that every scheme is supported
by a particular buyer or facilitator.

For example, declare an optional payment identifier on a payment request and
construct the matching entry for its payment payload:

```python
from inflowpay.x402 import (
PAYMENT_IDENTIFIER,
declare_payment_identifier,
generate_payment_id,
payment_identifier_entry,
)

declaration = declare_payment_identifier()
request_extensions = {PAYMENT_IDENTIFIER: declaration}
payment_id = generate_payment_id()
entry = payment_identifier_entry(declaration, payment_id)
assert entry is not None
payload_extensions = {PAYMENT_IDENTIFIER: entry}
```

The identifier is stored in `extensions["payment-identifier"]["info"]["id"]`.
It must contain 16–128 ASCII letters, digits, underscores, or hyphens.
`generate_payment_id()` uses a `pay_` prefix and 16 random bytes encoded as
32 hexadecimal characters. Generate one identifier for a payment and reuse it
when retrying that same payment; do not reuse it for a different payment.

`read_payment_identifier()` returns `None` for an invalid declaration.
`payment_identifier_entry()` returns `None` for an invalid declaration or identifier.
Both preserve extra fields in the declaration's `info` and `schema` and return
independent copies. The declaration uses `required: false`; supplying an
identifier remains useful for identifying retries even when it is optional.

Amounts in these models are strings in the payment method's smallest units.
InFlow balance amounts use 18 decimal places; blockchain assets use their own
decimal scale. `normalize_decimal_string()` removes insignificant zeros without
floating-point conversion, rounding, or unit conversion. It is not a price
validator: strings outside plain decimal notation are returned unchanged.

Use `model_dump(by_alias=True, exclude_none=True)` when producing JSON-compatible
wire dictionaries from upstream models. Upstream fills omitted requirement
`extra` with `{}`; additional values inside `extra`, `payload`, and `extensions`
are preserved. These typed models do not preserve arbitrary unknown top-level
fields. Parsing a model or creating an identifier does not verify or settle a payment.

## x402 facilitator capabilities

Facilitator capabilities describe the payment schemes, networks, and extensions
Expand Down
26 changes: 26 additions & 0 deletions scripts/verify_distribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,20 @@ async def check_seller():
assert util.find_spec(name) is None, name
"""

X402_CONSUMER = """
from importlib import util
from inflowpay.x402 import (
PaymentRequirements, declare_payment_identifier, generate_payment_id,
payment_identifier_entry,
)
from x402.schemas import PaymentRequirements as UpstreamRequirements
assert PaymentRequirements is UpstreamRequirements
entry = payment_identifier_entry(declare_payment_identifier(), generate_payment_id())
assert entry is not None and entry['info']['required'] is False
for name in ('mpp', 'mcp', 'web3', 'solana', 'fastapi', 'rfc8785'):
assert util.find_spec(name) is None, name
"""


def main() -> None:
repository = Path(__file__).resolve().parents[1]
Expand Down Expand Up @@ -87,6 +101,18 @@ def main() -> None:
["uv", "pip", "install", "--python", str(python), str(wheels[0])], check=True
)
subprocess.run([str(python), "-I", "-c", CONSUMER], cwd=temporary, check=True)
x402_environment = temporary / "x402-venv"
subprocess.run(
["uv", "venv", "--python", sys.executable, str(x402_environment)], check=True
)
x402_python = x402_environment / (
"Scripts/python.exe" if sys.platform == "win32" else "bin/python"
)
subprocess.run(
["uv", "pip", "install", "--python", str(x402_python), f"{wheels[0]}[x402]"],
check=True,
)
subprocess.run([str(x402_python), "-I", "-c", X402_CONSUMER], cwd=temporary, check=True)
subprocess.run(
["uv", "pip", "install", "--python", str(python), f"{wheels[0]}[mpp]"], check=True
)
Expand Down
52 changes: 52 additions & 0 deletions src/inflowpay/x402/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""x402 V2 models and InFlow payment extensions."""

from x402.schemas import (
PaymentPayload,
PaymentRequired,
PaymentRequirements,
ResourceInfo,
SettleResponse,
SupportedKind,
SupportedResponse,
VerifyResponse,
)

from ._core import (
INFLOW_AMOUNT_SCALE,
INFLOW_EIP7702_GAS_SPONSORING,
NETWORK_INFLOW,
PAYMENT_IDENTIFIER,
X402_VERSION,
IdentifierDeclaration,
declare_payment_identifier,
declare_sponsorship,
generate_payment_id,
normalize_decimal_string,
payment_identifier_entry,
read_payment_identifier,
validate_payment_id,
)

__all__ = [
"INFLOW_AMOUNT_SCALE",
"INFLOW_EIP7702_GAS_SPONSORING",
"NETWORK_INFLOW",
"PAYMENT_IDENTIFIER",
"X402_VERSION",
"IdentifierDeclaration",
"PaymentPayload",
"PaymentRequired",
"PaymentRequirements",
"ResourceInfo",
"SettleResponse",
"SupportedKind",
"SupportedResponse",
"VerifyResponse",
"declare_payment_identifier",
"declare_sponsorship",
"generate_payment_id",
"normalize_decimal_string",
"payment_identifier_entry",
"read_payment_identifier",
"validate_payment_id",
]
104 changes: 104 additions & 0 deletions src/inflowpay/x402/_core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import re
import secrets
from copy import deepcopy
from typing import TypedDict, cast

X402_VERSION = 2
NETWORK_INFLOW = "inflow:1"
INFLOW_AMOUNT_SCALE = 18
PAYMENT_IDENTIFIER = "payment-identifier"
INFLOW_EIP7702_GAS_SPONSORING = "inflowEip7702GasSponsoring"
_ID_PATTERN = "^[a-zA-Z0-9_-]+$"


class IdentifierDeclaration(TypedDict):
info: dict[str, object]
schema: dict[str, object]


def validate_payment_id(value: object) -> bool:
return (
isinstance(value, str)
and 16 <= len(value) <= 128
and re.fullmatch(_ID_PATTERN, value) is not None
)


def generate_payment_id(prefix: str = "pay_") -> str:
"""Append 32 cryptographically random hexadecimal characters to the prefix."""
if (
not isinstance(prefix, str)
or len(prefix) > 96
or not re.fullmatch(r"[a-zA-Z0-9_-]*", prefix)
):
raise ValueError(
"Payment identifier prefix must contain at most 96 ASCII letters, digits, '_' or '-'"
)
return prefix + secrets.token_hex(16)


def declare_payment_identifier() -> IdentifierDeclaration:
return {
"info": {"required": False},
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": {"type": "string", "minLength": 16, "maxLength": 128, "pattern": _ID_PATTERN},
"required": {"type": "boolean"},
},
"required": ["required"],
},
}


def _object(value: object) -> dict[str, object]:
# Extension input is decoded JSON; nested values remain untrusted objects.
return cast(dict[str, object], value) if isinstance(value, dict) else {}


def read_payment_identifier(value: object) -> IdentifierDeclaration | None:
"""Read a declaration without sharing its mutable data with the caller."""
fields = _object(value)
info, schema = _object(fields.get("info")), _object(fields.get("schema"))
properties = _object(schema.get("properties"))
identifier = _object(properties.get("id"))
if not (
isinstance(info.get("required"), bool)
and schema.get("$schema") == "https://json-schema.org/draft/2020-12/schema"
and schema.get("type") == "object"
and identifier.get("type") == "string"
and identifier.get("minLength") == 16
and identifier.get("maxLength") == 128
and identifier.get("pattern") == _ID_PATTERN
and _object(properties.get("required")).get("type") == "boolean"
and schema.get("required") == ["required"]
):
return None
return {"info": deepcopy(info), "schema": deepcopy(schema)}


def payment_identifier_entry(declaration: object, payment_id: str) -> IdentifierDeclaration | None:
if not validate_payment_id(payment_id):
return None
entry = read_payment_identifier(declaration)
if entry is not None:
entry["info"]["id"] = payment_id
return entry


def declare_sponsorship() -> dict[str, object]:
return {INFLOW_EIP7702_GAS_SPONSORING: {"info": {"version": "1"}}}


def normalize_decimal_string(value: str) -> str:
"""Remove insignificant zeros without rounding; non-plain decimal strings are unchanged."""
if re.fullmatch(r"-?[0-9]+(?:\.[0-9]+)?", value) is None:
return value
integer, _, fraction = value.removeprefix("-").partition(".")
integer = integer.lstrip("0") or "0"
fraction = fraction.rstrip("0")
if integer == "0" and not fraction:
return "0"
result = integer + ("." + fraction if fraction else "")
return "-" + result if value.startswith("-") else result
Loading
Loading