diff --git a/python/coinbase-agentkit/changelog.d/add-sardis-action-provider.feature.md b/python/coinbase-agentkit/changelog.d/add-sardis-action-provider.feature.md new file mode 100644 index 000000000..edab5afe5 --- /dev/null +++ b/python/coinbase-agentkit/changelog.d/add-sardis-action-provider.feature.md @@ -0,0 +1 @@ +Added new Sardis payment action provider for policy-controlled AI agent payments. The provider supports payment execution, balance checking, policy validation, policy management with natural language rules, and transaction history across Base, Polygon, Ethereum, Arbitrum, and Optimism networks. diff --git a/python/coinbase-agentkit/coinbase_agentkit/__init__.py b/python/coinbase-agentkit/coinbase_agentkit/__init__.py index 8c95b1c99..17cc04655 100644 --- a/python/coinbase-agentkit/coinbase_agentkit/__init__.py +++ b/python/coinbase-agentkit/coinbase_agentkit/__init__.py @@ -19,6 +19,7 @@ nillion_action_provider, onramp_action_provider, pyth_action_provider, + sardis_action_provider, ssh_action_provider, superfluid_action_provider, twitter_action_provider, @@ -69,6 +70,7 @@ "nillion_action_provider", "onramp_action_provider", "pyth_action_provider", + "sardis_action_provider", "ssh_action_provider", "superfluid_action_provider", "twitter_action_provider", diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py index c24574ea4..85dd1202f 100644 --- a/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py @@ -27,6 +27,7 @@ from .nillion.nillion_action_provider import NillionActionProvider, nillion_action_provider from .onramp.onramp_action_provider import OnrampActionProvider, onramp_action_provider from .pyth.pyth_action_provider import PythActionProvider, pyth_action_provider +from .sardis.sardis_action_provider import SardisActionProvider, sardis_action_provider from .ssh.ssh_action_provider import SshActionProvider, ssh_action_provider from .superfluid.superfluid_action_provider import ( SuperfluidActionProvider, @@ -69,6 +70,8 @@ "onramp_action_provider", "PythActionProvider", "pyth_action_provider", + "SardisActionProvider", + "sardis_action_provider", "SshActionProvider", "ssh_action_provider", "SuperfluidActionProvider", diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/sardis/README.md b/python/coinbase-agentkit/coinbase_agentkit/action_providers/sardis/README.md new file mode 100644 index 000000000..4697e906c --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/sardis/README.md @@ -0,0 +1,60 @@ +# Sardis Action Provider + +The Sardis action provider enables AI agents to make **policy-controlled financial transactions** through [Sardis](https://sardis.sh) — the Payment OS for the Agent Economy. + +## Features + +- **Policy-Controlled Payments**: Every payment enforces spending rules automatically +- **Natural Language Policies**: Set limits like "Max $50 per transaction, daily limit $500" +- **Multi-Chain Support**: Base, Polygon, Ethereum, Arbitrum, Optimism +- **Stablecoin Payments**: USDC, USDT, PYUSD, EURC +- **Non-Custodial**: MPC wallets — Sardis never holds private keys + +## Setup + +1. Get an API key at [sardis.sh/dashboard](https://sardis.sh/dashboard) +2. Create a wallet and note the wallet ID +3. Set environment variables: + +```bash +export SARDIS_API_KEY="sk_..." +export SARDIS_WALLET_ID="wal_..." +``` + +## Usage + +```python +from coinbase_agentkit import AgentKit, AgentKitConfig, sardis_action_provider + +# Add Sardis payments to your agent +agent_kit = AgentKit(AgentKitConfig( + # ... wallet config ... + action_providers=[sardis_action_provider()] +)) +``` + +## Actions + +| Action | Description | +|--------|-------------| +| `sardis_pay` | Execute a policy-controlled payment | +| `sardis_check_balance` | Check wallet balance and spending limits | +| `sardis_check_policy` | Dry-run policy validation (does NOT execute) | +| `sardis_set_policy` | Set spending policy with natural language | +| `sardis_list_transactions` | View transaction history | + +## Supported Chains & Tokens + +| Chain | Tokens | +|-------|--------| +| Base | USDC, EURC | +| Polygon | USDC, USDT, EURC | +| Ethereum | USDC, USDT, PYUSD, EURC | +| Arbitrum | USDC, USDT | +| Optimism | USDC, USDT | + +## Links + +- [Sardis Documentation](https://sardis.sh/docs) +- [API Reference](https://sardis.sh/docs/api) +- [GitHub](https://github.com/sardis-labs/sardis) diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/sardis/__init__.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/sardis/__init__.py new file mode 100644 index 000000000..372009c37 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/sardis/__init__.py @@ -0,0 +1,8 @@ +"""Sardis payment action provider for policy-controlled AI agent payments.""" + +from .sardis_action_provider import SardisActionProvider, sardis_action_provider + +__all__ = [ + "SardisActionProvider", + "sardis_action_provider", +] diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/sardis/constants.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/sardis/constants.py new file mode 100644 index 000000000..d7c12b594 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/sardis/constants.py @@ -0,0 +1,13 @@ +"""Constants for Sardis action provider.""" + +SARDIS_API_BASE_URL = "https://api.sardis.sh/api/v2" + +SUPPORTED_TOKENS = ["USDC", "USDT", "PYUSD", "EURC"] + +SUPPORTED_CHAINS = { + "base": {"chain_id": "8453", "tokens": ["USDC", "EURC"]}, + "polygon": {"chain_id": "137", "tokens": ["USDC", "USDT", "EURC"]}, + "ethereum": {"chain_id": "1", "tokens": ["USDC", "USDT", "PYUSD", "EURC"]}, + "arbitrum": {"chain_id": "42161", "tokens": ["USDC", "USDT"]}, + "optimism": {"chain_id": "10", "tokens": ["USDC", "USDT"]}, +} diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/sardis/sardis_action_provider.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/sardis/sardis_action_provider.py new file mode 100644 index 000000000..c42c62076 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/sardis/sardis_action_provider.py @@ -0,0 +1,346 @@ +"""Sardis payment action provider.""" + +import os +from json import dumps +from typing import Any + +import requests + +from ...network import Network +from ..action_decorator import create_action +from ..action_provider import ActionProvider +from .constants import SARDIS_API_BASE_URL +from .schemas import ( + SardisCheckBalanceSchema, + SardisCheckPolicySchema, + SardisListTransactionsSchema, + SardisPaySchema, + SardisSetPolicySchema, +) + + +class SardisActionProvider(ActionProvider): + """Provides actions for policy-controlled AI agent payments via Sardis. + + Sardis is the Payment OS for the Agent Economy — infrastructure enabling + AI agents to make real financial transactions safely through non-custodial + MPC wallets with natural language spending policies. + """ + + def __init__( + self, + api_key: str | None = None, + wallet_id: str | None = None, + base_url: str | None = None, + ): + super().__init__("sardis", []) + + self.api_key = api_key or os.getenv("SARDIS_API_KEY") + self.wallet_id = wallet_id or os.getenv("SARDIS_WALLET_ID") + self.base_url = base_url or os.getenv("SARDIS_API_BASE_URL", SARDIS_API_BASE_URL) + + if not self.api_key: + raise ValueError( + "SARDIS_API_KEY is not configured. " + "Get one at https://sardis.sh/dashboard" + ) + if not self.wallet_id: + raise ValueError( + "SARDIS_WALLET_ID is not configured. " + "Create a wallet at https://sardis.sh/dashboard" + ) + + def _headers(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "User-Agent": "coinbase-agentkit-sardis/1.0", + } + + def _post(self, path: str, data: dict) -> dict: + resp = requests.post( + f"{self.base_url}{path}", + json=data, + headers=self._headers(), + timeout=30, + ) + resp.raise_for_status() + return resp.json() + + def _get(self, path: str, params: dict | None = None) -> dict: + resp = requests.get( + f"{self.base_url}{path}", + params=params, + headers=self._headers(), + timeout=30, + ) + resp.raise_for_status() + return resp.json() + + @create_action( + name="sardis_pay", + description=""" +Execute a policy-controlled payment through Sardis. + +Use this tool when the user asks to send money, pay a merchant, transfer funds, +or make any kind of payment. Sardis enforces the wallet's spending policy +automatically — if the payment violates a limit the transaction will be +rejected and the response will explain why. + +A successful response will return a JSON payload: + {"success": true, "tx_id": "tx_abc123", "status": "executed", "amount": "25.00", "to": "openai.com", "token": "USDC"} + +A failure response will return an error message: + Error executing payment: Policy violation - amount exceeds per-transaction limit of $50.00""", + schema=SardisPaySchema, + ) + def sardis_pay(self, args: dict[str, Any]) -> str: + """Execute a payment with automatic policy enforcement. + + Args: + args: Dictionary containing to, amount, token, and purpose fields. + + Returns: + str: A message containing the payment result or error details. + + """ + validated = SardisPaySchema(**args) + + try: + result = self._post(f"/wallets/{self.wallet_id}/pay/onchain", { + "to": validated.to, + "amount": validated.amount, + "token": validated.token, + "chain": "base", + "memo": validated.purpose or None, + }) + return f"Successfully executed payment:\n{dumps(result)}" + except requests.exceptions.HTTPError as e: + error_body = "" + if e.response is not None: + try: + error_body = e.response.json().get("detail", str(e)) + except Exception: + error_body = e.response.text + return f"Error executing payment: {error_body or e}" + except requests.exceptions.RequestException as e: + return f"Error executing payment: {e}" + + @create_action( + name="sardis_check_balance", + description=""" +Check the current wallet balance and spending limits. + +Use this tool when the user asks about their balance, how much they can still +spend, remaining limits, or anything related to wallet funds. + +A successful response will return a JSON payload: + {"wallet_id": "wal_abc", "balance": 1250.00, "token": "USDC", "chain": "base", "remaining": 750.00} + +A failure response will return an error message: + Error checking balance: 401 Unauthorized""", + schema=SardisCheckBalanceSchema, + ) + def sardis_check_balance(self, args: dict[str, Any]) -> str: + """Check wallet balance and spending limits. + + Args: + args: Dictionary containing token and chain fields. + + Returns: + str: A message containing the balance info or error details. + + """ + validated = SardisCheckBalanceSchema(**args) + + try: + result = self._get( + f"/wallets/{self.wallet_id}/balance", + params={"token": validated.token, "chain": validated.chain}, + ) + return f"Successfully retrieved wallet balance:\n{dumps(result)}" + except requests.exceptions.HTTPError as e: + error_body = "" + if e.response is not None: + try: + error_body = e.response.json().get("detail", str(e)) + except Exception: + error_body = e.response.text + return f"Error checking balance: {error_body or e}" + except requests.exceptions.RequestException as e: + return f"Error checking balance: {e}" + + @create_action( + name="sardis_check_policy", + description=""" +Check whether a payment would be allowed by the wallet's spending policy. + +Use this tool BEFORE making a payment if the user wants to verify whether a +transaction will succeed, or when they ask "can I pay X?" or "would this be +allowed?". This does NOT execute the payment — it is a dry-run validation. + +A successful response will return a JSON payload: + {"allowed": true, "reason": "All policy checks passed", "checks_passed": ["amount_limit", "vendor_allowlist"]} + +A failure response will return an error message: + Error checking policy: 401 Unauthorized""", + schema=SardisCheckPolicySchema, + ) + def sardis_check_policy(self, args: dict[str, Any]) -> str: + """Dry-run policy validation for a payment. + + Args: + args: Dictionary containing to, amount, token, and purpose fields. + + Returns: + str: A message containing the policy check result or error details. + + """ + validated = SardisCheckPolicySchema(**args) + + try: + result = self._post("/policies/check", { + "agent_id": self.wallet_id, + "amount": validated.amount, + "currency": validated.token, + "merchant_id": validated.to, + }) + return f"Successfully checked payment policy:\n{dumps(result)}" + except requests.exceptions.HTTPError as e: + error_body = "" + if e.response is not None: + try: + error_body = e.response.json().get("detail", str(e)) + except Exception: + error_body = e.response.text + return f"Error checking policy: {error_body or e}" + except requests.exceptions.RequestException as e: + return f"Error checking policy: {e}" + + @create_action( + name="sardis_set_policy", + description=""" +Set or update the spending policy on the wallet using natural language. + +Use this tool when the user says things like "set my limit to $50 per +transaction", "change daily budget to $500", or gives any natural-language +spending rule. + +A successful response will return a JSON payload: + {"success": true, "wallet_id": "wal_abc", "limit_per_tx": 50.0, "limit_total": 500.0, "policy_text": "Max $50/tx, daily limit $500"} + +A failure response will return an error message: + Error setting policy: 403 Forbidden""", + schema=SardisSetPolicySchema, + ) + def sardis_set_policy(self, args: dict[str, Any]) -> str: + """Set spending policy with natural language parsing. + + Args: + args: Dictionary containing policy_text, max_per_tx, and max_total fields. + + Returns: + str: A message containing the policy update result or error details. + + """ + validated = SardisSetPolicySchema(**args) + + payload: dict[str, Any] = { + "natural_language": validated.policy_text, + "agent_id": self.wallet_id, + "confirm": True, + } + + try: + result = self._post("/policies/apply", payload) + return f"Successfully updated spending policy:\n{dumps(result)}" + except requests.exceptions.HTTPError as e: + error_body = "" + if e.response is not None: + try: + error_body = e.response.json().get("detail", str(e)) + except Exception: + error_body = e.response.text + return f"Error setting policy: {error_body or e}" + except requests.exceptions.RequestException as e: + return f"Error setting policy: {e}" + + @create_action( + name="sardis_list_transactions", + description=""" +List recent transactions from the wallet's ledger. + +Use this tool when the user asks to see their transaction history, recent +payments, spending activity, or audit trail. + +A successful response will return a JSON payload: + {"wallet_id": "wal_abc", "count": 3, "transactions": [{"tx_id": "tx_1", "amount": "25.00", "to": "openai.com", "status": "executed"}]} + +A failure response will return an error message: + Error listing transactions: 401 Unauthorized""", + schema=SardisListTransactionsSchema, + ) + def sardis_list_transactions(self, args: dict[str, Any]) -> str: + """Retrieve transaction history. + + Args: + args: Dictionary containing limit field. + + Returns: + str: A message containing the transaction list or error details. + + """ + validated = SardisListTransactionsSchema(**args) + capped_limit = min(validated.limit, 50) + + try: + result = self._get( + "/ledger/entries", + params={"wallet_id": self.wallet_id, "limit": capped_limit}, + ) + return f"Successfully retrieved transaction history:\n{dumps(result)}" + except requests.exceptions.HTTPError as e: + error_body = "" + if e.response is not None: + try: + error_body = e.response.json().get("detail", str(e)) + except Exception: + error_body = e.response.text + return f"Error listing transactions: {error_body or e}" + except requests.exceptions.RequestException as e: + return f"Error listing transactions: {e}" + + def supports_network(self, network: Network) -> bool: + """Check if network is supported by Sardis payment actions. + + Sardis supports EVM networks including Base, Polygon, Ethereum, + Arbitrum, and Optimism. + + Returns: + bool: True if the network is an EVM network. + + """ + return network.protocol_family == "evm" + + +def sardis_action_provider( + api_key: str | None = None, + wallet_id: str | None = None, + base_url: str | None = None, +) -> SardisActionProvider: + """Create and return a new SardisActionProvider instance. + + Args: + api_key: Sardis API key. Falls back to SARDIS_API_KEY env var. + wallet_id: Sardis wallet ID. Falls back to SARDIS_WALLET_ID env var. + base_url: Override the Sardis API base URL. + + Returns: + SardisActionProvider: Configured provider instance. + + """ + return SardisActionProvider( + api_key=api_key, + wallet_id=wallet_id, + base_url=base_url, + ) diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/sardis/schemas.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/sardis/schemas.py new file mode 100644 index 000000000..ab8e6b9cc --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/sardis/schemas.py @@ -0,0 +1,71 @@ +"""Schemas for Sardis action provider.""" + +from pydantic import BaseModel, Field + + +class SardisPaySchema(BaseModel): + """Input argument schema for executing a policy-controlled payment.""" + + to: str = Field( + ..., + description='Recipient address or merchant identifier (e.g. "0xabc...", "openai.com", "anthropic:api")', + ) + amount: str = Field( + ..., description='Payment amount in token units (e.g. "25.00")' + ) + token: str = Field( + default="USDC", + description="Token to pay with. Supported: USDC, USDT, PYUSD, EURC", + ) + purpose: str = Field( + default="", + description='Human-readable reason for the payment (e.g. "Monthly API subscription")', + ) + + +class SardisCheckBalanceSchema(BaseModel): + """Input argument schema for checking wallet balance and spending limits.""" + + token: str = Field( + default="USDC", description="Token to query (default: USDC)" + ) + chain: str = Field( + default="base", description="Blockchain network (default: base)" + ) + + +class SardisCheckPolicySchema(BaseModel): + """Input argument schema for dry-run policy validation.""" + + to: str = Field(..., description="Recipient address or merchant identifier") + amount: str = Field(..., description="Payment amount to validate") + token: str = Field(default="USDC", description="Token type (default: USDC)") + purpose: str = Field( + default="", description="Payment purpose (some policies require this)" + ) + + +class SardisSetPolicySchema(BaseModel): + """Input argument schema for setting a spending policy.""" + + policy_text: str = Field( + ..., + description='Natural language policy description (e.g. "Max $50 per transaction, daily limit $500")', + ) + max_per_tx: str = Field( + default="", + description="Optional explicit per-transaction limit override", + ) + max_total: str = Field( + default="", + description="Optional explicit total spending limit override", + ) + + +class SardisListTransactionsSchema(BaseModel): + """Input argument schema for listing recent transactions.""" + + limit: int = Field( + default=10, + description="Maximum number of transactions to return (default: 10, max: 50)", + ) diff --git a/python/coinbase-agentkit/tests/action_providers/sardis/__init__.py b/python/coinbase-agentkit/tests/action_providers/sardis/__init__.py new file mode 100644 index 000000000..82a4c7cc0 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/sardis/__init__.py @@ -0,0 +1 @@ +"""Tests for Sardis action provider.""" diff --git a/python/coinbase-agentkit/tests/action_providers/sardis/conftest.py b/python/coinbase-agentkit/tests/action_providers/sardis/conftest.py new file mode 100644 index 000000000..0c6f0151a --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/sardis/conftest.py @@ -0,0 +1,24 @@ +"""Test fixtures for Sardis action provider tests.""" + +import pytest + +MOCK_API_KEY = "sk_test_sardis_mock_key_123" +MOCK_WALLET_ID = "wal_test_mock_wallet_456" +MOCK_BASE_URL = "https://api.sardis.sh/v2" + + +@pytest.fixture +def mock_env(monkeypatch): + """Set up mock environment variables for Sardis credentials.""" + monkeypatch.setenv("SARDIS_API_KEY", MOCK_API_KEY) + monkeypatch.setenv("SARDIS_WALLET_ID", MOCK_WALLET_ID) + + +@pytest.fixture +def mock_provider(mock_env): + """Create a Sardis action provider with mock credentials.""" + from coinbase_agentkit.action_providers.sardis.sardis_action_provider import ( + sardis_action_provider, + ) + + return sardis_action_provider() diff --git a/python/coinbase-agentkit/tests/action_providers/sardis/test_sardis_action_provider.py b/python/coinbase-agentkit/tests/action_providers/sardis/test_sardis_action_provider.py new file mode 100644 index 000000000..b9130585c --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/sardis/test_sardis_action_provider.py @@ -0,0 +1,343 @@ +"""Tests for the Sardis action provider.""" + +from unittest.mock import Mock, patch + +import pytest + +from coinbase_agentkit.action_providers.sardis.sardis_action_provider import ( + sardis_action_provider, +) +from coinbase_agentkit.action_providers.sardis.schemas import ( + SardisCheckBalanceSchema, + SardisCheckPolicySchema, + SardisListTransactionsSchema, + SardisPaySchema, + SardisSetPolicySchema, +) +from coinbase_agentkit.network import Network + +MOCK_API_KEY = "sk_test_sardis_mock_key_123" +MOCK_WALLET_ID = "wal_test_mock_wallet_456" + + +# --------------------------------------------------------------------------- +# Initialization tests +# --------------------------------------------------------------------------- + + +@pytest.mark.usefixtures("mock_env") +def test_provider_init_with_env_vars(): + """Test provider initialization with environment variables.""" + provider = sardis_action_provider() + assert provider.api_key == MOCK_API_KEY + assert provider.wallet_id == MOCK_WALLET_ID + + +def test_provider_init_with_args(): + """Test provider initialization with explicit arguments.""" + provider = sardis_action_provider( + api_key="sk_explicit", + wallet_id="wal_explicit", + base_url="https://custom.api.com", + ) + assert provider.api_key == "sk_explicit" + assert provider.wallet_id == "wal_explicit" + assert provider.base_url == "https://custom.api.com" + + +def test_provider_init_missing_api_key(): + """Test provider initialization fails with missing API key.""" + with pytest.raises(ValueError, match="SARDIS_API_KEY is not configured"): + sardis_action_provider() + + +def test_provider_init_missing_wallet_id(monkeypatch): + """Test provider initialization fails with missing wallet ID.""" + monkeypatch.setenv("SARDIS_API_KEY", MOCK_API_KEY) + with pytest.raises(ValueError, match="SARDIS_WALLET_ID is not configured"): + sardis_action_provider() + + +# --------------------------------------------------------------------------- +# Network support tests +# --------------------------------------------------------------------------- + + +def test_supports_evm_network(mock_provider): + """Test that EVM networks are supported.""" + evm_network = Network(chain_id="8453", protocol_family="evm", network_id="base-mainnet") + assert mock_provider.supports_network(evm_network) is True + + +def test_does_not_support_non_evm_network(mock_provider): + """Test that non-EVM networks are not supported.""" + sol_network = Network(chain_id="1", protocol_family="solana", network_id="solana-mainnet") + assert mock_provider.supports_network(sol_network) is False + + +# --------------------------------------------------------------------------- +# Schema validation tests +# --------------------------------------------------------------------------- + + +def test_pay_schema_required_fields(): + """Test SardisPaySchema requires to and amount.""" + schema = SardisPaySchema(to="openai.com", amount="25.00") + assert schema.to == "openai.com" + assert schema.amount == "25.00" + assert schema.token == "USDC" + assert schema.purpose == "" + + +def test_pay_schema_all_fields(): + """Test SardisPaySchema with all fields.""" + schema = SardisPaySchema( + to="0xabc", amount="100.00", token="USDT", purpose="API subscription" + ) + assert schema.token == "USDT" + assert schema.purpose == "API subscription" + + +def test_check_balance_schema_defaults(): + """Test SardisCheckBalanceSchema defaults.""" + schema = SardisCheckBalanceSchema() + assert schema.token == "USDC" + assert schema.chain == "base" + + +def test_check_policy_schema(): + """Test SardisCheckPolicySchema validation.""" + schema = SardisCheckPolicySchema(to="merchant.com", amount="50.00") + assert schema.to == "merchant.com" + assert schema.token == "USDC" + + +def test_set_policy_schema(): + """Test SardisSetPolicySchema validation.""" + schema = SardisSetPolicySchema(policy_text="Max $50 per transaction") + assert schema.policy_text == "Max $50 per transaction" + assert schema.max_per_tx == "" + assert schema.max_total == "" + + +def test_list_transactions_schema_defaults(): + """Test SardisListTransactionsSchema defaults.""" + schema = SardisListTransactionsSchema() + assert schema.limit == 10 + + +# --------------------------------------------------------------------------- +# Action execution tests +# --------------------------------------------------------------------------- + + +class TestSardisPay: + """Tests for the sardis_pay action.""" + + @patch("coinbase_agentkit.action_providers.sardis.sardis_action_provider.requests.post") + def test_pay_success(self, mock_post, mock_provider): + """Test successful payment execution.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "tx_hash": "0xabc123", + "explorer_url": "https://basescan.org/tx/0xabc123", + "status": "submitted", + } + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + result = mock_provider.sardis_pay({ + "to": "openai.com", + "amount": "25.00", + }) + + assert "Successfully executed payment" in result + assert "0xabc123" in result + # Called twice: once for analytics, once for the actual API call + assert mock_post.call_count >= 1 + + @patch("coinbase_agentkit.action_providers.sardis.sardis_action_provider.requests.post") + def test_pay_policy_violation(self, mock_post, mock_provider): + """Test payment rejected by policy.""" + mock_response = Mock() + mock_response.status_code = 403 + mock_response.json.return_value = { + "detail": "Policy violation - amount exceeds per-transaction limit" + } + mock_response.raise_for_status.side_effect = __import__( + "requests" + ).exceptions.HTTPError(response=mock_response) + mock_post.return_value = mock_response + + result = mock_provider.sardis_pay({ + "to": "openai.com", + "amount": "99999.00", + }) + + assert "Error executing payment" in result + assert "Policy violation" in result + + +class TestSardisCheckBalance: + """Tests for the sardis_check_balance action.""" + + @patch("coinbase_agentkit.action_providers.sardis.sardis_action_provider.requests.get") + def test_check_balance_success(self, mock_get, mock_provider): + """Test successful balance check.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "wallet_id": MOCK_WALLET_ID, + "balance": "1250.00", + "token": "USDC", + "chain": "base", + "address": "0x1234567890abcdef", + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + result = mock_provider.sardis_check_balance({}) + + assert "Successfully retrieved wallet balance" in result + assert "1250.00" in result + + @patch("coinbase_agentkit.action_providers.sardis.sardis_action_provider.requests.get") + def test_check_balance_error(self, mock_get, mock_provider): + """Test balance check with API error.""" + mock_response = Mock() + mock_response.status_code = 401 + mock_response.json.return_value = {"detail": "Unauthorized"} + mock_response.raise_for_status.side_effect = __import__( + "requests" + ).exceptions.HTTPError(response=mock_response) + mock_get.return_value = mock_response + + result = mock_provider.sardis_check_balance({}) + + assert "Error checking balance" in result + + +class TestSardisCheckPolicy: + """Tests for the sardis_check_policy action.""" + + @patch("coinbase_agentkit.action_providers.sardis.sardis_action_provider.requests.post") + def test_check_policy_allowed(self, mock_post, mock_provider): + """Test policy check that passes.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "allowed": True, + "reason": "OK", + "policy_id": "pol_abc123", + } + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + result = mock_provider.sardis_check_policy({ + "to": "openai.com", + "amount": "25.00", + }) + + assert "Successfully checked payment policy" in result + assert "allowed" in result + + @patch("coinbase_agentkit.action_providers.sardis.sardis_action_provider.requests.post") + def test_check_policy_denied(self, mock_post, mock_provider): + """Test policy check that fails.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "allowed": False, + "reason": "per_transaction_limit", + "policy_id": "pol_abc123", + } + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + result = mock_provider.sardis_check_policy({ + "to": "openai.com", + "amount": "99999.00", + }) + + assert "Successfully checked payment policy" in result + assert "allowed" in result + + +class TestSardisSetPolicy: + """Tests for the sardis_set_policy action.""" + + @patch("coinbase_agentkit.action_providers.sardis.sardis_action_provider.requests.post") + def test_set_policy_success(self, mock_post, mock_provider): + """Test successful policy update.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "success": True, + "wallet_id": MOCK_WALLET_ID, + "limit_per_tx": 50.0, + "limit_total": 500.0, + "policy_text": "Max $50/tx, daily limit $500", + } + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + result = mock_provider.sardis_set_policy({ + "policy_text": "Max $50 per transaction, daily limit $500", + }) + + assert "Successfully updated spending policy" in result + assert "50.0" in result + + +class TestSardisListTransactions: + """Tests for the sardis_list_transactions action.""" + + @patch("coinbase_agentkit.action_providers.sardis.sardis_action_provider.requests.get") + def test_list_transactions_success(self, mock_get, mock_provider): + """Test successful transaction listing.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "entries": [ + { + "tx_id": "tx_1", + "amount": "25.00", + "from_wallet": MOCK_WALLET_ID, + "to_wallet": "openai.com", + "currency": "USDC", + "created_at": "2026-03-08T10:00:00Z", + }, + { + "tx_id": "tx_2", + "amount": "10.00", + "from_wallet": MOCK_WALLET_ID, + "to_wallet": "anthropic.com", + "currency": "USDC", + "created_at": "2026-03-08T09:00:00Z", + }, + ], + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + result = mock_provider.sardis_list_transactions({}) + + assert "Successfully retrieved transaction history" in result + assert "tx_1" in result + assert "tx_2" in result + + @patch("coinbase_agentkit.action_providers.sardis.sardis_action_provider.requests.get") + def test_list_transactions_caps_limit(self, mock_get, mock_provider): + """Test that limit is capped at 50.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"entries": []} + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + mock_provider.sardis_list_transactions({"limit": 100}) + + # Verify the limit was capped to 50 + call_args = mock_get.call_args + assert call_args[1]["params"]["limit"] == 50