diff --git a/e2e/facilitators/text-facilitator-protocol.txt b/e2e/facilitators/text-facilitator-protocol.txt index 1779205e..c87b7ac3 100644 --- a/e2e/facilitators/text-facilitator-protocol.txt +++ b/e2e/facilitators/text-facilitator-protocol.txt @@ -47,7 +47,7 @@ Example configuration: } ``` -Python facilitator example (eip3009 only): +Python facilitator example (eip3009 + permit2): ```json { "name": "python", @@ -57,7 +57,7 @@ Python facilitator example (eip3009 only): "x402Versions": [2], "extensions": ["bazaar"], "evm": { - "transferMethods": ["eip3009"] + "transferMethods": ["eip3009", "permit2"] }, "environment": { "required": ["PORT", "EVM_PRIVATE_KEY", "SVM_PRIVATE_KEY", "APTOS_PRIVATE_KEY"], diff --git a/e2e/src/discovery.ts b/e2e/src/discovery.ts index 2950367b..fafc7ee1 100644 --- a/e2e/src/discovery.ts +++ b/e2e/src/discovery.ts @@ -321,12 +321,6 @@ export class TestDiscovery { }); for (const facilitator of matchingFacilitators) { - // TODO: Python SDK currently lacks Permit2 support. - // We skip these scenarios when using the python facilitator to avoid expected failures. - if (facilitator.name === 'python' && (endpoint.transferMethod === 'permit2' || (endpoint as any).permit2)) { - continue; - } - scenarios.push({ client, server, diff --git a/python/x402/README.md b/python/x402/README.md index 4968efdc..40ba5df5 100644 --- a/python/x402/README.md +++ b/python/x402/README.md @@ -29,6 +29,16 @@ uv add "bankofai.x402[all]" ## Quick Start +### EVM Transfer Methods + +The Python SDK supports both EVM transfer methods used by the TypeScript SDK: + +- `eip3009` for tokens that implement `transferWithAuthorization` +- `permit2` for networks and assets configured to use Permit2 witness settlement + +On BSC mainnet and testnet, the default stablecoin route uses `permit2`. The payer +wallet must pre-approve the configured Permit2 contract before settlement. + ### Client (Async) ```python @@ -264,6 +274,22 @@ client.register("eip155:8453", CustomScheme()) - `x402.mechanisms.svm` - Solana implementation - `x402.extensions` - Protocol extensions (Bazaar discovery) +## Integration Tests + +The Python integration suite includes: + +- Base Sepolia `eip3009` +- BSC Testnet `permit2` + +For BSC Testnet `permit2` integration tests, set: + +- `BSC_CLIENT_PRIVATE_KEY` +- `BSC_FACILITATOR_PRIVATE_KEY` +- `BSC_TESTNET_RPC_URL` + +The payer wallet must also hold testnet BNB, testnet USDT, and a token approval for +the configured BSC Permit2 contract. + ## Examples See [examples/python](https://github.com/coinbase/x402/tree/main/examples/python). diff --git a/python/x402/src/bankofai/x402/mechanisms/evm/__init__.py b/python/x402/src/bankofai/x402/mechanisms/evm/__init__.py index 375436d2..3a6624a5 100644 --- a/python/x402/src/bankofai/x402/mechanisms/evm/__init__.py +++ b/python/x402/src/bankofai/x402/mechanisms/evm/__init__.py @@ -15,8 +15,16 @@ ERR_INSUFFICIENT_BALANCE, ERR_INVALID_SIGNATURE, ERR_MISSING_EIP712_DOMAIN, + ERR_MISSING_PERMIT2_ADDRESS, ERR_NETWORK_MISMATCH, ERR_NONCE_ALREADY_USED, + ERR_PERMIT2_ALLOWANCE_REQUIRED, + ERR_PERMIT2_AMOUNT_MISMATCH, + ERR_PERMIT2_DEADLINE_EXPIRED, + ERR_PERMIT2_INVALID_SIGNATURE, + ERR_PERMIT2_NOT_YET_VALID, + ERR_PERMIT2_RECIPIENT_MISMATCH, + ERR_PERMIT2_TOKEN_MISMATCH, ERR_RECIPIENT_MISMATCH, ERR_SMART_WALLET_DEPLOYMENT_FAILED, ERR_TRANSACTION_FAILED, @@ -26,11 +34,16 @@ ERR_VALID_BEFORE_EXPIRED, IS_VALID_SIGNATURE_ABI, NETWORK_CONFIGS, + PERMIT2_ADDRESSES, + PERMIT2_WITNESS_TYPES, SCHEME_EXACT, TRANSFER_WITH_AUTHORIZATION_BYTES_ABI, TRANSFER_WITH_AUTHORIZATION_VRS_ABI, TX_STATUS_FAILED, TX_STATUS_SUCCESS, + X402_EXACT_PERMIT2_PROXY_ABI, + X402_PERMIT2_PROXY_ADDRESSES, + X402_UPTO_PERMIT2_PROXY_ADDRESSES, AssetInfo, NetworkConfig, ) @@ -67,6 +80,9 @@ ExactEIP3009Payload, ExactEvmPayloadV1, ExactEvmPayloadV2, + ExactPermit2Authorization, + ExactPermit2Payload, + Permit2Witness, TransactionReceipt, TypedDataDomain, TypedDataField, @@ -124,9 +140,17 @@ "ERR_NONCE_ALREADY_USED", "ERR_INSUFFICIENT_BALANCE", "ERR_MISSING_EIP712_DOMAIN", + "ERR_MISSING_PERMIT2_ADDRESS", "ERR_NETWORK_MISMATCH", "ERR_UNSUPPORTED_SCHEME", "ERR_TRANSACTION_FAILED", + "ERR_PERMIT2_ALLOWANCE_REQUIRED", + "ERR_PERMIT2_AMOUNT_MISMATCH", + "ERR_PERMIT2_DEADLINE_EXPIRED", + "ERR_PERMIT2_INVALID_SIGNATURE", + "ERR_PERMIT2_NOT_YET_VALID", + "ERR_PERMIT2_RECIPIENT_MISMATCH", + "ERR_PERMIT2_TOKEN_MISMATCH", "ERR_FAILED_TO_GET_NETWORK_CONFIG", "ERR_FAILED_TO_GET_ASSET_INFO", "ERR_FAILED_TO_VERIFY_SIGNATURE", @@ -135,13 +159,21 @@ "AUTHORIZATION_STATE_ABI", "BALANCE_OF_ABI", "IS_VALID_SIGNATURE_ABI", + "PERMIT2_ADDRESSES", + "PERMIT2_WITNESS_TYPES", + "X402_PERMIT2_PROXY_ADDRESSES", + "X402_UPTO_PERMIT2_PROXY_ADDRESSES", + "X402_EXACT_PERMIT2_PROXY_ABI", "AssetInfo", "NetworkConfig", # Types "ExactEIP3009Authorization", "ExactEIP3009Payload", + "ExactPermit2Authorization", + "ExactPermit2Payload", "ExactEvmPayloadV1", "ExactEvmPayloadV2", + "Permit2Witness", "TypedDataDomain", "TypedDataField", "TransactionReceipt", diff --git a/python/x402/src/bankofai/x402/mechanisms/evm/constants.py b/python/x402/src/bankofai/x402/mechanisms/evm/constants.py index 2531b26b..ea01cec1 100644 --- a/python/x402/src/bankofai/x402/mechanisms/evm/constants.py +++ b/python/x402/src/bankofai/x402/mechanisms/evm/constants.py @@ -48,6 +48,16 @@ ERR_FAILED_TO_GET_ASSET_INFO = "invalid_exact_evm_failed_to_get_asset_info" ERR_FAILED_TO_VERIFY_SIGNATURE = "invalid_exact_evm_failed_to_verify_signature" ERR_TRANSACTION_FAILED = "transaction_failed" +ERR_MISSING_PERMIT2_ADDRESS = "missing_permit2_address" +ERR_INVALID_PERMIT2_SPENDER = "invalid_permit2_spender" +ERR_PERMIT2_RECIPIENT_MISMATCH = "permit2_recipient_mismatch" +ERR_INVALID_PERMIT2_FACILITATOR = "invalid_permit2_facilitator" +ERR_PERMIT2_DEADLINE_EXPIRED = "permit2_deadline_expired" +ERR_PERMIT2_NOT_YET_VALID = "permit2_not_yet_valid" +ERR_PERMIT2_AMOUNT_MISMATCH = "permit2_amount_mismatch" +ERR_PERMIT2_TOKEN_MISMATCH = "permit2_token_mismatch" +ERR_PERMIT2_INVALID_SIGNATURE = "permit2_invalid_signature" +ERR_PERMIT2_ALLOWANCE_REQUIRED = "permit2_allowance_required" class _AssetInfoRequired(TypedDict): @@ -122,6 +132,63 @@ class NetworkConfig(_NetworkConfigRequired, total=False): "decimals": 6, }, }, + # BSC Mainnet + "eip155:56": { + "chain_id": 56, + "default_asset": { + "address": "0x55d398326f99059fF775485246999027B3197955", + "name": "Tether USD", + "version": "1", + "decimals": 18, + "asset_transfer_method": "permit2", + }, + }, + # BSC Testnet + "eip155:97": { + "chain_id": 97, + "default_asset": { + "address": "0x337610d27c682E347C9cD60BD4b3b107C9d34dDd", + "name": "Tether USD", + "version": "1", + "decimals": 18, + "asset_transfer_method": "permit2", + }, + }, +} + +PERMIT2_ADDRESSES: dict[str, str] = { + "eip155:1": "0x000000000022D473030F116dDEE9F6B43aC78BA3", + "eip155:56": "0x31c2F6fcFf4F8759b3Bd5Bf0e1084A055615c768", + "eip155:97": "0x31c2F6fcFf4F8759b3Bd5Bf0e1084A055615c768", +} + +X402_PERMIT2_PROXY_ADDRESSES: dict[str, str] = { + "eip155:56": "0xEe38Ec718255fe78e9D16aCC0e1183C731679b23", + "eip155:97": "0xEe38Ec718255fe78e9D16aCC0e1183C731679b23", +} + +X402_UPTO_PERMIT2_PROXY_ADDRESSES: dict[str, str] = { + "eip155:56": "0x2b30Ed9F37c7C21ae8779c5753B1cCf264DfD63C", + "eip155:97": "0x2b30Ed9F37c7C21ae8779c5753B1cCf264DfD63C", +} + +PERMIT2_WITNESS_TYPES: dict[str, list[dict[str, str]]] = { + "PermitWitnessTransferFrom": [ + {"name": "permitted", "type": "TokenPermissions"}, + {"name": "spender", "type": "address"}, + {"name": "nonce", "type": "uint256"}, + {"name": "deadline", "type": "uint256"}, + {"name": "witness", "type": "Witness"}, + ], + "TokenPermissions": [ + {"name": "token", "type": "address"}, + {"name": "amount", "type": "uint256"}, + ], + "Witness": [ + {"name": "to", "type": "address"}, + {"name": "facilitator", "type": "address"}, + {"name": "validAfter", "type": "uint256"}, + ], } # V1 legacy constants are in x402.mechanisms.evm.v1.constants @@ -179,6 +246,70 @@ class NetworkConfig(_NetworkConfigRequired, total=False): } ] +ERC20_ALLOWANCE_ABI = [ + { + "inputs": [ + {"name": "owner", "type": "address"}, + {"name": "spender", "type": "address"}, + ], + "name": "allowance", + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + } +] + +ERC20_APPROVE_ABI = [ + { + "inputs": [ + {"name": "spender", "type": "address"}, + {"name": "amount", "type": "uint256"}, + ], + "name": "approve", + "outputs": [{"name": "", "type": "bool"}], + "stateMutability": "nonpayable", + "type": "function", + } +] + +X402_EXACT_PERMIT2_PROXY_ABI = [ + { + "inputs": [ + { + "name": "permit", + "type": "tuple", + "components": [ + { + "name": "permitted", + "type": "tuple", + "components": [ + {"name": "token", "type": "address"}, + {"name": "amount", "type": "uint256"}, + ], + }, + {"name": "nonce", "type": "uint256"}, + {"name": "deadline", "type": "uint256"}, + ], + }, + {"name": "owner", "type": "address"}, + { + "name": "witness", + "type": "tuple", + "components": [ + {"name": "to", "type": "address"}, + {"name": "facilitator", "type": "address"}, + {"name": "validAfter", "type": "uint256"}, + ], + }, + {"name": "signature", "type": "bytes"}, + ], + "name": "settle", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + } +] + BALANCE_OF_ABI = [ { "inputs": [{"name": "account", "type": "address"}], diff --git a/python/x402/src/bankofai/x402/mechanisms/evm/eip712.py b/python/x402/src/bankofai/x402/mechanisms/evm/eip712.py index bebd4d9b..57a21a15 100644 --- a/python/x402/src/bankofai/x402/mechanisms/evm/eip712.py +++ b/python/x402/src/bankofai/x402/mechanisms/evm/eip712.py @@ -12,7 +12,6 @@ from .types import ( AUTHORIZATION_TYPES, - DOMAIN_TYPES, ExactEIP3009Authorization, TypedDataDomain, ) @@ -135,11 +134,20 @@ def hash_domain(domain: TypedDataDomain) -> bytes: """ domain_data = { "name": domain.name, - "version": domain.version, "chainId": domain.chain_id, "verifyingContract": domain.verifying_contract, } - return hash_struct("EIP712Domain", DOMAIN_TYPES, domain_data) + domain_types = { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ] + } + if domain.version is not None: + domain_data["version"] = domain.version + domain_types["EIP712Domain"].insert(1, {"name": "version", "type": "string"}) + return hash_struct("EIP712Domain", domain_types, domain_data) def hash_typed_data( @@ -162,7 +170,16 @@ def hash_typed_data( 32-byte hash suitable for signing/verification. """ # Merge domain types with provided types - all_types = {**DOMAIN_TYPES, **types} + domain_types = { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ] + } + if domain.version is not None: + domain_types["EIP712Domain"].insert(1, {"name": "version", "type": "string"}) + all_types = {**domain_types, **types} domain_separator = hash_domain(domain) struct_hash = hash_struct(primary_type, all_types, message) diff --git a/python/x402/src/bankofai/x402/mechanisms/evm/exact/client.py b/python/x402/src/bankofai/x402/mechanisms/evm/exact/client.py index 66157a82..46a17147 100644 --- a/python/x402/src/bankofai/x402/mechanisms/evm/exact/client.py +++ b/python/x402/src/bankofai/x402/mechanisms/evm/exact/client.py @@ -2,19 +2,37 @@ from __future__ import annotations +import time from datetime import timedelta from typing import Any from ....schemas import PaymentRequirements -from ..constants import SCHEME_EXACT +from ..constants import ( + BALANCE_OF_ABI, + ERC20_ALLOWANCE_ABI, + ERC20_APPROVE_ABI, + ERR_INSUFFICIENT_BALANCE, + PERMIT2_ADDRESSES, + PERMIT2_WITNESS_TYPES, + SCHEME_EXACT, + X402_PERMIT2_PROXY_ADDRESSES, +) from ..eip712 import build_typed_data_for_signing from ..signer import ClientEvmSigner -from ..types import ExactEIP3009Authorization, ExactEIP3009Payload, TypedDataField +from ..types import ( + ExactEIP3009Authorization, + ExactEIP3009Payload, + ExactPermit2Authorization, + ExactPermit2Payload, + Permit2Witness, + TypedDataField, +) from ..utils import ( create_nonce, create_validity_window, get_asset_info, get_evm_chain_id, + normalize_address, ) @@ -58,7 +76,7 @@ def create_payment_payload( self, requirements: PaymentRequirements, ) -> dict[str, Any]: - """Create signed EIP-3009 inner payload. + """Create signed EIP-3009 or Permit2 inner payload. Args: requirements: Payment requirements from server. @@ -67,6 +85,12 @@ def create_payment_payload( Inner payload dict (authorization + signature). x402Client wraps this with x402_version, accepted, resource, extensions. """ + self._ensure_sufficient_balance(requirements) + + extra = requirements.extra or {} + if extra.get("assetTransferMethod") == "permit2": + return self._create_permit2_payload(requirements) + nonce = create_nonce() valid_after, valid_before = create_validity_window( timedelta(seconds=requirements.max_timeout_seconds or 3600) @@ -143,3 +167,147 @@ def _sign_authorization( sig_bytes = self._signer.sign_typed_data(domain, typed_fields, primary_type, message) return "0x" + sig_bytes.hex() + + def _create_permit2_payload(self, requirements: PaymentRequirements) -> dict[str, Any]: + """Create signed Permit2 payload.""" + network = str(requirements.network) + permit2_address = PERMIT2_ADDRESSES.get(network) + proxy_address = X402_PERMIT2_PROXY_ADDRESSES.get(network) + if not permit2_address or not proxy_address: + raise ValueError(f"No Permit2 configuration for network {network}") + + self._ensure_permit2_allowance(requirements, permit2_address) + + facilitator_address = (requirements.extra or {}).get("permit2FacilitatorAddress") + if not facilitator_address: + raise ValueError( + "Permit2 facilitator address is required in payment requirements extra" + ) + + now = int(time.time()) + authorization = ExactPermit2Authorization( + from_address=normalize_address(self._signer.address), + permitted_token=normalize_address(requirements.asset), + permitted_amount=str(requirements.amount), + spender=normalize_address(proxy_address), + nonce=create_nonce(), + deadline=str(now + (requirements.max_timeout_seconds or 3600)), + witness=Permit2Witness( + to=normalize_address(requirements.pay_to), + facilitator=normalize_address(str(facilitator_address)), + valid_after=str(now - 600), + ), + ) + signature = self._sign_permit2(authorization, requirements, permit2_address) + return ExactPermit2Payload( + permit2_authorization=authorization, signature=signature + ).to_dict() + + def _sign_permit2( + self, + authorization: ExactPermit2Authorization, + requirements: PaymentRequirements, + permit2_address: str, + ) -> str: + """Sign PermitWitnessTransferFrom typed data.""" + typed_fields: dict[str, list[TypedDataField]] = {} + for type_name, fields in PERMIT2_WITNESS_TYPES.items(): + typed_fields[type_name] = [ + TypedDataField(name=f["name"], type=f["type"]) for f in fields + ] + + domain = { + "name": "Permit2", + "chainId": get_evm_chain_id(str(requirements.network)), + "verifyingContract": normalize_address(permit2_address), + } + message = { + "permitted": { + "token": normalize_address(authorization.permitted_token), + "amount": int(authorization.permitted_amount), + }, + "spender": normalize_address(authorization.spender), + "nonce": int(str(authorization.nonce), 0), + "deadline": int(authorization.deadline), + "witness": { + "to": normalize_address(authorization.witness.to), + "facilitator": normalize_address(authorization.witness.facilitator), + "validAfter": int(authorization.witness.valid_after), + }, + } + + sig_bytes = self._signer.sign_typed_data( + domain, + typed_fields, + "PermitWitnessTransferFrom", + message, + ) + return "0x" + sig_bytes.hex() + + def _ensure_sufficient_balance(self, requirements: PaymentRequirements) -> None: + """Best-effort ERC-20 balance preflight. + + Runs only when the configured client signer exposes read_contract(). + """ + read_contract = getattr(self._signer, "read_contract", None) + if not callable(read_contract): + return + + try: + balance = read_contract( + requirements.asset, + BALANCE_OF_ABI, + "balanceOf", + self._signer.address, + ) + except NotImplementedError: + return + + if int(balance) < int(requirements.amount): + raise ValueError( + f"{ERR_INSUFFICIENT_BALANCE}: Insufficient token balance. Required: {requirements.amount}, Available: {balance}" + ) + + def _ensure_permit2_allowance( + self, requirements: PaymentRequirements, permit2_address: str + ) -> None: + """Best-effort local Permit2 approval fallback when sponsoring is unavailable.""" + read_contract = getattr(self._signer, "read_contract", None) + if not callable(read_contract): + return + + try: + allowance = int( + read_contract( + requirements.asset, + ERC20_ALLOWANCE_ABI, + "allowance", + self._signer.address, + permit2_address, + ) + ) + except NotImplementedError: + return + if allowance >= int(requirements.amount): + return + + write_contract = getattr(self._signer, "write_contract", None) + wait_for_receipt = getattr(self._signer, "wait_for_transaction_receipt", None) + if not callable(write_contract) or not callable(wait_for_receipt): + return + + tx_hash = write_contract( + requirements.asset, + ERC20_APPROVE_ABI, + "approve", + permit2_address, + (1 << 256) - 1, + ) + receipt = wait_for_receipt(tx_hash) + status = getattr(receipt, "status", None) + if status is None and isinstance(receipt, dict): + status = receipt.get("status") + if status not in (1, "success"): + raise ValueError( + f"transaction_failed: local Permit2 approval failed with status={status}" + ) diff --git a/python/x402/src/bankofai/x402/mechanisms/evm/exact/facilitator.py b/python/x402/src/bankofai/x402/mechanisms/evm/exact/facilitator.py index ecf3b070..ac668ffd 100644 --- a/python/x402/src/bankofai/x402/mechanisms/evm/exact/facilitator.py +++ b/python/x402/src/bankofai/x402/mechanisms/evm/exact/facilitator.py @@ -39,6 +39,7 @@ from ..types import ERC6492SignatureData, ExactEIP3009Payload from ..utils import bytes_to_hex, get_evm_chain_id, hex_to_bytes, normalize_address from ..verify import verify_universal_signature +from .permit2 import settle_permit2, verify_permit2 @dataclass @@ -77,15 +78,23 @@ def __init__( self._config = config or ExactEvmSchemeConfig() def get_extra(self, network: Network) -> dict[str, Any] | None: - """Get mechanism-specific extra data. EVM: None. + """Get mechanism-specific extra data. Args: network: Network identifier. Returns: - None for EVM scheme. + Supported transfer methods and Permit2 facilitator address when available. """ - return None + from ..constants import X402_PERMIT2_PROXY_ADDRESSES + + signers = self._signer.get_addresses() + extra: dict[str, Any] = {"supportedAssetTransferMethods": ["eip3009"]} + if X402_PERMIT2_PROXY_ADDRESSES.get(str(network)): + extra["supportedAssetTransferMethods"].append("permit2") + if signers: + extra["permit2FacilitatorAddress"] = signers[0] + return extra def get_signers(self, network: Network) -> list[str]: """Get facilitator wallet addresses. @@ -122,7 +131,11 @@ def verify( Returns: VerifyResponse with is_valid and payer. """ - evm_payload = ExactEIP3009Payload.from_dict(payload.payload) + raw = payload.payload or {} + if "permit2Authorization" in raw: + return verify_permit2(self._signer, payload, requirements, raw) + + evm_payload = ExactEIP3009Payload.from_dict(raw) payer = evm_payload.authorization.from_address network = str(requirements.network) @@ -256,6 +269,10 @@ def settle( Returns: SettleResponse with success, transaction, and payer. """ + raw = payload.payload or {} + if "permit2Authorization" in raw: + return settle_permit2(self._signer, payload, requirements, raw) + # First verify verify_result = self.verify(payload, requirements, context) if not verify_result.is_valid: diff --git a/python/x402/src/bankofai/x402/mechanisms/evm/exact/permit2.py b/python/x402/src/bankofai/x402/mechanisms/evm/exact/permit2.py new file mode 100644 index 00000000..3b821859 --- /dev/null +++ b/python/x402/src/bankofai/x402/mechanisms/evm/exact/permit2.py @@ -0,0 +1,227 @@ +"""EVM Permit2 verification and settlement helpers.""" + +from __future__ import annotations + +import time +from typing import Any + +from ....schemas import PaymentPayload, PaymentRequirements, SettleResponse, VerifyResponse +from ..constants import ( + ERC20_ALLOWANCE_ABI, + ERR_INVALID_PERMIT2_FACILITATOR, + ERR_INVALID_PERMIT2_SPENDER, + ERR_MISSING_PERMIT2_ADDRESS, + ERR_PERMIT2_ALLOWANCE_REQUIRED, + ERR_PERMIT2_AMOUNT_MISMATCH, + ERR_PERMIT2_DEADLINE_EXPIRED, + ERR_PERMIT2_INVALID_SIGNATURE, + ERR_PERMIT2_NOT_YET_VALID, + ERR_PERMIT2_RECIPIENT_MISMATCH, + ERR_PERMIT2_TOKEN_MISMATCH, + ERR_TRANSACTION_FAILED, + PERMIT2_ADDRESSES, + PERMIT2_WITNESS_TYPES, + TX_STATUS_SUCCESS, + X402_EXACT_PERMIT2_PROXY_ABI, + X402_PERMIT2_PROXY_ADDRESSES, +) +from ..signer import FacilitatorEvmSigner +from ..types import ExactPermit2Payload, TypedDataDomain, TypedDataField +from ..utils import get_evm_chain_id, normalize_address + + +def verify_permit2( + signer: FacilitatorEvmSigner, + payload: PaymentPayload, + requirements: PaymentRequirements, + raw: dict[str, Any], +) -> VerifyResponse: + """Verify a Permit2 exact payment payload.""" + permit2_payload = ExactPermit2Payload.from_dict(raw) + payer = permit2_payload.permit2_authorization.from_address + network = str(requirements.network) + + permit2_address = PERMIT2_ADDRESSES.get(network) + proxy_address = X402_PERMIT2_PROXY_ADDRESSES.get(network) + if not permit2_address or not proxy_address: + return VerifyResponse( + is_valid=False, invalid_reason=ERR_MISSING_PERMIT2_ADDRESS, payer=payer + ) + + if normalize_address(permit2_payload.permit2_authorization.spender) != normalize_address( + proxy_address + ): + return VerifyResponse( + is_valid=False, invalid_reason=ERR_INVALID_PERMIT2_SPENDER, payer=payer + ) + + if normalize_address(permit2_payload.permit2_authorization.witness.to) != normalize_address( + requirements.pay_to + ): + return VerifyResponse( + is_valid=False, invalid_reason=ERR_PERMIT2_RECIPIENT_MISMATCH, payer=payer + ) + + facilitator_address = (requirements.extra or {}).get("permit2FacilitatorAddress") + if not facilitator_address: + return VerifyResponse( + is_valid=False, invalid_reason=ERR_INVALID_PERMIT2_FACILITATOR, payer=payer + ) + if normalize_address( + permit2_payload.permit2_authorization.witness.facilitator + ) != normalize_address(str(facilitator_address)): + return VerifyResponse( + is_valid=False, invalid_reason=ERR_INVALID_PERMIT2_FACILITATOR, payer=payer + ) + + now = int(time.time()) + if int(permit2_payload.permit2_authorization.deadline) < now + 6: + return VerifyResponse( + is_valid=False, invalid_reason=ERR_PERMIT2_DEADLINE_EXPIRED, payer=payer + ) + if int(permit2_payload.permit2_authorization.witness.valid_after) > now: + return VerifyResponse(is_valid=False, invalid_reason=ERR_PERMIT2_NOT_YET_VALID, payer=payer) + if int(permit2_payload.permit2_authorization.permitted_amount) != int(requirements.amount): + return VerifyResponse( + is_valid=False, invalid_reason=ERR_PERMIT2_AMOUNT_MISMATCH, payer=payer + ) + if normalize_address( + permit2_payload.permit2_authorization.permitted_token + ) != normalize_address(requirements.asset): + return VerifyResponse( + is_valid=False, invalid_reason=ERR_PERMIT2_TOKEN_MISMATCH, payer=payer + ) + + typed_fields = _permit2_typed_fields() + domain = TypedDataDomain( + name="Permit2", + chain_id=get_evm_chain_id(network), + verifying_contract=normalize_address(permit2_address), + ) + message = { + "permitted": { + "token": normalize_address(permit2_payload.permit2_authorization.permitted_token), + "amount": int(permit2_payload.permit2_authorization.permitted_amount), + }, + "spender": normalize_address(permit2_payload.permit2_authorization.spender), + "nonce": int(str(permit2_payload.permit2_authorization.nonce), 0), + "deadline": int(permit2_payload.permit2_authorization.deadline), + "witness": { + "to": normalize_address(permit2_payload.permit2_authorization.witness.to), + "facilitator": normalize_address( + permit2_payload.permit2_authorization.witness.facilitator + ), + "validAfter": int(permit2_payload.permit2_authorization.witness.valid_after), + }, + } + + is_valid = signer.verify_typed_data( + payer, + domain, + typed_fields, + "PermitWitnessTransferFrom", + message, + bytes.fromhex(permit2_payload.signature.removeprefix("0x")), + ) + if not is_valid: + return VerifyResponse( + is_valid=False, invalid_reason=ERR_PERMIT2_INVALID_SIGNATURE, payer=payer + ) + + try: + allowance = signer.read_contract( + normalize_address(requirements.asset), + ERC20_ALLOWANCE_ABI, + "allowance", + normalize_address(payer), + normalize_address(permit2_address), + ) + if int(allowance) < int(requirements.amount): + return VerifyResponse( + is_valid=False, invalid_reason=ERR_PERMIT2_ALLOWANCE_REQUIRED, payer=payer + ) + except Exception: + pass + + return VerifyResponse(is_valid=True, payer=payer) + + +def settle_permit2( + signer: FacilitatorEvmSigner, + payload: PaymentPayload, + requirements: PaymentRequirements, + raw: dict[str, Any], +) -> SettleResponse: + """Settle a Permit2 exact payment on-chain.""" + verify = verify_permit2(signer, payload, requirements, raw) + if not verify.is_valid: + return SettleResponse( + success=False, + error_reason=verify.invalid_reason, + network=str(payload.accepted.network), + payer=verify.payer, + transaction="", + ) + + permit2_payload = ExactPermit2Payload.from_dict(raw) + network = str(requirements.network) + proxy_address = X402_PERMIT2_PROXY_ADDRESSES[network] + + permit = ( + ( + normalize_address(permit2_payload.permit2_authorization.permitted_token), + int(permit2_payload.permit2_authorization.permitted_amount), + ), + int(str(permit2_payload.permit2_authorization.nonce), 0), + int(permit2_payload.permit2_authorization.deadline), + ) + witness = ( + normalize_address(permit2_payload.permit2_authorization.witness.to), + normalize_address(permit2_payload.permit2_authorization.witness.facilitator), + int(permit2_payload.permit2_authorization.witness.valid_after), + ) + + try: + tx_hash = signer.write_contract( + normalize_address(proxy_address), + X402_EXACT_PERMIT2_PROXY_ABI, + "settle", + permit, + normalize_address(permit2_payload.permit2_authorization.from_address), + witness, + bytes.fromhex(permit2_payload.signature.removeprefix("0x")), + ) + receipt = signer.wait_for_transaction_receipt(tx_hash) + if receipt.status != TX_STATUS_SUCCESS: + return SettleResponse( + success=False, + error_reason=ERR_TRANSACTION_FAILED, + transaction=tx_hash, + network=network, + payer=permit2_payload.permit2_authorization.from_address, + ) + return SettleResponse( + success=True, + transaction=tx_hash, + network=network, + payer=permit2_payload.permit2_authorization.from_address, + ) + except Exception as e: + return SettleResponse( + success=False, + error_reason=ERR_TRANSACTION_FAILED, + error_message=str(e), + transaction="", + network=network, + payer=permit2_payload.permit2_authorization.from_address, + ) + + +def _permit2_typed_fields() -> dict[str, list[TypedDataField]]: + """Convert Permit2 type descriptors to signer fields.""" + typed_fields: dict[str, list[TypedDataField]] = {} + for type_name, fields in PERMIT2_WITNESS_TYPES.items(): + typed_fields[type_name] = [ + TypedDataField(name=field["name"], type=field["type"]) for field in fields + ] + return typed_fields diff --git a/python/x402/src/bankofai/x402/mechanisms/evm/exact/server.py b/python/x402/src/bankofai/x402/mechanisms/evm/exact/server.py index 3534c1f3..9249ffb9 100644 --- a/python/x402/src/bankofai/x402/mechanisms/evm/exact/server.py +++ b/python/x402/src/bankofai/x402/mechanisms/evm/exact/server.py @@ -157,6 +157,16 @@ def enhance_payment_requirements( if "assetTransferMethod" not in requirements.extra and atm: requirements.extra["assetTransferMethod"] = atm + facilitator_extra = supported_kind.extra or {} + if ( + requirements.extra.get("assetTransferMethod") == "permit2" + and "permit2FacilitatorAddress" not in requirements.extra + and facilitator_extra.get("permit2FacilitatorAddress") + ): + requirements.extra["permit2FacilitatorAddress"] = facilitator_extra[ + "permit2FacilitatorAddress" + ] + return requirements def _default_money_conversion(self, amount: float, network: str) -> AssetAmount: diff --git a/python/x402/src/bankofai/x402/mechanisms/evm/signers.py b/python/x402/src/bankofai/x402/mechanisms/evm/signers.py index eec1bde9..c5d0df7d 100644 --- a/python/x402/src/bankofai/x402/mechanisms/evm/signers.py +++ b/python/x402/src/bankofai/x402/mechanisms/evm/signers.py @@ -61,14 +61,20 @@ class EthAccountSigner: account: eth_account LocalAccount instance. """ - def __init__(self, account: LocalAccount) -> None: + def __init__(self, account: LocalAccount, rpc_url: str | None = None) -> None: """Initialize signer with eth_account LocalAccount. Args: account: eth_account LocalAccount instance (from Account.from_key, Account.from_mnemonic, etc.). + rpc_url: Optional RPC endpoint URL used to enable best-effort + on-chain reads such as ERC-20 balance preflight. """ self._account = account + self._w3 = None + if rpc_url: + self._w3 = Web3(Web3.HTTPProvider(rpc_url)) + self._w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0) @property def address(self) -> str: @@ -110,10 +116,11 @@ def sign_typed_data( if isinstance(domain, TypedDataDomain): domain_dict = { "name": domain.name, - "version": domain.version, "chainId": domain.chain_id, "verifyingContract": domain.verifying_contract, } + if domain.version is not None: + domain_dict["version"] = domain.version else: domain_dict = domain @@ -125,6 +132,69 @@ def sign_typed_data( ) return bytes(signed.signature) + def read_contract( + self, + address: str, + abi: list[dict[str, Any]], + function_name: str, + *args: Any, + ) -> Any: + """Read data from a contract when rpc_url was supplied. + + Raises: + NotImplementedError: If this signer was created without rpc_url. + """ + if self._w3 is None: + raise NotImplementedError("EthAccountSigner requires rpc_url for read_contract()") + + contract = self._w3.eth.contract( + address=Web3.to_checksum_address(address), + abi=abi, + ) + func = getattr(contract.functions, function_name) + return func(*args).call() + + def write_contract( + self, + address: str, + abi: list[dict[str, Any]], + function_name: str, + *args: Any, + ) -> str: + """Write to a contract when rpc_url was supplied.""" + if self._w3 is None: + raise NotImplementedError("EthAccountSigner requires rpc_url for write_contract()") + + contract = self._w3.eth.contract( + address=Web3.to_checksum_address(address), + abi=abi, + ) + func = getattr(contract.functions, function_name) + tx = func(*args).build_transaction( + { + "from": self.address, + "nonce": self._w3.eth.get_transaction_count(self.address), + "chainId": self._w3.eth.chain_id, + "gasPrice": self._w3.eth.gas_price, + } + ) + tx["gas"] = self._w3.eth.estimate_gas(tx) + signed = self._account.sign_transaction(tx) + try: + raw = signed.raw_transaction + except AttributeError: + raw = signed.rawTransaction + tx_hash = self._w3.eth.send_raw_transaction(raw) + return tx_hash.hex() + + def wait_for_transaction_receipt(self, tx_hash: str, timeout: int = 120) -> Any: + """Wait for a submitted transaction to confirm.""" + if self._w3 is None: + raise NotImplementedError( + "EthAccountSigner requires rpc_url for wait_for_transaction_receipt()" + ) + return self._w3.eth.wait_for_transaction_receipt(tx_hash, timeout=timeout) + class FacilitatorWeb3Signer: """Facilitator-side EVM signer using web3.py. @@ -249,14 +319,14 @@ def verify_typed_data( True if signature is valid. """ # Build full types including EIP712Domain - full_types: dict[str, list[dict[str, str]]] = { - "EIP712Domain": [ - {"name": "name", "type": "string"}, - {"name": "version", "type": "string"}, - {"name": "chainId", "type": "uint256"}, - {"name": "verifyingContract", "type": "address"}, - ] - } + domain_type_fields: list[dict[str, str]] = [ + {"name": "name", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ] + if domain.version is not None: + domain_type_fields.insert(1, {"name": "version", "type": "string"}) + full_types: dict[str, list[dict[str, str]]] = {"EIP712Domain": domain_type_fields} for type_name, fields in types.items(): full_types[type_name] = [ {"name": f.name, "type": f.type} if isinstance(f, TypedDataField) else f @@ -274,12 +344,13 @@ def verify_typed_data( "primaryType": primary_type, "domain": { "name": domain.name, - "version": domain.version, "chainId": domain.chain_id, "verifyingContract": domain.verifying_contract, }, "message": msg_copy, } + if domain.version is not None: + typed_data["domain"]["version"] = domain.version # Try EOA signature verification first recovered = Account.recover_message( diff --git a/python/x402/src/bankofai/x402/mechanisms/evm/types.py b/python/x402/src/bankofai/x402/mechanisms/evm/types.py index e6d18f55..ee6a1b1c 100644 --- a/python/x402/src/bankofai/x402/mechanisms/evm/types.py +++ b/python/x402/src/bankofai/x402/mechanisms/evm/types.py @@ -68,8 +68,82 @@ def from_dict(cls, data: dict[str, Any]) -> "ExactEIP3009Payload": # Type aliases for V1/V2 compatibility +@dataclass +class Permit2Witness: + """Permit2 witness struct for exact proxy settlement.""" + + to: str + facilitator: str + valid_after: str + + +@dataclass +class ExactPermit2Authorization: + """Permit2 authorization data.""" + + from_address: str + permitted_token: str + permitted_amount: str + spender: str + nonce: str + deadline: str + witness: Permit2Witness + + +@dataclass +class ExactPermit2Payload: + """Permit2 payload for exact EVM payments.""" + + permit2_authorization: ExactPermit2Authorization + signature: str + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return { + "permit2Authorization": { + "from": self.permit2_authorization.from_address, + "permitted": { + "token": self.permit2_authorization.permitted_token, + "amount": self.permit2_authorization.permitted_amount, + }, + "spender": self.permit2_authorization.spender, + "nonce": self.permit2_authorization.nonce, + "deadline": self.permit2_authorization.deadline, + "witness": { + "to": self.permit2_authorization.witness.to, + "facilitator": self.permit2_authorization.witness.facilitator, + "validAfter": self.permit2_authorization.witness.valid_after, + }, + }, + "signature": self.signature, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ExactPermit2Payload": + """Create from dictionary.""" + auth = data.get("permit2Authorization", {}) + permitted = auth.get("permitted", {}) + witness = auth.get("witness", {}) + return cls( + permit2_authorization=ExactPermit2Authorization( + from_address=auth.get("from", ""), + permitted_token=permitted.get("token", ""), + permitted_amount=permitted.get("amount", ""), + spender=auth.get("spender", ""), + nonce=str(auth.get("nonce", "")), + deadline=str(auth.get("deadline", "")), + witness=Permit2Witness( + to=witness.get("to", ""), + facilitator=witness.get("facilitator", ""), + valid_after=str(witness.get("validAfter", "")), + ), + ), + signature=data.get("signature", ""), + ) + + ExactEvmPayloadV1 = ExactEIP3009Payload -ExactEvmPayloadV2 = ExactEIP3009Payload +ExactEvmPayloadV2 = ExactEIP3009Payload | ExactPermit2Payload @dataclass @@ -77,9 +151,9 @@ class TypedDataDomain: """EIP-712 domain separator.""" name: str - version: str chain_id: int verifying_contract: str + version: str | None = None @dataclass diff --git a/python/x402/src/bankofai/x402/mechanisms/tron/exact/client.py b/python/x402/src/bankofai/x402/mechanisms/tron/exact/client.py index 478d723c..08d6d462 100644 --- a/python/x402/src/bankofai/x402/mechanisms/tron/exact/client.py +++ b/python/x402/src/bankofai/x402/mechanisms/tron/exact/client.py @@ -8,6 +8,7 @@ from ....schemas import PaymentRequirements from ..constants import ( AUTHORIZATION_TYPES, + ERR_INSUFFICIENT_FUNDS, PERMIT2_ADDRESSES, PERMIT2_WITNESS_TYPES, SCHEME_EXACT, @@ -46,6 +47,8 @@ def create_payment_payload( Returns: Inner payload dict (authorization + signature). """ + self._ensure_sufficient_balance(requirements) + extra = requirements.extra or {} method = extra.get("assetTransferMethod", "eip3009") @@ -131,6 +134,8 @@ def _create_permit2_payload(self, requirements: PaymentRequirements) -> dict[str "Permit2 facilitator address is required in payment requirements extra" ) + self._ensure_permit2_allowance(requirements, permit2_address) + permit2_authorization = { "from": normalize_address_for_signing(self._signer.address), "permitted": { @@ -190,3 +195,48 @@ def _sign_permit2( primary_type="PermitWitnessTransferFrom", message=message, ) + + def _ensure_sufficient_balance(self, requirements: PaymentRequirements) -> None: + """Check TRC-20 balance before signing a payment payload.""" + balance = self._signer.read_contract( + address=requirements.asset, + function_name="balanceOf", + args=[self._signer.address], + ) + if int(str(balance)) < int(requirements.amount): + raise ValueError( + f"{ERR_INSUFFICIENT_FUNDS}: Insufficient token balance. Required: {requirements.amount}, Available: {balance}" + ) + + def _ensure_permit2_allowance( + self, requirements: PaymentRequirements, permit2_address: str + ) -> None: + """Best-effort local Permit2 approval fallback when sponsoring is unavailable.""" + allowance = int( + str( + self._signer.read_contract( + address=requirements.asset, + function_name="allowance", + args=[self._signer.address, permit2_address], + ) + ) + ) + if allowance >= int(requirements.amount): + return + + write_contract = getattr(self._signer, "write_contract", None) + wait_for_receipt = getattr(self._signer, "wait_for_transaction_receipt", None) + if not callable(write_contract) or not callable(wait_for_receipt): + return + + tx_hash = write_contract( + requirements.asset, + "approve", + [permit2_address, (1 << 256) - 1], + ) + receipt = wait_for_receipt(tx_hash) + status = getattr(receipt, "status", None) + if status not in ("success", 1): + raise ValueError( + f"transaction_failed: local Permit2 approval failed with status={status}" + ) diff --git a/python/x402/src/bankofai/x402/mechanisms/tron/signers.py b/python/x402/src/bankofai/x402/mechanisms/tron/signers.py index bd72be55..4e8ce030 100644 --- a/python/x402/src/bankofai/x402/mechanisms/tron/signers.py +++ b/python/x402/src/bankofai/x402/mechanisms/tron/signers.py @@ -317,3 +317,34 @@ def read_contract( contract = self._client.get_contract(address) func = getattr(contract.functions, function_name) return func(*(args or [])) + + def write_contract( + self, + address: str, + function_name: str, + args: list[Any], + fee_limit: int = 1_000_000_000, + ) -> str: + """Execute a contract write call and return the txid.""" + contract = self._client.get_contract(address) + func = getattr(contract.functions, function_name) + txn = func(*args).with_owner(self._address).fee_limit(fee_limit).build().sign(self._pk) + result = txn.broadcast() + return str(result.txid) + + def wait_for_transaction_receipt( + self, tx_hash: str, max_attempts: int = 30 + ) -> TronTransactionReceipt: + """Poll until the transaction is confirmed.""" + for _ in range(max_attempts): + try: + info = self._client.get_transaction_info(tx_hash) + result = info.get("receipt", {}).get("result", "") + if result == "SUCCESS": + return TronTransactionReceipt(status="success", tx_hash=tx_hash) + if result and result != "SUCCESS": + return TronTransactionReceipt(status="reverted", tx_hash=tx_hash) + except Exception: + pass + time.sleep(1) + return TronTransactionReceipt(status="pending", tx_hash=tx_hash) diff --git a/python/x402/src/bankofai/x402/schemas/helpers.py b/python/x402/src/bankofai/x402/schemas/helpers.py index 58560ae5..6de49cc0 100644 --- a/python/x402/src/bankofai/x402/schemas/helpers.py +++ b/python/x402/src/bankofai/x402/schemas/helpers.py @@ -215,8 +215,9 @@ def matches_network_pattern(network: Network, pattern: Network) -> bool: def derive_network_pattern(networks: list[Network]) -> Network: """Derive common pattern from list of networks. - If all networks share same namespace, returns wildcard pattern. - Otherwise returns first network. + If a single network is provided, returns that exact network. + If multiple networks share the same namespace, returns a wildcard pattern. + Otherwise returns the first network. Args: networks: List of networks. @@ -228,6 +229,8 @@ def derive_network_pattern(networks: list[Network]) -> Network: ValueError: If networks list is empty. Examples: + >>> derive_network_pattern(["eip155:8453"]) + 'eip155:8453' >>> derive_network_pattern(["eip155:8453", "eip155:84532"]) 'eip155:*' >>> derive_network_pattern(["eip155:8453", "solana:mainnet"]) @@ -236,6 +239,9 @@ def derive_network_pattern(networks: list[Network]) -> Network: if not networks: raise ValueError("At least one network required") + if len(networks) == 1: + return networks[0] + namespaces = {n.split(":")[0] for n in networks} if len(namespaces) == 1: return f"{namespaces.pop()}:*" diff --git a/python/x402/tests/integrations/test_evm_bsc_permit2.py b/python/x402/tests/integrations/test_evm_bsc_permit2.py new file mode 100644 index 00000000..9b2711ea --- /dev/null +++ b/python/x402/tests/integrations/test_evm_bsc_permit2.py @@ -0,0 +1,149 @@ +"""BSC permit2 integration tests for the Python x402 SDK. + +These tests perform REAL blockchain transactions on BSC Testnet. + +Required environment variables: +- BSC_CLIENT_PRIVATE_KEY +- BSC_FACILITATOR_PRIVATE_KEY +- BSC_TESTNET_RPC_URL + +The payer wallet must already have: +- testnet BNB for gas +- testnet USDT +- an approval for the configured BSC Permit2 contract +""" + +import os + +import pytest +from eth_account import Account + +from bankofai.x402 import x402ClientSync, x402FacilitatorSync, x402ResourceServerSync +from bankofai.x402.mechanisms.evm import SCHEME_EXACT +from bankofai.x402.mechanisms.evm.constants import NETWORK_CONFIGS +from bankofai.x402.mechanisms.evm.exact import ( + ExactEvmClientScheme, + ExactEvmFacilitatorScheme, + ExactEvmSchemeConfig, + ExactEvmServerScheme, +) +from bankofai.x402.mechanisms.evm.signers import EthAccountSigner, FacilitatorWeb3Signer +from bankofai.x402.schemas import ( + PaymentPayload, + PaymentRequirements, + ResourceConfig, + SupportedResponse, +) + +BSC_CLIENT_PRIVATE_KEY = os.environ.get("BSC_CLIENT_PRIVATE_KEY") +BSC_FACILITATOR_PRIVATE_KEY = os.environ.get("BSC_FACILITATOR_PRIVATE_KEY") +BSC_RPC_URL = os.environ.get("BSC_TESTNET_RPC_URL") + +BSC_TESTNET = "eip155:97" +BSC_USDT_ADDRESS = NETWORK_CONFIGS[BSC_TESTNET]["default_asset"]["address"] + +pytestmark = pytest.mark.skipif( + not BSC_CLIENT_PRIVATE_KEY or not BSC_FACILITATOR_PRIVATE_KEY or not BSC_RPC_URL, + reason=( + "BSC_CLIENT_PRIVATE_KEY, BSC_FACILITATOR_PRIVATE_KEY, and " + "BSC_TESTNET_RPC_URL are required for BSC permit2 integration tests" + ), +) + + +class EvmFacilitatorClientSync: + """Facilitator client wrapper for x402ResourceServerSync.""" + + scheme = SCHEME_EXACT + network = BSC_TESTNET + x402_version = 2 + + def __init__(self, facilitator: x402FacilitatorSync): + self._facilitator = facilitator + + def verify( + self, + payload: PaymentPayload, + requirements: PaymentRequirements, + ): + return self._facilitator.verify(payload, requirements) + + def settle( + self, + payload: PaymentPayload, + requirements: PaymentRequirements, + ): + return self._facilitator.settle(payload, requirements) + + def get_supported(self) -> SupportedResponse: + return self._facilitator.get_supported() + + +class TestBscPermit2Integration: + """Integration tests for the Python EVM permit2 flow on BSC Testnet.""" + + def setup_method(self) -> None: + client_account = Account.from_key(BSC_CLIENT_PRIVATE_KEY) + self.client_signer = EthAccountSigner(client_account) + self.facilitator_signer = FacilitatorWeb3Signer( + private_key=BSC_FACILITATOR_PRIVATE_KEY, + rpc_url=BSC_RPC_URL, + ) + self.client_address = self.client_signer.address + self.facilitator_address = self.facilitator_signer.address + + self.client = x402ClientSync().register( + BSC_TESTNET, + ExactEvmClientScheme(self.client_signer), + ) + self.facilitator = x402FacilitatorSync().register( + [BSC_TESTNET], + ExactEvmFacilitatorScheme( + self.facilitator_signer, + ExactEvmSchemeConfig(deploy_erc4337_with_eip6492=True), + ), + ) + facilitator_client = EvmFacilitatorClientSync(self.facilitator) + self.server = x402ResourceServerSync(facilitator_client) + self.server.register(BSC_TESTNET, ExactEvmServerScheme()) + self.server.initialize() + + def test_server_should_successfully_verify_and_settle_bsc_permit2_payment(self) -> None: + """Exercise the full BSC Testnet permit2 flow against real chain state.""" + config = ResourceConfig( + scheme=SCHEME_EXACT, + network=BSC_TESTNET, + pay_to=self.facilitator_address, + price="$0.0001", + ) + accepts = self.server.build_payment_requirements(config) + + assert len(accepts) == 1 + assert accepts[0].network == BSC_TESTNET + assert accepts[0].asset == BSC_USDT_ADDRESS + assert accepts[0].extra["assetTransferMethod"] == "permit2" + assert accepts[0].extra["permit2FacilitatorAddress"].lower() == ( + self.facilitator_address.lower() + ) + + payment_required = self.server.create_payment_required_response(accepts) + payment_payload = self.client.create_payment_payload(payment_required) + + assert "permit2Authorization" in payment_payload.payload + assert ( + payment_payload.payload["permit2Authorization"]["witness"]["facilitator"].lower() + == self.facilitator_address.lower() + ) + + accepted = self.server.find_matching_requirements(accepts, payment_payload) + assert accepted is not None + + verify_response = self.server.verify_payment(payment_payload, accepted) + assert verify_response.is_valid is True + assert verify_response.payer.lower() == self.client_address.lower() + + settle_response = self.server.settle_payment(payment_payload, accepted) + assert settle_response.success is True + assert settle_response.network == BSC_TESTNET + assert settle_response.transaction.startswith("0x") + assert settle_response.payer.lower() == self.client_address.lower() diff --git a/python/x402/tests/integrations/test_mcp_evm_bsc_permit2.py b/python/x402/tests/integrations/test_mcp_evm_bsc_permit2.py new file mode 100644 index 00000000..27f68bee --- /dev/null +++ b/python/x402/tests/integrations/test_mcp_evm_bsc_permit2.py @@ -0,0 +1,246 @@ +"""BSC Testnet permit2 MCP integration tests with real blockchain settlement.""" + +import asyncio +import os +import socket +import threading +import time + +import pytest + +mcp = pytest.importorskip("mcp", reason="mcp package not available") +from mcp import ClientSession # noqa: E402 +from mcp.client.streamable_http import streamable_http_client # noqa: E402 +from mcp.server.fastmcp import FastMCP # noqa: E402 +from mcp.types import TextContent # noqa: E402 + +from bankofai.x402 import x402ClientSync, x402FacilitatorSync, x402ResourceServerSync # noqa: E402 +from bankofai.x402.mcp import create_payment_wrapper, x402MCPClientSync # noqa: E402 +from bankofai.x402.mechanisms.evm.exact import ( # noqa: E402 + ExactEvmClientScheme, + ExactEvmFacilitatorScheme, + ExactEvmSchemeConfig, + ExactEvmServerScheme, +) +from bankofai.x402.mechanisms.evm.signers import ( # noqa: E402 + EthAccountSigner, + FacilitatorWeb3Signer, +) +from bankofai.x402.schemas import ResourceConfig, ResourceInfo # noqa: E402 + +BSC_CLIENT_PRIVATE_KEY = os.environ.get("BSC_CLIENT_PRIVATE_KEY") +BSC_FACILITATOR_PRIVATE_KEY = os.environ.get("BSC_FACILITATOR_PRIVATE_KEY") +BSC_RPC_URL = os.environ.get("BSC_TESTNET_RPC_URL") + +TEST_NETWORK = "eip155:97" +TEST_PORT_PAID = 4101 + +pytestmark = pytest.mark.skipif( + not BSC_CLIENT_PRIVATE_KEY or not BSC_FACILITATOR_PRIVATE_KEY or not BSC_RPC_URL, + reason=( + "BSC_CLIENT_PRIVATE_KEY, BSC_FACILITATOR_PRIVATE_KEY, and " + "BSC_TESTNET_RPC_URL are required for MCP BSC permit2 integration tests" + ), +) + + +class EvmFacilitatorClientSync: + """Facilitator client wrapper for x402ResourceServerSync.""" + + scheme = "exact" + network = TEST_NETWORK + x402_version = 2 + + def __init__(self, facilitator: x402FacilitatorSync): + self._facilitator = facilitator + + def verify(self, payload, requirements): + return self._facilitator.verify(payload, requirements) + + def settle(self, payload, requirements): + return self._facilitator.settle(payload, requirements) + + def get_supported(self): + return self._facilitator.get_supported() + + +class MCPClientAdapter: + """Adapter that wraps mcp.ClientSession to x402.mcp.MCPClientInterface.""" + + def __init__(self, session: ClientSession): + self._session = session + + def connect(self, transport): + pass + + def close(self): + pass + + def call_tool(self, params, **kwargs): + import nest_asyncio + + nest_asyncio.apply() + + name = params.get("name", "") + arguments = params.get("arguments", {}) + meta = None + if "_meta" in params: + meta = params["_meta"] + elif "_meta" in kwargs: + meta = kwargs["_meta"] + elif "meta" in kwargs: + meta = kwargs["meta"] + + if meta is not None: + result = asyncio.run(self._session.call_tool(name, arguments, meta=meta)) + else: + result = asyncio.run(self._session.call_tool(name, arguments)) + + content = [] + for item in result.content: + if isinstance(item, TextContent): + content.append({"type": "text", "text": item.text}) + else: + content.append({"type": getattr(item, "type", "text"), "text": str(item)}) + + return type( + "MCPResult", + (), + { + "content": content, + "isError": result.isError, + "_meta": result.meta if hasattr(result, "meta") and result.meta else {}, + "structuredContent": ( + result.structuredContent if hasattr(result, "structuredContent") else None + ), + }, + )() + + def list_tools(self): + import nest_asyncio + + nest_asyncio.apply() + + result = asyncio.run(self._session.list_tools()) + tools = [] + for tool in result.tools: + tools.append({"name": tool.name, "description": tool.description}) + return {"tools": tools} + + +class TestMCPBscPermit2Integration: + """MCP integration coverage for BSC permit2.""" + + def setup_method(self): + from eth_account import Account + + client_account = Account.from_key(BSC_CLIENT_PRIVATE_KEY) + self.client_signer = EthAccountSigner(client_account) + self.facilitator_signer = FacilitatorWeb3Signer( + private_key=BSC_FACILITATOR_PRIVATE_KEY, + rpc_url=BSC_RPC_URL, + ) + + self.client = x402ClientSync().register( + TEST_NETWORK, + ExactEvmClientScheme(self.client_signer), + ) + self.facilitator = x402FacilitatorSync().register( + [TEST_NETWORK], + ExactEvmFacilitatorScheme( + self.facilitator_signer, + ExactEvmSchemeConfig(deploy_erc4337_with_eip6492=True), + ), + ) + + facilitator_client = EvmFacilitatorClientSync(self.facilitator) + self.server = x402ResourceServerSync(facilitator_client) + self.server.register(TEST_NETWORK, ExactEvmServerScheme()) + self.server.initialize() + + def test_paid_tool_with_real_bsc_permit2_transaction(self): + """Run the MCP auto-payment flow against BSC Testnet permit2.""" + config = ResourceConfig( + scheme="exact", + network=TEST_NETWORK, + pay_to=self.facilitator_signer.address, + price="$0.0001", + ) + accepts = self.server.build_payment_requirements(config) + assert accepts[0].extra["assetTransferMethod"] == "permit2" + + weather_wrapper = create_payment_wrapper( + self.server, + accepts=accepts, + resource=ResourceInfo( + url="mcp://tool/get_weather", + description="Get weather for a city", + mime_type="application/json", + ), + ) + + mcp_server = FastMCP("x402-test-server-bsc", json_response=True, port=TEST_PORT_PAID) + + @mcp_server.tool( + name="get_weather", + description="Get weather for a city. Requires payment of $0.0001.", + ) + @weather_wrapper + async def get_weather(city: str) -> str: + return '{"city": "' + city + '", "weather": "sunny", "temperature": 72}' + + server_thread = threading.Thread( + target=lambda: mcp_server.run(transport="streamable-http"), + daemon=True, + ) + server_thread.start() + + max_wait = 5.0 + start_time = time.time() + while time.time() - start_time < max_wait: + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(0.1) + result = sock.connect_ex(("localhost", TEST_PORT_PAID)) + sock.close() + if result == 0: + break + except Exception: + pass + time.sleep(0.1) + else: + raise RuntimeError( + f"Server failed to start on port {TEST_PORT_PAID} within {max_wait}s" + ) + + try: + + async def run_client(): + async with streamable_http_client(f"http://localhost:{TEST_PORT_PAID}/mcp") as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + + adapter = MCPClientAdapter(session) + x402_mcp = x402MCPClientSync( + adapter, + self.client, + auto_payment=True, + on_payment_requested=lambda ctx: True, + ) + + result = x402_mcp.call_tool("get_weather", {"city": "Shanghai"}) + + assert result.payment_made is True + assert result.is_error is False + assert result.payment_response is not None + assert result.payment_response.success is True + assert result.payment_response.transaction.startswith("0x") + assert result.payment_response.network == TEST_NETWORK + + asyncio.run(run_client()) + finally: + pass diff --git a/python/x402/tests/unit/core/test_facilitator.py b/python/x402/tests/unit/core/test_facilitator.py index 0abe4b96..3c27ea2e 100644 --- a/python/x402/tests/unit/core/test_facilitator.py +++ b/python/x402/tests/unit/core/test_facilitator.py @@ -383,11 +383,32 @@ def test_find_with_wildcard_pattern(self): """Test finding facilitator with wildcard network pattern.""" facilitator = x402Facilitator() mock_scheme = MockSchemeNetworkFacilitator("exact") - # Register with wildcard pattern (derived from single network) - facilitator.register(["eip155:8453"], mock_scheme) + facilitator.register(["eip155:8453", "eip155:84532"], mock_scheme) # Exact match should work assert facilitator._find_facilitator("exact", "eip155:8453") is mock_scheme + assert facilitator._find_facilitator("exact", "eip155:84532") is mock_scheme + + def test_find_exact_network_does_not_match_other_same_family_network(self): + """Single-network registrations should not widen to the whole family.""" + facilitator = x402Facilitator() + mock_scheme = MockSchemeNetworkFacilitator("exact") + facilitator.register(["tron:nile"], mock_scheme) + + assert facilitator._find_facilitator("exact", "tron:nile") is mock_scheme + assert facilitator._find_facilitator("exact", "tron:mainnet") is None + + def test_find_distinct_schemes_for_same_family_networks(self): + """Different signers on the same family should stay isolated by network.""" + facilitator = x402Facilitator() + nile_scheme = MockSchemeNetworkFacilitator("exact") + mainnet_scheme = MockSchemeNetworkFacilitator("exact") + + facilitator.register(["tron:nile"], nile_scheme) + facilitator.register(["tron:mainnet"], mainnet_scheme) + + assert facilitator._find_facilitator("exact", "tron:nile") is nile_scheme + assert facilitator._find_facilitator("exact", "tron:mainnet") is mainnet_scheme class TestFindFacilitatorV1: diff --git a/python/x402/tests/unit/mechanisms/evm/test_client.py b/python/x402/tests/unit/mechanisms/evm/test_client.py index 23329eb7..05cb2e44 100644 --- a/python/x402/tests/unit/mechanisms/evm/test_client.py +++ b/python/x402/tests/unit/mechanisms/evm/test_client.py @@ -179,3 +179,129 @@ def test_raw_local_account_can_sign_payload(self): assert "signature" in payload assert payload["signature"].startswith("0x") assert len(payload["signature"]) > 2 # not just "0x" + + def test_should_create_permit2_payload_for_bsc_testnet(self): + """Should create Permit2 payload when requirements request Permit2.""" + account = Account.create() + client = ExactEvmClientScheme(signer=account) + network = "eip155:97" + + requirements = PaymentRequirements( + scheme="exact", + network=network, + asset="0x337610d27c682E347C9cD60BD4b3b107C9d34dDd", + amount="1000000000000000000", + pay_to="0x0987654321098765432109876543210987654321", + max_timeout_seconds=3600, + extra={ + "name": "Tether USD", + "version": "1", + "assetTransferMethod": "permit2", + "permit2FacilitatorAddress": "0x1111111111111111111111111111111111111111", + }, + ) + + payload = client.create_payment_payload(requirements) + + assert "permit2Authorization" in payload + assert payload["signature"].startswith("0x") + assert payload["permit2Authorization"]["spender"] == ( + "0xEe38Ec718255fe78e9D16aCC0e1183C731679b23" + ) + assert payload["permit2Authorization"]["witness"]["facilitator"] == ( + "0x1111111111111111111111111111111111111111" + ) + + +class _ReadCapableSigner: + def __init__(self, address: str, balance: int) -> None: + self._address = address + self._balance = balance + + @property + def address(self) -> str: + return self._address + + def sign_typed_data( + self, domain, types, primary_type, message + ): # pragma: no cover - not reached + return bytes.fromhex("11" * 65) + + def read_contract(self, address, abi, function_name, *args): + assert function_name == "balanceOf" + return self._balance + + +class _Permit2CapableSigner(_ReadCapableSigner): + def __init__(self, address: str, balance: int, allowance: int) -> None: + super().__init__(address, balance) + self._allowance = allowance + self.write_calls: list[tuple[str, str]] = [] + + def read_contract(self, address, abi, function_name, *args): + if function_name == "balanceOf": + return self._balance + if function_name == "allowance": + return self._allowance + raise AssertionError(f"unexpected function {function_name}") + + def write_contract(self, address, abi, function_name, *args): + self.write_calls.append((address, function_name)) + return "0xapprovalhash" + + def wait_for_transaction_receipt(self, tx_hash): + assert tx_hash == "0xapprovalhash" + return {"status": 1} + + +def test_create_payment_payload_fails_fast_on_insufficient_balance(): + signer = _ReadCapableSigner("0x1111111111111111111111111111111111111111", balance=0) + client = ExactEvmClientScheme(signer) + requirements = PaymentRequirements( + scheme="exact", + network="eip155:97", + asset="0x337610d27c682E347C9cD60BD4b3b107C9d34dDd", + amount="1000000", + pay_to="0x0987654321098765432109876543210987654321", + max_timeout_seconds=3600, + extra={ + "name": "Tether USD", + "version": "1", + "assetTransferMethod": "permit2", + "permit2FacilitatorAddress": "0x1111111111111111111111111111111111111111", + }, + ) + + try: + client.create_payment_payload(requirements) + raise AssertionError("expected insufficient_balance error") + except ValueError as exc: + assert "insufficient_balance" in str(exc) + + +def test_create_payment_payload_locally_approves_permit2_when_allowance_is_insufficient(): + signer = _Permit2CapableSigner( + "0x1111111111111111111111111111111111111111", + balance=10**18, + allowance=0, + ) + client = ExactEvmClientScheme(signer) + requirements = PaymentRequirements( + scheme="exact", + network="eip155:97", + asset="0x337610d27c682E347C9cD60BD4b3b107C9d34dDd", + amount="1000000", + pay_to="0x0987654321098765432109876543210987654321", + max_timeout_seconds=3600, + extra={ + "name": "Tether USD", + "version": "1", + "assetTransferMethod": "permit2", + "permit2FacilitatorAddress": "0x1111111111111111111111111111111111111111", + }, + ) + + payload = client.create_payment_payload(requirements) + + assert "permit2Authorization" in payload + assert signer.write_calls == [(requirements.asset, "approve")] diff --git a/python/x402/tests/unit/mechanisms/evm/test_facilitator.py b/python/x402/tests/unit/mechanisms/evm/test_facilitator.py index 2b6868ca..824a5145 100644 --- a/python/x402/tests/unit/mechanisms/evm/test_facilitator.py +++ b/python/x402/tests/unit/mechanisms/evm/test_facilitator.py @@ -411,14 +411,25 @@ def test_caip_family_attribute(self): assert facilitator.caip_family == "eip155:*" - def test_get_extra_returns_none(self): - """get_extra should return None for EVM.""" + def test_get_extra_returns_supported_transfer_methods(self): + """get_extra should advertise transfer methods for the network.""" signer = MockFacilitatorSigner() facilitator = ExactEvmFacilitatorScheme(signer) extra = facilitator.get_extra("eip155:8453") - assert extra is None + assert extra == {"supportedAssetTransferMethods": ["eip3009"]} + + def test_get_extra_includes_permit2_for_bsc(self): + """BSC networks should advertise Permit2 support and facilitator address.""" + signer = MockFacilitatorSigner(["0x1111111111111111111111111111111111111111"]) + facilitator = ExactEvmFacilitatorScheme(signer) + + extra = facilitator.get_extra("eip155:97") + + assert extra is not None + assert extra["supportedAssetTransferMethods"] == ["eip3009", "permit2"] + assert extra["permit2FacilitatorAddress"] == "0x1111111111111111111111111111111111111111" def test_get_signers_returns_signer_addresses(self): """get_signers should return list of signer addresses.""" diff --git a/python/x402/tests/unit/mechanisms/evm/test_server.py b/python/x402/tests/unit/mechanisms/evm/test_server.py index 4f4e5338..c30ea773 100644 --- a/python/x402/tests/unit/mechanisms/evm/test_server.py +++ b/python/x402/tests/unit/mechanisms/evm/test_server.py @@ -251,6 +251,34 @@ def test_should_set_default_asset_if_not_specified(self): config = get_network_config(network) assert result.asset == config["default_asset"]["address"] + def test_should_propagate_permit2_facilitator_address(self): + """Should copy permit2 facilitator address from supported kind extra.""" + server = ExactEvmServerScheme() + network = "eip155:97" + + requirements = PaymentRequirements( + scheme="exact", + network=network, + asset=get_network_config(network)["default_asset"]["address"], + amount="1000000000000000000", + pay_to="0x1234567890123456789012345678901234567890", + max_timeout_seconds=3600, + extra={"assetTransferMethod": "permit2"}, + ) + + supported_kind = SupportedKind( + x402_version=2, + scheme="exact", + network=network, + extra={"permit2FacilitatorAddress": "0x1111111111111111111111111111111111111111"}, + ) + + result = server.enhance_payment_requirements(requirements, supported_kind, []) + + assert result.extra["permit2FacilitatorAddress"] == ( + "0x1111111111111111111111111111111111111111" + ) + class TestRegisterMoneyParser: """Test registerMoneyParser method.""" diff --git a/python/x402/tests/unit/mechanisms/evm/test_types.py b/python/x402/tests/unit/mechanisms/evm/test_types.py index d3b764ba..31d516af 100644 --- a/python/x402/tests/unit/mechanisms/evm/test_types.py +++ b/python/x402/tests/unit/mechanisms/evm/test_types.py @@ -5,6 +5,9 @@ ExactEIP3009Payload, ExactEvmPayloadV1, ExactEvmPayloadV2, + ExactPermit2Authorization, + ExactPermit2Payload, + Permit2Witness, ) @@ -178,10 +181,46 @@ def test_v1_should_be_alias_of_eip3009_payload(self): """V1 should be alias of ExactEIP3009Payload.""" assert ExactEvmPayloadV1 is ExactEIP3009Payload - def test_v2_should_be_alias_of_eip3009_payload(self): - """V2 should be alias of ExactEIP3009Payload.""" - assert ExactEvmPayloadV2 is ExactEIP3009Payload + def test_v2_should_accept_eip3009_payload(self): + """V2 should include EIP-3009 payload support.""" + assert isinstance(ExactEIP3009Payload, type) - def test_v1_and_v2_should_be_same(self): - """V1 and V2 should be the same type.""" - assert ExactEvmPayloadV1 is ExactEvmPayloadV2 + def test_v2_should_include_permit2_payload(self): + """V2 should also include Permit2 payload support.""" + args = getattr(ExactEvmPayloadV2, "__args__", ()) + assert ExactEIP3009Payload in args + assert ExactPermit2Payload in args + + +class TestExactPermit2Payload: + """Test ExactPermit2Payload type.""" + + def test_round_trip_serialization(self): + """Should preserve Permit2 payload data through serialization.""" + payload = ExactPermit2Payload( + permit2_authorization=ExactPermit2Authorization( + from_address="0x1234567890123456789012345678901234567890", + permitted_token="0x337610d27c682E347C9cD60BD4b3b107C9d34dDd", + permitted_amount="1000000000000000000", + spender="0xEe38Ec718255fe78e9D16aCC0e1183C731679b23", + nonce="0x" + "12" * 32, + deadline="1000003600", + witness=Permit2Witness( + to="0x0987654321098765432109876543210987654321", + facilitator="0x1111111111111111111111111111111111111111", + valid_after="1000000000", + ), + ), + signature="0xabcd", + ) + + serialized = payload.to_dict() + restored = ExactPermit2Payload.from_dict(serialized) + + assert restored.permit2_authorization.from_address == ( + payload.permit2_authorization.from_address + ) + assert restored.permit2_authorization.witness.facilitator == ( + payload.permit2_authorization.witness.facilitator + ) + assert restored.signature == payload.signature diff --git a/python/x402/tests/unit/mechanisms/tron/test_client.py b/python/x402/tests/unit/mechanisms/tron/test_client.py new file mode 100644 index 00000000..02f8fb0b --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tron/test_client.py @@ -0,0 +1,90 @@ +"""Tests for ExactTronClientScheme client balance preflight.""" + +try: + from bankofai.x402.mechanisms.tron.exact.client import ExactTronClientScheme +except ImportError: + import pytest + + pytest.skip("TRON client requires tronpy", allow_module_level=True) + +from bankofai.x402.schemas import PaymentRequirements + + +class _MockTronSigner: + address = "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf" + + def __init__(self, balance: int) -> None: + self._balance = balance + + def sign_typed_data( + self, domain, types, primary_type, message + ): # pragma: no cover - not reached + return "0xdeadbeef" + + def read_contract(self, address, function_name, args=None): + assert function_name == "balanceOf" + return self._balance + + +class _Permit2CapableTronSigner(_MockTronSigner): + def __init__(self, balance: int, allowance: int) -> None: + super().__init__(balance) + self._allowance = allowance + self.write_calls: list[tuple[str, str]] = [] + + def read_contract(self, address, function_name, args=None): + if function_name == "balanceOf": + return self._balance + if function_name == "allowance": + return self._allowance + raise AssertionError(f"unexpected function {function_name}") + + def write_contract(self, address, function_name, args): + self.write_calls.append((address, function_name)) + return "approvaltxid" + + def wait_for_transaction_receipt(self, tx_hash): + assert tx_hash == "approvaltxid" + return type("Receipt", (), {"status": "success"})() + + +def test_create_payment_payload_fails_fast_on_insufficient_balance(): + signer = _MockTronSigner(balance=0) + scheme = ExactTronClientScheme(signer) + requirements = PaymentRequirements( + scheme="exact", + network="tron:nile", + asset="TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + amount="100", + pay_to="TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs", + max_timeout_seconds=300, + extra={"name": "Tether USD", "version": "1"}, + ) + + try: + scheme.create_payment_payload(requirements) + raise AssertionError("expected insufficient_funds error") + except ValueError as exc: + assert "insufficient_funds" in str(exc) + + +def test_create_payment_payload_locally_approves_permit2_when_allowance_is_insufficient(): + signer = _Permit2CapableTronSigner(balance=1000, allowance=0) + scheme = ExactTronClientScheme(signer) + requirements = PaymentRequirements( + scheme="exact", + network="tron:nile", + asset="TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + amount="100", + pay_to="TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs", + max_timeout_seconds=300, + extra={ + "assetTransferMethod": "permit2", + "permit2FacilitatorAddress": "TSForFRqxmZdJ6Yfx2rNaFykhuQLc9cTMR", + }, + ) + + payload = scheme.create_payment_payload(requirements) + + assert "permit2Authorization" in payload + assert signer.write_calls == [(requirements.asset, "approve")] diff --git a/typescript/packages/mcp/package.json b/typescript/packages/mcp/package.json index 8e0a12f1..b9b78426 100644 --- a/typescript/packages/mcp/package.json +++ b/typescript/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@bankofai/x402-mcp", - "version": "2.6.0-beta.9", + "version": "2.6.0-beta.10", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/mcp/src/command/mcp-server.ts b/typescript/packages/mcp/src/command/mcp-server.ts index aa36dd95..8f07a205 100644 --- a/typescript/packages/mcp/src/command/mcp-server.ts +++ b/typescript/packages/mcp/src/command/mcp-server.ts @@ -58,18 +58,55 @@ async function main(): Promise { version, }); + const balanceArgsSchema = z + .object({ + network: z.string().optional(), + asset: z.string().optional(), + token: z.string().optional(), + pair: z.string().optional(), + }) + .strict(); + + const payArgsSchema = z + .object({ + url: z.string().url(), + method: z.string().optional(), + data: z.string().optional(), + query: z.string().optional(), + headers: z.string().optional(), + network: z.string().optional(), + asset: z.string().optional(), + token: z.string().optional(), + pair: z.string().optional(), + max_amount: z.string().optional(), + correlation_id: z.string().optional(), + }) + .strict(); + + const approveArgsSchema = z + .object({ + url: z.string().url(), + method: z.string().optional(), + data: z.string().optional(), + query: z.string().optional(), + headers: z.string().optional(), + network: z.string().optional(), + asset: z.string().optional(), + token: z.string().optional(), + pair: z.string().optional(), + max_amount: z.string().optional(), + }) + .strict(); + server.tool("x402_status", "Show configured x402 wallet status.", {}, async () => { return toTextResult(runCli(["status"])); }); - server.tool( + server.registerTool( "x402_balance", - "Show configured x402 wallet balances.", { - network: z.string().optional(), - asset: z.string().optional(), - token: z.string().optional(), - pair: z.string().optional(), + description: "Show configured x402 wallet balances.", + inputSchema: balanceArgsSchema, }, async args => { const commandArgs = ["balance"]; @@ -81,21 +118,11 @@ async function main(): Promise { }, ); - server.tool( + server.registerTool( "x402_pay", - "Call an x402-protected URL and automatically complete payment.", { - url: z.string().url(), - method: z.string().optional(), - data: z.string().optional(), - query: z.string().optional(), - headers: z.string().optional(), - network: z.string().optional(), - asset: z.string().optional(), - token: z.string().optional(), - pair: z.string().optional(), - max_amount: z.string().optional(), - correlation_id: z.string().optional(), + description: "Call an x402-protected URL and automatically complete payment.", + inputSchema: payArgsSchema, }, async args => { const commandArgs = ["pay", args.url]; @@ -114,20 +141,11 @@ async function main(): Promise { }, ); - server.tool( + server.registerTool( "x402_approve", - "Approve Permit2 allowance for the selected x402 payment option.", { - url: z.string().url(), - method: z.string().optional(), - data: z.string().optional(), - query: z.string().optional(), - headers: z.string().optional(), - network: z.string().optional(), - asset: z.string().optional(), - token: z.string().optional(), - pair: z.string().optional(), - max_amount: z.string().optional(), + description: "Approve Permit2 allowance for the selected x402 payment option.", + inputSchema: approveArgsSchema, }, async args => { const commandArgs = ["approve", args.url]; diff --git a/typescript/packages/mcp/src/command/runtime.ts b/typescript/packages/mcp/src/command/runtime.ts index cecc0890..cb66663d 100644 --- a/typescript/packages/mcp/src/command/runtime.ts +++ b/typescript/packages/mcp/src/command/runtime.ts @@ -94,6 +94,25 @@ const DEFAULT_PAYMENT_ASSETS: Partial< }, }; +const NETWORK_ALIASES: Record = { + mainnet: "tron:mainnet", + nile: "tron:nile", + shasta: "tron:shasta", + "tron:mainnet": "tron:mainnet", + "tron:nile": "tron:nile", + "tron:shasta": "tron:shasta", + tron_mainnet: "tron:mainnet", + tron_nile: "tron:nile", + tron_shasta: "tron:shasta", + bsc: "eip155:56", + "bsc-mainnet": "eip155:56", + bsc_mainnet: "eip155:56", + "eip155:56": "eip155:56", + "bsc-testnet": "eip155:97", + bsc_testnet: "eip155:97", + "eip155:97": "eip155:97", +}; + export type ParsedCliOptions = Record; function readJsonFile(file: string): Record | undefined { @@ -293,22 +312,16 @@ function resolvePreferredNetwork(network?: string): string | undefined { return undefined; } - if (network.startsWith("tron:") || network.startsWith("eip155:")) { - return network; - } + return NETWORK_ALIASES[network.trim().toLowerCase()]; +} - switch (network) { - case "mainnet": - case "nile": - case "shasta": - return `tron:${network}`; - case "bsc": - return "eip155:56"; - case "bsc-testnet": - return "eip155:97"; - default: - return undefined; +function requireSupportedNetwork(network: string, optionName = "--network"): string { + const resolved = resolvePreferredNetwork(network); + if (!resolved) { + throw new Error(`Unsupported network for ${optionName}: ${network}`); } + + return resolved; } function normalizeSelectorValue(value?: string): string | undefined { @@ -327,23 +340,23 @@ function parsePairSelector(pair?: string): { network?: string; asset?: string } if (pair.includes("/")) { const [network, asset] = pair.split("/", 2); return { - network: resolvePreferredNetwork(network) ?? network, + network: requireSupportedNetwork(network, "--pair"), asset, }; } const parts = pair.split(":"); if (parts.length >= 3) { + const network = parts.slice(0, -1).join(":"); return { - network: - resolvePreferredNetwork(parts.slice(0, -1).join(":")) ?? parts.slice(0, -1).join(":"), + network: requireSupportedNetwork(network, "--pair"), asset: parts.at(-1), }; } if (parts.length === 2) { return { - network: resolvePreferredNetwork(parts[0]) ?? parts[0], + network: requireSupportedNetwork(parts[0], "--pair"), asset: parts[1], }; } @@ -695,8 +708,59 @@ export async function runStatus(): Promise { process.stdout.write(JSON.stringify(result, null, 2) + "\n"); } -function getPreferredAsset(options: CliBalanceOptions): string | undefined { - return parsePairSelector(options.pair).asset ?? options.asset ?? options.token; +function isHexAddress(value: string): value is `0x${string}` { + return /^0x[a-fA-F0-9]{40}$/.test(value); +} + +function isTronAddress(value: string): boolean { + return TronWeb.isAddress(value); +} + +function resolvePreferredAssetInfo(args: { + network: SupportedNetwork; + asset?: string; + token?: string; + pairAsset?: string; +}): { asset: string; symbol?: string } | undefined { + const explicitToken = args.token?.trim(); + const explicitAsset = args.asset?.trim(); + const explicitPairAsset = args.pairAsset?.trim(); + const explicitSelector = explicitPairAsset ?? explicitAsset ?? explicitToken; + const defaultAsset = getDefaultPaymentAsset(args.network); + + if (!explicitSelector) { + return defaultAsset; + } + + if (args.network === "mainnet" || args.network === "nile" || args.network === "shasta") { + if ( + defaultAsset?.symbol && + normalizeSelectorValue(explicitSelector) === normalizeSelectorValue(defaultAsset.symbol) + ) { + return defaultAsset; + } + + if (!isTronAddress(explicitSelector)) { + throw new Error(`Invalid token address format for tron:${args.network}: ${explicitSelector}`); + } + + return { asset: explicitSelector }; + } + + if ( + defaultAsset?.symbol && + normalizeSelectorValue(explicitSelector) === normalizeSelectorValue(defaultAsset.symbol) + ) { + return defaultAsset; + } + + if (!isHexAddress(explicitSelector)) { + throw new Error( + `Invalid token address format for ${EVM_NETWORKS[args.network].chainId}: ${explicitSelector}`, + ); + } + + return { asset: explicitSelector }; } function getDefaultPaymentAsset( @@ -980,10 +1044,40 @@ export async function runBalance(options: CliBalanceOptions = {}): Promise const { tronKey, evmKey, tronGridApiKey } = await resolveKeys(); const result: Record = {}; const pairSelector = parsePairSelector(options.pair); - const preferredNetwork = resolvePreferredNetwork(options.network) ?? pairSelector.network; - const preferredAsset = getPreferredAsset(options); + const preferredNetwork = + options.network !== undefined ? requireSupportedNetwork(options.network) : pairSelector.network; + const includeTron = !preferredNetwork || preferredNetwork.startsWith("tron:"); + const includeEvm = !preferredNetwork || preferredNetwork.startsWith("eip155:"); + + if (preferredNetwork?.startsWith("tron:")) { + resolvePreferredAssetInfo({ + network: preferredNetwork.slice("tron:".length) as keyof typeof TRON_RPC_URLS, + asset: options.asset, + token: options.token, + pairAsset: pairSelector.asset, + }); + } - if (tronKey) { + if (preferredNetwork?.startsWith("eip155:")) { + const evmNetwork = + preferredNetwork === "eip155:56" + ? "bsc" + : preferredNetwork === "eip155:97" + ? "bsc-testnet" + : undefined; + if (!evmNetwork) { + throw new Error(`Unsupported network for --network: ${preferredNetwork}`); + } + + resolvePreferredAssetInfo({ + network: evmNetwork, + asset: options.asset, + token: options.token, + pairAsset: pairSelector.asset, + }); + } + + if (tronKey && includeTron) { const tronWeb = buildTronWeb(TRON_RPC_URLS.nile, tronKey, tronGridApiKey); const signer = createClientTronSigner(tronWeb, tronKey); const trxSun = await tronWeb.trx.getBalance(signer.address); @@ -991,10 +1085,12 @@ export async function runBalance(options: CliBalanceOptions = {}): Promise preferredNetwork && preferredNetwork.startsWith("tron:") ? (preferredNetwork.slice("tron:".length) as keyof typeof TRON_RPC_URLS) : "nile"; - const tokenInfo = - preferredAsset && (!preferredNetwork || preferredNetwork.startsWith("tron:")) - ? { asset: preferredAsset } - : getDefaultPaymentAsset(tronNetwork); + const tokenInfo = resolvePreferredAssetInfo({ + network: tronNetwork, + asset: options.asset, + token: options.token, + pairAsset: pairSelector.asset, + }); result.tron = { address: signer.address, network: `tron:${tronNetwork}`, @@ -1016,7 +1112,7 @@ export async function runBalance(options: CliBalanceOptions = {}): Promise } } - if (evmKey) { + if (evmKey && includeEvm) { const account = privateKeyToAccount(normalizeHexPrivateKey(evmKey)); const balances: Record = {}; @@ -1037,9 +1133,12 @@ export async function runBalance(options: CliBalanceOptions = {}): Promise nativeBalance: balance.toString(), }; - const tokenInfo = preferredAsset - ? { asset: preferredAsset } - : getDefaultPaymentAsset(networkName as keyof typeof EVM_NETWORKS); + const tokenInfo = resolvePreferredAssetInfo({ + network: networkName as keyof typeof EVM_NETWORKS, + asset: options.asset, + token: options.token, + pairAsset: pairSelector.asset, + }); if (tokenInfo?.asset) { entry.token = { diff --git a/typescript/packages/mechanisms/evm/src/constants.ts b/typescript/packages/mechanisms/evm/src/constants.ts index 98a27b1a..e31af547 100644 --- a/typescript/packages/mechanisms/evm/src/constants.ts +++ b/typescript/packages/mechanisms/evm/src/constants.ts @@ -29,6 +29,7 @@ export const permit2WitnessTypes = { ], Witness: [ { name: "to", type: "address" }, + { name: "facilitator", type: "address" }, { name: "validAfter", type: "uint256" }, ], } as const; @@ -137,6 +138,17 @@ export const erc20AllowanceAbi = [ }, ] as const; +/** ERC-20 balanceOf(address) ABI for checking token balances before signing. */ +export const erc20BalanceOfAbi = [ + { + type: "function", + name: "balanceOf", + inputs: [{ name: "account", type: "address" }], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, +] as const; + /** Gas limit for a standard ERC-20 approve() transaction. */ export const ERC20_APPROVE_GAS_LIMIT = 70_000n; @@ -147,25 +159,83 @@ export const DEFAULT_MAX_FEE_PER_GAS = 1_000_000_000n; export const DEFAULT_MAX_PRIORITY_FEE_PER_GAS = 100_000_000n; /** - * Canonical Permit2 contract address. - * Same address on all EVM chains via CREATE2 deployment. + * Canonical Uniswap Permit2 contract address. + * Used as the default on EVM chains that do not override Permit2 deployment. * * @see https://github.com/Uniswap/permit2 */ export const PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3" as const; /** - * x402ExactPermit2Proxy contract address. - * Current deployed address on BSC mainnet and BSC testnet. + * Chain-specific Permit2 deployments. + * BSC uses PancakeSwap's Permit2 deployment instead of the canonical Uniswap address. + */ +export const PERMIT2_ADDRESSES: Record = { + "eip155:56": "0x31c2F6fcFf4F8759b3Bd5Bf0e1084A055615c768", + "eip155:97": "0x31c2F6fcFf4F8759b3Bd5Bf0e1084A055615c768", +}; + +/** + * Default x402ExactPermit2Proxy contract address. + * Preserved for backwards compatibility in exports and tests. */ export const x402ExactPermit2ProxyAddress = "0xEe38Ec718255fe78e9D16aCC0e1183C731679b23" as const; /** - * x402UptoPermit2Proxy contract address. - * Current deployed address on BSC mainnet and BSC testnet. + * Chain-specific x402ExactPermit2Proxy deployments. + */ +export const X402_EXACT_PERMIT2_PROXY_ADDRESSES: Record = { + "eip155:56": x402ExactPermit2ProxyAddress, + "eip155:97": x402ExactPermit2ProxyAddress, +}; + +/** + * Default x402UptoPermit2Proxy contract address. + * Preserved for backwards compatibility in exports and tests. */ export const x402UptoPermit2ProxyAddress = "0x2b30Ed9F37c7C21ae8779c5753B1cCf264DfD63C" as const; +/** + * Chain-specific x402UptoPermit2Proxy deployments. + */ +export const X402_UPTO_PERMIT2_PROXY_ADDRESSES: Record = { + "eip155:56": x402UptoPermit2ProxyAddress, + "eip155:97": x402UptoPermit2ProxyAddress, +}; + +/** + * Resolve the Permit2 contract address for an EVM network. + * Falls back to the canonical Uniswap deployment when a chain-specific override is not configured. + * + * @param network - CAIP-2 EVM network identifier. + * @returns The Permit2 contract address for the requested network. + */ +export function getPermit2Address(network: string): `0x${string}` { + return PERMIT2_ADDRESSES[network] ?? PERMIT2_ADDRESS; +} + +/** + * Resolve the x402 exact Permit2 proxy address for an EVM network. + * Falls back to the default exported address when a chain-specific override is not configured. + * + * @param network - CAIP-2 EVM network identifier. + * @returns The x402 exact Permit2 proxy contract address for the requested network. + */ +export function getX402ExactPermit2ProxyAddress(network: string): `0x${string}` { + return X402_EXACT_PERMIT2_PROXY_ADDRESSES[network] ?? x402ExactPermit2ProxyAddress; +} + +/** + * Resolve the x402 upto Permit2 proxy address for an EVM network. + * Falls back to the default exported address when a chain-specific override is not configured. + * + * @param network - CAIP-2 EVM network identifier. + * @returns The x402 upto Permit2 proxy contract address for the requested network. + */ +export function getX402UptoPermit2ProxyAddress(network: string): `0x${string}` { + return X402_UPTO_PERMIT2_PROXY_ADDRESSES[network] ?? x402UptoPermit2ProxyAddress; +} + /** * Shared ABI components for the Permit2 witness tuple. * Used in both x402ExactPermit2ProxyABI and x402UptoPermit2ProxyABI to keep them in sync. @@ -173,6 +243,7 @@ export const x402UptoPermit2ProxyAddress = "0x2b30Ed9F37c7C21ae8779c5753B1cCf264 */ const permit2WitnessABIComponents = [ { name: "to", type: "address", internalType: "address" }, + { name: "facilitator", type: "address", internalType: "address" }, { name: "validAfter", type: "uint256", internalType: "uint256" }, ] as const; diff --git a/typescript/packages/mechanisms/evm/src/exact/client/eip2612.ts b/typescript/packages/mechanisms/evm/src/exact/client/eip2612.ts index 7c305cd7..3914a195 100644 --- a/typescript/packages/mechanisms/evm/src/exact/client/eip2612.ts +++ b/typescript/packages/mechanisms/evm/src/exact/client/eip2612.ts @@ -1,6 +1,6 @@ import { getAddress } from "viem"; import type { Eip2612GasSponsoringInfo } from "@bankofai/x402-extensions"; -import { eip2612PermitTypes, eip2612NoncesAbi, PERMIT2_ADDRESS } from "../../constants"; +import { eip2612PermitTypes, eip2612NoncesAbi, getPermit2Address } from "../../constants"; import { ClientEvmSigner } from "../../signer"; /** @@ -16,6 +16,7 @@ import { ClientEvmSigner } from "../../signer"; * @param tokenAddress - The ERC-20 token contract address * @param tokenName - The token name (from paymentRequirements.extra.name) * @param tokenVersion - The token version (from paymentRequirements.extra.version) + * @param network - The target EVM network used to resolve Permit2 * @param chainId - The chain ID * @param deadline - The deadline for the permit (unix timestamp as string) * @param permittedAmount - The Permit2 permitted amount (must match exactly) @@ -26,12 +27,13 @@ export async function signEip2612Permit( tokenAddress: `0x${string}`, tokenName: string, tokenVersion: string, + network: string, chainId: number, deadline: string, permittedAmount: string, ): Promise { const owner = signer.address; - const spender = getAddress(PERMIT2_ADDRESS); + const spender = getAddress(getPermit2Address(network)); // Query the current EIP-2612 nonce from the token contract const nonce = (await signer.readContract({ diff --git a/typescript/packages/mechanisms/evm/src/exact/client/erc20approval.ts b/typescript/packages/mechanisms/evm/src/exact/client/erc20approval.ts index a8044930..0115215e 100644 --- a/typescript/packages/mechanisms/evm/src/exact/client/erc20approval.ts +++ b/typescript/packages/mechanisms/evm/src/exact/client/erc20approval.ts @@ -4,7 +4,7 @@ import { type Erc20ApprovalGasSponsoringInfo, } from "@bankofai/x402-extensions"; import { - PERMIT2_ADDRESS, + getPermit2Address, erc20ApproveAbi, ERC20_APPROVE_GAS_LIMIT, DEFAULT_MAX_FEE_PER_GAS, @@ -23,16 +23,18 @@ import { ClientEvmSigner } from "../../signer"; * * @param signer - The client EVM signer (must support signTransaction, getTransactionCount) * @param tokenAddress - The ERC-20 token contract address + * @param network - The target EVM network used to resolve Permit2 * @param chainId - The chain ID * @returns The ERC-20 approval gas sponsoring info object */ export async function signErc20ApprovalTransaction( signer: ClientEvmSigner, tokenAddress: `0x${string}`, + network: string, chainId: number, ): Promise { const from = signer.address; - const spender = getAddress(PERMIT2_ADDRESS); + const spender = getAddress(getPermit2Address(network)); // Encode approve(PERMIT2_ADDRESS, MaxUint256) calldata const data = encodeFunctionData({ @@ -76,3 +78,57 @@ export async function signErc20ApprovalTransaction( version: ERC20_APPROVAL_GAS_SPONSORING_VERSION, }; } + +/** + * Broadcasts a local ERC-20 approval transaction when the facilitator does not + * advertise approval sponsoring. + * + * @param signer - Client signer capable of broadcasting or signing raw approval transactions. + * @param tokenAddress - ERC-20 token contract address to approve. + * @param network - Network used to resolve the Permit2 deployment. + * @param chainId - Chain ID used when signing a raw fallback transaction. + * @returns The approval transaction hash. + */ +export async function broadcastErc20ApprovalTransaction( + signer: ClientEvmSigner, + tokenAddress: `0x${string}`, + network: string, + chainId: number, +): Promise<`0x${string}`> { + if (signer.sendTransaction && signer.waitForTransactionReceipt) { + const tx = encodeFunctionData({ + abi: erc20ApproveAbi, + functionName: "approve", + args: [getAddress(getPermit2Address(network)), maxUint256], + }); + const hash = await signer.sendTransaction({ + to: tokenAddress, + data: tx, + }); + const receipt = await signer.waitForTransactionReceipt({ hash }); + if (receipt.status !== "success") { + throw new Error(`local_approve_failed: approval transaction ${hash} did not succeed`); + } + return hash; + } + + if ( + signer.signTransaction && + signer.getTransactionCount && + signer.estimateFeesPerGas && + signer.sendRawTransaction && + signer.waitForTransactionReceipt + ) { + const info = await signErc20ApprovalTransaction(signer, tokenAddress, network, chainId); + const hash = await signer.sendRawTransaction({ + serializedTransaction: info.signedTransaction, + }); + const receipt = await signer.waitForTransactionReceipt({ hash }); + if (receipt.status !== "success") { + throw new Error(`local_approve_failed: approval transaction ${hash} did not succeed`); + } + return hash; + } + + throw new Error("local_approve_unsupported: EVM signer cannot broadcast approval transactions"); +} diff --git a/typescript/packages/mechanisms/evm/src/exact/client/permit2.ts b/typescript/packages/mechanisms/evm/src/exact/client/permit2.ts index 1c71988a..eea54a2f 100644 --- a/typescript/packages/mechanisms/evm/src/exact/client/permit2.ts +++ b/typescript/packages/mechanisms/evm/src/exact/client/permit2.ts @@ -2,8 +2,8 @@ import { PaymentRequirements, PaymentPayloadResult } from "@bankofai/x402-core/t import { encodeFunctionData, getAddress } from "viem"; import { permit2WitnessTypes, - PERMIT2_ADDRESS, - x402ExactPermit2ProxyAddress, + getPermit2Address, + getX402ExactPermit2ProxyAddress, erc20ApproveAbi, erc20AllowanceAbi, } from "../../constants"; @@ -37,17 +37,22 @@ export async function createPermit2Payload( // Upper time bound is enforced by Permit2's deadline field const deadline = (now + paymentRequirements.maxTimeoutSeconds).toString(); + const facilitator = + (paymentRequirements.extra?.permit2FacilitatorAddress as `0x${string}` | undefined) ?? + (getAddress(paymentRequirements.payTo) as `0x${string}`); + const permit2Authorization: ExactPermit2Payload["permit2Authorization"] = { from: signer.address, permitted: { token: getAddress(paymentRequirements.asset), amount: paymentRequirements.amount, }, - spender: x402ExactPermit2ProxyAddress, + spender: getX402ExactPermit2ProxyAddress(paymentRequirements.network), nonce, deadline, witness: { to: getAddress(paymentRequirements.payTo), + facilitator: getAddress(facilitator), validAfter, }, }; @@ -84,11 +89,12 @@ async function signPermit2Authorization( requirements: PaymentRequirements, ): Promise<`0x${string}`> { const chainId = getEvmChainId(requirements.network); + const permit2Address = getPermit2Address(requirements.network); const domain = { name: "Permit2", chainId, - verifyingContract: PERMIT2_ADDRESS, + verifyingContract: permit2Address, }; const message = { @@ -101,6 +107,7 @@ async function signPermit2Authorization( deadline: BigInt(permit2Authorization.deadline), witness: { to: getAddress(permit2Authorization.witness.to), + facilitator: getAddress(permit2Authorization.witness.facilitator), validAfter: BigInt(permit2Authorization.witness.validAfter), }, }; @@ -118,25 +125,30 @@ async function signPermit2Authorization( * The user sends this transaction (paying gas) before using Permit2 flow. * * @param tokenAddress - The ERC20 token contract address + * @param network - The target EVM network used to resolve the Permit2 deployment * @returns Transaction data to send for approval * * @example * ```typescript - * const tx = createPermit2ApprovalTx("0x..."); + * const tx = createPermit2ApprovalTx("0x...", "eip155:97"); * await walletClient.sendTransaction({ * to: tx.to, * data: tx.data, * }); * ``` */ -export function createPermit2ApprovalTx(tokenAddress: `0x${string}`): { +export function createPermit2ApprovalTx( + tokenAddress: `0x${string}`, + network: string, +): { to: `0x${string}`; data: `0x${string}`; } { + const permit2Address = getPermit2Address(network); const data = encodeFunctionData({ abi: erc20ApproveAbi, functionName: "approve", - args: [PERMIT2_ADDRESS, MAX_UINT256], + args: [permit2Address, MAX_UINT256], }); return { @@ -152,6 +164,7 @@ export function createPermit2ApprovalTx(tokenAddress: `0x${string}`): { export interface Permit2AllowanceParams { tokenAddress: `0x${string}`; ownerAddress: `0x${string}`; + network: string; } /** @@ -178,10 +191,11 @@ export function getPermit2AllowanceReadParams(params: Permit2AllowanceParams): { functionName: "allowance"; args: [`0x${string}`, `0x${string}`]; } { + const permit2Address = getPermit2Address(params.network); return { address: getAddress(params.tokenAddress), abi: erc20AllowanceAbi, functionName: "allowance", - args: [getAddress(params.ownerAddress), PERMIT2_ADDRESS], + args: [getAddress(params.ownerAddress), permit2Address], }; } diff --git a/typescript/packages/mechanisms/evm/src/exact/client/scheme.ts b/typescript/packages/mechanisms/evm/src/exact/client/scheme.ts index e8d673ee..e5e1da70 100644 --- a/typescript/packages/mechanisms/evm/src/exact/client/scheme.ts +++ b/typescript/packages/mechanisms/evm/src/exact/client/scheme.ts @@ -7,13 +7,13 @@ import { import { EIP2612_GAS_SPONSORING, ERC20_APPROVAL_GAS_SPONSORING } from "@bankofai/x402-extensions"; import { ClientEvmSigner } from "../../signer"; import { AssetTransferMethod } from "../../types"; -import { PERMIT2_ADDRESS, erc20AllowanceAbi } from "../../constants"; +import { erc20AllowanceAbi, erc20BalanceOfAbi, getPermit2Address } from "../../constants"; import { getAddress } from "viem"; import { getEvmChainId } from "../../utils"; import { createEIP3009Payload } from "./eip3009"; import { createPermit2Payload } from "./permit2"; import { signEip2612Permit } from "./eip2612"; -import { signErc20ApprovalTransaction } from "./erc20approval"; +import { broadcastErc20ApprovalTransaction, signErc20ApprovalTransaction } from "./erc20approval"; /** * EVM client implementation for the Exact payment scheme. @@ -57,6 +57,8 @@ export class ExactEvmScheme implements SchemeNetworkClient { paymentRequirements: PaymentRequirements, context?: PaymentPayloadContext, ): Promise { + await this.ensureSufficientTokenBalance(paymentRequirements); + const assetTransferMethod = (paymentRequirements.extra?.assetTransferMethod as AssetTransferMethod) ?? "eip3009"; @@ -86,12 +88,57 @@ export class ExactEvmScheme implements SchemeNetworkClient { }; } + await this.ensureLocalPermit2Approval(paymentRequirements); + return result; } return createEIP3009Payload(this.signer, x402Version, paymentRequirements); } + /** + * Falls back to a local approve(Permit2, MaxUint256) transaction when the + * server does not advertise approval sponsoring. + * + * @param requirements - Payment requirement whose Permit2 allowance should be ensured. + */ + private async ensureLocalPermit2Approval(requirements: PaymentRequirements): Promise { + const canBroadcastDirectly = + !!this.signer.sendTransaction && !!this.signer.waitForTransactionReceipt; + const canBroadcastSigned = + !!this.signer.signTransaction && + !!this.signer.getTransactionCount && + !!this.signer.estimateFeesPerGas && + !!this.signer.sendRawTransaction && + !!this.signer.waitForTransactionReceipt; + if (!canBroadcastDirectly && !canBroadcastSigned) { + return; + } + + const tokenAddress = getAddress(requirements.asset) as `0x${string}`; + const permit2Address = getPermit2Address(requirements.network); + const amount = BigInt(requirements.amount); + + const allowance = (await this.signer.readContract({ + address: tokenAddress, + abi: erc20AllowanceAbi, + functionName: "allowance", + args: [this.signer.address, permit2Address], + })) as bigint; + + if (allowance >= amount) { + return; + } + + const chainId = getEvmChainId(requirements.network); + await broadcastErc20ApprovalTransaction( + this.signer, + tokenAddress, + requirements.network, + chainId, + ); + } + /** * Attempts to sign an EIP-2612 permit for gasless Permit2 approval. * @@ -126,6 +173,7 @@ export class ExactEvmScheme implements SchemeNetworkClient { const chainId = getEvmChainId(requirements.network); const tokenAddress = getAddress(requirements.asset) as `0x${string}`; + const permit2Address = getPermit2Address(requirements.network); // Check if user already has sufficient Permit2 allowance try { @@ -133,7 +181,7 @@ export class ExactEvmScheme implements SchemeNetworkClient { address: tokenAddress, abi: erc20AllowanceAbi, functionName: "allowance", - args: [this.signer.address, PERMIT2_ADDRESS], + args: [this.signer.address, permit2Address], })) as bigint; if (allowance >= BigInt(requirements.amount)) { @@ -156,6 +204,7 @@ export class ExactEvmScheme implements SchemeNetworkClient { tokenAddress, tokenName, tokenVersion, + requirements.network, chainId, deadline, requirements.amount, @@ -202,6 +251,7 @@ export class ExactEvmScheme implements SchemeNetworkClient { const chainId = getEvmChainId(requirements.network); const tokenAddress = getAddress(requirements.asset) as `0x${string}`; + const permit2Address = getPermit2Address(requirements.network); // Check if user already has sufficient Permit2 allowance try { @@ -209,7 +259,7 @@ export class ExactEvmScheme implements SchemeNetworkClient { address: tokenAddress, abi: erc20AllowanceAbi, functionName: "allowance", - args: [this.signer.address, PERMIT2_ADDRESS], + args: [this.signer.address, permit2Address], })) as bigint; if (allowance >= BigInt(requirements.amount)) { @@ -220,10 +270,37 @@ export class ExactEvmScheme implements SchemeNetworkClient { } // Sign the approve(Permit2, MaxUint256) transaction - const info = await signErc20ApprovalTransaction(this.signer, tokenAddress, chainId); + const info = await signErc20ApprovalTransaction( + this.signer, + tokenAddress, + requirements.network, + chainId, + ); return { [ERC20_APPROVAL_GAS_SPONSORING.key]: { info }, }; } + + /** + * Performs a best-effort ERC-20 balance check before signing. + * This avoids creating payloads that are guaranteed to fail at facilitator verify/settle. + * + * @param requirements - Payment requirement whose token balance should be checked. + */ + private async ensureSufficientTokenBalance(requirements: PaymentRequirements): Promise { + const tokenAddress = getAddress(requirements.asset) as `0x${string}`; + const balance = (await this.signer.readContract({ + address: tokenAddress, + abi: erc20BalanceOfAbi, + functionName: "balanceOf", + args: [this.signer.address], + })) as bigint; + + if (balance < BigInt(requirements.amount)) { + throw new Error( + `insufficient_balance: Insufficient token balance. Required: ${requirements.amount}, Available: ${balance.toString()}`, + ); + } + } } diff --git a/typescript/packages/mechanisms/evm/src/exact/facilitator/erc20approval.ts b/typescript/packages/mechanisms/evm/src/exact/facilitator/erc20approval.ts index f5af587e..a7f21c5a 100644 --- a/typescript/packages/mechanisms/evm/src/exact/facilitator/erc20approval.ts +++ b/typescript/packages/mechanisms/evm/src/exact/facilitator/erc20approval.ts @@ -10,7 +10,7 @@ import { validateErc20ApprovalGasSponsoringInfo, type Erc20ApprovalGasSponsoringInfo, } from "@bankofai/x402-extensions"; -import { PERMIT2_ADDRESS, erc20ApproveAbi } from "../../constants"; +import { erc20ApproveAbi, getPermit2Address } from "../../constants"; import { ErrErc20ApprovalInvalidFormat, ErrErc20ApprovalFromMismatch, @@ -43,13 +43,16 @@ const APPROVE_SELECTOR = "0x095ea7b3"; * @param info - The ERC-20 approval gas sponsoring info * @param payer - The expected payer address * @param tokenAddress - The expected token address + * @param network - CAIP-2 EVM network identifier used to resolve Permit2. * @returns Validation result with invalidReason and invalidMessage on failure */ export async function validateErc20ApprovalForPayment( info: Erc20ApprovalGasSponsoringInfo, payer: `0x${string}`, tokenAddress: `0x${string}`, + network: string, ): Promise> { + const permit2Address = getPermit2Address(network); if (!validateErc20ApprovalGasSponsoringInfo(info)) { return { isValid: false, @@ -74,11 +77,11 @@ export async function validateErc20ApprovalForPayment( }; } - if (getAddress(info.spender) !== getAddress(PERMIT2_ADDRESS)) { + if (getAddress(info.spender) !== getAddress(permit2Address)) { return { isValid: false, invalidReason: ErrErc20ApprovalSpenderNotPermit2, - invalidMessage: `Expected spender=${PERMIT2_ADDRESS}, got ${info.spender}`, + invalidMessage: `Expected spender=${permit2Address}, got ${info.spender}`, }; } @@ -109,11 +112,11 @@ export async function validateErc20ApprovalForPayment( data: data as `0x${string}`, }); const calldataSpender = getAddress(decoded.args[0] as `0x${string}`); - if (calldataSpender !== getAddress(PERMIT2_ADDRESS)) { + if (calldataSpender !== getAddress(permit2Address)) { return { isValid: false, invalidReason: ErrErc20ApprovalTxWrongSpender, - invalidMessage: `approve() spender is ${calldataSpender}, expected Permit2 ${PERMIT2_ADDRESS}`, + invalidMessage: `approve() spender is ${calldataSpender}, expected Permit2 ${permit2Address}`, }; } } catch { diff --git a/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts b/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts index 87098b27..ec670e9e 100644 --- a/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts +++ b/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts @@ -16,10 +16,10 @@ import type { Eip2612GasSponsoringInfo } from "@bankofai/x402-extensions"; import { getAddress } from "viem"; import { eip3009ABI, - PERMIT2_ADDRESS, + getPermit2Address, permit2WitnessTypes, x402ExactPermit2ProxyABI, - x402ExactPermit2ProxyAddress, + getX402ExactPermit2ProxyAddress, erc20AllowanceAbi, } from "../../constants"; import { @@ -78,11 +78,14 @@ export async function verifyPermit2( const chainId = getEvmChainId(requirements.network); const tokenAddress = getAddress(requirements.asset); + const permit2Address = getPermit2Address(requirements.network); + const proxyAddress = getX402ExactPermit2ProxyAddress(requirements.network); + const { facilitatorAddress, witnessFacilitator } = resolvePermit2Facilitator( + requirements, + permit2Payload, + ); - if ( - getAddress(permit2Payload.permit2Authorization.spender) !== - getAddress(x402ExactPermit2ProxyAddress) - ) { + if (getAddress(permit2Payload.permit2Authorization.spender) !== getAddress(proxyAddress)) { return { isValid: false, invalidReason: "invalid_permit2_spender", @@ -100,6 +103,14 @@ export async function verifyPermit2( }; } + if (getAddress(witnessFacilitator) !== getAddress(facilitatorAddress)) { + return { + isValid: false, + invalidReason: "invalid_permit2_facilitator_mismatch", + payer, + }; + } + const now = Math.floor(Date.now() / 1000); if (BigInt(permit2Payload.permit2Authorization.deadline) < BigInt(now + 6)) { return { @@ -142,7 +153,7 @@ export async function verifyPermit2( domain: { name: "Permit2", chainId, - verifyingContract: PERMIT2_ADDRESS, + verifyingContract: permit2Address, }, message: { permitted: { @@ -154,6 +165,7 @@ export async function verifyPermit2( deadline: BigInt(permit2Payload.permit2Authorization.deadline), witness: { to: getAddress(permit2Payload.permit2Authorization.witness.to), + facilitator: getAddress(witnessFacilitator), validAfter: BigInt(permit2Payload.permit2Authorization.witness.validAfter), }, }, @@ -240,12 +252,13 @@ async function _verifyPermit2Allowance( tokenAddress: `0x${string}`, context?: FacilitatorContext, ): Promise { + const permit2Address = getPermit2Address(requirements.network); try { const allowance = (await signer.readContract({ address: tokenAddress, abi: erc20AllowanceAbi, functionName: "allowance", - args: [payer, PERMIT2_ADDRESS], + args: [payer, permit2Address], })) as bigint; if (allowance >= BigInt(requirements.amount)) { @@ -255,7 +268,12 @@ async function _verifyPermit2Allowance( // Allowance insufficient — try EIP-2612 gas sponsoring first const eip2612Info = extractEip2612GasSponsoringInfo(payload); if (eip2612Info) { - const result = validateEip2612PermitForPayment(eip2612Info, payer, tokenAddress); + const result = validateEip2612PermitForPayment( + eip2612Info, + payer, + tokenAddress, + requirements.network, + ); if (!result.isValid) { return { isValid: false, invalidReason: result.invalidReason!, payer }; } @@ -270,7 +288,12 @@ async function _verifyPermit2Allowance( if (erc20GasSponsorshipExtension) { const erc20Info = extractErc20ApprovalGasSponsoringInfo(payload); if (erc20Info) { - const result = await validateErc20ApprovalForPayment(erc20Info, payer, tokenAddress); + const result = await validateErc20ApprovalForPayment( + erc20Info, + payer, + tokenAddress, + requirements.network, + ); if (!result.isValid) { return { isValid: false, invalidReason: result.invalidReason!, payer }; } @@ -283,7 +306,12 @@ async function _verifyPermit2Allowance( // If allowance check fails, validate extensions if present; otherwise proceed optimistically const eip2612Info = extractEip2612GasSponsoringInfo(payload); if (eip2612Info) { - const result = validateEip2612PermitForPayment(eip2612Info, payer, tokenAddress); + const result = validateEip2612PermitForPayment( + eip2612Info, + payer, + tokenAddress, + requirements.network, + ); if (!result.isValid) { return { isValid: false, invalidReason: result.invalidReason!, payer }; } @@ -369,11 +397,13 @@ async function _settlePermit2WithEIP2612( eip2612Info: Eip2612GasSponsoringInfo, ): Promise { const payer = permit2Payload.permit2Authorization.from; + const proxyAddress = getX402ExactPermit2ProxyAddress(payload.accepted.network); + const { witnessFacilitator } = resolvePermit2Facilitator(payload.accepted, permit2Payload); try { const { v, r, s } = splitEip2612Signature(eip2612Info.signature); const tx = await signer.writeContract({ - address: x402ExactPermit2ProxyAddress, + address: proxyAddress, abi: x402ExactPermit2ProxyABI, functionName: "settleWithPermit", args: [ @@ -395,6 +425,7 @@ async function _settlePermit2WithEIP2612( getAddress(payer), { to: getAddress(permit2Payload.permit2Authorization.witness.to), + facilitator: getAddress(witnessFacilitator), validAfter: BigInt(permit2Payload.permit2Authorization.witness.validAfter), }, permit2Payload.signature, @@ -425,6 +456,8 @@ async function _settlePermit2WithERC20Approval( erc20Info: { signedTransaction: string }, ): Promise { const payer = permit2Payload.permit2Authorization.from; + const proxyAddress = getX402ExactPermit2ProxyAddress(payload.accepted.network); + const { witnessFacilitator } = resolvePermit2Facilitator(payload.accepted, permit2Payload); try { const approvalTxHash = await extensionSigner.sendRawTransaction({ @@ -446,7 +479,7 @@ async function _settlePermit2WithERC20Approval( } const tx = await extensionSigner.writeContract({ - address: x402ExactPermit2ProxyAddress, + address: proxyAddress, abi: x402ExactPermit2ProxyABI, functionName: "settle", args: [ @@ -461,6 +494,7 @@ async function _settlePermit2WithERC20Approval( getAddress(payer), { to: getAddress(permit2Payload.permit2Authorization.witness.to), + facilitator: getAddress(witnessFacilitator), validAfter: BigInt(permit2Payload.permit2Authorization.witness.validAfter), }, permit2Payload.signature, @@ -487,9 +521,11 @@ async function _settlePermit2Direct( permit2Payload: ExactPermit2Payload, ): Promise { const payer = permit2Payload.permit2Authorization.from; + const proxyAddress = getX402ExactPermit2ProxyAddress(payload.accepted.network); + const { witnessFacilitator } = resolvePermit2Facilitator(payload.accepted, permit2Payload); try { const tx = await signer.writeContract({ - address: x402ExactPermit2ProxyAddress, + address: proxyAddress, abi: x402ExactPermit2ProxyABI, functionName: "settle", args: [ @@ -504,6 +540,7 @@ async function _settlePermit2Direct( getAddress(payer), { to: getAddress(permit2Payload.permit2Authorization.witness.to), + facilitator: getAddress(witnessFacilitator), validAfter: BigInt(permit2Payload.permit2Authorization.witness.validAfter), }, permit2Payload.signature, @@ -600,13 +637,16 @@ function _mapSettleError( * @param info - The EIP-2612 gas sponsoring info * @param payer - The expected payer address * @param tokenAddress - The expected token address + * @param network - CAIP-2 EVM network identifier used to resolve Permit2. * @returns Validation result with optional invalidReason */ function validateEip2612PermitForPayment( info: Eip2612GasSponsoringInfo, payer: `0x${string}`, tokenAddress: `0x${string}`, + network: string, ): { isValid: boolean; invalidReason?: string } { + const permit2Address = getPermit2Address(network); if (!validateEip2612GasSponsoringInfo(info)) { return { isValid: false, invalidReason: "invalid_eip2612_extension_format" }; } @@ -619,7 +659,7 @@ function validateEip2612PermitForPayment( return { isValid: false, invalidReason: "eip2612_asset_mismatch" }; } - if (getAddress(info.spender as `0x${string}`) !== getAddress(PERMIT2_ADDRESS)) { + if (getAddress(info.spender as `0x${string}`) !== getAddress(permit2Address)) { return { isValid: false, invalidReason: "eip2612_spender_not_permit2" }; } @@ -631,6 +671,27 @@ function validateEip2612PermitForPayment( return { isValid: true }; } +/** + * Resolves the expected facilitator address for Permit2 witness validation and settlement. + * + * @param requirements - The payment requirements associated with the payment. + * @param permit2Payload - The Permit2 payload supplied by the client. + * @returns The expected facilitator address and the witness facilitator value to use. + */ +function resolvePermit2Facilitator( + requirements: PaymentRequirements, + permit2Payload: ExactPermit2Payload, +): { facilitatorAddress: `0x${string}`; witnessFacilitator: `0x${string}` } { + const facilitatorAddress = + (requirements.extra?.permit2FacilitatorAddress as `0x${string}` | undefined) ?? + (getAddress(requirements.payTo) as `0x${string}`); + const witnessFacilitator = + (permit2Payload.permit2Authorization.witness.facilitator as `0x${string}` | undefined) ?? + facilitatorAddress; + + return { facilitatorAddress, witnessFacilitator }; +} + /** * Splits a 65-byte EIP-2612 signature into v, r, s components. * diff --git a/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts b/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts index 52a1650d..0c7d52ac 100644 --- a/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts @@ -48,13 +48,18 @@ export class ExactEvmScheme implements SchemeNetworkFacilitator { } /** - * Returns undefined — EVM has no mechanism-specific extra data. + * Returns mechanism-specific extra data for supported kinds. * * @param _ - The network identifier (unused) - * @returns undefined + * @returns Extra metadata for clients, including the Permit2 facilitator address */ getExtra(_: string): Record | undefined { - return undefined; + const facilitatorAddress = this.signer.getAddresses()[0]; + return facilitatorAddress + ? { + permit2FacilitatorAddress: facilitatorAddress, + } + : undefined; } /** diff --git a/typescript/packages/mechanisms/evm/src/exact/server/scheme.ts b/typescript/packages/mechanisms/evm/src/exact/server/scheme.ts index 35cd85e8..e0bfebc9 100644 --- a/typescript/packages/mechanisms/evm/src/exact/server/scheme.ts +++ b/typescript/packages/mechanisms/evm/src/exact/server/scheme.ts @@ -99,10 +99,21 @@ export class ExactEvmScheme implements SchemeNetworkServer { }, extensionKeys: string[], ): Promise { - // Mark unused parameters to satisfy linter - void supportedKind; void extensionKeys; - return Promise.resolve(paymentRequirements); + const existingMethod = paymentRequirements.extra?.assetTransferMethod as string | undefined; + const permit2FacilitatorAddress = + (paymentRequirements.extra?.permit2FacilitatorAddress as string | undefined) ?? + (supportedKind.extra?.permit2FacilitatorAddress as string | undefined); + + return Promise.resolve({ + ...paymentRequirements, + extra: { + ...paymentRequirements.extra, + ...(existingMethod === "permit2" && permit2FacilitatorAddress + ? { permit2FacilitatorAddress } + : {}), + }, + }); } /** diff --git a/typescript/packages/mechanisms/evm/src/index.ts b/typescript/packages/mechanisms/evm/src/index.ts index 946047f3..19fcd90f 100644 --- a/typescript/packages/mechanisms/evm/src/index.ts +++ b/typescript/packages/mechanisms/evm/src/index.ts @@ -32,8 +32,14 @@ export { isPermit2Payload, isEIP3009Payload } from "./types"; // Constants export { PERMIT2_ADDRESS, + PERMIT2_ADDRESSES, + getPermit2Address, x402ExactPermit2ProxyAddress, + X402_EXACT_PERMIT2_PROXY_ADDRESSES, + getX402ExactPermit2ProxyAddress, x402UptoPermit2ProxyAddress, + X402_UPTO_PERMIT2_PROXY_ADDRESSES, + getX402UptoPermit2ProxyAddress, permit2WitnessTypes, authorizationTypes, eip3009ABI, diff --git a/typescript/packages/mechanisms/evm/src/signer.ts b/typescript/packages/mechanisms/evm/src/signer.ts index 3e6f79bd..b3656845 100644 --- a/typescript/packages/mechanisms/evm/src/signer.ts +++ b/typescript/packages/mechanisms/evm/src/signer.ts @@ -49,6 +49,22 @@ export type ClientEvmSigner = { * Required for ERC-20 approval gas sponsoring. */ estimateFeesPerGas?(): Promise<{ maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }>; + /** + * Optional: Broadcast a transaction directly from the connected wallet client. + * Used for local Permit2 approval fallback when the facilitator does not + * advertise an approval sponsoring extension. + */ + sendTransaction?(args: { to: `0x${string}`; data: `0x${string}` }): Promise<`0x${string}`>; + /** + * Optional: Broadcast a pre-signed raw transaction. + * Used together with signTransaction() for local approval fallback. + */ + sendRawTransaction?(args: { serializedTransaction: `0x${string}` }): Promise<`0x${string}`>; + /** + * Optional: Wait for a locally-broadcast transaction to confirm. + * Required for local Permit2 approval fallback. + */ + waitForTransactionReceipt?(args: { hash: `0x${string}` }): Promise<{ status: string }>; }; /** @@ -110,6 +126,8 @@ export type FacilitatorEvmSigner = { * @param publicClient.readContract - The readContract method from the public client * @param publicClient.getTransactionCount - Optional getTransactionCount for ERC-20 approval * @param publicClient.estimateFeesPerGas - Optional estimateFeesPerGas for ERC-20 approval + * @param publicClient.sendRawTransaction - Optional raw transaction broadcaster for local approval fallback + * @param publicClient.waitForTransactionReceipt - Optional receipt waiter for local approval fallback * @returns A complete ClientEvmSigner * * @example @@ -132,6 +150,8 @@ export function toClientEvmSigner( }): Promise; getTransactionCount?(args: { address: `0x${string}` }): Promise; estimateFeesPerGas?(): Promise<{ maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }>; + sendRawTransaction?(args: { serializedTransaction: `0x${string}` }): Promise<`0x${string}`>; + waitForTransactionReceipt?(args: { hash: `0x${string}` }): Promise<{ status: string }>; }, ): ClientEvmSigner { const readContract = signer.readContract ?? publicClient?.readContract.bind(publicClient); @@ -167,6 +187,23 @@ export function toClientEvmSigner( result.estimateFeesPerGas = () => estimateFeesPerGas(); } + const sendTransaction = signer.sendTransaction; + if (sendTransaction) { + result.sendTransaction = args => sendTransaction(args); + } + + const sendRawTransaction = + signer.sendRawTransaction ?? publicClient?.sendRawTransaction?.bind(publicClient); + if (sendRawTransaction) { + result.sendRawTransaction = args => sendRawTransaction(args); + } + + const waitForTransactionReceipt = + signer.waitForTransactionReceipt ?? publicClient?.waitForTransactionReceipt?.bind(publicClient); + if (waitForTransactionReceipt) { + result.waitForTransactionReceipt = args => waitForTransactionReceipt(args); + } + return result; } diff --git a/typescript/packages/mechanisms/evm/src/types.ts b/typescript/packages/mechanisms/evm/src/types.ts index ca243ca9..da3f1742 100644 --- a/typescript/packages/mechanisms/evm/src/types.ts +++ b/typescript/packages/mechanisms/evm/src/types.ts @@ -27,6 +27,7 @@ export type ExactEIP3009Payload = { */ export type Permit2Witness = { to: `0x${string}`; + facilitator: `0x${string}`; validAfter: string; }; diff --git a/typescript/packages/mechanisms/evm/test/unit/constants.test.ts b/typescript/packages/mechanisms/evm/test/unit/constants.test.ts index 2bd03051..d64688b1 100644 --- a/typescript/packages/mechanisms/evm/test/unit/constants.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/constants.test.ts @@ -4,6 +4,7 @@ import { authorizationTypes, eip3009ABI, permit2WitnessTypes, + getPermit2Address, x402ExactPermit2ProxyAddress, PERMIT2_ADDRESS, } from "../../src/constants"; @@ -91,11 +92,22 @@ describe("EVM Constants", () => { expect(hasExtra).toBe(false); }); - it("Witness type must have exactly 'to' and 'validAfter' fields", () => { + it("Witness type must have exactly 'to', 'facilitator', and 'validAfter' fields", () => { const witnessFields = permit2WitnessTypes.Witness; - expect(witnessFields).toHaveLength(2); + expect(witnessFields).toHaveLength(3); expect(witnessFields[0].name).toBe("to"); - expect(witnessFields[1].name).toBe("validAfter"); + expect(witnessFields[1].name).toBe("facilitator"); + expect(witnessFields[2].name).toBe("validAfter"); + }); + }); + + describe("Permit2 address resolution", () => { + it("should use PancakeSwap Permit2 on BSC testnet", () => { + expect(getPermit2Address("eip155:97")).toBe("0x31c2F6fcFf4F8759b3Bd5Bf0e1084A055615c768"); + }); + + it("should fall back to canonical Permit2 on other EVM chains", () => { + expect(getPermit2Address("eip155:84532")).toBe(PERMIT2_ADDRESS); }); }); @@ -124,6 +136,7 @@ describe("EVM Constants", () => { deadline: 9999999999n, witness: { to: "0x9876543210987654321098765432109876543210" as `0x${string}`, + facilitator: "0x1111111111111111111111111111111111111111" as `0x${string}`, validAfter: 0n, }, } as const; @@ -170,6 +183,7 @@ describe("EVM Constants", () => { ...canonicalMessage, witness: { to: "0x0000000000000000000000000000000000000001" as `0x${string}`, + facilitator: "0x1111111111111111111111111111111111111111" as `0x${string}`, validAfter: 0n, }, }, diff --git a/typescript/packages/mechanisms/evm/test/unit/exact/client.test.ts b/typescript/packages/mechanisms/evm/test/unit/exact/client.test.ts index d4d86a1d..c5284a27 100644 --- a/typescript/packages/mechanisms/evm/test/unit/exact/client.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/exact/client.test.ts @@ -9,6 +9,22 @@ import { PaymentRequirements } from "@bankofai/x402-core/types"; import { PERMIT2_ADDRESS, x402ExactPermit2ProxyAddress } from "../../../src/constants"; import { isPermit2Payload, isEIP3009Payload } from "../../../src/types"; +function makeReadContractMock({ + balance = BigInt("1000000000000"), + allowance = BigInt(0), + fallback = BigInt(0), +}: { + balance?: bigint; + allowance?: bigint; + fallback?: bigint; +} = {}) { + return vi.fn().mockImplementation(({ functionName }) => { + if (functionName === "balanceOf") return Promise.resolve(balance); + if (functionName === "allowance") return Promise.resolve(allowance); + return Promise.resolve(fallback); + }); +} + describe("ExactEvmScheme (Client)", () => { let client: ExactEvmScheme; let mockSigner: ClientEvmSigner; @@ -18,7 +34,10 @@ describe("ExactEvmScheme (Client)", () => { mockSigner = { address: "0x1234567890123456789012345678901234567890", signTypedData: vi.fn().mockResolvedValue("0xmocksignature123456789"), - readContract: vi.fn().mockResolvedValue(BigInt(0)), + readContract: vi.fn().mockImplementation(({ functionName }) => { + if (functionName === "balanceOf") return Promise.resolve(BigInt("1000000000000")); + return Promise.resolve(BigInt(0)); + }), }; client = new ExactEvmScheme(mockSigner); }); @@ -258,6 +277,27 @@ describe("ExactEvmScheme (Client)", () => { expect(result.payload.authorization).toBeDefined(); }); + it("should fail fast when token balance is insufficient", async () => { + (mockSigner.readContract as ReturnType).mockImplementation( + ({ functionName }) => + Promise.resolve(functionName === "balanceOf" ? BigInt(0) : BigInt(0)), + ); + + const requirements: PaymentRequirements = { + scheme: "exact", + network: "eip155:8453", + amount: "1000000", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + maxTimeoutSeconds: 300, + extra: { name: "USD Coin", version: "2", assetTransferMethod: "eip3009" }, + }; + + await expect(client.createPaymentPayload(2, requirements)).rejects.toThrow( + "insufficient_balance", + ); + }); + it("should use Permit2 when assetTransferMethod is permit2", async () => { const requirements: PaymentRequirements = { scheme: "exact", @@ -362,7 +402,7 @@ describe("Permit2 Approval Helpers", () => { describe("createPermit2ApprovalTx", () => { it("should create approval transaction data", () => { const tokenAddress = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as `0x${string}`; - const tx = createPermit2ApprovalTx(tokenAddress); + const tx = createPermit2ApprovalTx(tokenAddress, "eip155:84532"); expect(tx.to.toLowerCase()).toBe(tokenAddress.toLowerCase()); expect(tx.data).toBeDefined(); @@ -371,11 +411,20 @@ describe("Permit2 Approval Helpers", () => { it("should encode approve function call", () => { const tokenAddress = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as `0x${string}`; - const tx = createPermit2ApprovalTx(tokenAddress); + const tx = createPermit2ApprovalTx(tokenAddress, "eip155:84532"); // approve(address,uint256) selector is 0x095ea7b3 expect(tx.data.startsWith("0x095ea7b3")).toBe(true); }); + + it("should target PancakeSwap Permit2 on BSC testnet", () => { + const tokenAddress = "0x55d398326f99059fF775485246999027B3197955" as `0x${string}`; + const tx = createPermit2ApprovalTx(tokenAddress, "eip155:97"); + + expect(tx.data.toLowerCase()).toContain( + "31c2f6fcff4f8759b3bd5bf0e1084a055615c768".toLowerCase(), + ); + }); }); describe("getPermit2AllowanceReadParams", () => { @@ -383,6 +432,7 @@ describe("Permit2 Approval Helpers", () => { const params = getPermit2AllowanceReadParams({ tokenAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", ownerAddress: "0x1234567890123456789012345678901234567890", + network: "eip155:84532", }); expect(params.address.toLowerCase()).toBe( @@ -399,6 +449,7 @@ describe("Permit2 Approval Helpers", () => { const params = getPermit2AllowanceReadParams({ tokenAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", ownerAddress: "0x1234567890123456789012345678901234567890", + network: "eip155:84532", }); expect(params.abi).toBeDefined(); @@ -486,6 +537,7 @@ describe("Permit2 Approval Flow", () => { const readParams = getPermit2AllowanceReadParams({ tokenAddress, ownerAddress, + network: "eip155:84532", }); expect(readParams).toBeDefined(); @@ -496,7 +548,7 @@ describe("Permit2 Approval Flow", () => { // Step 3: Check if approval needed if (checkNeedsApproval(currentAllowance, requiredAmount)) { // Step 4: Create approval transaction - const tx = createPermit2ApprovalTx(tokenAddress); + const tx = createPermit2ApprovalTx(tokenAddress, "eip155:84532"); expect(tx.to).toBeDefined(); expect(tx.data).toBeDefined(); @@ -557,6 +609,7 @@ describe("Permit2 Approval Flow", () => { mockSigner = { address: "0x1234567890123456789012345678901234567890", signTypedData: vi.fn().mockResolvedValue("0xmocksignature123456789"), + readContract: makeReadContractMock(), }; client = new ExactEvmScheme(mockSigner); }); @@ -577,6 +630,7 @@ describe("Permit2 Approval Flow", () => { const readParams = getPermit2AllowanceReadParams({ tokenAddress, ownerAddress: mockSigner.address, + network: requirements.network, }); expect(readParams.functionName).toBe("allowance"); @@ -585,7 +639,7 @@ describe("Permit2 Approval Flow", () => { expect(needsApproval).toBe(true); // Step 2: Create and "send" approval tx - const approvalTx = createPermit2ApprovalTx(tokenAddress); + const approvalTx = createPermit2ApprovalTx(tokenAddress, requirements.network); expect(approvalTx.to.toLowerCase()).toBe(tokenAddress.toLowerCase()); // In real app: await walletClient.sendTransaction(approvalTx) @@ -643,7 +697,7 @@ describe("Permit2 Approval Flow", () => { const signer: ClientEvmSigner = { address: "0x1234567890123456789012345678901234567890", signTypedData: vi.fn().mockResolvedValue("0xmocksig"), - readContract: vi.fn().mockResolvedValue(BigInt(0)), + readContract: makeReadContractMock(), }; const scheme = new ExactEvmScheme(signer); const result = await scheme.createPaymentPayload(2, permit2Requirements, { @@ -657,7 +711,10 @@ describe("Permit2 Approval Flow", () => { const signerWithReader: ClientEvmSigner = { address: "0x1234567890123456789012345678901234567890", signTypedData: vi.fn().mockResolvedValue("0xmocksig"), - readContract: vi.fn().mockResolvedValue(BigInt("999999999999999999")), + readContract: makeReadContractMock({ + balance: BigInt("1000000000000"), + allowance: BigInt("999999999999999999"), + }), }; const schemeWithReader = new ExactEvmScheme(signerWithReader); @@ -676,10 +733,11 @@ describe("Permit2 Approval Flow", () => { signTypedData: vi.fn().mockResolvedValue( "0x" + "ab".repeat(32) + "cd".repeat(32) + "1b", // 65 byte sig ), - readContract: vi - .fn() - .mockResolvedValueOnce(BigInt(0)) // allowance check returns 0 - .mockResolvedValueOnce(BigInt(5)), // nonce query returns 5 + readContract: vi.fn().mockImplementation(({ functionName }) => { + if (functionName === "balanceOf") return Promise.resolve(BigInt("1000000000000")); + if (functionName === "allowance") return Promise.resolve(BigInt(0)); + return Promise.resolve(BigInt(5)); + }), }; const schemeWithReader = new ExactEvmScheme(signerWithReader); @@ -703,7 +761,7 @@ describe("Permit2 Approval Flow", () => { const signer: ClientEvmSigner = { address: "0x1234567890123456789012345678901234567890", signTypedData: vi.fn().mockResolvedValue("0xmocksig"), - readContract: vi.fn().mockResolvedValue(BigInt(0)), + readContract: makeReadContractMock(), }; const scheme = new ExactEvmScheme(signer); const eip3009Requirements: PaymentRequirements = { @@ -742,7 +800,7 @@ describe("Permit2 Approval Flow", () => { const signer: ClientEvmSigner = { address: "0x1234567890123456789012345678901234567890", signTypedData: vi.fn().mockResolvedValue("0xmocksig"), - readContract: vi.fn().mockResolvedValue(BigInt(0)), + readContract: makeReadContractMock(), signTransaction: vi.fn().mockResolvedValue("0x02ab"), getTransactionCount: vi.fn().mockResolvedValue(0), estimateFeesPerGas: vi @@ -761,7 +819,10 @@ describe("Permit2 Approval Flow", () => { const signer: ClientEvmSigner = { address: "0x1234567890123456789012345678901234567890", signTypedData: vi.fn().mockResolvedValue("0xmocksig"), - readContract: vi.fn().mockResolvedValue(BigInt("999999999999999999")), + readContract: makeReadContractMock({ + balance: BigInt("1000000000000"), + allowance: BigInt("999999999999999999"), + }), signTransaction: vi.fn().mockResolvedValue("0x02ab"), getTransactionCount: vi.fn().mockResolvedValue(0), estimateFeesPerGas: vi @@ -782,7 +843,7 @@ describe("Permit2 Approval Flow", () => { const signer: ClientEvmSigner = { address: "0x1234567890123456789012345678901234567890", signTypedData: vi.fn().mockResolvedValue("0xmocksig"), - readContract: vi.fn().mockResolvedValue(BigInt(0)), + readContract: makeReadContractMock(), // No signTransaction, getTransactionCount, estimateFeesPerGas }; const scheme = new ExactEvmScheme(signer); @@ -800,7 +861,7 @@ describe("Permit2 Approval Flow", () => { const signer: ClientEvmSigner = { address: "0x1234567890123456789012345678901234567890", signTypedData: vi.fn().mockResolvedValue("0xmocksig"), - readContract: vi.fn().mockResolvedValue(BigInt(0)), // zero allowance + readContract: makeReadContractMock({ allowance: BigInt(0) }), signTransaction: vi.fn().mockResolvedValue(mockSignedTx), getTransactionCount: vi.fn().mockResolvedValue(5), estimateFeesPerGas: vi @@ -824,6 +885,28 @@ describe("Permit2 Approval Flow", () => { expect(info.version).toBe("1"); }); + it("should locally approve when sponsoring is unavailable and allowance is insufficient", async () => { + const signer: ClientEvmSigner = { + address: "0x1234567890123456789012345678901234567890", + signTypedData: vi.fn().mockResolvedValue("0xmocksig"), + readContract: makeReadContractMock({ allowance: BigInt(0) }), + sendTransaction: vi.fn().mockResolvedValue("0xapprovalhash"), + waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: "success" }), + }; + const scheme = new ExactEvmScheme(signer); + + const result = await scheme.createPaymentPayload(2, erc20Requirements, { + extensions: {}, + }); + + expect(result.extensions).toBeUndefined(); + expect(signer.sendTransaction).toHaveBeenCalled(); + expect(signer.waitForTransactionReceipt).toHaveBeenCalledWith({ + hash: "0xapprovalhash", + }); + expect(isPermit2Payload(result.payload)).toBe(true); + }); + it("should use EIP-2612 over ERC-20 approval when token has EIP-2612 support", async () => { const eip2612CompatibleRequirements: PaymentRequirements = { ...erc20Requirements, @@ -837,10 +920,11 @@ describe("Permit2 Approval Flow", () => { const signer: ClientEvmSigner = { address: "0x1234567890123456789012345678901234567890", signTypedData: vi.fn().mockResolvedValue("0x" + "ab".repeat(32) + "cd".repeat(32) + "1b"), - readContract: vi - .fn() - .mockResolvedValueOnce(BigInt(0)) // allowance check - .mockResolvedValueOnce(BigInt(3)), // nonce for EIP-2612 + readContract: vi.fn().mockImplementation(({ functionName }) => { + if (functionName === "balanceOf") return Promise.resolve(BigInt("1000000000000")); + if (functionName === "allowance") return Promise.resolve(BigInt(0)); + return Promise.resolve(BigInt(3)); + }), signTransaction: vi.fn(), // Should NOT be called getTransactionCount: vi.fn(), estimateFeesPerGas: vi.fn(), @@ -867,7 +951,7 @@ describe("Permit2 Approval Flow", () => { const signer: ClientEvmSigner = { address: "0x1234567890123456789012345678901234567890", signTypedData: vi.fn().mockResolvedValue("0xmocksig"), - readContract: vi.fn().mockResolvedValue(BigInt(0)), // zero allowance + readContract: makeReadContractMock({ allowance: BigInt(0) }), signTransaction: vi.fn().mockResolvedValue(mockSignedTx), getTransactionCount: vi.fn().mockResolvedValue(0), estimateFeesPerGas: vi diff --git a/typescript/packages/mechanisms/evm/test/unit/exact/facilitator.test.ts b/typescript/packages/mechanisms/evm/test/unit/exact/facilitator.test.ts index 3c201ca4..bf38fc1a 100644 --- a/typescript/packages/mechanisms/evm/test/unit/exact/facilitator.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/exact/facilitator.test.ts @@ -6,6 +6,22 @@ import { PaymentRequirements, PaymentPayload } from "@bankofai/x402-core/types"; import { x402ExactPermit2ProxyAddress, PERMIT2_ADDRESS } from "../../../src/constants"; import { ERC20_APPROVAL_GAS_SPONSORING } from "@bankofai/x402-extensions"; +function makeReadContractMock({ + balance = BigInt("10000000000"), + allowance = BigInt(0), + fallback = BigInt(0), +}: { + balance?: bigint; + allowance?: bigint; + fallback?: bigint; +} = {}) { + return vi.fn().mockImplementation(({ functionName }) => { + if (functionName === "balanceOf") return Promise.resolve(balance); + if (functionName === "allowance") return Promise.resolve(allowance); + return Promise.resolve(fallback); + }); +} + // Mock viem's transaction parsing utilities for ERC-20 approval tests // Uses importOriginal to preserve all other viem exports (getAddress, etc.) vi.mock("viem", async importOriginal => { @@ -28,14 +44,14 @@ describe("ExactEvmScheme (Facilitator)", () => { mockClientSigner = { address: "0x1234567890123456789012345678901234567890", signTypedData: vi.fn().mockResolvedValue("0xmocksignature"), - readContract: vi.fn().mockResolvedValue(BigInt(0)), + readContract: makeReadContractMock(), }; client = new ClientExactEvmScheme(mockClientSigner); // Create mock facilitator signer mockFacilitatorSigner = { getAddresses: vi.fn().mockReturnValue(["0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0"]), - readContract: vi.fn().mockResolvedValue(0n), // Mock nonce state + readContract: makeReadContractMock({ fallback: 0n }), // Mock nonce state verifyTypedData: vi.fn().mockResolvedValue(true), // Mock signature verification writeContract: vi.fn().mockResolvedValue("0xtxhash"), sendTransaction: vi.fn().mockResolvedValue("0xtxhash"), @@ -163,9 +179,7 @@ describe("ExactEvmScheme (Facilitator)", () => { resource: { url: "", description: "", mimeType: "" }, }; - const wrongNetworkRequirements = { ...requirements, network: "eip155:1" as any }; - - const result = await facilitator.verify(fullPayload, wrongNetworkRequirements); + const result = await facilitator.verify(fullPayload, requirements); expect(result.isValid).toBe(false); // Verification should fail (network mismatch or other validation error) @@ -314,7 +328,7 @@ describe("ExactEvmScheme (Facilitator)", () => { }; // Mock readContract to return zero allowance - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValue(BigInt(0)); + mockFacilitatorSigner.readContract = makeReadContractMock({ allowance: BigInt(0) }); const permit2Payload: PaymentPayload = { x402Version: 2, @@ -528,7 +542,7 @@ describe("ExactEvmScheme (Facilitator)", () => { }; // Mock readContract to return zero allowance - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValue(BigInt(0)); + mockFacilitatorSigner.readContract = makeReadContractMock({ allowance: BigInt(0) }); const permit2Payload: PaymentPayload = { x402Version: 2, @@ -648,7 +662,7 @@ describe("ExactEvmScheme (Facilitator)", () => { const permit2ClientSigner: ClientEvmSigner = { address: "0x1234567890123456789012345678901234567890", signTypedData: vi.fn().mockResolvedValue("0x" + "ab".repeat(32) + "cd".repeat(32) + "1b"), - readContract: vi.fn().mockResolvedValue(BigInt(0)), + readContract: makeReadContractMock(), }; const permit2Client = new ClientExactEvmScheme(permit2ClientSigner); const paymentPayload = await permit2Client.createPaymentPayload(2, permit2Requirements); @@ -688,7 +702,7 @@ describe("ExactEvmScheme (Facilitator)", () => { it("should reject when allowance is 0 and no EIP-2612 extension", async () => { // Mock: allowance returns 0 - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValueOnce(0n); // allowance check = 0 + mockFacilitatorSigner.readContract = makeReadContractMock({ allowance: 0n }); const permit2Requirements: PaymentRequirements = { scheme: "exact", @@ -703,7 +717,7 @@ describe("ExactEvmScheme (Facilitator)", () => { const permit2ClientSigner: ClientEvmSigner = { address: "0x1234567890123456789012345678901234567890", signTypedData: vi.fn().mockResolvedValue("0x" + "ab".repeat(32) + "cd".repeat(32) + "1b"), - readContract: vi.fn().mockResolvedValue(BigInt(0)), + readContract: makeReadContractMock(), }; const permit2Client = new ClientExactEvmScheme(permit2ClientSigner); const paymentPayload = await permit2Client.createPaymentPayload(2, permit2Requirements); @@ -740,7 +754,7 @@ describe("ExactEvmScheme (Facilitator)", () => { const permit2ClientSigner: ClientEvmSigner = { address: "0x1234567890123456789012345678901234567890", signTypedData: vi.fn().mockResolvedValue("0x" + "ab".repeat(32) + "cd".repeat(32) + "1b"), - readContract: vi.fn().mockResolvedValue(BigInt(0)), + readContract: makeReadContractMock(), }; const permit2Client = new ClientExactEvmScheme(permit2ClientSigner); const paymentPayload = await permit2Client.createPaymentPayload(2, permit2Requirements); @@ -1015,7 +1029,7 @@ describe("ExactEvmScheme (Facilitator)", () => { } it("should reject when allowance is 0 and no ERC-20 extension (no context)", async () => { - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValueOnce(0n); + mockFacilitatorSigner.readContract = makeReadContractMock({ allowance: 0n }); const payload = makeErc20Permit2Payload(); const result = await facilitator.verify(payload, erc20Requirements); @@ -1025,7 +1039,7 @@ describe("ExactEvmScheme (Facilitator)", () => { }); it("should reject when ERC-20 extension has invalid format (bad address)", async () => { - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValueOnce(0n); + mockFacilitatorSigner.readContract = makeReadContractMock({ allowance: 0n }); const payload = makeErc20Permit2Payload({ erc20ApprovalGasSponsoring: { @@ -1048,7 +1062,7 @@ describe("ExactEvmScheme (Facilitator)", () => { }); it("should reject when ERC-20 extension `from` doesn't match payer", async () => { - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValueOnce(0n); + mockFacilitatorSigner.readContract = makeReadContractMock({ allowance: 0n }); const payload = makeErc20Permit2Payload({ erc20ApprovalGasSponsoring: { @@ -1071,7 +1085,7 @@ describe("ExactEvmScheme (Facilitator)", () => { }); it("should reject when ERC-20 extension `asset` doesn't match token", async () => { - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValueOnce(0n); + mockFacilitatorSigner.readContract = makeReadContractMock({ allowance: 0n }); const payload = makeErc20Permit2Payload({ erc20ApprovalGasSponsoring: { @@ -1094,7 +1108,7 @@ describe("ExactEvmScheme (Facilitator)", () => { }); it("should reject when ERC-20 extension spender is not PERMIT2_ADDRESS", async () => { - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValueOnce(0n); + mockFacilitatorSigner.readContract = makeReadContractMock({ allowance: 0n }); const payload = makeErc20Permit2Payload({ erc20ApprovalGasSponsoring: { @@ -1118,7 +1132,7 @@ describe("ExactEvmScheme (Facilitator)", () => { it("should accept when allowance insufficient but valid ERC-20 extension present", async () => { // allowance=0 (verifyPermit2 returns permit2_allowance_required, scheme handles it) - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValueOnce(0n); + mockFacilitatorSigner.readContract = makeReadContractMock({ allowance: 0n }); // Mock viem functions used in validateErc20ApprovalForPayment const { parseTransaction, recoverTransactionAddress } = await import("viem"); @@ -1138,7 +1152,7 @@ describe("ExactEvmScheme (Facilitator)", () => { }); it("should reject when calldata targets wrong address (not PERMIT2_ADDRESS)", async () => { - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValueOnce(0n); + mockFacilitatorSigner.readContract = makeReadContractMock({ allowance: 0n }); const wrongSpenderCalldata = "0x095ea7b3" + diff --git a/typescript/packages/mechanisms/evm/test/unit/signer.test.ts b/typescript/packages/mechanisms/evm/test/unit/signer.test.ts index ed2faf15..39d026ce 100644 --- a/typescript/packages/mechanisms/evm/test/unit/signer.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/signer.test.ts @@ -31,6 +31,23 @@ describe("EVM Signer Converters", () => { expect(result.readContract).toBeDefined(); }); + it("should compose raw broadcast helpers from publicClient", () => { + const mockAccount = { + address: "0x1234567890123456789012345678901234567890" as `0x${string}`, + signTypedData: async () => "0xsignature" as `0x${string}`, + }; + + const mockPublicClient = { + readContract: async () => BigInt(42), + sendRawTransaction: async () => "0xhash" as `0x${string}`, + waitForTransactionReceipt: async () => ({ status: "success" }), + }; + + const result = toClientEvmSigner(mockAccount, mockPublicClient); + expect(result.sendRawTransaction).toBeDefined(); + expect(result.waitForTransactionReceipt).toBeDefined(); + }); + it("should throw when neither signer nor publicClient has readContract", () => { const mockAccount = { address: "0x1234567890123456789012345678901234567890" as `0x${string}`, diff --git a/typescript/packages/mechanisms/tron/src/constants.ts b/typescript/packages/mechanisms/tron/src/constants.ts index de0ce44b..a70cb0ef 100644 --- a/typescript/packages/mechanisms/tron/src/constants.ts +++ b/typescript/packages/mechanisms/tron/src/constants.ts @@ -155,6 +155,19 @@ export const erc20AllowanceAbi = [ }, ] as const; +/** + * ABI for TRC-20 balanceOf check. + */ +export const trc20BalanceOfAbi = [ + { + type: "function", + name: "balanceOf", + inputs: [{ name: "account", type: "address" }], + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + }, +] as const; + /** * ABI for TRC-20 approve used by Permit2 setup flows. */ diff --git a/typescript/packages/mechanisms/tron/src/exact/client/scheme.ts b/typescript/packages/mechanisms/tron/src/exact/client/scheme.ts index bca1a0e9..66e681f4 100644 --- a/typescript/packages/mechanisms/tron/src/exact/client/scheme.ts +++ b/typescript/packages/mechanisms/tron/src/exact/client/scheme.ts @@ -7,10 +7,11 @@ import { import { TRC20_APPROVAL_GAS_SPONSORING } from "@bankofai/x402-extensions"; import { ClientTronSigner } from "../../signer"; import { AssetTransferMethod } from "../../types"; +import { trc20BalanceOfAbi } from "../../constants"; import { createEIP3009Payload } from "./eip3009"; import { createPermit2Payload } from "./permit2"; import { getPermit2AllowanceReadParams } from "./permit2Helpers"; -import { signTrc20ApprovalTransaction } from "./trc20approval"; +import { broadcastTrc20ApprovalTransaction, signTrc20ApprovalTransaction } from "./trc20approval"; /** * TRON client implementation for the Exact payment scheme. @@ -43,6 +44,8 @@ export class ExactTronScheme implements SchemeNetworkClient { paymentRequirements: PaymentRequirements, context?: PaymentPayloadContext, ): Promise { + await this.ensureSufficientTokenBalance(paymentRequirements); + // Mark unused parameters to satisfy linter void context; @@ -60,6 +63,8 @@ export class ExactTronScheme implements SchemeNetworkClient { }; } + await this.ensureLocalPermit2Approval(paymentRequirements); + return result; } @@ -111,4 +116,55 @@ export class ExactTronScheme implements SchemeNetworkClient { [TRC20_APPROVAL_GAS_SPONSORING.key]: { info }, }; } + + /** + * Performs a best-effort TRC-20 balance check before signing. + * + * @param requirements - Payment requirement whose token balance should be checked. + */ + private async ensureSufficientTokenBalance(requirements: PaymentRequirements): Promise { + const balance = (await this.signer.readContract({ + address: requirements.asset, + abi: trc20BalanceOfAbi, + functionName: "balanceOf", + args: [this.signer.address], + })) as bigint; + + if (balance < BigInt(requirements.amount)) { + throw new Error( + `insufficient_funds: Insufficient token balance. Required: ${requirements.amount}, Available: ${balance.toString()}`, + ); + } + } + + /** + * Falls back to a local TRC-20 approve(Permit2, MaxUint256) transaction when + * the facilitator does not advertise approval sponsoring. + * + * @param requirements - Payment requirement whose Permit2 allowance should be ensured. + */ + private async ensureLocalPermit2Approval(requirements: PaymentRequirements): Promise { + const canBroadcast = + !!this.signer.buildTriggerSmartContractTransaction && + !!this.signer.signTransaction && + !!this.signer.sendRawTransaction && + !!this.signer.waitForTransactionReceipt; + if (!canBroadcast) { + return; + } + + const allowance = (await this.signer.readContract( + getPermit2AllowanceReadParams({ + tokenAddress: requirements.asset, + ownerAddress: this.signer.address, + network: requirements.network, + }), + )) as bigint; + + if (allowance >= BigInt(requirements.amount)) { + return; + } + + await broadcastTrc20ApprovalTransaction(this.signer, requirements.asset, requirements.network); + } } diff --git a/typescript/packages/mechanisms/tron/src/exact/client/trc20approval.ts b/typescript/packages/mechanisms/tron/src/exact/client/trc20approval.ts index 037553be..0ed1e810 100644 --- a/typescript/packages/mechanisms/tron/src/exact/client/trc20approval.ts +++ b/typescript/packages/mechanisms/tron/src/exact/client/trc20approval.ts @@ -69,3 +69,33 @@ export async function signTrc20ApprovalTransaction( version: TRC20_APPROVAL_GAS_SPONSORING_VERSION, }; } + +/** + * Broadcasts a local TRC-20 approval transaction when sponsoring is unavailable. + * + * @param signer - TRON signer capable of building, signing, and broadcasting approval transactions. + * @param tokenAddress - Token contract that should grant Permit2 allowance. + * @param network - Network identifier used to resolve the Permit2 contract address. + * @returns The approval transaction hash. + */ +export async function broadcastTrc20ApprovalTransaction( + signer: ClientTronSigner, + tokenAddress: string, + network: string, +): Promise { + if (!signer.sendRawTransaction || !signer.waitForTransactionReceipt) { + throw new Error( + "local_approve_unsupported: TRON signer cannot broadcast approval transactions", + ); + } + + const info = await signTrc20ApprovalTransaction(signer, tokenAddress, network); + const hash = await signer.sendRawTransaction({ + signedTransaction: info.signedTransaction, + }); + const receipt = await signer.waitForTransactionReceipt({ hash }); + if (receipt.status !== "success") { + throw new Error(`local_approve_failed: approval transaction ${hash} did not succeed`); + } + return hash; +} diff --git a/typescript/packages/mechanisms/tron/src/signer.ts b/typescript/packages/mechanisms/tron/src/signer.ts index 59dd45a4..81039cad 100644 --- a/typescript/packages/mechanisms/tron/src/signer.ts +++ b/typescript/packages/mechanisms/tron/src/signer.ts @@ -60,6 +60,18 @@ export interface ClientTronSigner { * Optional capability used for TRC-20 approval gas sponsoring. */ signTransaction?(transaction: TronTransaction): Promise; + + /** + * Broadcast a signed raw TRON transaction. + * Used for local Permit2 approval fallback when the facilitator does not + * advertise approval sponsoring. + */ + sendRawTransaction?(args: { signedTransaction: TronSignedTransaction }): Promise; + + /** + * Wait for a locally-broadcast transaction to confirm. + */ + waitForTransactionReceipt?(args: { hash: string }): Promise<{ status: string }>; } /** @@ -184,6 +196,8 @@ export function toClientTronSigner( readContract?: ClientTronSigner["readContract"]; buildTriggerSmartContractTransaction?: ClientTronSigner["buildTriggerSmartContractTransaction"]; signTransaction?: ClientTronSigner["signTransaction"]; + sendRawTransaction?: ClientTronSigner["sendRawTransaction"]; + waitForTransactionReceipt?: ClientTronSigner["waitForTransactionReceipt"]; }, tronWeb?: TronWeb, ): ClientTronSigner { @@ -239,6 +253,40 @@ export function toClientTronSigner( tronWeb.defaultPrivateKey, )) as unknown as TronSignedTransaction : undefined), + sendRawTransaction: + signer.sendRawTransaction ?? + (tronWeb + ? async args => { + const result = await tronWeb.trx.sendRawTransaction(args.signedTransaction); + if (result.result === false) { + throw new Error(String(result.code ?? "Failed to broadcast TRON transaction")); + } + return result.txid; + } + : undefined), + waitForTransactionReceipt: + signer.waitForTransactionReceipt ?? + (tronWeb + ? async args => { + const start = Date.now(); + while (Date.now() - start < 60_000) { + try { + const info = (await tronWeb.trx.getTransactionInfo(args.hash)) as TronTxInfo; + const result = info.receipt?.result; + if (result === "SUCCESS") { + return { status: "success" }; + } + if (result && result !== "SUCCESS") { + return { status: "reverted" }; + } + } catch { + // Poll until confirmation or timeout. + } + await new Promise(resolve => setTimeout(resolve, 1_000)); + } + return { status: "pending" }; + } + : undefined), }; } diff --git a/typescript/packages/mechanisms/tron/test/unit/exact/client.test.ts b/typescript/packages/mechanisms/tron/test/unit/exact/client.test.ts index f3bd0e1a..259ba1dd 100644 --- a/typescript/packages/mechanisms/tron/test/unit/exact/client.test.ts +++ b/typescript/packages/mechanisms/tron/test/unit/exact/client.test.ts @@ -35,7 +35,10 @@ describe("ExactTronScheme (Client)", () => { mockSigner = { address: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", signTypedData: vi.fn().mockResolvedValue("0x" + "ab".repeat(32) + "cd".repeat(32) + "1b"), - readContract: vi.fn().mockResolvedValue(BigInt(0)), + readContract: vi.fn().mockImplementation(({ functionName }) => { + if (functionName === "balanceOf") return Promise.resolve(BigInt("1000000")); + return Promise.resolve(BigInt(0)); + }), buildTriggerSmartContractTransaction: vi.fn().mockResolvedValue({ raw_data: { contract: [{ parameter: { value: {} } }] }, raw_data_hex: "abcd", @@ -127,6 +130,17 @@ describe("ExactTronScheme (Client)", () => { "TIP-712 domain parameters", ); }); + + it("should fail fast when TRC-20 balance is insufficient", async () => { + (mockSigner.readContract as ReturnType).mockImplementation(({ functionName }) => + Promise.resolve(functionName === "balanceOf" ? BigInt(0) : BigInt(0)), + ); + const client = new ExactTronScheme(mockSigner); + + await expect(client.createPaymentPayload(2, tip712Requirements)).rejects.toThrow( + "insufficient_funds", + ); + }); }); describe("Permit2 path", () => { @@ -209,8 +223,8 @@ describe("ExactTronScheme (Client)", () => { }); it("should skip TRC-20 approval extension when allowance is already sufficient", async () => { - (mockSigner.readContract as ReturnType).mockResolvedValueOnce( - BigInt("1000000"), + (mockSigner.readContract as ReturnType).mockImplementation(({ functionName }) => + Promise.resolve(functionName === "balanceOf" ? BigInt("1000000") : BigInt("1000000")), ); const client = new ExactTronScheme(mockSigner); const result = await client.createPaymentPayload(2, permit2Requirements, { @@ -225,5 +239,20 @@ describe("ExactTronScheme (Client)", () => { expect(mockSigner.buildTriggerSmartContractTransaction).not.toHaveBeenCalled(); expect(mockSigner.signTransaction).not.toHaveBeenCalled(); }); + + it("should locally approve when sponsoring is unavailable and allowance is insufficient", async () => { + const sendRawTransaction = vi.fn().mockResolvedValue("approvaltxid"); + const waitForTransactionReceipt = vi.fn().mockResolvedValue({ status: "success" }); + mockSigner.sendRawTransaction = sendRawTransaction; + mockSigner.waitForTransactionReceipt = waitForTransactionReceipt; + + const client = new ExactTronScheme(mockSigner); + const result = await client.createPaymentPayload(2, permit2Requirements); + + expect(result.extensions).toBeUndefined(); + expect(sendRawTransaction).toHaveBeenCalled(); + expect(waitForTransactionReceipt).toHaveBeenCalledWith({ hash: "approvaltxid" }); + expect(result.payload).toHaveProperty("permit2Authorization"); + }); }); }); diff --git a/typescript/packages/mechanisms/tron/test/unit/signer.test.ts b/typescript/packages/mechanisms/tron/test/unit/signer.test.ts index 3175ea76..c85cbc8d 100644 --- a/typescript/packages/mechanisms/tron/test/unit/signer.test.ts +++ b/typescript/packages/mechanisms/tron/test/unit/signer.test.ts @@ -28,12 +28,17 @@ describe("TRON signer helpers", () => { it("composes readContract from TronWeb when signer lacks it", async () => { const call = vi.fn().mockResolvedValue("99"); + const sendRawTransaction = vi.fn().mockResolvedValue({ result: true, txid: "0xtxid" }); const tronWeb = { contract: vi.fn().mockResolvedValue({ methods: { balanceOf: (..._args: unknown[]) => ({ call }), }, }), + trx: { + sendRawTransaction, + getTransactionInfo: vi.fn().mockResolvedValue({ receipt: { result: "SUCCESS" } }), + }, }; const result = toClientTronSigner( @@ -53,6 +58,14 @@ describe("TRON signer helpers", () => { }), ).resolves.toBe("99"); expect(call).toHaveBeenCalled(); + await expect( + result.sendRawTransaction?.({ + signedTransaction: { raw_data: {}, raw_data_hex: "00" } as any, + }), + ).resolves.toBe("0xtxid"); + await expect(result.waitForTransactionReceipt?.({ hash: "0xtxid" })).resolves.toEqual({ + status: "success", + }); }); it("throws when neither signer nor TronWeb can read contracts", () => {