diff --git a/README.md b/README.md index d6539c1..f71f048 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/scripts/verify_distribution.py b/scripts/verify_distribution.py index 5770acb..6844fee 100644 --- a/scripts/verify_distribution.py +++ b/scripts/verify_distribution.py @@ -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] @@ -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 ) diff --git a/src/inflowpay/x402/__init__.py b/src/inflowpay/x402/__init__.py new file mode 100644 index 0000000..43a9e52 --- /dev/null +++ b/src/inflowpay/x402/__init__.py @@ -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", +] diff --git a/src/inflowpay/x402/_core.py b/src/inflowpay/x402/_core.py new file mode 100644 index 0000000..d511bc2 --- /dev/null +++ b/src/inflowpay/x402/_core.py @@ -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 diff --git a/tests/fixtures/x402-core.json b/tests/fixtures/x402-core.json new file mode 100644 index 0000000..373ffc9 --- /dev/null +++ b/tests/fixtures/x402-core.json @@ -0,0 +1,310 @@ +{ + "cases": [ + { + "id": "x402.core.identifier-minimum", + "suite": "x402-core", + "operation": "x402.core.identifier-valid", + "input": { + "value": "aaaaaaaaaaaaaaaa" + }, + "expect": { + "result": true + } + }, + { + "id": "x402.core.identifier-maximum", + "suite": "x402-core", + "operation": "x402.core.identifier-valid", + "input": { + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "expect": { + "result": true + } + }, + { + "id": "x402.core.identifier-mixed", + "suite": "x402-core", + "operation": "x402.core.identifier-valid", + "input": { + "value": "ABC_def-0123456789" + }, + "expect": { + "result": true + } + }, + { + "id": "x402.core.identifier-short", + "suite": "x402-core", + "operation": "x402.core.identifier-valid", + "input": { + "value": "aaaaaaaaaaaaaaa" + }, + "expect": { + "result": false + } + }, + { + "id": "x402.core.identifier-long", + "suite": "x402-core", + "operation": "x402.core.identifier-valid", + "input": { + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "expect": { + "result": false + } + }, + { + "id": "x402.core.identifier-space", + "suite": "x402-core", + "operation": "x402.core.identifier-valid", + "input": { + "value": "pay_0123456789 abcdef" + }, + "expect": { + "result": false + } + }, + { + "id": "x402.core.identifier-unicode", + "suite": "x402-core", + "operation": "x402.core.identifier-valid", + "input": { + "value": "pay_0123456789éabcdef" + }, + "expect": { + "result": false + } + }, + { + "id": "x402.core.identifier-empty", + "suite": "x402-core", + "operation": "x402.core.identifier-valid", + "input": { + "value": "" + }, + "expect": { + "result": false + } + }, + { + "id": "x402.core.identifier-null", + "suite": "x402-core", + "operation": "x402.core.identifier-valid", + "input": { + "value": null + }, + "expect": { + "result": false + } + }, + { + "id": "x402.core.identifier-declaration", + "suite": "x402-core", + "operation": "x402.core.identifier-declaration", + "input": {}, + "expect": { + "result": { + "info": { + "required": false + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + } + } + }, + { + "id": "x402.core.identifier-entry-false", + "suite": "x402-core", + "operation": "x402.core.identifier-entry", + "input": { + "declaration": { + "info": { + "required": false, + "merchant": "test-shop" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "result": { + "info": { + "required": false, + "merchant": "test-shop", + "id": "pay_0123456789abcdef0123456789abcdef" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + } + } + }, + { + "id": "x402.core.identifier-entry-true", + "suite": "x402-core", + "operation": "x402.core.identifier-entry", + "input": { + "declaration": { + "info": { + "required": true, + "merchant": "test-shop" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "result": { + "info": { + "required": true, + "merchant": "test-shop", + "id": "pay_0123456789abcdef0123456789abcdef" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + } + } + }, + { + "id": "x402.core.identifier-invalid-entry", + "suite": "x402-core", + "operation": "x402.core.identifier-entry", + "input": { + "declaration": { + "info": { + "required": false + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + }, + "payment_id": "short" + }, + "expect": { + "result": null + } + }, + { + "id": "x402.core.identifier-null-properties", + "suite": "x402-core", + "operation": "x402.core.identifier-entry", + "input": { + "declaration": { + "info": { + "required": false + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": null, + "required": [ + "required" + ] + } + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "result": null + } + } + ] +} diff --git a/tests/test_x402_core.py b/tests/test_x402_core.py new file mode 100644 index 0000000..f59d316 --- /dev/null +++ b/tests/test_x402_core.py @@ -0,0 +1,231 @@ +import json +from copy import deepcopy +from pathlib import Path +from typing import cast + +import pytest +from x402 import schemas + +from inflowpay.x402 import ( + INFLOW_AMOUNT_SCALE, + INFLOW_EIP7702_GAS_SPONSORING, + NETWORK_INFLOW, + PAYMENT_IDENTIFIER, + X402_VERSION, + 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, +) + +# Copied unchanged from inflow-specs/fixtures/x402.mjs, x402-core suite. +CASES = json.loads(Path(__file__).with_name("fixtures").joinpath("x402-core.json").read_text())[ + "cases" +] + + +@pytest.mark.parametrize("case", CASES, ids=[c["id"] for c in CASES]) +def test_shared_core(case: dict[str, object]) -> None: + data = cast(dict[str, object], case["input"]) + expected = cast(dict[str, object], case["expect"])["result"] + before = deepcopy(data) + match case["operation"]: + case "x402.core.identifier-valid": + result: object = validate_payment_id(data["value"]) + case "x402.core.identifier-declaration": + result = declare_payment_identifier() + case "x402.core.identifier-entry": + result = payment_identifier_entry(data["declaration"], str(data["payment_id"])) + case _: + pytest.fail(f"Unknown operation: {case['operation']}") + assert result == expected + assert data == before + + +@pytest.mark.parametrize("prefix", ["pay_", "", "A_b-09", "p" * 96]) +def test_generated_identifier(prefix: str) -> None: + values = {generate_payment_id(prefix) for _ in range(100)} + assert len(values) == 100 + for value in values: + assert value.startswith(prefix) + assert len(value) == len(prefix) + 32 + assert validate_payment_id(value) + assert len(bytes.fromhex(value[len(prefix) :])) == 16 + + +@pytest.mark.parametrize("prefix", [None, 1, "p" * 97, "space here", "é", "pay_\n"]) +def test_invalid_prefix(prefix: object) -> None: + with pytest.raises(ValueError, match="prefix"): + generate_payment_id(cast(str, prefix)) + + +@pytest.mark.parametrize("value", [False, 123, {}, [], "a" * 16 + "\n"]) +def test_invalid_identifier(value: object) -> None: + assert not validate_payment_id(value) + + +@pytest.mark.parametrize("value", [None, [], "", {"info": []}, {"info": {"required": 1}}]) +def test_invalid_declaration(value: object) -> None: + assert read_payment_identifier(value) is None + + +@pytest.mark.parametrize( + ("path", "value"), + [ + (("info", "required"), None), + (("schema",), None), + (("schema", "$schema"), "wrong"), + (("schema", "type"), "array"), + (("schema", "properties"), []), + (("schema", "properties", "id"), None), + (("schema", "properties", "id", "type"), "integer"), + (("schema", "properties", "id", "minLength"), 15), + (("schema", "properties", "id", "maxLength"), 129), + (("schema", "properties", "id", "pattern"), ".*"), + (("schema", "properties", "required"), None), + (("schema", "properties", "required", "type"), "string"), + (("schema", "required"), []), + (("schema", "required"), ["id", "required"]), + (("schema", "required"), "required"), + ], +) +def test_malformed_schema(path: tuple[str, ...], value: object) -> None: + declaration = declare_payment_identifier() + current = cast(dict[str, object], declaration) + for key in path[:-1]: + current = cast(dict[str, object], current[key]) + current[path[-1]] = value + assert read_payment_identifier(declaration) is None + assert payment_identifier_entry(declaration, "pay_0123456789abcdef") is None + + +def test_declarations_are_independent() -> None: + declaration = declare_payment_identifier() + declaration["info"]["merchant"] = {"name": "shop"} + declaration["schema"]["title"] = "Identifier" + original = deepcopy(declaration) + entry = payment_identifier_entry(declaration, "pay_0123456789abcdef") + assert entry is not None + assert entry["info"]["required"] is False + assert entry["schema"]["title"] == "Identifier" + cast(dict[str, object], entry["info"]["merchant"])["name"] = "changed" + cast(dict[str, object], entry["schema"]["properties"]).clear() + assert declaration == original + assert "merchant" not in declare_payment_identifier()["info"] + assert read_payment_identifier(declare_payment_identifier()) is not None + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("0", "0"), + ("-000.000", "0"), + ("00012.3400", "12.34"), + ("-0012.3400", "-12.34"), + ("0012", "12"), + ("0.00001", "0.00001"), + ( + "123456789012345678901234567890.123456789012345678900", + "123456789012345678901234567890.1234567890123456789", + ), + ("1e3", "1e3"), + ("NaN", "NaN"), + ("", ""), + ("+1", "+1"), + ("1.", "1."), + (".1", ".1"), + (" 1", " 1"), + ("1\n", "1\n"), + ("\u0661", "\u0661"), + ], +) +def test_decimal(value: str, expected: str) -> None: + assert normalize_decimal_string(value) == expected + + +def test_sponsorship_declaration() -> None: + result = declare_sponsorship() + assert result == {INFLOW_EIP7702_GAS_SPONSORING: {"info": {"version": "1"}}} + result.clear() + assert declare_sponsorship() + + +def test_models_are_upstream_types() -> None: + for model in ( + PaymentPayload, + PaymentRequired, + PaymentRequirements, + ResourceInfo, + SettleResponse, + SupportedKind, + SupportedResponse, + VerifyResponse, + ): + assert model is getattr(schemas, model.__name__) + assert X402_VERSION == 2 + assert NETWORK_INFLOW == "inflow:1" + assert INFLOW_AMOUNT_SCALE == 18 + + +@pytest.mark.parametrize("scheme", ["balance", "exact", "upto", "custom-scheme"]) +def test_upstream_wire_round_trip(scheme: str) -> None: + requirements = { + "scheme": scheme, + "network": NETWORK_INFLOW, + "asset": "USDC", + "amount": "1000000000000000000", + "payTo": "seller-id", + "maxTimeoutSeconds": 300, + "extra": {"nested": {"future": ["value"]}}, + } + wire = { + "x402Version": 2, + "accepted": requirements, + "payload": {"transactionId": "transaction-id", "custom": {"signed": "preserved"}}, + "extensions": { + PAYMENT_IDENTIFIER: payment_identifier_entry( + declare_payment_identifier(), "pay_0123456789abcdef" + ), + "custom": {"future": [1, 2]}, + }, + } + before = deepcopy(wire) + model = PaymentPayload.model_validate(wire) + assert model.model_dump(by_alias=True, exclude_none=True) == before + assert wire == before + required_wire = {"x402Version": 2, "accepts": [requirements], "extensions": wire["extensions"]} + assert ( + PaymentRequired.model_validate(required_wire).model_dump(by_alias=True, exclude_none=True) + == required_wire + ) + + +def test_upstream_optional_fields() -> None: + requirements = PaymentRequirements.model_validate( + { + "scheme": "balance", + "network": NETWORK_INFLOW, + "asset": "USDC", + "amount": "1", + "payTo": "seller", + "maxTimeoutSeconds": 30, + } + ) + assert requirements.extra == {} + assert requirements.model_dump(by_alias=True)["extra"] == {} + supported = SupportedResponse.model_validate( + {"kinds": [{"x402Version": 2, "scheme": "balance", "network": NETWORK_INFLOW}]} + ) + assert supported.extensions == [] + assert supported.signers == {}