diff --git a/examples/python/clients/batch-settlement/README.md b/examples/python/clients/batch-settlement/README.md index d7800cbf0f..d0c060cd0a 100644 --- a/examples/python/clients/batch-settlement/README.md +++ b/examples/python/clients/batch-settlement/README.md @@ -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 | @@ -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. | diff --git a/examples/python/clients/batch-settlement/main.py b/examples/python/clients/batch-settlement/main.py index 3488b76dae..9932bace08 100644 --- a/examples/python/clients/batch-settlement/main.py +++ b/examples/python/clients/batch-settlement/main.py @@ -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. @@ -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}" diff --git a/examples/python/servers/batch-settlement/README.md b/examples/python/servers/batch-settlement/README.md index 0e63b75fde..52a1456f0b 100644 --- a/examples/python/servers/batch-settlement/README.md +++ b/examples/python/servers/batch-settlement/README.md @@ -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. diff --git a/examples/python/servers/batch-settlement/main.py b/examples/python/servers/batch-settlement/main.py index b367501986..a5f2e7ac0d 100644 --- a/examples/python/servers/batch-settlement/main.py +++ b/examples/python/servers/batch-settlement/main.py @@ -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) @@ -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"}, }, }, } diff --git a/python/x402/__init__.py b/python/x402/__init__.py index 890ad6c4d7..668a27b273 100644 --- a/python/x402/__init__.py +++ b/python/x402/__init__.py @@ -72,6 +72,7 @@ PaymentFlowConfig, PaymentFlowName, PaymentFlowPhases, + PaymentPayloadContext, ResolvedPaymentFlow, SchemeNetworkClient, SchemeNetworkClientV1, @@ -209,6 +210,7 @@ "SchemeNetworkServer", "SchemeNetworkFacilitator", "SchemeNetworkFacilitatorV1", + "PaymentPayloadContext", "PaymentFlowName", "PaymentFlowPhases", "PaymentFlowConfig", diff --git a/python/x402/changelog.d/mindeposit-hint-batch-settlement.feature.md b/python/x402/changelog.d/mindeposit-hint-batch-settlement.feature.md new file mode 100644 index 0000000000..53d1441568 --- /dev/null +++ b/python/x402/changelog.d/mindeposit-hint-batch-settlement.feature.md @@ -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. diff --git a/python/x402/client_base.py b/python/x402/client_base.py index fd85e28dc5..bb16345a0e 100644 --- a/python/x402/client_base.py +++ b/python/x402/client_base.py @@ -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, @@ -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.""" @@ -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: @@ -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) @@ -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) @@ -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) diff --git a/python/x402/interfaces.py b/python/x402/interfaces.py index cc913c187c..559e97ff48 100644 --- a/python/x402/interfaces.py +++ b/python/x402/interfaces.py @@ -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. diff --git a/python/x402/mechanisms/evm/batch_settlement/client/__init__.py b/python/x402/mechanisms/evm/batch_settlement/client/__init__.py index 04112c8c18..3987b78ddc 100644 --- a/python/x402/mechanisms/evm/batch_settlement/client/__init__.py +++ b/python/x402/mechanisms/evm/batch_settlement/client/__init__.py @@ -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, ) @@ -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", diff --git a/python/x402/mechanisms/evm/batch_settlement/client/config.py b/python/x402/mechanisms/evm/batch_settlement/client/config.py index dc31066713..7da4d97291 100644 --- a/python/x402/mechanisms/evm/batch_settlement/client/config.py +++ b/python/x402/mechanisms/evm/batch_settlement/client/config.py @@ -38,6 +38,7 @@ class BatchSettlementDepositStrategyContext: current_balance: str minimum_deposit_amount: str deposit_amount: str + max_deposit: str | None = None # Return either a positive integer string, False to skip the deposit, or None @@ -128,16 +129,70 @@ def validate_deposit_policy(policy: BatchSettlementDepositPolicy | None) -> None raise ValueError("deposit_multiplier must be an integer >= 3") +_DIGITS = re.compile(r"^\d+$") + + +def parse_announced_min_deposit(value: Any, request_amount: int) -> int | None: + """Parse a server-announced ``extra.minDeposit`` when it is a valid deposit target. + + Returns the parsed minimum deposit target, or ``None`` when invalid or below + ``request_amount``. + """ + if not isinstance(value, str) or not _DIGITS.fullmatch(value): + return None + + parsed = int(value) + if parsed <= 0 or parsed < request_amount: + return None + + return parsed + + +def max_deposit_from_spend_cap( + max_amount_per_payment: Any, + deposit_multiplier: int = 5, +) -> int | None: + """Derive the deposit ceiling as ``deposit_multiplier ×`` the resolved spend cap. + + Returns the atomic deposit ceiling, or ``None`` when the payment is uncapped. + """ + if ( + not isinstance(max_amount_per_payment, str) + or not _DIGITS.fullmatch(max_amount_per_payment) + or int(max_amount_per_payment) <= 0 + ): + return None + return int(max_amount_per_payment) * deposit_multiplier + + +def apply_max_deposit(deposit: int, needed: int, max_deposit: int | None = None) -> str: + """Clamp a computed deposit to ``max_deposit``. Throw when the voucher gap exceeds the cap.""" + if max_deposit is None: + return str(deposit) + if needed > max_deposit: + raise ValueError( + f"Required deposit {needed} exceeds deposit_multiplier × " + f"spend_controls.max_amount_per_payment ({max_deposit}). " + "Raise max_amount_per_payment or deposit_multiplier." + ) + return str(max_deposit if deposit > max_deposit else deposit) + + def deposit_amount_for_request( policy: BatchSettlementDepositPolicy | None, request_amount: int, + needed: int, + extra: dict[str, Any] | None, + max_deposit: int | None = None, ) -> str: - """Compute the deposit amount based on the policy multiplier (default 5x).""" - mult = policy.deposit_multiplier if (policy and policy.deposit_multiplier) else 5 - return str(mult * int(request_amount)) - - -_DIGITS = re.compile(r"^\d+$") + """Compute the deposit amount from the voucher gap, server hint, or deposit multiplier.""" + announced = parse_announced_min_deposit( + extra.get("minDeposit") if extra else None, request_amount + ) + multiplier = policy.deposit_multiplier if (policy and policy.deposit_multiplier) else 5 + target = announced if announced is not None else multiplier * int(request_amount) + deposit = needed if needed > target else target + return apply_max_deposit(deposit, needed, max_deposit) def normalize_strategy_deposit_amount(value: str | int | bool) -> str: @@ -164,6 +219,9 @@ def normalize_strategy_deposit_amount(value: str | int | bool) -> str: "is_batch_settlement_evm_scheme_options", "resolve_client_options", "validate_deposit_policy", + "parse_announced_min_deposit", + "max_deposit_from_spend_cap", + "apply_max_deposit", "deposit_amount_for_request", "normalize_strategy_deposit_amount", ] diff --git a/python/x402/mechanisms/evm/batch_settlement/client/scheme.py b/python/x402/mechanisms/evm/batch_settlement/client/scheme.py index 83474c4169..af5e11bc1c 100644 --- a/python/x402/mechanisms/evm/batch_settlement/client/scheme.py +++ b/python/x402/mechanisms/evm/batch_settlement/client/scheme.py @@ -11,6 +11,7 @@ "EVM mechanism requires ethereum packages. Install with: pip install x402[evm]" ) from e +from .....interfaces import PaymentPayloadContext from .....schemas import PaymentRequired, PaymentRequirements, SettleResponse from ....evm.constants import ERC20_ALLOWANCE_ABI, PERMIT2_ADDRESS from ....evm.signer import ( @@ -32,7 +33,9 @@ BatchSettlementDepositPolicy, BatchSettlementDepositStrategyContext, BatchSettlementEvmSchemeOptions, + apply_max_deposit, deposit_amount_for_request, + max_deposit_from_spend_cap, normalize_strategy_deposit_amount, resolve_client_options, validate_deposit_policy, @@ -105,8 +108,21 @@ def create_payment_payload( self, requirements: PaymentRequirements, extensions: dict[str, Any] | None = None, + context: PaymentPayloadContext | None = None, ) -> dict[str, Any]: - """Create the inner payment payload dict for a batch-settlement request.""" + """Create the inner payment payload dict for a batch-settlement request. + + Args: + requirements: Server payment requirements (scheme, network, asset, amount). + extensions: Server-declared extensions from PaymentRequired. + context: Optional extensions and the resolved atomic spend cap. + """ + if context is not None: + if context.extensions is not None: + extensions = context.extensions + max_amount_per_payment = context.max_amount_per_payment + else: + max_amount_per_payment = None deps = self._deps() config = build_channel_config(deps, requirements) channel_id = compute_channel_id(config, str(requirements.network)) @@ -131,8 +147,20 @@ def create_payment_payload( needs_top_up = not needs_initial_deposit and int(max_claimable_amount) > current_balance if needs_initial_deposit or needs_top_up: - computed_deposit = deposit_amount_for_request(self._deposit_policy, request_amount) - minimum_deposit_amount = str(int(max_claimable_amount) - current_balance) + minimum_deposit_amount = int(max_claimable_amount) - current_balance + multiplier = ( + self._deposit_policy.deposit_multiplier + if (self._deposit_policy and self._deposit_policy.deposit_multiplier) + else 5 + ) + max_deposit = max_deposit_from_spend_cap(max_amount_per_payment, multiplier) + computed_deposit = deposit_amount_for_request( + self._deposit_policy, + request_amount, + minimum_deposit_amount, + requirements.extra, + max_deposit, + ) deposit_amount = self._resolve_deposit_amount( BatchSettlementDepositStrategyContext( payment_requirements=requirements, @@ -142,8 +170,9 @@ def create_payment_payload( request_amount=str(request_amount), max_claimable_amount=max_claimable_amount, current_balance=str(current_balance), - minimum_deposit_amount=minimum_deposit_amount, + minimum_deposit_amount=str(minimum_deposit_amount), deposit_amount=computed_deposit, + max_deposit=None if max_deposit is None else str(max_deposit), ) ) if deposit_amount is None: @@ -221,7 +250,11 @@ def _resolve_deposit_amount( f"deposit_strategy returned {deposit_amount}, below required top-up " f"{context.minimum_deposit_amount}" ) - return deposit_amount + return apply_max_deposit( + int(deposit_amount), + int(context.minimum_deposit_amount), + None if context.max_deposit is None else int(context.max_deposit), + ) def _create_voucher_payload( self, diff --git a/python/x402/mechanisms/evm/batch_settlement/constants.py b/python/x402/mechanisms/evm/batch_settlement/constants.py index b7baa12338..b2cab1e0ea 100644 --- a/python/x402/mechanisms/evm/batch_settlement/constants.py +++ b/python/x402/mechanisms/evm/batch_settlement/constants.py @@ -38,6 +38,9 @@ CHANNEL_STATE_POLL_S = 2.0 CHANNEL_STATE_POLL_INTERVAL_S = 0.15 +# Default server SDK multiplier for `extra.minDeposit` when no floor is configured. +DEFAULT_SERVER_MIN_DEPOSIT_MULTIPLIER = 10 + # EIP-712 domain shared by all batch-settlement typed-data signatures BATCH_SETTLEMENT_DOMAIN_NAME = "x402 Batch Settlement" BATCH_SETTLEMENT_DOMAIN_VERSION = "1" @@ -152,6 +155,7 @@ "DEFAULT_MAX_CLAIMS_PER_BATCH", "CHANNEL_STATE_POLL_S", "CHANNEL_STATE_POLL_INTERVAL_S", + "DEFAULT_SERVER_MIN_DEPOSIT_MULTIPLIER", "DEFAULT_ONCHAIN_STATE_TTL_MS", "PAYLOAD_TYPE_DEPOSIT", "PAYLOAD_TYPE_VOUCHER", diff --git a/python/x402/mechanisms/evm/batch_settlement/errors.py b/python/x402/mechanisms/evm/batch_settlement/errors.py index 7b6169c9ed..b5de516abd 100644 --- a/python/x402/mechanisms/evm/batch_settlement/errors.py +++ b/python/x402/mechanisms/evm/batch_settlement/errors.py @@ -29,6 +29,7 @@ ERR_DEPOSIT_PAYLOAD = "invalid_batch_settlement_evm_deposit_payload" ERR_DEPOSIT_SIMULATION_FAILED = "invalid_batch_settlement_evm_deposit_simulation_failed" +ERR_DEPOSIT_BELOW_MIN_DEPOSIT = "invalid_batch_settlement_evm_deposit_below_min_deposit" ERR_DEPOSIT_TRANSACTION_FAILED = "invalid_batch_settlement_evm_deposit_transaction_failed" # ERC-6492 counterfactual deployment errors (ERC-3009 deposit path). Wire values keep the diff --git a/python/x402/mechanisms/evm/batch_settlement/server/__init__.py b/python/x402/mechanisms/evm/batch_settlement/server/__init__.py index d757d893f5..54729a1c8f 100644 --- a/python/x402/mechanisms/evm/batch_settlement/server/__init__.py +++ b/python/x402/mechanisms/evm/batch_settlement/server/__init__.py @@ -3,6 +3,7 @@ Re-exports the scheme, storage backends, and channel managers. """ +from ..errors import ERR_DEPOSIT_BELOW_MIN_DEPOSIT from .channel_manager import ( AutoSettlementConfig, AutoSettlementContext, @@ -53,4 +54,5 @@ "RefundChannelSelector", "AutoSettlementConfig", "AutoSettlementContext", + "ERR_DEPOSIT_BELOW_MIN_DEPOSIT", ] diff --git a/python/x402/mechanisms/evm/batch_settlement/server/scheme.py b/python/x402/mechanisms/evm/batch_settlement/server/scheme.py index 86ee36c054..2aeac79e26 100644 --- a/python/x402/mechanisms/evm/batch_settlement/server/scheme.py +++ b/python/x402/mechanisms/evm/batch_settlement/server/scheme.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re import threading from collections.abc import Callable from dataclasses import dataclass @@ -41,13 +42,18 @@ ) from ...default_assets import find_default_asset, get_default_asset from ...utils import get_asset_info, parse_amount -from ..constants import MIN_WITHDRAW_DELAY, SCHEME_BATCH_SETTLEMENT +from ..constants import ( + DEFAULT_SERVER_MIN_DEPOSIT_MULTIPLIER, + MIN_WITHDRAW_DELAY, + SCHEME_BATCH_SETTLEMENT, +) from ..types import AuthorizerSigner from .storage import Channel, ChannelStorage, InMemoryChannelStorage MoneyParser = Callable[[str | int | float, str], AssetAmount | None] _ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" +_DIGITS = re.compile(r"^\d+$") @dataclass @@ -56,6 +62,7 @@ class BatchSettlementEvmSchemeServerConfig: receiver_authorizer_signer: AuthorizerSigner | None = None withdraw_delay: int | None = None onchain_state_ttl_ms: int | None = None + enforce_min_deposit: bool | None = None @dataclass @@ -105,6 +112,7 @@ def __init__( if cfg.onchain_state_ttl_ms is not None else _default_onchain_state_ttl_ms(self._withdraw_delay) ) + self._enforce_min_deposit = cfg.enforce_min_deposit if cfg.enforce_min_deposit else False self._money_parsers: list[MoneyParser] = [] self._request_lock = threading.Lock() @@ -122,6 +130,10 @@ def get_withdraw_delay(self) -> int: def get_onchain_state_ttl_ms(self) -> int: return self._onchain_state_ttl_ms + def get_enforce_min_deposit(self) -> bool: + """Return whether deposits below the announced ``extra.minDeposit`` hint are rejected.""" + return self._enforce_min_deposit + def get_receiver_authorizer_signer(self) -> AuthorizerSigner | None: return self._receiver_authorizer_signer @@ -266,9 +278,54 @@ def enhance_payment_requirements( if "assetTransferMethod" not in extra and atm: extra["assetTransferMethod"] = atm + extra["minDeposit"] = self.resolve_min_deposit_hint(requirements) + requirements.extra = extra return requirements + def resolve_min_deposit_hint(self, payment_requirements: PaymentRequirements) -> str: + """Resolve the ``extra.minDeposit`` hint written on every 402.""" + amount = int(payment_requirements.amount) + extra = payment_requirements.extra or {} + route_override = extra.get("minDeposit") + + configured_min: int | None = None + if isinstance(route_override, str): + if _DIGITS.fullmatch(route_override): + configured_min = self._parse_atomic_min_deposit(route_override) + else: + configured_min = self._resolve_route_money_min_deposit( + route_override, payment_requirements + ) + + if configured_min is None: + return str(amount * DEFAULT_SERVER_MIN_DEPOSIT_MULTIPLIER) + + return str(amount if amount > configured_min else configured_min) + + def _resolve_route_money_min_deposit(self, money: str, requirement: PaymentRequirements) -> int: + """Convert a route-level Money ``extra.minDeposit`` override to atomic units.""" + default_asset = find_default_asset(requirement.asset, str(requirement.network)) + if not default_asset: + raise ValueError( + "extra.minDeposit money values are only supported for default assets; " + f"use an integer atomic string for {requirement.asset} on {requirement.network}." + ) + + parsed = parse_money(money) + return self._parse_atomic_min_deposit( + convert_to_token_amount(parsed["amount"], default_asset["decimals"]) + ) + + def _parse_atomic_min_deposit(self, amount: str) -> int: + """Validate and normalize an atomic min deposit amount.""" + if not _DIGITS.fullmatch(amount): + raise ValueError("minDeposit must resolve to a positive integer") + value = int(amount) + if value <= 0: + raise ValueError("minDeposit must resolve to a positive integer") + return value + def validate_facilitator_support( self, network: Network, diff --git a/python/x402/mechanisms/evm/batch_settlement/server/verify.py b/python/x402/mechanisms/evm/batch_settlement/server/verify.py index d3ea07048a..34facdf77b 100644 --- a/python/x402/mechanisms/evm/batch_settlement/server/verify.py +++ b/python/x402/mechanisms/evm/batch_settlement/server/verify.py @@ -13,6 +13,7 @@ ERR_CUMULATIVE_AMOUNT_BELOW_CLAIMED, ERR_CUMULATIVE_AMOUNT_MISMATCH, ERR_CUMULATIVE_EXCEEDS_BALANCE, + ERR_DEPOSIT_BELOW_MIN_DEPOSIT, ERR_INVALID_VOUCHER_SIGNATURE, ERR_VERIFICATION_STATE_UNAVAILABLE, ) @@ -191,6 +192,14 @@ def handle_before_verify(scheme: BatchSettlementEvmScheme, ctx: VerifyContext): if not is_paid_payload and not is_zero_charge_payload: return None + if scheme.get_enforce_min_deposit() and is_deposit_payload(raw): + min_deposit = int(scheme.resolve_min_deposit_hint(requirements)) + if int(raw["deposit"]["amount"]) < min_deposit: + return AbortResult( + reason=ERR_DEPOSIT_BELOW_MIN_DEPOSIT, + message="Deposit amount is below the server minimum", + ) + try: voucher = raw["voucher"] claimed_channel_id = str(voucher["channelId"]) diff --git a/python/x402/mechanisms/evm/batch_settlement/types.py b/python/x402/mechanisms/evm/batch_settlement/types.py index f9143b19c5..54f4c2b7fd 100644 --- a/python/x402/mechanisms/evm/batch_settlement/types.py +++ b/python/x402/mechanisms/evm/batch_settlement/types.py @@ -546,6 +546,7 @@ class PaymentRequirementsExtra: name: str version: str asset_transfer_method: str | None = None # "eip3009" (default) or "permit2" + min_deposit: str | None = None channel_state: ChannelStateExtra | None = None voucher_state: VoucherStateExtra | None = None @@ -558,6 +559,8 @@ def to_dict(self) -> dict[str, Any]: } if self.asset_transfer_method is not None: out["assetTransferMethod"] = self.asset_transfer_method + if self.min_deposit is not None: + out["minDeposit"] = self.min_deposit if self.channel_state is not None: out["channelState"] = self.channel_state.to_dict() if self.voucher_state is not None: @@ -574,6 +577,7 @@ def from_dict(cls, data: dict[str, Any]) -> PaymentRequirementsExtra: name=data["name"], version=data["version"], asset_transfer_method=data.get("assetTransferMethod"), + min_deposit=str(data["minDeposit"]) if data.get("minDeposit") is not None else None, channel_state=ChannelStateExtra.from_dict(cs) if cs else None, voucher_state=VoucherStateExtra.from_dict(vs) if vs else None, ) diff --git a/python/x402/tests/unit/core/test_client.py b/python/x402/tests/unit/core/test_client.py index 1206393c68..5d6c99f16e 100644 --- a/python/x402/tests/unit/core/test_client.py +++ b/python/x402/tests/unit/core/test_client.py @@ -32,6 +32,7 @@ class MockSchemeClient: def __init__(self, scheme: str = "mock"): self.scheme = scheme self.create_calls: list = [] + self.create_contexts: list = [] # Treat any asset as a recognized default so non-spend-control tests pass # the default allowlist (USD cap still applies unless overridden). self.find_default_asset = lambda asset, _network=None: { @@ -40,8 +41,9 @@ def __init__(self, scheme: str = "mock"): "symbol": "MOCK", } - def create_payment_payload(self, requirements): + def create_payment_payload(self, requirements, context=None): self.create_calls.append(requirements) + self.create_contexts.append(context) return {"mock": "payload", "network": requirements.network} def set_find_default_asset(self, lookup): @@ -1134,6 +1136,61 @@ async def test_compares_non_integer_decimal_amounts_to_usd_cap_directly(self): self._required(self._req(asset=rlusd["asset"], amount="1.01", network=xrpl)) ) + @pytest.mark.asyncio + async def test_passes_the_resolved_atomic_spend_cap_on_payment_payload_context(self): + client, mock_client = self._client_with_default_asset(self.usdc) + await client.create_payment_payload( + self._required(self._req(asset=self.usdc["asset"], amount="1000")) + ) + assert mock_client.create_contexts[0].max_amount_per_payment == "1000000" + + @pytest.mark.asyncio + async def test_omits_the_spend_cap_on_context_when_spend_controls_are_disabled(self): + client, mock_client = self._client_with_default_asset(self.usdc, False) + await client.create_payment_payload( + self._required(self._req(asset=self.usdc["asset"], amount="5000000")) + ) + assert mock_client.create_contexts[0].max_amount_per_payment is None + + @pytest.mark.asyncio + async def test_omits_the_spend_cap_on_context_when_the_usd_cap_is_disabled(self): + client, mock_client = self._client_with_default_asset( + self.usdc, {"max_amount_per_payment": False} + ) + await client.create_payment_payload( + self._required(self._req(asset=self.usdc["asset"], amount="5000000")) + ) + assert mock_client.create_contexts[0].max_amount_per_payment is None + + @pytest.mark.asyncio + async def test_passes_a_custom_money_usd_cap_on_context_in_atomic_units(self): + client, mock_client = self._client_with_default_asset( + self.usdc, {"max_amount_per_payment": "$5"} + ) + await client.create_payment_payload( + self._required(self._req(asset=self.usdc["asset"], amount="1000")) + ) + assert mock_client.create_contexts[0].max_amount_per_payment == "5000000" + + @pytest.mark.asyncio + async def test_passes_an_allowed_assets_atomic_cap_on_context(self): + client, mock_client = self._client_with_default_asset( + self.usdc, + { + "allowed_assets": [ + { + "asset": self.usdc["asset"], + "network": self.network, + "max_amount_per_payment": "500000", + } + ] + }, + ) + await client.create_payment_payload( + self._required(self._req(asset=self.usdc["asset"], amount="100")) + ) + assert mock_client.create_contexts[0].max_amount_per_payment == "500000" + class TestPaymentFlowSelection: """Client selection drops unrecognized paymentFlow and prefers authorization.""" diff --git a/python/x402/tests/unit/mechanisms/evm/batch_settlement/client/test_config.py b/python/x402/tests/unit/mechanisms/evm/batch_settlement/client/test_config.py index 5a0089539b..4ecc0bf6fb 100644 --- a/python/x402/tests/unit/mechanisms/evm/batch_settlement/client/test_config.py +++ b/python/x402/tests/unit/mechanisms/evm/batch_settlement/client/test_config.py @@ -9,8 +9,11 @@ DEFAULT_SALT, BatchSettlementDepositPolicy, BatchSettlementEvmSchemeOptions, + apply_max_deposit, deposit_amount_for_request, + max_deposit_from_spend_cap, normalize_strategy_deposit_amount, + parse_announced_min_deposit, resolve_client_options, validate_deposit_policy, ) @@ -85,16 +88,77 @@ def test_rejects_bool_multiplier(self): class TestDepositAmountForRequest: - def test_default_multiplier_is_5(self): - assert deposit_amount_for_request(None, 100) == "500" + cap = 1_000_000 - def test_explicit_multiplier(self): - policy = BatchSettlementDepositPolicy(deposit_multiplier=10) - assert deposit_amount_for_request(policy, 100) == "1000" + def test_uses_announced_min_deposit_when_valid(self): + assert ( + deposit_amount_for_request(None, 1000, 1000, {"minDeposit": "12000"}, self.cap) + == "12000" + ) + + def test_ignores_invalid_min_deposit_values(self): + assert ( + deposit_amount_for_request(None, 1000, 1000, {"minDeposit": "abc"}, self.cap) == "5000" + ) + assert deposit_amount_for_request(None, 1000, 1000, {"minDeposit": "0"}, self.cap) == "5000" + assert ( + deposit_amount_for_request(None, 1000, 1000, {"minDeposit": "500"}, self.cap) == "5000" + ) + + def test_falls_back_to_deposit_multiplier_when_min_deposit_is_absent(self): + assert ( + deposit_amount_for_request( + BatchSettlementDepositPolicy(deposit_multiplier=7), + 1000, + 1000, + None, + self.cap, + ) + == "7000" + ) + + def test_uses_the_voucher_gap_when_it_exceeds_the_target(self): + assert ( + deposit_amount_for_request(None, 1000, 8000, {"minDeposit": "5000"}, self.cap) == "8000" + ) + + def test_clamps_the_target_to_max_deposit_when_the_voucher_gap_still_fits(self): + assert deposit_amount_for_request(None, 1000, 1000, {"minDeposit": "12000"}, 4000) == "4000" + + def test_throws_when_the_voucher_gap_exceeds_max_deposit(self): + with pytest.raises(ValueError, match="exceeds deposit_multiplier"): + deposit_amount_for_request(None, 1000, 8000, {"minDeposit": "5000"}, 4000) + + def test_skips_the_ceiling_when_no_spend_cap_is_set(self): + assert deposit_amount_for_request(None, 1000, 1000, {"minDeposit": "15000"}) == "15000" + + +class TestMaxDepositFromSpendCap: + def test_multiplies_the_spend_cap_by_deposit_multiplier(self): + assert max_deposit_from_spend_cap("1000") == 5000 + assert max_deposit_from_spend_cap("1000", 7) == 7000 + + def test_returns_none_when_no_spend_cap_is_configured(self): + assert max_deposit_from_spend_cap(None) is None + + +class TestApplyMaxDeposit: + def test_returns_the_deposit_when_it_is_within_the_ceiling(self): + assert apply_max_deposit(12000, 1000, 20000) == "12000" + + def test_returns_the_deposit_unchanged_when_uncapped(self): + assert apply_max_deposit(12000, 1000) == "12000" + + +class TestParseAnnouncedMinDeposit: + def test_accepts_positive_integers_at_or_above_request_amount(self): + assert parse_announced_min_deposit("1000", 1000) == 1000 + assert parse_announced_min_deposit("5000", 1000) == 5000 - def test_none_multiplier_falls_back_to_default(self): - policy = BatchSettlementDepositPolicy() - assert deposit_amount_for_request(policy, 100) == "500" + def test_rejects_invalid_values(self): + assert parse_announced_min_deposit(None, 1000) is None + assert parse_announced_min_deposit("999", 1000) is None + assert parse_announced_min_deposit("-1", 1000) is None class TestNormalizeStrategyDepositAmount: diff --git a/python/x402/tests/unit/mechanisms/evm/batch_settlement/client/test_scheme.py b/python/x402/tests/unit/mechanisms/evm/batch_settlement/client/test_scheme.py index ce34e2e8f5..f392f10d90 100644 --- a/python/x402/tests/unit/mechanisms/evm/batch_settlement/client/test_scheme.py +++ b/python/x402/tests/unit/mechanisms/evm/batch_settlement/client/test_scheme.py @@ -7,6 +7,7 @@ try: from eth_account import Account + from x402.interfaces import PaymentPayloadContext from x402.mechanisms.evm.batch_settlement.client.channel import ( BatchSettlementClientDeps, build_channel_config, @@ -43,6 +44,7 @@ RECEIVER = "0x3333333333333333333333333333333333333333" RECEIVER_AUTHORIZER = "0x4444444444444444444444444444444444444444" TEST_PRIVATE_KEY = "0xa915e4eaadfaa5e6f59574d2c8e1d2a4cd2b6c0c0b9f6a3c7d9e2b8f5a4e3c2d" +SPEND_CAP = PaymentPayloadContext(max_amount_per_payment="1000000") def _signer() -> EthAccountSigner: @@ -184,6 +186,81 @@ def test_unsupported_asset_transfer_method_raises(self): with pytest.raises(ValueError, match="assetTransferMethod"): s.create_payment_payload(req) + def test_uses_deposit_multiplier_when_depositing(self): + signer = _signer() + s = BatchSettlementEvmScheme(signer, BatchSettlementDepositPolicy(deposit_multiplier=7)) + result = s.create_payment_payload(_requirements(amount="1000"), context=SPEND_CAP) + assert result["deposit"]["amount"] == "7000" + + def test_honors_valid_extra_min_deposit_over_deposit_multiplier(self): + signer = _signer() + s = BatchSettlementEvmScheme(signer, BatchSettlementDepositPolicy(deposit_multiplier=7)) + result = s.create_payment_payload( + _requirements( + amount="1000", + extra={ + "name": "USDC", + "version": "2", + "receiverAuthorizer": RECEIVER_AUTHORIZER, + "withdrawDelay": 900, + "minDeposit": "15000", + }, + ), + context=SPEND_CAP, + ) + assert result["deposit"]["amount"] == "15000" + + def test_falls_back_to_deposit_multiplier_when_extra_min_deposit_is_below_amount(self): + signer = _signer() + s = BatchSettlementEvmScheme(signer, BatchSettlementDepositPolicy(deposit_multiplier=5)) + result = s.create_payment_payload( + _requirements( + amount="1000", + extra={ + "name": "USDC", + "version": "2", + "receiverAuthorizer": RECEIVER_AUTHORIZER, + "withdrawDelay": 900, + "minDeposit": "500", + }, + ), + context=SPEND_CAP, + ) + assert result["deposit"]["amount"] == "5000" + + def test_clamps_extra_min_deposit_to_spend_cap_times_deposit_multiplier(self): + signer = _signer() + s = BatchSettlementEvmScheme(signer, BatchSettlementDepositPolicy(deposit_multiplier=5)) + result = s.create_payment_payload( + _requirements( + amount="1000", + extra={ + "name": "USDC", + "version": "2", + "receiverAuthorizer": RECEIVER_AUTHORIZER, + "withdrawDelay": 900, + "minDeposit": "15000", + }, + ), + context=PaymentPayloadContext(max_amount_per_payment="800"), + ) + assert result["deposit"]["amount"] == "4000" + + def test_rejects_a_deposit_when_the_voucher_gap_exceeds_spend_cap_times_multiplier(self): + signer = _signer() + s = BatchSettlementEvmScheme(signer) + with pytest.raises(ValueError, match="exceeds deposit_multiplier"): + s.create_payment_payload( + _requirements(amount="1000"), + context=PaymentPayloadContext(max_amount_per_payment="100"), + ) + + def test_allows_a_deposit_when_no_spend_cap_is_configured(self): + signer = _signer() + s = BatchSettlementEvmScheme(signer) + result = s.create_payment_payload(_requirements(amount="1000")) + assert result["deposit"]["amount"] == "5000" + def _make_payment_payload(payload: dict) -> PaymentPayload: return PaymentPayload(x402_version=2, accepted=_requirements(), payload=payload) diff --git a/python/x402/tests/unit/mechanisms/evm/batch_settlement/server/test_hooks.py b/python/x402/tests/unit/mechanisms/evm/batch_settlement/server/test_hooks.py index acf5cab24e..0778e5fa68 100644 --- a/python/x402/tests/unit/mechanisms/evm/batch_settlement/server/test_hooks.py +++ b/python/x402/tests/unit/mechanisms/evm/batch_settlement/server/test_hooks.py @@ -13,11 +13,13 @@ ERR_CHANNEL_BUSY, ERR_CHANNEL_ID_MISMATCH, ERR_CUMULATIVE_AMOUNT_MISMATCH, + ERR_DEPOSIT_BELOW_MIN_DEPOSIT, ERR_INVALID_CHANNEL_ID, ERR_VERIFICATION_STATE_UNAVAILABLE, ) from x402.mechanisms.evm.batch_settlement.server.scheme import ( BatchSettlementEvmScheme, + BatchSettlementEvmSchemeServerConfig, ) from x402.mechanisms.evm.batch_settlement.server.settle import handle_before_settle from x402.mechanisms.evm.batch_settlement.server.storage import ( @@ -94,6 +96,31 @@ def _voucher_payload( ) +def _deposit_payload( + deposit_amount: str, + max_claimable: str = "1000", + *, + channel_id: str | None = None, +) -> PaymentPayload: + return PaymentPayload( + x402_version=2, + payload={ + "type": "deposit", + "channelConfig": _channel_config().to_dict(), + "voucher": { + "channelId": channel_id or _channel_id(), + "maxClaimableAmount": max_claimable, + "signature": "0x" + "11" * 65, + }, + "deposit": { + "amount": deposit_amount, + "authorization": {}, + }, + }, + accepted=_requirements(amount=max_claimable), + ) + + def _requirements(amount: str = "100") -> PaymentRequirements: return PaymentRequirements( scheme=SCHEME_BATCH_SETTLEMENT, @@ -228,6 +255,74 @@ def _add_pending(current): out = handle_before_verify(scheme, ctx) assert out is None + def test_does_not_reject_deposits_below_min_deposit_when_enforcement_is_disabled(self): + scheme = _scheme() + requirements = PaymentRequirements( + scheme=SCHEME_BATCH_SETTLEMENT, + network=NETWORK, + asset=USDC, + amount="1000", + pay_to=RECEIVER, + max_timeout_seconds=60, + extra={"receiverAuthorizer": AUTHORIZER, "minDeposit": "10000"}, + ) + result = handle_before_verify( + scheme, + VerifyContext( + payment_payload=_deposit_payload("5000", "1000"), + requirements=requirements, + ), + ) + assert result is None + + def test_rejects_deposits_below_min_deposit_when_enforcement_is_enabled(self): + enforcing_server = BatchSettlementEvmScheme( + RECEIVER, BatchSettlementEvmSchemeServerConfig(enforce_min_deposit=True) + ) + requirements = PaymentRequirements( + scheme=SCHEME_BATCH_SETTLEMENT, + network=NETWORK, + asset=USDC, + amount="1000", + pay_to=RECEIVER, + max_timeout_seconds=60, + extra={"receiverAuthorizer": AUTHORIZER, "minDeposit": "10000"}, + ) + + below_min = handle_before_verify( + enforcing_server, + VerifyContext( + payment_payload=_deposit_payload("5000", "1000"), + requirements=requirements, + ), + ) + assert isinstance(below_min, AbortResult) + assert below_min.reason == ERR_DEPOSIT_BELOW_MIN_DEPOSIT + + at_min = handle_before_verify( + enforcing_server, + VerifyContext( + payment_payload=_deposit_payload("10000", "1000"), + requirements=requirements, + ), + ) + assert at_min is None + + def test_enforces_the_default_10x_min_deposit_hint_when_extra_min_deposit_is_omitted(self): + enforcing_server = BatchSettlementEvmScheme( + RECEIVER, BatchSettlementEvmSchemeServerConfig(enforce_min_deposit=True) + ) + requirements = _requirements(amount="1000") + below_default = handle_before_verify( + enforcing_server, + VerifyContext( + payment_payload=_deposit_payload("5000", "1000"), + requirements=requirements, + ), + ) + assert isinstance(below_default, AbortResult) + assert below_default.reason == ERR_DEPOSIT_BELOW_MIN_DEPOSIT + class TestHandleAfterVerify: def test_reserves_after_valid_verify(self): diff --git a/python/x402/tests/unit/mechanisms/evm/batch_settlement/server/test_scheme.py b/python/x402/tests/unit/mechanisms/evm/batch_settlement/server/test_scheme.py index 2727e882b1..4c5b6ef809 100644 --- a/python/x402/tests/unit/mechanisms/evm/batch_settlement/server/test_scheme.py +++ b/python/x402/tests/unit/mechanisms/evm/batch_settlement/server/test_scheme.py @@ -81,6 +81,13 @@ def test_defaults(self): assert s.get_receiver_authorizer_signer() is None assert isinstance(s.get_storage(), InMemoryChannelStorage) assert s.scheme == SCHEME_BATCH_SETTLEMENT + assert s.get_enforce_min_deposit() is False + + def test_allows_enforce_min_deposit_to_be_enabled(self): + s = BatchSettlementEvmScheme( + RECEIVER, BatchSettlementEvmSchemeServerConfig(enforce_min_deposit=True) + ) + assert s.get_enforce_min_deposit() is True def test_overrides_applied(self): storage = InMemoryChannelStorage() @@ -194,6 +201,7 @@ def test_local_signer_sets_receiver_authorizer(self): out = s.enhance_payment_requirements(self._req(), self._kind(), []) assert out.extra["receiverAuthorizer"] == AUTHORIZER_ADDR assert out.extra["withdrawDelay"] == 1800 + assert out.extra["minDeposit"] == "10000" def test_falls_back_to_facilitator_authorizer(self): s = BatchSettlementEvmScheme(RECEIVER) @@ -245,6 +253,67 @@ def test_rejects_zero_receiver_authorizer(self): with pytest.raises(ValueError, match="receiverAuthorizer"): s.enhance_payment_requirements(self._req(), kind, []) + def test_defaults_extra_min_deposit_to_10x_amount(self): + s = BatchSettlementEvmScheme(RECEIVER) + enhanced = s.enhance_payment_requirements( + self._req(amount="2500"), + self._kind(extra={"receiverAuthorizer": AUTHORIZER_ADDR}), + [], + ) + assert enhanced.extra["minDeposit"] == "25000" + + def test_converts_route_extra_min_deposit_money_on_default_assets(self): + s = BatchSettlementEvmScheme(RECEIVER) + enhanced = s.enhance_payment_requirements( + self._req( + amount="1000", + extra={"receiverAuthorizer": AUTHORIZER_ADDR, "minDeposit": "$1"}, + ), + self._kind(extra={"receiverAuthorizer": AUTHORIZER_ADDR}), + [], + ) + assert enhanced.extra["minDeposit"] == "1000000" + + def test_uses_request_amount_when_it_exceeds_route_extra_min_deposit_money(self): + s = BatchSettlementEvmScheme(RECEIVER) + enhanced = s.enhance_payment_requirements( + self._req( + amount="2000000", + extra={"receiverAuthorizer": AUTHORIZER_ADDR, "minDeposit": "$1"}, + ), + self._kind(extra={"receiverAuthorizer": AUTHORIZER_ADDR}), + [], + ) + assert enhanced.extra["minDeposit"] == "2000000" + + def test_accepts_route_extra_min_deposit_atomic_strings_for_any_asset(self): + custom_asset = "0x00000000000000000000000000000000000000aa" + s = BatchSettlementEvmScheme(RECEIVER) + enhanced = s.enhance_payment_requirements( + self._req( + amount="1000", + asset=custom_asset, + extra={"receiverAuthorizer": AUTHORIZER_ADDR, "minDeposit": "5000000"}, + ), + self._kind(extra={"receiverAuthorizer": AUTHORIZER_ADDR}), + [], + ) + assert enhanced.extra["minDeposit"] == "5000000" + + def test_rejects_route_extra_min_deposit_money_for_non_default_assets(self): + custom_asset = "0x00000000000000000000000000000000000000aa" + s = BatchSettlementEvmScheme(RECEIVER) + with pytest.raises(ValueError, match="only supported for default assets"): + s.enhance_payment_requirements( + self._req( + amount="1000", + asset=custom_asset, + extra={"receiverAuthorizer": AUTHORIZER_ADDR, "minDeposit": "$1"}, + ), + self._kind(extra={"receiverAuthorizer": AUTHORIZER_ADDR}), + [], + ) + class TestRequestContext: def test_merge_and_take_round_trip(self): diff --git a/python/x402/tests/unit/mechanisms/evm/batch_settlement/test_constants.py b/python/x402/tests/unit/mechanisms/evm/batch_settlement/test_constants.py index f97541db56..0f5efbf6c3 100644 --- a/python/x402/tests/unit/mechanisms/evm/batch_settlement/test_constants.py +++ b/python/x402/tests/unit/mechanisms/evm/batch_settlement/test_constants.py @@ -201,6 +201,7 @@ def test_all_err_constants_share_prefix(self): E.ERR_CHARGE_EXCEEDS_SIGNED_CUMULATIVE, E.ERR_REFUND_NO_BALANCE, E.ERR_REFUND_AMOUNT_INVALID, + E.ERR_DEPOSIT_BELOW_MIN_DEPOSIT, ], ) def test_documented_codes_present(self, code: str): @@ -222,3 +223,6 @@ def test_max_claims_per_batch(self): def test_onchain_state_ttl_ms(self): assert C.DEFAULT_ONCHAIN_STATE_TTL_MS == 60_000 + + def test_default_server_min_deposit_multiplier(self): + assert C.DEFAULT_SERVER_MIN_DEPOSIT_MULTIPLIER == 10