Skip to content
Closed
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
18 changes: 17 additions & 1 deletion examples/python/clients/batch-settlement/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@ at the end to claw back any unused channel balance.
uv sync --reinstall-package x402
```

## Deposit policy

The client deposits `extra.minDeposit` when the server announced a valid hint, otherwise `amount × DEPOSIT_MULTIPLIER` (default `5`, minimum `3`).

`x402Client` spend controls still cap each request's `amount` (default `$1` on USDC). That same atomic cap is the escrow ceiling:

`maxDeposit = maxAmountPerPayment × depositMultiplier`

So the default `$1` cap and multiplier `5` lock at most `$5`. `spend_controls=False` (or any uncapped asset) leaves the deposit uncapped too.

Use `deposit_strategy` only for app-specific decisions:

- **`None`** — use the SDK default (`deposit_amount` in context).
- **`False`** — skip this deposit attempt.
- **Base-unit string or `int`** — custom amount; must be **≥ `minimum_deposit_amount`**, and still respects `max_deposit` when a spend cap is set.

## Environment

| Variable | Required | Description |
Expand All @@ -22,7 +38,7 @@ uv sync --reinstall-package x402
| `EVM_VOUCHER_SIGNER_PRIVATE_KEY` | no | Dedicated voucher-signing key (`payerAuthorizer`). Defaults to `EVM_PRIVATE_KEY`. |
| `EVM_RPC_URL` | no | EVM JSON-RPC endpoint. Defaults to `https://sepolia.base.org`. |
| `CHANNEL_SALT` | no | 32-byte hex salt for channel ID derivation. Defaults to all-zeros. |
| `DEPOSIT_MULTIPLIER` | no | Deposit = multiplier × request price (default `5`). |
| `DEPOSIT_MULTIPLIER` | no | Deposit target is `amount ×` this multiplier when `extra.minDeposit` is absent; lock ceiling is `spendCap ×` this multiplier (integer **≥ 3**; default `5`). |
| `STORAGE_DIR` | no | Directory for persistent file-backed channel storage. Defaults to in-memory. |
| `NUMBER_OF_REQUESTS` | no | Number of paid requests to issue (default `3`). |
| `REFUND_AFTER_REQUESTS` | no | Set to `true` to issue a cooperative refund at the end. |
Expand Down
5 changes: 4 additions & 1 deletion examples/python/clients/batch-settlement/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
EVM_VOUCHER_SIGNER_PRIVATE_KEY Optional. Dedicated voucher-signing key (payerAuthorizer).
EVM_RPC_URL Optional. Defaults to https://sepolia.base.org.
CHANNEL_SALT Optional. 32-byte hex salt for channel ID derivation.
DEPOSIT_MULTIPLIER Optional. Deposit = multiplier * request_price (default 5).
DEPOSIT_MULTIPLIER Optional. Deposit target is amount × this multiplier when extra.minDeposit is absent; lock ceiling is spendCap × this multiplier (integer ≥ 3; default 5).
STORAGE_DIR Optional. Directory for persistent file-backed channel storage.
NUMBER_OF_REQUESTS Optional. Number of paid requests to issue (default 3).
REFUND_AFTER_REQUESTS Optional. Set to "true" to issue a cooperative refund at the end.
Expand Down Expand Up @@ -94,6 +94,9 @@ async def main() -> None:
),
)
client = x402Client().register("eip155:*", batch_scheme)
# Per-request cap on PaymentRequirements.amount (default "$1" if omitted).
# Deposit ceiling is this cap × deposit_multiplier ($5 at the defaults).
client.set_spend_controls({"max_amount_per_payment": "$1"})
http_client = x402HTTPClient(client)

url = f"{RESOURCE_SERVER_URL}{ENDPOINT_PATH}"
Expand Down
2 changes: 1 addition & 1 deletion examples/python/servers/batch-settlement/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,5 @@ uv run python main.py

## Endpoints

- `GET /weather` — protected; usage-based-priced at a random 1–100% of `$0.01`.
- `GET /weather` — protected; usage-based-priced at a random 1–100% of `$0.01`. Every 402 also includes `extra.minDeposit` (SDK default `10 × amount`) so clients can size the channel deposit. Override per route with `accepts.extra.minDeposit` (`"$0.10"` on the default asset, or an atomic string). The server announces the hint only; it does not reject smaller deposits unless you set `enforce_min_deposit=True`.
- `GET /health` — unprotected.
3 changes: 3 additions & 0 deletions examples/python/servers/batch-settlement/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
receiver_authorizer_signer=(
LocalAuthorizerSigner(RECEIVER_AUTH_KEY) if RECEIVER_AUTH_KEY else None
),
enforce_min_deposit=False,
storage=FileChannelStorage(STORAGE_DIR) if STORAGE_DIR else None,
)
scheme = BatchSettlementEvmScheme(EVM_ADDRESS, scheme_config)
Expand Down Expand Up @@ -113,6 +114,8 @@ async def lifespan(_app: FastAPI):
"payTo": EVM_ADDRESS,
"price": MAX_PRICE,
"network": EVM_NETWORK,
# Optional: override the default 10× deposit hint (Money strings require the network default asset).
# "extra": {"minDeposit": "$0.10"},
},
},
}
Expand Down
2 changes: 2 additions & 0 deletions python/x402/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
PaymentFlowConfig,
PaymentFlowName,
PaymentFlowPhases,
PaymentPayloadContext,
ResolvedPaymentFlow,
SchemeNetworkClient,
SchemeNetworkClientV1,
Expand Down Expand Up @@ -209,6 +210,7 @@
"SchemeNetworkServer",
"SchemeNetworkFacilitator",
"SchemeNetworkFacilitatorV1",
"PaymentPayloadContext",
"PaymentFlowName",
"PaymentFlowPhases",
"PaymentFlowConfig",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Pass the resolved atomic `spend_controls` cap to every scheme on `PaymentPayloadContext.max_amount_per_payment` (omitted when uncapped) so capital-locking schemes can reuse client policy without re-resolving it. Batch-settlement EVM servers always announce `extra.minDeposit` (default `10 × amount`, optional per-route override via `accepts.extra.minDeposit`). Clients size deposits from the hint when valid, clamped to that cap × `deposit_multiplier` when a spend cap is set. Uncapped payments (`spend_controls=False` or no per-asset cap) also leave deposits uncapped. Older 402s fall back to `deposit_multiplier` for sizing. Servers may opt in to SDK enforcement via `enforce_min_deposit=True` (default off; facilitator never enforces). Export `invalid_batch_settlement_evm_deposit_below_min_deposit` for custom server enforcement.
168 changes: 107 additions & 61 deletions python/x402/client_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from typing_extensions import Self

from .hook_adapters import collect_client_scheme_hook_handles, get_labeled_client_hooks
from .interfaces import SchemeNetworkClient, SchemeNetworkClientV1
from .interfaces import PaymentPayloadContext, SchemeNetworkClient, SchemeNetworkClientV1
from .schemas import (
AbortResult,
Money,
Expand Down Expand Up @@ -213,6 +213,60 @@ class SpendControls(TypedDict, total=False):
allowed_assets: Literal[True] | list[SpendControlAsset]


def _is_atomic_amount(amount: str) -> bool:
"""Return whether ``amount`` is an integer atomic string."""
return bool(_ATOMIC_AMOUNT.fullmatch(amount))


def _find_spend_control_asset_entry(
controls: SpendControls,
network: str,
asset: str,
default_asset: dict[str, Any] | None,
) -> SpendControlAsset | None:
"""Find the matching ``allowed_assets`` entry for a requirement."""
if controls.get("allowed_assets") is True:
return None
for entry in controls.get("allowed_assets") or []:
if not matches_network_pattern(network, entry["network"]):
continue
if entry["asset"].lower() == asset.lower():
return entry
if default_asset is not None and default_asset["symbol"].lower() == entry["asset"].lower():
return entry
return None


def _resolve_atomic_spend_cap(
controls: SpendControls | Literal[False],
network: str,
asset: str,
default_asset: dict[str, Any] | None,
) -> str | None:
"""Resolve the atomic spend cap for filtering and payload context.

Returns ``None`` when the payment is uncapped.
"""
if controls is False:
return None

asset_entry = _find_spend_control_asset_entry(controls, network, asset, default_asset)
if asset_entry is not None and asset_entry.get("max_amount_per_payment") is not None:
cap = asset_entry["max_amount_per_payment"]
if not _is_atomic_amount(cap):
raise ValueError(
"spend_controls.allowed_assets[].max_amount_per_payment must be an "
f"integer atomic amount, not a dollar value; got {cap!r}"
)
return cap

if not default_asset or controls.get("max_amount_per_payment") is False:
return None

usd_limit = controls.get("max_amount_per_payment", DEFAULT_MAX_AMOUNT_PER_PAYMENT)
return convert_to_token_amount(parse_money(usd_limit)["amount"], default_asset["decimals"])


@dataclass
class SchemeRegistration:
"""Configuration for registering a payment scheme with a specific network."""
Expand Down Expand Up @@ -505,9 +559,6 @@ def _apply_spend_controls(
def raw_amount_of(requirement: RequirementsView) -> str:
return requirement.get_amount()

def amount_of(requirement: RequirementsView) -> int:
return int(raw_amount_of(requirement))

def scheme_for(requirement: RequirementsView) -> Any:
schemes = find_schemes_by_network(client_schemes_by_network, requirement.network)
if schemes is None:
Expand All @@ -521,29 +572,15 @@ def default_asset_for(requirement: RequirementsView) -> dict[str, Any] | None:
return None
return finder(requirement.asset, requirement.network)

def matches_asset_entry(entry: SpendControlAsset, requirement: RequirementsView) -> bool:
if not matches_network_pattern(requirement.network, entry["network"]):
return False
if entry["asset"].lower() == requirement.asset.lower():
return True
default_asset = default_asset_for(requirement)
return (
default_asset is not None
and default_asset["symbol"].lower() == entry["asset"].lower()
)

asset_entries = (
None if controls.get("allowed_assets") is True else controls.get("allowed_assets")
)
allow_any_asset = controls.get("allowed_assets") is True

def find_asset_entry(requirement: RequirementsView) -> SpendControlAsset | None:
if not asset_entries:
return None
for entry in asset_entries:
if matches_asset_entry(entry, requirement):
return entry
return None
return _find_spend_control_asset_entry(
controls,
requirement.network,
requirement.asset,
default_asset_for(requirement),
)

if allow_any_asset:
filtered = list(requirements)
Expand Down Expand Up @@ -573,53 +610,47 @@ def find_asset_entry(requirement: RequirementsView) -> SpendControlAsset | None:

kept: list[RequirementsView] = []
for requirement in filtered:
asset_entry = find_asset_entry(requirement)
if asset_entry is not None and asset_entry.get("max_amount_per_payment") is not None:
cap = asset_entry["max_amount_per_payment"]
if not _ATOMIC_AMOUNT.fullmatch(cap):
raise ValueError(
"spend_controls.allowed_assets[].max_amount_per_payment must be an "
f"integer atomic amount, not a dollar value; got {cap!r}"
)
if not _ATOMIC_AMOUNT.fullmatch(raw_amount_of(requirement)):
rejected_by_asset_cap = True
continue
ok = amount_of(requirement) <= int(cap)
if not ok:
rejected_by_asset_cap = True
else:
kept.append(requirement)
continue

default_asset = default_asset_for(requirement)
if not default_asset:
kept.append(requirement)
continue

if usd_limit is False:
asset_entry = _find_spend_control_asset_entry(
controls, requirement.network, requirement.asset, default_asset
)
cap = _resolve_atomic_spend_cap(
controls, requirement.network, requirement.asset, default_asset
)
if cap is None:
kept.append(requirement)
continue

raw_amount = raw_amount_of(requirement)
if not _ATOMIC_AMOUNT.fullmatch(raw_amount):
if not _is_atomic_amount(raw_amount):
if (
asset_entry is not None
and asset_entry.get("max_amount_per_payment") is not None
):
rejected_by_asset_cap = True
continue
# Decimal ledger value (e.g. XRPL IOU "0.01") — 1:1 USD vs the Money cap.
if usd_limit is False:
kept.append(requirement)
continue
value_scaled = int(convert_to_token_amount(raw_amount, 18))
cap_scaled = int(convert_to_token_amount(parse_money(usd_limit)["amount"], 18))
ok = value_scaled <= cap_scaled
if not ok:
rejected_usd_symbol = default_asset["symbol"]
rejected_usd_symbol = default_asset["symbol"] if default_asset else None
else:
kept.append(requirement)
continue

max_atomic = int(
convert_to_token_amount(
parse_money(usd_limit)["amount"],
default_asset["decimals"],
)
)
ok = amount_of(requirement) <= max_atomic
ok = int(raw_amount) <= int(cap)
if not ok:
rejected_usd_symbol = default_asset["symbol"]
if (
asset_entry is not None
and asset_entry.get("max_amount_per_payment") is not None
):
rejected_by_asset_cap = True
elif default_asset:
rejected_usd_symbol = default_asset["symbol"]
else:
kept.append(requirement)

Expand Down Expand Up @@ -770,13 +801,28 @@ def _create_payment_payload_v2_core(

client = schemes[selected.scheme]

# 5. Create inner payload (pass extensions for enrichment if scheme supports it)
# 5. Create inner payload (pass extensions + spend cap when the scheme
# accepts them).
server_extensions = payment_required.extensions
finder = getattr(client, "find_default_asset", None)
default_asset = finder(selected.asset, selected.network) if callable(finder) else None
payload_context = PaymentPayloadContext(
extensions=server_extensions,
max_amount_per_payment=_resolve_atomic_spend_cap(
self._spend_controls,
selected.network,
selected.asset,
default_asset,
),
)
sig = inspect.signature(client.create_payment_payload)
kwargs: dict[str, Any] = {}
if "context" in sig.parameters:
kwargs["context"] = payload_context
if "extensions" in sig.parameters:
inner_payload = client.create_payment_payload(
selected, extensions=server_extensions
)
kwargs["extensions"] = server_extensions
if kwargs:
inner_payload = client.create_payment_payload(selected, **kwargs)
else:
inner_payload = client.create_payment_payload(selected)

Expand Down
11 changes: 11 additions & 0 deletions python/x402/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,17 @@ def get_extension(self, key: str) -> FacilitatorExtension | None:
# ============================================================================


@dataclass
class PaymentPayloadContext:
"""Context passed to scheme ``create_payment_payload``.

``max_amount_per_payment`` is the resolved atomic spend cap; omitted when uncapped.
"""

extensions: dict[str, Any] | None = None
max_amount_per_payment: str | None = None


class SchemeNetworkClient(Protocol):
"""V2 client-side payment mechanism.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@
BatchSettlementDepositStrategyContext,
BatchSettlementEvmSchemeOptions,
ResolvedClientOptions,
apply_max_deposit,
deposit_amount_for_request,
is_batch_settlement_evm_scheme_options,
max_deposit_from_spend_cap,
parse_announced_min_deposit,
resolve_client_options,
validate_deposit_policy,
)
Expand Down Expand Up @@ -63,6 +66,9 @@
"create_batch_settlement_eip3009_deposit_payload",
"create_batch_settlement_permit2_deposit_payload",
"deposit_amount_for_request",
"apply_max_deposit",
"max_deposit_from_spend_cap",
"parse_announced_min_deposit",
"get_channel",
"has_channel",
"is_batch_settlement_evm_scheme_options",
Expand Down
Loading
Loading