diff --git a/src/omniclaw/client.py b/src/omniclaw/client.py index 89daa8d..aa7907d 100644 --- a/src/omniclaw/client.py +++ b/src/omniclaw/client.py @@ -284,6 +284,19 @@ def _nanopayment_network(self) -> str: ) return network + @staticmethod + def _route_value(route: Any) -> str: + return str(route.value if hasattr(route, "value") else route or "").strip().lower() + + @classmethod + def _route_uses_gateway_balance(cls, detected_route: Any, preferred_url_route: Any) -> bool: + preferred_route = cls._route_value(preferred_url_route) + if preferred_route in {"nanopayment", "gateway", "circle_gateway"}: + return True + if preferred_route == PaymentMethod.X402.value: + return False + return cls._route_value(detected_route) == PaymentMethod.NANOPAYMENT.value + def _init_nanopayments(self) -> None: """Initialize nanopayments components (direct private key only).""" if not self._config.nanopayments_enabled: @@ -975,9 +988,9 @@ async def pay( guards_passed: list[str] = [] # Detect payment route early to know which balance to check + source_network: Network | None = None try: # Try to get source network from Circle wallet first, then fall back to config default. - source_network = None try: wallet_info = self._wallet_service.get_wallet(wallet_id) source_network = Network.from_string(wallet_info.blockchain) @@ -1086,16 +1099,17 @@ async def pay( if consume_intent_id: await self._reservation.release(consume_intent_id) - # Get appropriate balance based on payment route - # For X402/nanopayment routes → check Gateway balance - # For Transfer/crosschain routes → check Circle wallet balance - circle_balance = self._wallet_service.get_usdc_balance_amount(wallet_id) + # Get appropriate balance based on payment route. + # Gateway nanopayments check Gateway balance. Direct x402 exact is + # delegated to the x402 adapter. Transfer/crosschain routes use Circle balance. reserved_total = await self._reservation.get_reserved_total(wallet_id) + preferred_url_route = kwargs.get("preferred_url_route") + required_amount = amount_decimal - # Check if this is a Gateway-based route - route_uses_gateway = detected_route in (PaymentMethod.X402, PaymentMethod.NANOPAYMENT) - - if route_uses_gateway and self._nano_adapter: + if ( + self._route_uses_gateway_balance(detected_route, preferred_url_route) + and self._nano_adapter + ): try: gateway_balance = await self.get_gateway_balance(wallet_id) available = Decimal(str(gateway_balance.available_decimal)) @@ -1106,20 +1120,24 @@ async def pay( self._logger.warning(f"Gateway balance check failed: {e}") available = Decimal("0") balance_source = "Gateway available: (check failed)" + elif self._route_value(detected_route) == PaymentMethod.X402.value: + available = amount_decimal + balance_source = "Direct x402: deferred to x402 adapter" else: + circle_balance = self._wallet_service.get_usdc_balance_amount(wallet_id) available = circle_balance - reserved_total balance_source = f"Circle: {available}" if lock_lost_event.is_set(): raise PaymentError("Wallet lock lease was lost before execution could start.") - if amount_decimal > available: - error_msg = f"Insufficient available balance ({balance_source}, Reserved: {reserved_total}, Required: {amount_decimal})" + if required_amount > available: + error_msg = f"Insufficient available balance ({balance_source}, Reserved: {reserved_total}, Required: {required_amount})" if guards_chain and reservation_tokens: await guards_chain.release(reservation_tokens) await self._ledger.update_status( ledger_entry.id, LedgerEntryStatus.FAILED, metadata_updates={"error": error_msg} ) raise InsufficientBalanceError( - error_msg, current_balance=available, required_amount=amount_decimal + error_msg, current_balance=available, required_amount=required_amount ) # Resilience Shell @@ -1359,15 +1377,18 @@ async def simulate( amount_decimal = Decimal(str(amount)) # Detect the actual route early so early-return reasons include it + source_network: Network | None = None try: + source_network = Network.from_string( + self._wallet_service.get_wallet(wallet_id).blockchain + ) detected_route = ( self._router.detect_method( recipient, - source_network=Network.from_string( - self._wallet_service.get_wallet(wallet_id).blockchain - ), + source_network=source_network, destination_chain=kwargs.get("destination_chain"), amount=amount_decimal, + preferred_url_route=kwargs.get("preferred_url_route"), ) or PaymentMethod.TRANSFER ) @@ -1384,15 +1405,17 @@ async def simulate( ), ) - # Get appropriate balance based on payment route - # For X402/nanopayment routes → check Gateway balance - # For Transfer/crosschain routes → check Circle wallet balance - circle_balance = self._wallet_service.get_usdc_balance_amount(wallet_id) + # Get appropriate balance based on payment route. + # Gateway nanopayments check Gateway balance. Direct x402 exact is delegated + # to the x402 adapter. Transfer/crosschain routes use Circle balance. reserved_total = await self._reservation.get_reserved_total(wallet_id) + preferred_url_route = kwargs.get("preferred_url_route") + required_amount = amount_decimal - route_uses_gateway = detected_route in (PaymentMethod.X402, PaymentMethod.NANOPAYMENT) - - if route_uses_gateway and self._nano_adapter: + if ( + self._route_uses_gateway_balance(detected_route, preferred_url_route) + and self._nano_adapter + ): try: # Direct private key mode - use ON-CHAIN query from omniclaw.protocols.nanopayments.wallet import GatewayWalletManager @@ -1412,15 +1435,19 @@ async def simulate( self._logger.warning(f"Gateway balance check failed: {e}") available = 0 balance_source = "Gateway: (check failed)" + elif self._route_value(detected_route) == PaymentMethod.X402.value: + available = amount_decimal + balance_source = "Direct x402: deferred to x402 adapter" else: + circle_balance = self._wallet_service.get_usdc_balance_amount(wallet_id) available = circle_balance - reserved_total balance_source = f"Circle: {available}" - if amount_decimal > available: + if required_amount > available: return SimulationResult( would_succeed=False, route=detected_route, - reason=f"Insufficient available balance ({balance_source}, Reserved: {reserved_total}, Required: {amount_decimal})", + reason=f"Insufficient available balance ({balance_source}, Reserved: {reserved_total}, Required: {required_amount})", ) # Check guards first diff --git a/src/omniclaw/payment/router.py b/src/omniclaw/payment/router.py index 7f0f37a..1987897 100644 --- a/src/omniclaw/payment/router.py +++ b/src/omniclaw/payment/router.py @@ -47,12 +47,15 @@ def get_adapters(self) -> list[ProtocolAdapter]: return list(self._adapters) @staticmethod - def _matches_preferred_route(adapter: ProtocolAdapter, preferred_route: Any) -> bool: + def _route_value(route: Any) -> str: + return str(route.value if hasattr(route, "value") else route or "").strip() + + @classmethod + def _matches_preferred_route(cls, adapter: ProtocolAdapter, preferred_route: Any) -> bool: if not preferred_route: return True adapter_method = getattr(adapter, "method", None) - adapter_value = adapter_method.value if hasattr(adapter_method, "value") else adapter_method - return str(adapter_value) == str(preferred_route) + return cls._route_value(adapter_method) == cls._route_value(preferred_route) def detect_method( self, diff --git a/tests/test_client.py b/tests/test_client.py index dd6f9e0..e8015fd 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,7 +8,8 @@ import tempfile from decimal import Decimal from pathlib import Path -from unittest.mock import MagicMock, patch +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -88,33 +89,39 @@ def test_init_loads_entity_secret_from_managed_store(self): assert client.config.entity_secret == "managed_secret" def test_init_production_requires_hardening_envs(self): - with patch.dict( - os.environ, - { - "CIRCLE_API_KEY": "prod_api_key", - "ENTITY_SECRET": "prod_entity_secret", - "OMNICLAW_ENV": "production", - }, - clear=True, - ), pytest.raises( - ConfigurationError, match="Missing required production environment variables" + with ( + patch.dict( + os.environ, + { + "CIRCLE_API_KEY": "prod_api_key", + "ENTITY_SECRET": "prod_entity_secret", + "OMNICLAW_ENV": "production", + }, + clear=True, + ), + pytest.raises( + ConfigurationError, match="Missing required production environment variables" + ), ): OmniClaw(network=Network.ARC_TESTNET) def test_init_production_requires_strict_settlement(self): - with patch.dict( - os.environ, - { - "CIRCLE_API_KEY": "prod_api_key", - "ENTITY_SECRET": "prod_entity_secret", - "OMNICLAW_ENV": "production", - "OMNICLAW_SELLER_NONCE_REDIS_URL": "redis://localhost:6379/0", - "OMNICLAW_WEBHOOK_VERIFICATION_KEY": "dummy-key", - "OMNICLAW_WEBHOOK_DEDUP_DB_PATH": "/tmp/omniclaw_webhook_dedup.sqlite3", - "OMNICLAW_STRICT_SETTLEMENT": "false", - }, - clear=True, - ), pytest.raises(ConfigurationError, match="OMNICLAW_STRICT_SETTLEMENT must be true"): + with ( + patch.dict( + os.environ, + { + "CIRCLE_API_KEY": "prod_api_key", + "ENTITY_SECRET": "prod_entity_secret", + "OMNICLAW_ENV": "production", + "OMNICLAW_SELLER_NONCE_REDIS_URL": "redis://localhost:6379/0", + "OMNICLAW_WEBHOOK_VERIFICATION_KEY": "dummy-key", + "OMNICLAW_WEBHOOK_DEDUP_DB_PATH": "/tmp/omniclaw_webhook_dedup.sqlite3", + "OMNICLAW_STRICT_SETTLEMENT": "false", + }, + clear=True, + ), + pytest.raises(ConfigurationError, match="OMNICLAW_STRICT_SETTLEMENT must be true"), + ): OmniClaw(network=Network.ARC_TESTNET) @@ -388,6 +395,211 @@ async def _mock_pay(**kwargs): assert captured_keys[0] == captured_keys[1] +class TestUrlRouteBalanceChecks: + def test_direct_x402_route_does_not_use_gateway_balance(self): + assert ( + OmniClaw._route_uses_gateway_balance( + PaymentMethod.X402, + PaymentMethod.X402.value, + ) + is False + ) + + def test_nanopayment_route_uses_gateway_balance(self): + assert ( + OmniClaw._route_uses_gateway_balance( + PaymentMethod.NANOPAYMENT, + PaymentMethod.NANOPAYMENT.value, + ) + is True + ) + + @pytest.mark.asyncio + async def test_pay_direct_x402_delegates_balance_to_adapter(self, client): + client._wallet_service.get_wallet = lambda _wid: SimpleNamespace(blockchain="ARC-TESTNET") + client._wallet_service.get_usdc_balance_amount = MagicMock( + side_effect=AssertionError("Circle balance not used") + ) + client._router.detect_method = MagicMock(return_value=PaymentMethod.X402) + client._router._find_adapter = MagicMock(side_effect=AssertionError("extra probe not used")) + client._router.pay = AsyncMock( + return_value=PaymentResult( + success=True, + transaction_id="x402-tx", + blockchain_tx=None, + amount=Decimal("0.001"), + recipient="http://127.0.0.1:4023/compute", + method=PaymentMethod.X402, + status=PaymentStatus.COMPLETED, + ) + ) + client.get_gateway_balance = AsyncMock(side_effect=AssertionError("gateway not used")) + + result = await client.pay( + wallet_id="wallet-123", + recipient="http://127.0.0.1:4023/compute", + amount=Decimal("0.001"), + preferred_url_route=PaymentMethod.X402.value, + skip_guards=True, + ) + + assert result.success is True + client.get_gateway_balance.assert_not_awaited() + client._wallet_service.get_usdc_balance_amount.assert_not_called() + client._router._find_adapter.assert_not_called() + client._router.pay.assert_awaited_once() + + @pytest.mark.asyncio + async def test_pay_direct_x402_does_not_require_circle_wallet_balance(self, client): + client._wallet_service.get_wallet = lambda _wid: SimpleNamespace(blockchain="ARC-TESTNET") + client._wallet_service.get_usdc_balance_amount = MagicMock( + side_effect=AssertionError("Circle balance not used") + ) + client._router.detect_method = MagicMock(return_value=PaymentMethod.X402) + client._router.pay = AsyncMock( + return_value=PaymentResult( + success=True, + transaction_id="x402-tx", + blockchain_tx=None, + amount=Decimal("0.001"), + recipient="http://127.0.0.1:4023/compute", + method=PaymentMethod.X402, + status=PaymentStatus.COMPLETED, + ) + ) + client.get_gateway_balance = AsyncMock(side_effect=AssertionError("gateway not used")) + + result = await client.pay( + wallet_id="wallet-123", + recipient="http://127.0.0.1:4023/compute", + amount=Decimal("0.001"), + preferred_url_route=PaymentMethod.X402.value, + skip_guards=True, + ) + + assert result.success is True + client.get_gateway_balance.assert_not_awaited() + client._wallet_service.get_usdc_balance_amount.assert_not_called() + client._router.pay.assert_awaited_once() + + @pytest.mark.asyncio + async def test_pay_nanopayment_route_uses_gateway_balance(self, client): + from omniclaw.protocols.nanopayments import GatewayBalance + + client._nano_adapter = object() + client._wallet_service.get_wallet = lambda _wid: SimpleNamespace(blockchain="ARC-TESTNET") + client._wallet_service.get_usdc_balance_amount = MagicMock( + side_effect=AssertionError("Circle balance not used") + ) + client._router.detect_method = MagicMock(return_value=PaymentMethod.NANOPAYMENT) + client._router._find_adapter = MagicMock(side_effect=AssertionError("direct x402 not used")) + client.get_gateway_balance = AsyncMock( + return_value=GatewayBalance( + total=2000, + available=2000, + formatted_total="0.002 USDC", + formatted_available="0.002 USDC", + ) + ) + client._router.pay = AsyncMock( + return_value=PaymentResult( + success=True, + transaction_id="nano-tx", + blockchain_tx=None, + amount=Decimal("0.001"), + recipient="http://127.0.0.1:4023/compute", + method=PaymentMethod.NANOPAYMENT, + status=PaymentStatus.COMPLETED, + ) + ) + + result = await client.pay( + wallet_id="wallet-123", + recipient="http://127.0.0.1:4023/compute", + amount=Decimal("0.001"), + preferred_url_route=PaymentMethod.NANOPAYMENT.value, + skip_guards=True, + ) + + assert result.success is True + client.get_gateway_balance.assert_awaited_once_with("wallet-123") + client._wallet_service.get_usdc_balance_amount.assert_not_called() + client._router._find_adapter.assert_not_called() + client._router.pay.assert_awaited_once() + + @pytest.mark.asyncio + async def test_simulate_direct_x402_does_not_preflight_twice(self, client): + from omniclaw.core.types import SimulationResult + + client._wallet_service.get_wallet = lambda _wid: SimpleNamespace(blockchain="ARC-TESTNET") + client._wallet_service.get_usdc_balance_amount = MagicMock( + side_effect=AssertionError("Circle balance not used") + ) + client._router.detect_method = MagicMock(return_value=PaymentMethod.X402) + client._router._find_adapter = MagicMock(side_effect=AssertionError("extra probe not used")) + client._router.simulate = AsyncMock( + return_value=SimulationResult( + would_succeed=True, + route=PaymentMethod.X402, + ) + ) + client.get_gateway_balance = AsyncMock(side_effect=AssertionError("gateway not used")) + + result = await client.simulate( + wallet_id="wallet-123", + recipient="http://127.0.0.1:4023/compute", + amount=Decimal("0.001"), + preferred_url_route=PaymentMethod.X402.value, + ) + + assert result.would_succeed is True + client.get_gateway_balance.assert_not_awaited() + client._wallet_service.get_usdc_balance_amount.assert_not_called() + client._router._find_adapter.assert_not_called() + client._router.simulate.assert_awaited_once() + + @pytest.mark.asyncio + async def test_simulate_nanopayment_route_uses_gateway_balance(self, client, monkeypatch): + from omniclaw.core.types import SimulationResult + + class FakeGatewayWalletManager: + def __init__(self, **kwargs): + pass + + async def get_gateway_available_balance(self): + return Decimal("0.002") + + client._nano_adapter = SimpleNamespace(signer=SimpleNamespace(raw_key="unused")) + client._wallet_service.get_wallet = lambda _wid: SimpleNamespace(blockchain="ARC-TESTNET") + client._wallet_service.get_usdc_balance_amount = MagicMock( + side_effect=AssertionError("Circle balance not used") + ) + client._router.detect_method = MagicMock(return_value=PaymentMethod.NANOPAYMENT) + client._router._find_adapter = MagicMock(side_effect=AssertionError("direct x402 not used")) + monkeypatch.setattr( + "omniclaw.protocols.nanopayments.wallet.GatewayWalletManager", + FakeGatewayWalletManager, + ) + client._router.simulate = AsyncMock( + return_value=SimulationResult( + would_succeed=True, + route=PaymentMethod.NANOPAYMENT, + ) + ) + + result = await client.simulate( + wallet_id="wallet-123", + recipient="http://127.0.0.1:4023/compute", + amount=Decimal("0.001"), + preferred_url_route=PaymentMethod.NANOPAYMENT.value, + ) + + assert result.would_succeed is True + client._wallet_service.get_usdc_balance_amount.assert_not_called() + client._router._find_adapter.assert_not_called() + client._router.simulate.assert_awaited_once() + + class TestSettlementReconciliation: @pytest.mark.asyncio async def test_finalize_pending_settlement_marks_completed(self, client): diff --git a/tests/test_payment_router.py b/tests/test_payment_router.py index e823b0a..999e599 100644 --- a/tests/test_payment_router.py +++ b/tests/test_payment_router.py @@ -1,6 +1,7 @@ """Unit tests for PaymentRouter and TransferAdapter.""" from decimal import Decimal +from types import SimpleNamespace from unittest.mock import MagicMock import pytest @@ -364,6 +365,17 @@ def test_detect_unknown_returns_none( # X402 adapter not registered assert method is None + def test_preferred_route_matches_enum_and_string( + self, + payment_router: PaymentRouter, + ) -> None: + """Test preferred URL route accepts enum and string values.""" + adapter = SimpleNamespace(method=PaymentMethod.X402) + + assert payment_router._matches_preferred_route(adapter, PaymentMethod.X402) is True + assert payment_router._matches_preferred_route(adapter, PaymentMethod.X402.value) is True + assert payment_router._matches_preferred_route(adapter, PaymentMethod.NANOPAYMENT) is False + def test_can_handle( self, payment_router: PaymentRouter,