diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2684796..5fde0c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,14 +20,28 @@ jobs: - name: Install OZ + forge-std run: | - forge install foundry-rs/forge-std --shallow + # Pin forge-std to the version the suite is developed against so CI + # can't drift onto a newer forge-std that changes cheatcode/std behavior. + forge install foundry-rs/forge-std@v1.9.4 --shallow # OZ v5.0.2 is not reachable via foundry's tag-API window (OZ is at v5.6.x). - # Pin by SHA; --shallow excluded because shallow clones can't checkout arbitrary commits. + # Pin by SHA (== the v5.0.2 release tag); --shallow excluded because + # shallow clones can't checkout arbitrary commits. forge install OpenZeppelin/openzeppelin-contracts@dbb6104ce834628e473d2173bbc9d47f81a9eec3 + # `forge build --sizes` / `forge test` auto-discover every suite under + # contracts/test + test/, so the multi-token escrow (unit ①) and swap + # adapter (unit ②) suites run here without an explicit test list. - run: forge build --sizes - run: forge test -vv + # Belt-and-suspenders: fail loudly if the new multi-token / swap suites + # ever stop being discovered (e.g. a bad path move), rather than silently + # shrinking coverage. + - name: Assert multi-token + swap suites are present + run: | + forge test --match-contract MultiTokenAgentEscrowTest -vv + forge test --match-contract SwapSettlementAdapterTest -vv + pytest: name: pytest runs-on: ubuntu-latest diff --git a/contracts/IAgentEscrow.sol b/contracts/IAgentEscrow.sol new file mode 100644 index 0000000..f4ca5ff --- /dev/null +++ b/contracts/IAgentEscrow.sol @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title IAgentEscrow + * @notice Shared lifecycle interface for agent-to-agent escrow, generalized + * over the settlement asset. This is the "Multi-Token A2A Escrow" + * surface referenced by the design spec §3.1 / §5 unit ⑤. + * + * @dev The native-ETH escrow (`AgentEscrow.sol`) is the **ETH profile** of this + * standard: the case where `token == address(0)`. `MultiTokenAgentEscrow` + * implements this interface for both native ETH (`address(0)`, via + * `msg.value`) and ERC-20 tokens (via `transferFrom` / `transfer`). + * + * Lifecycle (unchanged from the native EIP, now parameterized by `token`): + * create → (Locked) → confirm | releaseByAttestation → Released + * → requestRefund (after timeout+challenge) → Refunded + * → cancelPayment → Cancelled + * + * Design notes: + * - `Payment` carries an `address token` field. `address(0)` denotes the + * native-ETH profile; any other address is the ERC-20 being escrowed. + * - `amount` in `createPayment` is the *declared* amount. For the ETH + * profile it must equal `msg.value`. For ERC-20s it is the amount the + * escrow will attempt to pull; the *credited* amount is the measured + * balance delta (fee-on-transfer safe) — see `MultiTokenAgentEscrow`. + * - Every escrow event carries `token` so indexers can attribute flows + * per asset. + */ +interface IAgentEscrow { + enum State { + Created, + Locked, + Confirmed, + Released, + Refunded, + Cancelled + } + + struct Payment { + address payer; + address payee; + address token; // address(0) = native ETH profile; else the ERC-20 escrowed + uint256 amount; // credited amount held in escrow (balance-delta for ERC-20) + uint256 timeoutBlocks; // blocks until auto-expire + uint256 challengePeriod; // blocks payer must wait to reclaim after timeout + State state; + string requestId; // off-chain payment request ID + uint256 createdAt; // block number at creation + bytes32 policyHash; // 0x00 = payer-only release; non-zero enables oracle release + } + + // ─── Events (all carry `token`) ────────────────────────────────────────── + + event PaymentCreated( + string indexed requestId, + address indexed payer, + address indexed payee, + address token, + uint256 amount + ); + event PaymentLocked(string indexed requestId, address token); + event PaymentConfirmed(string indexed requestId, address indexed payer, address token); + event PaymentReleased(string indexed requestId, address indexed payee, address token, uint256 amount); + event PaymentReleasedByOracle(string indexed requestId, bytes32 policyHash, bytes32 attestationHash); + event PaymentRefunded(string indexed requestId, address indexed payer, address token, uint256 amount); + event PaymentCancelled(string indexed requestId, address indexed payer, address token, uint256 amount); + + // ─── Lifecycle ──────────────────────────────────────────────────────────── + + /** + * @notice Create a payment request and lock funds in escrow. + * @param requestId Off-chain payment request ID (unique). + * @param payee Recipient on release. + * @param token Settlement asset. `address(0)` = native ETH (send via `msg.value`); + * otherwise an ERC-20 the payer has approved to this contract. + * @param amount Declared amount. Native profile requires `amount == msg.value`; + * ERC-20 profile pulls up to `amount` via `transferFrom` and credits + * the measured balance delta. + * @param timeoutBlocks Blocks until the payment auto-expires. + * @param challengePeriod Blocks the payer must additionally wait after timeout to reclaim. + */ + function createPayment( + string calldata requestId, + address payee, + address token, + uint256 amount, + uint256 timeoutBlocks, + uint256 challengePeriod + ) external payable returns (bool); + + /// @notice Payer confirms work is done -> release funds to payee. + function confirmPayment(string calldata requestId) external returns (bool); + + /// @notice Oracle-mediated release, gated by the payment's `policyHash`. + function releaseByAttestation( + string calldata requestId, + bytes32 attestationHash, + bytes[] calldata signatures + ) external returns (bool); + + /// @notice Payer reclaims funds after timeout + challenge period. + function requestRefund(string calldata requestId) external returns (bool); + + /// @notice Cancel a still-locked payment (mutual agreement / pre-timeout). + function cancelPayment(string calldata requestId) external returns (bool); + + /// @notice Read a payment record. + function getPayment(string calldata requestId) external view returns (Payment memory); +} diff --git a/contracts/IPriceOracle.sol b/contracts/IPriceOracle.sol new file mode 100644 index 0000000..572629b --- /dev/null +++ b/contracts/IPriceOracle.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title IPriceOracle + * @notice Price-quote surface the `SwapSettlementAdapter` uses to compute the + * *expected* output of a swap and to bound realized slippage + * (design spec §3.5; plan unit ⑤'s `quote(tokenIn, tokenOut, amountIn)`). + * + * @dev DISTINCT FROM `IOracleAggregator`. `IOracleAggregator` answers a boolean + * "is this release attested?" for the escrow core. This interface answers a + * *pricing* question — "how much `tokenOut` is `amountIn` of `tokenIn` + * worth right now, and how fresh is that price?" — for the OPT-IN swap + * layer only. Keeping them separate honors spec §8: "Oracle used only for + * slippage bounds, never as the settlement authority" — the price oracle + * never moves funds and never authorizes a release; it only sets the + * expected-out reference the adapter measures realized slippage against. + * + * `updatedAt` is the unix timestamp of the underlying price observation. + * The adapter rejects the swap if `block.timestamp - updatedAt` exceeds its + * configured `maxPriceStaleness`, so a frozen/stale oracle can never be + * used to justify an off-market swap. + */ +interface IPriceOracle { + /// @notice Expected output of swapping `amountIn` of `tokenIn` into `tokenOut`. + /// @return amountOut Fair-value output at the oracle's current price. + /// @return updatedAt Unix timestamp of the price observation (for staleness). + function quote(address tokenIn, address tokenOut, uint256 amountIn) + external + view + returns (uint256 amountOut, uint256 updatedAt); +} diff --git a/contracts/ISwapRouter.sol b/contracts/ISwapRouter.sol new file mode 100644 index 0000000..c017d35 --- /dev/null +++ b/contracts/ISwapRouter.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title ISwapRouter + * @notice Minimal DEX-router surface the `SwapSettlementAdapter` swaps through + * at release (design spec §3.5, plan unit ②). + * + * @dev This is a deliberately tiny, exact-input swap interface — the smallest + * thing the adapter needs to convert the escrowed `tokenIn` into the + * payee's `tokenOut`. The real wiring to `switchboard/adapters/lucidly.py` + * (the production DEX/liquidity engine) lands in a later unit; for now the + * adapter depends only on this interface so it can be swapped for lucidly, + * a Uniswap-style router, or a mock without any change to the adapter. + * + * Semantics (Uniswap-v2 `swapExactTokensForTokens` shaped, single hop): + * - The caller (the adapter) must have `approve`d `amountIn` of `tokenIn` + * to this router before calling. + * - The router pulls exactly `amountIn` of `tokenIn` from the caller, + * performs the swap, and sends the resulting `tokenOut` to `recipient`. + * - `minAmountOut` is the router's OWN floor (it MUST revert if it cannot + * deliver at least this much). The adapter passes its slippage-derived + * floor here as defense-in-depth; the adapter ALSO re-checks the + * realized output itself after the call (never trusts the router alone). + * - Returns the actual `amountOut` delivered to `recipient`. + */ +interface ISwapRouter { + function swapExactInput( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 minAmountOut, + address recipient + ) external returns (uint256 amountOut); +} diff --git a/contracts/MultiTokenAgentEscrow.sol b/contracts/MultiTokenAgentEscrow.sol new file mode 100644 index 0000000..df0a45b --- /dev/null +++ b/contracts/MultiTokenAgentEscrow.sol @@ -0,0 +1,331 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {IOracleAggregator} from "./IOracleAggregator.sol"; +import {IAgentEscrow} from "./IAgentEscrow.sol"; + +/** + * @title MultiTokenAgentEscrow + * @notice Multi-token generalization of `AgentEscrow` (design spec §3.2, unit ①). + * Same create → confirm → release / refund / cancel lifecycle as the + * native-ETH escrow, parameterized by `address token`: + * + * - `token == address(0)` → native ETH via `msg.value` (the ETH + * profile; semantics match `AgentEscrow` exactly). + * - `token != address(0)` → an ERC-20 pulled via `transferFrom` on + * create and paid out via `transfer` on release / refund / cancel. + * + * @dev APPROACH A (spec §3.3 / §11): this is a NEW sibling contract. The shipped, + * EIP-drafted `AgentEscrow.sol` is left untouched so its audit/EIP surface + * is unchanged; abhicris makes the final A/B/C call. + * + * Security posture (mirrors and extends `AgentEscrow`): + * - `is IAgentEscrow` — the shared multi-token interface. + * - Checks-Effects-Interactions on every path; `nonReentrant` as defense + * in depth (all release/refund/cancel/attestation paths do external + * transfers — ETH via low-level call, ERC-20 via SafeERC20). + * - **Balance-delta accounting**: the credited amount is the *measured* + * increase in this contract's token balance across `transferFrom`, not + * the declared amount. This makes fee-on-transfer / rebasing tokens + * safe (the escrow never promises to release more than it actually + * holds) and is why non-standard tokens can only be accepted behind the + * per-token allowlist. + * - **Allowlist**: ERC-20s must be owner-allowlisted (`setTokenAllowed`). + * Native ETH (`address(0)`) is always allowed — it is the core profile + * and needs no allowlisting, matching `AgentEscrow`. + * - SafeERC20 tolerates non-boolean-returning tokens (USDT-style). + */ +contract MultiTokenAgentEscrow is IAgentEscrow, Ownable, ReentrancyGuard { + using SafeERC20 for IERC20; + + uint256 public immutable chainId; + + /// @notice Oracle aggregator consulted on `releaseByAttestation`. Set once + /// at construction. `address(0)` disables oracle release entirely. + IOracleAggregator public immutable oracleAggregator; + + /// @notice requestId -> Payment. + mapping(string => Payment) public payments; + + /// @notice Per-ERC-20 allowlist. Native ETH (`address(0)`) is implicitly + /// always allowed and is NOT represented here. + mapping(address => bool) public allowlist; + + /// @notice Owner-curated allowlist of trusted agent addresses (parity with + /// `AgentEscrow`; kept for downstream policy checks). + mapping(address => bool) public registeredAgents; + + event TokenAllowed(address indexed token, bool allowed); + event AgentRegistered(address indexed agent); + event AgentDeregistered(address indexed agent); + + /// @param _chainId Chain id this contract is deployed on. + /// @param _aggregator Optional oracle aggregator; `address(0)` disables oracle release. + constructor(uint256 _chainId, IOracleAggregator _aggregator) Ownable(msg.sender) { + chainId = _chainId; + oracleAggregator = _aggregator; + } + + // ─── Admin ───────────────────────────────────────────────────────────────── + + /// @notice Allow or disallow an ERC-20 as a settlement asset. + /// @dev Non-standard tokens (fee-on-transfer/rebasing) are only ever accepted + /// through this gate, keeping the core safe-by-default. + function setTokenAllowed(address token, bool allowed) external onlyOwner { + require(token != address(0), "native ETH always allowed"); + allowlist[token] = allowed; + emit TokenAllowed(token, allowed); + } + + function registerAgent(address agent) external onlyOwner { + require(agent != address(0), "agent cannot be zero address"); + registeredAgents[agent] = true; + emit AgentRegistered(agent); + } + + function deregisterAgent(address agent) external onlyOwner { + registeredAgents[agent] = false; + emit AgentDeregistered(agent); + } + + // ─── Create ────────────────────────────────────────────────────────────── + + /// @inheritdoc IAgentEscrow + /// @dev Payer-only release (policyHash = 0). See `createPaymentWithPolicy` + /// for the oracle-release variant. + function createPayment( + string calldata requestId, + address payee, + address token, + uint256 amount, + uint256 timeoutBlocks, + uint256 challengePeriod + ) external payable override nonReentrant returns (bool) { + return _createPayment(requestId, payee, token, amount, timeoutBlocks, challengePeriod, bytes32(0)); + } + + /// @notice Create a payment with an oracle-release policy (multi-token). + function createPaymentWithPolicy( + string calldata requestId, + address payee, + address token, + uint256 amount, + uint256 timeoutBlocks, + uint256 challengePeriod, + bytes32 policyHash + ) external payable nonReentrant returns (bool) { + if (policyHash != bytes32(0)) { + require(address(oracleAggregator) != address(0), "no aggregator configured"); + } + return _createPayment(requestId, payee, token, amount, timeoutBlocks, challengePeriod, policyHash); + } + + function _createPayment( + string calldata requestId, + address payee, + address token, + uint256 amount, + uint256 timeoutBlocks, + uint256 challengePeriod, + bytes32 policyHash + ) internal returns (bool) { + require(bytes(requestId).length > 0, "requestId cannot be empty"); + require(payee != address(0), "payee cannot be zero address"); + require(payments[requestId].createdAt == 0, "requestId already exists"); + require(timeoutBlocks > 0, "timeoutBlocks must be > 0"); + require(amount > 0, "amount must be > 0"); + + uint256 credited; + if (token == address(0)) { + // ── Native ETH profile: parity with AgentEscrow ── + require(msg.value == amount, "ETH: msg.value != amount"); + credited = msg.value; + } else { + // ── ERC-20 profile ── + require(msg.value == 0, "ERC20: no ETH"); + require(allowlist[token], "token not allowlisted"); + // Balance-delta accounting: credit exactly what arrived, which is + // correct even for fee-on-transfer / rebasing tokens. + uint256 balBefore = IERC20(token).balanceOf(address(this)); + IERC20(token).safeTransferFrom(msg.sender, address(this), amount); + uint256 balAfter = IERC20(token).balanceOf(address(this)); + credited = balAfter - balBefore; + require(credited > 0, "no tokens received"); + } + + payments[requestId] = Payment({ + payer: msg.sender, + payee: payee, + token: token, + amount: credited, + timeoutBlocks: timeoutBlocks, + challengePeriod: challengePeriod, + state: State.Locked, + requestId: requestId, + createdAt: block.number, + policyHash: policyHash + }); + + emit PaymentCreated(requestId, msg.sender, payee, token, credited); + emit PaymentLocked(requestId, token); + return true; + } + + // ─── Release / Refund / Cancel ───────────────────────────────────────────── + + /// @inheritdoc IAgentEscrow + function confirmPayment(string calldata requestId) + external + override + nonReentrant + returns (bool) + { + Payment storage p = payments[requestId]; + require(p.payer == msg.sender, "Only payer can confirm"); + require(p.state == State.Locked, "Payment not in Locked state"); + require(block.number < p.createdAt + p.timeoutBlocks, "Payment has expired"); + + uint256 amount = p.amount; + address payee = p.payee; + address token = p.token; + p.state = State.Released; + p.amount = 0; + + emit PaymentConfirmed(requestId, msg.sender, token); + emit PaymentReleased(requestId, payee, token, amount); + + _payOut(token, payee, amount); + return true; + } + + /// @inheritdoc IAgentEscrow + function releaseByAttestation( + string calldata requestId, + bytes32 attestationHash, + bytes[] calldata signatures + ) external override nonReentrant returns (bool) { + Payment storage p = payments[requestId]; + require(p.state == State.Locked, "Payment not in Locked state"); + require(p.policyHash != bytes32(0), "No oracle policy on this payment"); + require(block.number < p.createdAt + p.timeoutBlocks, "Payment has expired"); + require(address(oracleAggregator) != address(0), "No aggregator"); + require( + oracleAggregator.verifyRelease(p.policyHash, attestationHash, signatures), + "Oracle attestation rejected" + ); + + uint256 amount = p.amount; + address payee = p.payee; + address token = p.token; + bytes32 policyHash = p.policyHash; + p.state = State.Released; + p.amount = 0; + + emit PaymentReleasedByOracle(requestId, policyHash, attestationHash); + emit PaymentReleased(requestId, payee, token, amount); + + _payOut(token, payee, amount); + return true; + } + + /// @inheritdoc IAgentEscrow + function requestRefund(string calldata requestId) + external + override + nonReentrant + returns (bool) + { + Payment storage p = payments[requestId]; + require(p.payer == msg.sender, "Only payer can request refund"); + require(p.state == State.Locked, "Payment not in Locked state"); + require( + block.number >= p.createdAt + p.timeoutBlocks + p.challengePeriod, + "Challenge period not over" + ); + + uint256 amount = p.amount; + address payer = p.payer; + address token = p.token; + p.state = State.Refunded; + p.amount = 0; + + emit PaymentRefunded(requestId, payer, token, amount); + + _payOut(token, payer, amount); + return true; + } + + /// @inheritdoc IAgentEscrow + function cancelPayment(string calldata requestId) + external + override + nonReentrant + returns (bool) + { + Payment storage p = payments[requestId]; + require(p.payer == msg.sender, "Only payer can cancel"); + require(p.state == State.Locked, "Payment not in Locked state"); + + uint256 amount = p.amount; + address payer = p.payer; + address token = p.token; + p.state = State.Cancelled; + p.amount = 0; + + emit PaymentCancelled(requestId, payer, token, amount); + + _payOut(token, payer, amount); + return true; + } + + /// @dev Interaction step for both profiles. For ETH, a low-level call + /// (parity with AgentEscrow). For ERC-20, SafeERC20.transfer of the + /// held amount — for fee-on-transfer tokens the recipient receives + /// net-of-fee, and the escrow is drained of exactly what it held (no + /// underflow, no stuck dust attributable to this payment). + function _payOut(address token, address to, uint256 amount) internal { + if (amount == 0) return; + if (token == address(0)) { + (bool success,) = to.call{value: amount}(""); + require(success, "ETH transfer failed"); + } else { + IERC20(token).safeTransfer(to, amount); + } + } + + // ─── ERC-165 ───────────────────────────────────────────────────────────── + + /// @notice ERC-165 introspection. + /// @dev Advertises the multi-token A2A escrow interface (`IAgentEscrow`, + /// id `0x01dc5a49` = XOR of its 6 external selectors) and ERC-165 + /// itself. Lets off-chain clients (the Python `EscrowClient`, the + /// swap adapter, block explorers) discover that this contract speaks + /// the standard without a trial call. + function supportsInterface(bytes4 interfaceId) external pure returns (bool) { + return + interfaceId == type(IAgentEscrow).interfaceId || + interfaceId == type(IERC165).interfaceId; + } + + // ─── Views ───────────────────────────────────────────────────────────────── + + /// @inheritdoc IAgentEscrow + function getPayment(string calldata requestId) external view override returns (Payment memory) { + return payments[requestId]; + } + + function isState(string calldata requestId, State expected) external view returns (bool) { + return payments[requestId].state == expected; + } + + function isExpired(string calldata requestId) external view returns (bool) { + Payment storage p = payments[requestId]; + if (p.createdAt == 0) return false; + return block.number >= p.createdAt + p.timeoutBlocks && p.state == State.Locked; + } +} diff --git a/contracts/SwapSettlementAdapter.sol b/contracts/SwapSettlementAdapter.sol new file mode 100644 index 0000000..5561ec1 --- /dev/null +++ b/contracts/SwapSettlementAdapter.sol @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IAgentEscrow} from "./IAgentEscrow.sol"; +import {ISwapRouter} from "./ISwapRouter.sol"; +import {IPriceOracle} from "./IPriceOracle.sol"; + +/** + * @title SwapSettlementAdapter + * @notice Opt-in swap-at-release layer (design spec §3.5, plan unit ②). Converts + * the escrowed payer-token (`tokenIn`) into the payee's desired token + * (`tokenOut`) at release, bounded by a payee-set `maxSlippageBps` and + * an oracle staleness window. + * + * @dev DELIBERATELY OUTSIDE THE ESCROW CORE (spec §3.5 / §8). The trustless + * `MultiTokenAgentEscrow` primitive keeps ZERO DEX/oracle attack surface; + * all swap logic lives here and is only ever reached when a payer opts in. + * + * How it composes with the untouched escrow: the adapter is BOTH the escrow + * `payer` and the escrow `payee` for a swap-settled payment. `openSwapEscrow` + * pulls `tokenIn` from the real payer, funds a normal escrow payment + * (adapter = payer, adapter = payee), and records the swap intent (real + * payee, tokenOut, slippage bound). `settleWithSwap` then: + * 1. confirms the escrow (adapter is the payer, so the core's + * `Only payer can confirm` guard is satisfied) → escrow transfers the + * held `tokenIn` to the adapter; + * 2. quotes the oracle for the fair `tokenOut` out, rejecting a STALE + * price so a frozen feed can never justify an off-market swap; + * 3. derives a minimum acceptable output from `maxSlippageBps` and swaps + * through the router; + * 4. RE-CHECKS the realized output itself (never trusts the router alone) + * and reverts the WHOLE call if realized < min — because the escrow + * confirm and the swap are in one transaction, the revert leaves the + * escrow FUNDED and Locked (spec §6: "escrow stays funded"); + * 5. forwards the received `tokenOut` to the real payee. + * + * Security posture: + * - Checks-Effects-Interactions + `nonReentrant` across the whole + * escrow↔adapter↔router↔payee call chain (the escrow is ALSO + * nonReentrant, so the confirm cannot re-enter the adapter mid-swap). + * - Slippage is bounded by an ORACLE reference, not the router's own + * quote; the router's `minAmountOut` floor is passed as defense in + * depth but the adapter independently re-verifies realized output. + * - The oracle is used ONLY to price the slippage bound, never to move + * funds or authorize a release (spec §8). + * - `settleWithSwap` can only be called by the intent's payer, cannot + * change the negotiated `tokenOut`, and cannot LOOSEN the negotiated + * slippage bound. + */ +contract SwapSettlementAdapter is ReentrancyGuard { + using SafeERC20 for IERC20; + + /// @notice The escrow this adapter settles through. Immutable — the adapter + /// is pinned to one escrow instance so the trust surface is fixed. + IAgentEscrow public immutable escrow; + /// @notice DEX router used for the actual conversion. + ISwapRouter public immutable swapRouter; + /// @notice Price oracle used ONLY to bound slippage (never to move funds). + IPriceOracle public immutable priceOracle; + /// @notice Max age (seconds) of an oracle price observation before it is + /// rejected as stale. + uint256 public immutable maxPriceStaleness; + + uint256 internal constant BPS_DENOM = 10_000; + + /// @notice Per-requestId swap intent, recorded at `openSwapEscrow`. + struct SwapIntent { + address payer; // the real payer who opened the intent + address realPayee; // who ultimately receives tokenOut + address tokenIn; // token held in escrow + address tokenOut; // token the payee wants + uint256 maxSlippageBps; // payee-set slippage ceiling + bool exists; + bool settled; + } + + mapping(string => SwapIntent) public intents; + + event SwapIntentOpened( + string indexed requestId, + address indexed payer, + address indexed realPayee, + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 maxSlippageBps + ); + event SwapSettled( + string indexed requestId, + address indexed realPayee, + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 amountOut + ); + + constructor( + IAgentEscrow _escrow, + ISwapRouter _swapRouter, + IPriceOracle _priceOracle, + uint256 _maxPriceStaleness + ) { + require(address(_escrow) != address(0), "escrow required"); + require(address(_swapRouter) != address(0), "router required"); + require(address(_priceOracle) != address(0), "oracle required"); + require(_maxPriceStaleness > 0, "staleness required"); + escrow = _escrow; + swapRouter = _swapRouter; + priceOracle = _priceOracle; + maxPriceStaleness = _maxPriceStaleness; + } + + /** + * @notice Open a swap-settled escrow payment. Pulls `amountIn` of `tokenIn` + * from the caller, funds an escrow payment with the ADAPTER as both + * payer and payee, and records the swap intent. + * @dev The caller must `approve` `amountIn` of `tokenIn` to this adapter + * first. `tokenIn` must be allowlisted on the escrow (the adapter only + * forwards; the escrow's allowlist still governs what it will hold). + */ + function openSwapEscrow( + string calldata requestId, + address realPayee, + address tokenIn, + uint256 amountIn, + address tokenOut, + uint256 maxSlippageBps, + uint256 timeoutBlocks, + uint256 challengePeriod + ) external nonReentrant returns (bool) { + require(!intents[requestId].exists, "intent exists"); + require(realPayee != address(0), "payee required"); + require(tokenIn != address(0), "tokenIn must be ERC20"); + require(tokenOut != address(0), "tokenOut must be ERC20"); + require(tokenIn != tokenOut, "same token: use core escrow"); + require(amountIn > 0, "amountIn > 0"); + require(maxSlippageBps < BPS_DENOM, "slippage bps too high"); + + // Pull tokenIn from the payer into the adapter, then approve the escrow. + // Balance-delta so the escrow is funded with exactly what arrived. + uint256 balBefore = IERC20(tokenIn).balanceOf(address(this)); + IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn); + uint256 received = IERC20(tokenIn).balanceOf(address(this)) - balBefore; + require(received > 0, "no tokenIn received"); + + intents[requestId] = SwapIntent({ + payer: msg.sender, + realPayee: realPayee, + tokenIn: tokenIn, + tokenOut: tokenOut, + maxSlippageBps: maxSlippageBps, + exists: true, + settled: false + }); + + IERC20(tokenIn).forceApprove(address(escrow), received); + escrow.createPayment(requestId, address(this), tokenIn, received, timeoutBlocks, challengePeriod); + + emit SwapIntentOpened(requestId, msg.sender, realPayee, tokenIn, tokenOut, received, maxSlippageBps); + return true; + } + + /** + * @notice Release the escrow and swap the held `tokenIn` into `tokenOut`, + * forwarding it to the real payee. Reverts the WHOLE call (leaving + * the escrow funded) if realized slippage exceeds the negotiated + * bound or the oracle price is stale. + * @param requestId The swap-settled escrow payment. + * @param tokenOut Must equal the negotiated tokenOut (guard against + * redirecting the swap output). + * @param maxSlippageBps Must be <= the negotiated bound (cannot be loosened). + */ + function settleWithSwap(string calldata requestId, address tokenOut, uint256 maxSlippageBps) + external + nonReentrant + returns (uint256 amountOut) + { + SwapIntent storage intent = intents[requestId]; + require(intent.exists, "no intent"); + require(!intent.settled, "already settled"); + require(msg.sender == intent.payer, "only intent payer"); + require(tokenOut == intent.tokenOut, "tokenOut mismatch"); + require(maxSlippageBps <= intent.maxSlippageBps, "slippage bound too loose"); + + address tokenIn = intent.tokenIn; + address realPayee = intent.realPayee; + + // ── Effects: mark settled before any external call (CEI + reentrancy). ── + intent.settled = true; + + // ── Release the escrow to this adapter (adapter is the escrow payer). ── + uint256 inBefore = IERC20(tokenIn).balanceOf(address(this)); + escrow.confirmPayment(requestId); + uint256 amountIn = IERC20(tokenIn).balanceOf(address(this)) - inBefore; + require(amountIn > 0, "escrow released nothing"); + + // ── Price the swap via the oracle; reject a stale observation. ── + (uint256 expectedOut, uint256 updatedAt) = priceOracle.quote(tokenIn, tokenOut, amountIn); + require(expectedOut > 0, "oracle expected out = 0"); + require(block.timestamp - updatedAt <= maxPriceStaleness, "oracle price stale"); + + // Minimum acceptable output = expected * (1 - slippage). + uint256 minOut = (expectedOut * (BPS_DENOM - maxSlippageBps)) / BPS_DENOM; + + // ── Swap: approve router, execute, then RE-CHECK realized output. ── + uint256 outBefore = IERC20(tokenOut).balanceOf(address(this)); + IERC20(tokenIn).forceApprove(address(swapRouter), amountIn); + swapRouter.swapExactInput(tokenIn, tokenOut, amountIn, minOut, address(this)); + amountOut = IERC20(tokenOut).balanceOf(address(this)) - outBefore; + + // Independent slippage enforcement — never trust the router's floor alone. + require(amountOut >= minOut, "slippage exceeds bound"); + + // Clear any dangling router allowance (belt-and-suspenders). + IERC20(tokenIn).forceApprove(address(swapRouter), 0); + + // ── Forward the payee's token. ── + IERC20(tokenOut).safeTransfer(realPayee, amountOut); + + emit SwapSettled(requestId, realPayee, tokenIn, tokenOut, amountIn, amountOut); + } +} diff --git a/contracts/mocks/MockERC20.sol b/contracts/mocks/MockERC20.sol new file mode 100644 index 0000000..819ab7a --- /dev/null +++ b/contracts/mocks/MockERC20.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title MockERC20 + * @notice Minimal, standard ERC-20 for tests. No fees, no rebasing — the + * "well-behaved token" baseline for the ERC-20 happy path. + * @dev Deliberately dependency-free (no OZ import) so the mock stays trivially + * auditable and matches exactly the transfer semantics the escrow relies on. + */ +contract MockERC20 { + string public name; + string public symbol; + uint8 public constant decimals = 18; + + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor(string memory _name, string memory _symbol) { + name = _name; + symbol = _symbol; + } + + function mint(address to, uint256 amount) external { + totalSupply += amount; + balanceOf[to] += amount; + emit Transfer(address(0), to, amount); + } + + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + function transfer(address to, uint256 amount) external returns (bool) { + _transfer(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + uint256 allowed = allowance[from][msg.sender]; + require(allowed >= amount, "ERC20: insufficient allowance"); + if (allowed != type(uint256).max) { + allowance[from][msg.sender] = allowed - amount; + } + _transfer(from, to, amount); + return true; + } + + function _transfer(address from, address to, uint256 amount) internal virtual { + require(balanceOf[from] >= amount, "ERC20: insufficient balance"); + require(to != address(0), "ERC20: transfer to zero"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + emit Transfer(from, to, amount); + } +} diff --git a/contracts/mocks/MockFeeOnTransferERC20.sol b/contracts/mocks/MockFeeOnTransferERC20.sol new file mode 100644 index 0000000..39474f5 --- /dev/null +++ b/contracts/mocks/MockFeeOnTransferERC20.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {MockERC20} from "./MockERC20.sol"; + +/** + * @title MockFeeOnTransferERC20 + * @notice A non-standard ERC-20 that burns a fixed basis-point fee on every + * transfer, so the recipient receives *less* than the sent amount. + * This is the canonical case that breaks "declared amount == credited + * amount" accounting and is why the escrow must credit by measured + * balance delta. + * @dev `feeBps` of the transferred amount is destroyed (removed from supply); + * recipient gets `amount - fee`. + */ +contract MockFeeOnTransferERC20 is MockERC20 { + uint256 public immutable feeBps; // e.g. 100 = 1% + + constructor(string memory _name, string memory _symbol, uint256 _feeBps) + MockERC20(_name, _symbol) + { + require(_feeBps < 10_000, "fee too high"); + feeBps = _feeBps; + } + + function _transfer(address from, address to, uint256 amount) internal override { + require(balanceOf[from] >= amount, "ERC20: insufficient balance"); + require(to != address(0), "ERC20: transfer to zero"); + uint256 fee = (amount * feeBps) / 10_000; + uint256 net = amount - fee; + balanceOf[from] -= amount; + balanceOf[to] += net; + totalSupply -= fee; // burn the fee + emit Transfer(from, to, net); + if (fee > 0) { + emit Transfer(from, address(0), fee); + } + } +} diff --git a/contracts/mocks/MockPriceOracle.sol b/contracts/mocks/MockPriceOracle.sol new file mode 100644 index 0000000..3b2f090 --- /dev/null +++ b/contracts/mocks/MockPriceOracle.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IPriceOracle} from "../IPriceOracle.sol"; + +/** + * @title MockPriceOracle + * @notice Test-only price oracle. Returns a deterministic expected-out driven by + * a per-pair rate and a settable `updatedAt`, so tests can exercise the + * swap adapter's slippage bound AND its staleness rejection without a + * real price feed. + * + * @dev `rate1e18[tokenIn][tokenOut]` is the price of 1e18 units of `tokenIn` + * expressed in `tokenOut`, scaled by 1e18. `amountOut = amountIn * rate / 1e18`. + * Both mock tokens are 18-decimals, so no decimal normalization is needed + * for the mock (a production oracle would normalize by token decimals). + */ +contract MockPriceOracle is IPriceOracle { + /// @dev price of 1e18 `tokenIn` in `tokenOut`, scaled 1e18. + mapping(address => mapping(address => uint256)) public rate1e18; + /// @dev unix timestamp of the last price update, per pair. 0 => never set. + mapping(address => mapping(address => uint256)) public updatedAtOf; + + function setRate(address tokenIn, address tokenOut, uint256 rate, uint256 updatedAt) external { + rate1e18[tokenIn][tokenOut] = rate; + updatedAtOf[tokenIn][tokenOut] = updatedAt; + } + + function quote(address tokenIn, address tokenOut, uint256 amountIn) + external + view + override + returns (uint256 amountOut, uint256 updatedAt) + { + uint256 rate = rate1e18[tokenIn][tokenOut]; + require(rate > 0, "MockPriceOracle: no rate"); + amountOut = (amountIn * rate) / 1e18; + updatedAt = updatedAtOf[tokenIn][tokenOut]; + } +} diff --git a/contracts/mocks/MockSwapRouter.sol b/contracts/mocks/MockSwapRouter.sol new file mode 100644 index 0000000..b059150 --- /dev/null +++ b/contracts/mocks/MockSwapRouter.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ISwapRouter} from "../ISwapRouter.sol"; + +/** + * @title MockSwapRouter + * @notice Test-only DEX router. Pulls `tokenIn` from the caller and pays out + * `tokenOut` to `recipient` at a settable per-pair rate, so tests can + * drive BOTH a fair swap and a bad-execution (high-slippage) swap. + * + * @dev The router must be pre-funded with `tokenOut` (like real liquidity). + * `rate1e18[tokenIn][tokenOut]` is the realized output of 1e18 `tokenIn` + * in `tokenOut`, scaled 1e18 — set it BELOW the oracle rate to simulate + * slippage/bad execution, or equal to it for a fair fill. + * + * By default it honors its own `minAmountOut` floor (reverts if it can't + * meet it), same as a real router. `setEnforceMinOut(false)` makes it + * IGNORE its floor — modeling a broken/malicious/misconfigured router — so + * tests can prove the ADAPTER'S OWN realized-out re-check catches slippage + * independently ("never trust the router alone", spec §8). + */ +contract MockSwapRouter is ISwapRouter { + /// @dev realized output of 1e18 `tokenIn` in `tokenOut`, scaled 1e18. + mapping(address => mapping(address => uint256)) public rate1e18; + /// @dev when false, the router does NOT enforce its own minAmountOut floor. + bool public enforceMinOut = true; + + function setRate(address tokenIn, address tokenOut, uint256 rate) external { + rate1e18[tokenIn][tokenOut] = rate; + } + + function setEnforceMinOut(bool on) external { + enforceMinOut = on; + } + + function swapExactInput( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 minAmountOut, + address recipient + ) external override returns (uint256 amountOut) { + uint256 rate = rate1e18[tokenIn][tokenOut]; + require(rate > 0, "MockSwapRouter: no liquidity"); + + // Pull exactly amountIn of tokenIn from the caller (adapter). + require( + IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn), + "MockSwapRouter: transferFrom failed" + ); + + amountOut = (amountIn * rate) / 1e18; + if (enforceMinOut) { + require(amountOut >= minAmountOut, "MockSwapRouter: insufficient output"); + } + + require( + IERC20(tokenOut).transfer(recipient, amountOut), + "MockSwapRouter: payout failed" + ); + } +} diff --git a/contracts/test/IAgentEscrow.t.sol b/contracts/test/IAgentEscrow.t.sol new file mode 100644 index 0000000..2c2a04b --- /dev/null +++ b/contracts/test/IAgentEscrow.t.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IAgentEscrow} from "../IAgentEscrow.sol"; + +/// @dev Unit ⑤ — interface conformance. +/// A minimal stub `is IAgentEscrow` must compile, proving the interface +/// is implementable. The `Payment` struct must carry a `token` field, and +/// the interface must declare the full lifecycle +/// (createPayment / confirmPayment / releaseByAttestation / requestRefund +/// / cancelPayment / getPayment). +contract IAgentEscrowStub is IAgentEscrow { + IAgentEscrow.Payment internal _p; + + function createPayment( + string calldata, + address, // payee + address, // token + uint256, // amount + uint256, // timeoutBlocks + uint256 // challengePeriod + ) external payable override returns (bool) { + return true; + } + + function confirmPayment(string calldata) external override returns (bool) { + return true; + } + + function releaseByAttestation( + string calldata, + bytes32, + bytes[] calldata + ) external override returns (bool) { + return true; + } + + function requestRefund(string calldata) external override returns (bool) { + return true; + } + + function cancelPayment(string calldata) external override returns (bool) { + return true; + } + + function getPayment(string calldata) external view override returns (IAgentEscrow.Payment memory) { + return _p; + } +} + +contract IAgentEscrowTest { + IAgentEscrowStub internal stub; + + function setUp() public { + stub = new IAgentEscrowStub(); + } + + /// The `Payment` struct must expose a `token` field (this is the whole point + /// of the multi-token generalization). Compiling the read proves it exists. + function test_paymentStructHasTokenField() public view { + IAgentEscrow.Payment memory p = stub.getPayment("req-x"); + // reference `.token` so the compiler enforces the field's existence + require(p.token == address(0), "default token is zero (native ETH profile)"); + } + + /// A concrete implementation can be handled purely through the interface + /// type — proving the ABI is complete for the Python/off-chain client. + function test_reachableThroughInterfaceType() public { + IAgentEscrow esc = IAgentEscrow(address(stub)); + require(esc.confirmPayment("req-x"), "confirm via interface"); + require(esc.requestRefund("req-x"), "refund via interface"); + require(esc.cancelPayment("req-x"), "cancel via interface"); + bytes[] memory sigs = new bytes[](0); + require(esc.releaseByAttestation("req-x", bytes32(0), sigs), "release via interface"); + } +} diff --git a/contracts/test/MultiTokenAgentEscrow.t.sol b/contracts/test/MultiTokenAgentEscrow.t.sol new file mode 100644 index 0000000..5e03e8f --- /dev/null +++ b/contracts/test/MultiTokenAgentEscrow.t.sol @@ -0,0 +1,369 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {MultiTokenAgentEscrow} from "../MultiTokenAgentEscrow.sol"; +import {IAgentEscrow} from "../IAgentEscrow.sol"; +import {IOracleAggregator} from "../IOracleAggregator.sol"; +import {MockOracleAggregator} from "../mocks/MockOracleAggregator.sol"; +import {MockERC20} from "../mocks/MockERC20.sol"; +import {MockFeeOnTransferERC20} from "../mocks/MockFeeOnTransferERC20.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +/// @dev Inline Forge cheatcode interface — mirrors AgentEscrowOracle.t.sol so we +/// don't depend on a forge-std submodule being present. +interface Vm { + function deal(address who, uint256 amount) external; + function prank(address who) external; + function startPrank(address who) external; + function stopPrank() external; + function expectRevert(bytes calldata revertData) external; + function expectRevert(bytes4 revertData) external; + function expectRevert() external; + function roll(uint256 newBlock) external; +} + +contract MultiTokenAgentEscrowTest { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + MultiTokenAgentEscrow internal escrow; + MockOracleAggregator internal agg; + MockERC20 internal usdc; + MockFeeOnTransferERC20 internal feeToken; + + address internal constant NATIVE = address(0); + address internal payer = address(0xA11CE); + address internal payee = address(0xB0B); + address internal anyone = address(0xC0DE); + + bytes32 internal constant POLICY = keccak256("policy: deliver report"); + bytes32 internal constant ATTEST = keccak256("attestation: delivered ok"); + + function setUp() public { + agg = new MockOracleAggregator(); + escrow = new MultiTokenAgentEscrow(31337, IOracleAggregator(address(agg))); + + usdc = new MockERC20("USD Coin", "USDC"); + feeToken = new MockFeeOnTransferERC20("Fee Token", "FEE", 100); // 1% fee + + // Owner (this test contract) allowlists the standard + fee tokens. + escrow.setTokenAllowed(address(usdc), true); + escrow.setTokenAllowed(address(feeToken), true); + + vm.deal(payer, 100 ether); + usdc.mint(payer, 1_000_000e18); + feeToken.mint(payer, 1_000_000e18); + } + + // ─── Interface conformance ──────────────────────────────────────────────── + + function test_isIAgentEscrow() public view { + // Must be usable purely through the shared interface. + IAgentEscrow esc = IAgentEscrow(address(escrow)); + esc.getPayment("nope"); + } + + /// @dev ERC-165: the escrow advertises the multi-token A2A interface so + /// off-chain clients can discover it without a trial call. The spec + /// pins the interface id to `0x01dc5a49` (XOR of IAgentEscrow's six + /// external selectors). + function test_supportsInterface() public view { + // The interface id must equal the spec'd constant. + require( + type(IAgentEscrow).interfaceId == bytes4(0x01dc5a49), + "IAgentEscrow interfaceId is 0x01dc5a49" + ); + + // Advertises IAgentEscrow. + require(escrow.supportsInterface(0x01dc5a49), "supports IAgentEscrow"); + require( + escrow.supportsInterface(type(IAgentEscrow).interfaceId), + "supports IAgentEscrow (via type())" + ); + + // Advertises ERC-165 itself (id 0x01ffc9a7). + require(escrow.supportsInterface(0x01ffc9a7), "supports ERC-165"); + + // Does NOT claim unrelated interfaces (e.g. the invalid 0xffffffff or a + // random id). + require(!escrow.supportsInterface(0xffffffff), "rejects invalid id"); + require(!escrow.supportsInterface(0xdeadbeef), "rejects unrelated id"); + } + + // ─── ETH profile parity with AgentEscrow ────────────────────────────────── + + function test_eth_createAndConfirm_parity() public { + vm.startPrank(payer); + escrow.createPayment{value: 1 ether}("eth-1", payee, NATIVE, 1 ether, 100, 10); + escrow.confirmPayment("eth-1"); + vm.stopPrank(); + + require(payee.balance == 1 ether, "payee received 1 ETH"); + IAgentEscrow.Payment memory p = escrow.getPayment("eth-1"); + require(p.token == NATIVE, "token is native"); + require(uint8(p.state) == uint8(IAgentEscrow.State.Released), "released"); + } + + function test_eth_requiresMsgValueEqualsAmount() public { + vm.startPrank(payer); + vm.expectRevert(bytes("ETH: msg.value != amount")); + escrow.createPayment{value: 0.5 ether}("eth-bad", payee, NATIVE, 1 ether, 100, 10); + vm.stopPrank(); + } + + function test_eth_rejectsZeroAmount() public { + vm.startPrank(payer); + vm.expectRevert(bytes("amount must be > 0")); + escrow.createPayment{value: 0}("eth-zero", payee, NATIVE, 0, 100, 10); + vm.stopPrank(); + } + + function test_eth_timeoutRefund() public { + vm.startPrank(payer); + escrow.createPayment{value: 2 ether}("eth-refund", payee, NATIVE, 2 ether, 100, 10); + vm.stopPrank(); + + vm.roll(block.number + 111); // timeout(100) + challenge(10) + 1 + vm.prank(payer); + escrow.requestRefund("eth-refund"); + + require(payer.balance == 100 ether, "payer fully refunded"); + } + + function test_eth_cancel() public { + vm.startPrank(payer); + escrow.createPayment{value: 3 ether}("eth-cancel", payee, NATIVE, 3 ether, 100, 10); + escrow.cancelPayment("eth-cancel"); + vm.stopPrank(); + require(payer.balance == 100 ether, "payer got ETH back on cancel"); + } + + function test_eth_releaseByAttestation() public { + vm.startPrank(payer); + escrow.createPaymentWithPolicy{value: 1 ether}("eth-att", payee, NATIVE, 1 ether, 100, 10, POLICY); + vm.stopPrank(); + + agg.setAccept(POLICY, ATTEST, true); + bytes[] memory sigs = new bytes[](1); + sigs[0] = hex"beef"; + vm.prank(anyone); + escrow.releaseByAttestation("eth-att", ATTEST, sigs); + + require(payee.balance == 1 ether, "payee received via oracle release"); + } + + // ─── ERC-20 happy path ───────────────────────────────────────────────────── + + function test_erc20_createAndConfirm() public { + vm.startPrank(payer); + usdc.approve(address(escrow), 500e18); + escrow.createPayment("usdc-1", payee, address(usdc), 500e18, 100, 10); + + // Credited amount recorded at creation, before release zeroes it out. + IAgentEscrow.Payment memory created = escrow.getPayment("usdc-1"); + require(created.token == address(usdc), "token recorded"); + require(created.amount == 500e18, "credited amount for standard token == declared"); + + escrow.confirmPayment("usdc-1"); + vm.stopPrank(); + + require(usdc.balanceOf(payee) == 500e18, "payee received 500 USDC"); + require(usdc.balanceOf(address(escrow)) == 0, "escrow drained"); + IAgentEscrow.Payment memory released = escrow.getPayment("usdc-1"); + require(uint8(released.state) == uint8(IAgentEscrow.State.Released), "released"); + require(released.amount == 0, "amount zeroed on release"); + } + + function test_erc20_pullsExactlyDeclaredForStandardToken() public { + uint256 before = usdc.balanceOf(payer); + vm.startPrank(payer); + usdc.approve(address(escrow), 500e18); + escrow.createPayment("usdc-pull", payee, address(usdc), 500e18, 100, 10); + vm.stopPrank(); + require(usdc.balanceOf(payer) == before - 500e18, "exactly 500 pulled"); + require(usdc.balanceOf(address(escrow)) == 500e18, "escrow holds 500"); + } + + function test_erc20_mustNotSendETH() public { + vm.startPrank(payer); + usdc.approve(address(escrow), 500e18); + vm.expectRevert(bytes("ERC20: no ETH")); + escrow.createPayment{value: 1 wei}("usdc-eth", payee, address(usdc), 500e18, 100, 10); + vm.stopPrank(); + } + + function test_erc20_timeoutRefund() public { + vm.startPrank(payer); + usdc.approve(address(escrow), 200e18); + escrow.createPayment("usdc-refund", payee, address(usdc), 200e18, 100, 10); + vm.stopPrank(); + + uint256 balBefore = usdc.balanceOf(payer); + vm.roll(block.number + 111); + vm.prank(payer); + escrow.requestRefund("usdc-refund"); + require(usdc.balanceOf(payer) == balBefore + 200e18, "USDC refunded"); + } + + function test_erc20_cancel() public { + vm.startPrank(payer); + usdc.approve(address(escrow), 200e18); + escrow.createPayment("usdc-cancel", payee, address(usdc), 200e18, 100, 10); + uint256 balBefore = usdc.balanceOf(payer); + escrow.cancelPayment("usdc-cancel"); + vm.stopPrank(); + require(usdc.balanceOf(payer) == balBefore + 200e18, "USDC returned on cancel"); + } + + function test_erc20_releaseByAttestation() public { + vm.startPrank(payer); + usdc.approve(address(escrow), 300e18); + escrow.createPaymentWithPolicy("usdc-att", payee, address(usdc), 300e18, 100, 10, POLICY); + vm.stopPrank(); + + agg.setAccept(POLICY, ATTEST, true); + bytes[] memory sigs = new bytes[](1); + sigs[0] = hex"beef"; + vm.prank(anyone); + escrow.releaseByAttestation("usdc-att", ATTEST, sigs); + + require(usdc.balanceOf(payee) == 300e18, "payee received USDC via oracle"); + } + + // ─── Fee-on-transfer via balance-delta accounting ────────────────────────── + + function test_feeOnTransfer_creditsMeasuredDelta_notDeclared() public { + // Declared 1000; 1% fee => escrow actually receives 990. The credited + // amount MUST be the measured 990, not the declared 1000. + vm.startPrank(payer); + feeToken.approve(address(escrow), 1000e18); + escrow.createPayment("fee-1", payee, address(feeToken), 1000e18, 100, 10); + vm.stopPrank(); + + IAgentEscrow.Payment memory p = escrow.getPayment("fee-1"); + require(p.amount == 990e18, "credited = measured balance delta (990), not declared (1000)"); + require(feeToken.balanceOf(address(escrow)) == 990e18, "escrow holds exactly what arrived"); + } + + function test_feeOnTransfer_releaseTransfersHeldAmount_noUnderflow() public { + vm.startPrank(payer); + feeToken.approve(address(escrow), 1000e18); + escrow.createPayment("fee-2", payee, address(feeToken), 1000e18, 100, 10); + escrow.confirmPayment("fee-2"); + vm.stopPrank(); + + // Escrow held 990; transfer out applies another 1% fee => payee gets 980.1. + // Critically: escrow must be fully drained and must not revert on underflow. + require(feeToken.balanceOf(address(escrow)) == 0, "escrow fully drained on release"); + // 990 - 1% = 980.1 + require(feeToken.balanceOf(payee) == 9801e17, "payee received net-of-second-fee"); + } + + function test_feeOnTransfer_refundReturnsHeldAmount() public { + vm.startPrank(payer); + feeToken.approve(address(escrow), 1000e18); + escrow.createPayment("fee-3", payee, address(feeToken), 1000e18, 100, 10); + vm.stopPrank(); + + uint256 balBefore = feeToken.balanceOf(payer); + vm.roll(block.number + 111); + vm.prank(payer); + escrow.requestRefund("fee-3"); + // Escrow held 990, refund transfer applies 1% => payer gets 980.1 back. + require(feeToken.balanceOf(payer) == balBefore + 9801e17, "payer refunded held-net"); + require(feeToken.balanceOf(address(escrow)) == 0, "escrow drained on refund"); + } + + // ─── Allowlist gating ────────────────────────────────────────────────────── + + function test_nonAllowlistedToken_rejected() public { + MockERC20 rando = new MockERC20("Random", "RND"); + rando.mint(payer, 1000e18); + vm.startPrank(payer); + rando.approve(address(escrow), 100e18); + vm.expectRevert(bytes("token not allowlisted")); + escrow.createPayment("rnd-1", payee, address(rando), 100e18, 100, 10); + vm.stopPrank(); + } + + function test_nativeEth_alwaysAllowed_noAllowlistNeeded() public { + // Deploy a fresh escrow with NO allowlist entries; ETH must still work. + MultiTokenAgentEscrow fresh = new MultiTokenAgentEscrow(31337, IOracleAggregator(address(agg))); + vm.deal(payer, 5 ether); + vm.startPrank(payer); + fresh.createPayment{value: 1 ether}("eth-fresh", payee, NATIVE, 1 ether, 100, 10); + fresh.confirmPayment("eth-fresh"); + vm.stopPrank(); + require(payee.balance == 1 ether, "ETH works with empty allowlist"); + } + + function test_setTokenAllowed_onlyOwner() public { + vm.prank(anyone); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, anyone)); + escrow.setTokenAllowed(address(usdc), false); + } + + function test_deallowlist_blocksNewPayments() public { + escrow.setTokenAllowed(address(usdc), false); + vm.startPrank(payer); + usdc.approve(address(escrow), 100e18); + vm.expectRevert(bytes("token not allowlisted")); + escrow.createPayment("usdc-off", payee, address(usdc), 100e18, 100, 10); + vm.stopPrank(); + } + + // ─── Lifecycle guards unchanged ──────────────────────────────────────────── + + function test_duplicateRequestIdRejected() public { + vm.startPrank(payer); + escrow.createPayment{value: 1 ether}("dup", payee, NATIVE, 1 ether, 100, 10); + vm.expectRevert(bytes("requestId already exists")); + escrow.createPayment{value: 1 ether}("dup", payee, NATIVE, 1 ether, 100, 10); + vm.stopPrank(); + } + + function test_onlyPayerCanConfirm() public { + vm.prank(payer); + escrow.createPayment{value: 1 ether}("c1", payee, NATIVE, 1 ether, 100, 10); + vm.prank(anyone); + vm.expectRevert(bytes("Only payer can confirm")); + escrow.confirmPayment("c1"); + } + + function test_confirmAfterTimeoutReverts() public { + vm.prank(payer); + escrow.createPayment{value: 1 ether}("c2", payee, NATIVE, 1 ether, 100, 10); + vm.roll(block.number + 101); + vm.prank(payer); + vm.expectRevert(bytes("Payment has expired")); + escrow.confirmPayment("c2"); + } + + function test_refundBeforeChallengeEndsReverts() public { + vm.prank(payer); + escrow.createPayment{value: 1 ether}("c3", payee, NATIVE, 1 ether, 100, 10); + vm.roll(block.number + 100); // timeout hit but challenge not over + vm.prank(payer); + vm.expectRevert(bytes("Challenge period not over")); + escrow.requestRefund("c3"); + } + + function test_releaseByAttestation_revertsWhenAggregatorRejects() public { + vm.startPrank(payer); + escrow.createPaymentWithPolicy{value: 1 ether}("c4", payee, NATIVE, 1 ether, 100, 10, POLICY); + vm.stopPrank(); + bytes[] memory sigs = new bytes[](0); + vm.prank(anyone); + vm.expectRevert(bytes("Oracle attestation rejected")); + escrow.releaseByAttestation("c4", ATTEST, sigs); + } + + function test_releaseByAttestation_revertsOnNoPolicy() public { + vm.prank(payer); + escrow.createPayment{value: 1 ether}("c5", payee, NATIVE, 1 ether, 100, 10); + agg.setAccept(POLICY, ATTEST, true); + bytes[] memory sigs = new bytes[](0); + vm.prank(anyone); + vm.expectRevert(bytes("No oracle policy on this payment")); + escrow.releaseByAttestation("c5", ATTEST, sigs); + } +} diff --git a/contracts/test/SwapSettlementAdapter.t.sol b/contracts/test/SwapSettlementAdapter.t.sol new file mode 100644 index 0000000..032867f --- /dev/null +++ b/contracts/test/SwapSettlementAdapter.t.sol @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {MultiTokenAgentEscrow} from "../MultiTokenAgentEscrow.sol"; +import {IAgentEscrow} from "../IAgentEscrow.sol"; +import {IOracleAggregator} from "../IOracleAggregator.sol"; +import {SwapSettlementAdapter} from "../SwapSettlementAdapter.sol"; +import {ISwapRouter} from "../ISwapRouter.sol"; +import {IPriceOracle} from "../IPriceOracle.sol"; +import {MockOracleAggregator} from "../mocks/MockOracleAggregator.sol"; +import {MockPriceOracle} from "../mocks/MockPriceOracle.sol"; +import {MockSwapRouter} from "../mocks/MockSwapRouter.sol"; +import {MockERC20} from "../mocks/MockERC20.sol"; + +/// @dev Inline Forge cheatcode interface — mirrors MultiTokenAgentEscrow.t.sol so +/// we don't depend on a forge-std submodule being present. +interface Vm { + function deal(address who, uint256 amount) external; + function prank(address who) external; + function startPrank(address who) external; + function stopPrank() external; + function expectRevert(bytes calldata revertData) external; + function expectRevert(bytes4 revertData) external; + function expectRevert() external; + function roll(uint256 newBlock) external; + function warp(uint256 newTimestamp) external; +} + +/** + * @title SwapSettlementAdapterTest + * @notice Unit ② tests (design spec §3.5 / §5): swap-at-release converts the + * escrowed payer-token into the payee's desired token, bounded by an + * oracle-derived slippage floor. The adapter sits OUTSIDE the escrow + * core — it acts as the escrow `payee`/`payer` for swap-settled + * payments, so the trustless escrow primitive keeps zero DEX/oracle + * attack surface. + * + * Required cases: + * - successful X→Y swap at release (payer USDC -> payee DAI); + * - slippage-exceeded revert leaves the escrow FUNDED (atomic); + * - oracle-stale rejection (frozen price cannot justify a swap). + */ +contract SwapSettlementAdapterTest { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + MultiTokenAgentEscrow internal escrow; + MockOracleAggregator internal agg; + SwapSettlementAdapter internal adapter; + MockPriceOracle internal oracle; + MockSwapRouter internal router; + + MockERC20 internal usdc; // payer token (tokenIn) + MockERC20 internal dai; // payee token (tokenOut) + + address internal payer = address(0xA11CE); + address internal payee = address(0xB0B); + address internal anyone = address(0xC0DE); + + uint256 internal constant MAX_STALENESS = 3600; // 1 hour + uint256 internal constant NOW = 1_700_000_000; + + function setUp() public { + agg = new MockOracleAggregator(); + escrow = new MultiTokenAgentEscrow(31337, IOracleAggregator(address(agg))); + + oracle = new MockPriceOracle(); + router = new MockSwapRouter(); + + adapter = new SwapSettlementAdapter( + IAgentEscrow(address(escrow)), + ISwapRouter(address(router)), + IPriceOracle(address(oracle)), + MAX_STALENESS + ); + + usdc = new MockERC20("USD Coin", "USDC"); + dai = new MockERC20("Dai", "DAI"); + + // The escrow must accept USDC (the token actually held in escrow). + escrow.setTokenAllowed(address(usdc), true); + + // 1 USDC == 1 DAI fair value. + oracle.setRate(address(usdc), address(dai), 1e18, NOW); + // Router can fill 1:1 by default (fair execution). Fund its DAI liquidity. + router.setRate(address(usdc), address(dai), 1e18); + dai.mint(address(router), 1_000_000e18); + + usdc.mint(payer, 1_000_000e18); + vm.warp(NOW); + } + + // ─── Happy path: X→Y swap at release ──────────────────────────────────────── + + function test_settleWithSwap_convertsPayerTokenToPayeeToken() public { + // Payer opens a swap-settled escrow: fund 1000 USDC, payee wants DAI, + // tolerate up to 0.5% slippage. + vm.startPrank(payer); + usdc.approve(address(adapter), 1000e18); + adapter.openSwapEscrow( + "swap-1", + payee, + address(usdc), + 1000e18, + address(dai), + 50, // 0.5% + 100, + 10 + ); + // Escrow now holds the USDC, with the ADAPTER as payee. + IAgentEscrow.Payment memory p = escrow.getPayment("swap-1"); + require(p.token == address(usdc), "escrow holds USDC"); + require(p.amount == 1000e18, "escrow credited 1000 USDC"); + require(p.payee == address(adapter), "adapter is escrow payee"); + require(usdc.balanceOf(address(escrow)) == 1000e18, "escrow funded"); + + // Release + swap. + adapter.settleWithSwap("swap-1", address(dai), 50); + vm.stopPrank(); + + // Payee received DAI (1:1), escrow drained, adapter holds nothing. + require(dai.balanceOf(payee) == 1000e18, "payee received 1000 DAI"); + require(usdc.balanceOf(address(escrow)) == 0, "escrow drained of USDC"); + require(usdc.balanceOf(address(adapter)) == 0, "adapter holds no USDC"); + require(dai.balanceOf(address(adapter)) == 0, "adapter holds no DAI"); + + IAgentEscrow.Payment memory released = escrow.getPayment("swap-1"); + require(uint8(released.state) == uint8(IAgentEscrow.State.Released), "escrow released"); + } + + // ─── Slippage-exceeded revert leaves escrow funded ────────────────────────── + + function test_settleWithSwap_revertsWhenSlippageExceedsBound_escrowStaysFunded() public { + vm.startPrank(payer); + usdc.approve(address(adapter), 1000e18); + adapter.openSwapEscrow( + "swap-slip", + payee, + address(usdc), + 1000e18, + address(dai), + 50, // tolerate only 0.5% + 100, + 10 + ); + vm.stopPrank(); + + // The router fills at 0.98 DAI per USDC (2% realized slippage, over the + // 0.5% bound) AND does not enforce its own minOut floor — modeling a + // broken/malicious router. The ADAPTER'S OWN realized-out re-check must + // still catch it and revert the WHOLE release. + router.setRate(address(usdc), address(dai), 98e16); + router.setEnforceMinOut(false); + + vm.prank(payer); + vm.expectRevert(bytes("slippage exceeds bound")); + adapter.settleWithSwap("swap-slip", address(dai), 50); + + // Escrow stays funded and Locked; payee got nothing. + require(usdc.balanceOf(address(escrow)) == 1000e18, "escrow still funded"); + require(dai.balanceOf(payee) == 0, "payee got nothing"); + IAgentEscrow.Payment memory p = escrow.getPayment("swap-slip"); + require(uint8(p.state) == uint8(IAgentEscrow.State.Locked), "escrow still Locked"); + require(p.amount == 1000e18, "escrow amount intact"); + } + + function test_settleWithSwap_revertsWhenRouterEnforcesFloor_escrowStaysFunded() public { + // Same over-bound fill, but here the router DOES enforce its own floor. + // The revert still leaves the escrow funded and Locked (atomicity holds + // regardless of which guard fires first). + vm.startPrank(payer); + usdc.approve(address(adapter), 1000e18); + adapter.openSwapEscrow("swap-floor", payee, address(usdc), 1000e18, address(dai), 50, 100, 10); + vm.stopPrank(); + + router.setRate(address(usdc), address(dai), 98e16); // 2% slippage + // enforceMinOut defaults true. + + vm.prank(payer); + vm.expectRevert(); // router-side "insufficient output" + adapter.settleWithSwap("swap-floor", address(dai), 50); + + require(usdc.balanceOf(address(escrow)) == 1000e18, "escrow still funded"); + require(dai.balanceOf(payee) == 0, "payee got nothing"); + IAgentEscrow.Payment memory p = escrow.getPayment("swap-floor"); + require(uint8(p.state) == uint8(IAgentEscrow.State.Locked), "escrow still Locked"); + } + + // ─── Oracle-stale rejection ───────────────────────────────────────────────── + + function test_settleWithSwap_revertsWhenOraclePriceStale_escrowStaysFunded() public { + vm.startPrank(payer); + usdc.approve(address(adapter), 1000e18); + adapter.openSwapEscrow( + "swap-stale", + payee, + address(usdc), + 1000e18, + address(dai), + 50, + 100, + 10 + ); + vm.stopPrank(); + + // Advance time so the oracle observation (set at NOW) is older than the + // max staleness window. Even though the router could fill fairly, the + // stale price must not be trusted to bound the swap. + vm.warp(NOW + MAX_STALENESS + 1); + + vm.prank(payer); + vm.expectRevert(bytes("oracle price stale")); + adapter.settleWithSwap("swap-stale", address(dai), 50); + + require(usdc.balanceOf(address(escrow)) == 1000e18, "escrow still funded"); + require(dai.balanceOf(payee) == 0, "payee got nothing"); + IAgentEscrow.Payment memory p = escrow.getPayment("swap-stale"); + require(uint8(p.state) == uint8(IAgentEscrow.State.Locked), "escrow still Locked"); + } + + // ─── Guards ───────────────────────────────────────────────────────────────── + + function test_settleWithSwap_onlyPayerCanSettle() public { + vm.startPrank(payer); + usdc.approve(address(adapter), 1000e18); + adapter.openSwapEscrow("swap-auth", payee, address(usdc), 1000e18, address(dai), 50, 100, 10); + vm.stopPrank(); + + vm.prank(anyone); + vm.expectRevert(bytes("only intent payer")); + adapter.settleWithSwap("swap-auth", address(dai), 50); + } + + function test_settleWithSwap_rejectsMismatchedTokenOut() public { + vm.startPrank(payer); + usdc.approve(address(adapter), 1000e18); + adapter.openSwapEscrow("swap-tok", payee, address(usdc), 1000e18, address(dai), 50, 100, 10); + // Caller passes a different tokenOut than was negotiated at open. + vm.expectRevert(bytes("tokenOut mismatch")); + adapter.settleWithSwap("swap-tok", address(usdc), 50); + vm.stopPrank(); + } + + function test_settleWithSwap_rejectsWeakerSlippageThanNegotiated() public { + // Payee negotiated <=0.5%; caller must not be able to loosen it to 5%. + vm.startPrank(payer); + usdc.approve(address(adapter), 1000e18); + adapter.openSwapEscrow("swap-loose", payee, address(usdc), 1000e18, address(dai), 50, 100, 10); + vm.expectRevert(bytes("slippage bound too loose")); + adapter.settleWithSwap("swap-loose", address(dai), 500); + vm.stopPrank(); + } + + function test_openSwapEscrow_pullsPayerTokenIntoEscrow() public { + uint256 before = usdc.balanceOf(payer); + vm.startPrank(payer); + usdc.approve(address(adapter), 250e18); + adapter.openSwapEscrow("swap-pull", payee, address(usdc), 250e18, address(dai), 50, 100, 10); + vm.stopPrank(); + require(usdc.balanceOf(payer) == before - 250e18, "250 USDC pulled from payer"); + require(usdc.balanceOf(address(escrow)) == 250e18, "escrow holds 250 USDC"); + require(usdc.balanceOf(address(adapter)) == 0, "adapter passes funds straight through to escrow"); + } +} diff --git a/docs/agent-payment-protocol.md b/docs/agent-payment-protocol.md index d67c673..4c8f239 100644 --- a/docs/agent-payment-protocol.md +++ b/docs/agent-payment-protocol.md @@ -1,9 +1,10 @@ # Agent-to-Agent Payment Protocol — switchboard -**Status:** Draft v1.1 +**Status:** Draft v1.2 **Reference impl:** [`src/payment_protocol.py`](../src/payment_protocol.py) **On-chain side:** [`contracts/AgentEscrow.sol`](../contracts/AgentEscrow.sol) **Tracks issue:** [#2 — Add agent-to-agent payment protocol](https://github.com/kcolbchain/switchboard/issues/2) +**Multi-token extension:** [`docs/agent-wallet-multitoken-settlement.md`](./agent-wallet-multitoken-settlement.md) §3.4 --- @@ -23,13 +24,13 @@ Canonical structure (all fields lowercase snake_case): | field | type | required | notes | | --------------------------- | ------- | -------- | ---------------------------------------------------------- | -| `version` | string | yes | Protocol version. Current = `"1.1"`. | +| `version` | string | yes | Protocol version. Current = `"1.2"`. | | `request_id` | string | yes | UUIDv4 chosen by payer. Used as on-chain key. | | `payer` | string | yes | Checksummed EVM address. | | `payee` | string | yes | Checksummed EVM address. | | `amount_wei` | int | yes | Amount in smallest denomination of `currency`. | | `amount_usd` | string | no | Optional USD equivalent at request time, decimal as string.| -| `currency` | string | yes | `"ETH"`, `"USDC"`, `"USDT"`, etc. | +| `currency` | string | yes | `"ETH"`, `"USDC"`, `"USDT"`, etc. — v1.1-compatible alias for the ETH profile; kept for back-compat. | | `chain_id` | int | yes | EIP-155 chain ID. `1` = mainnet, `8453` = Base, etc. | | `timeout_blocks` | int | yes | Blocks after `created_at` before payee can no longer claim.| | `challenge_period_blocks` | int | yes | Blocks after `timeout_blocks` before payer can refund. | @@ -37,6 +38,7 @@ Canonical structure (all fields lowercase snake_case): | `metadata` | object | no | Arbitrary JSON object — protocol-opaque. | | `created_at` | float | yes | Unix epoch seconds, set by payer at request time. | | `status` | string | yes | Local mirror of on-chain state. See §4. | +| `settlement_token` | object | no | **v1.2.** Negotiated settlement token. `null`/absent = ETH profile (v1.1 compat). See §2.3. | | `signature_alg` | string | no | Signature registry name. Default = `"none"`. | | `signature` | string | no | Signature bytes. Base64 in JSON; omitted/empty when unsigned. | @@ -54,10 +56,31 @@ json.dumps(d, sort_keys=True, separators=(',', ':')) content_hash = "0x" + sha256(canonical_json).hexdigest() ``` -`content_hash` is computed over **all fields except `created_at`, `status`, `signature_alg`, and `signature`**. Rationale: `created_at` and `status` are instance-time / mutable; `signature_alg` and `signature` are derived envelope fields and must not self-cover. Two `PaymentRequest` objects representing the same payment intent (same `request_id`, payer, payee, amount, terms, metadata) MUST produce the same `content_hash` regardless of when they were instantiated, what their current local status is, or whether they have already been signed. +`content_hash` is computed over **all fields except `created_at`, `status`, `settlement_token`, `signature_alg`, and `signature`**. Rationale: `created_at` and `status` are instance-time / mutable; `settlement_token` is a negotiated result set after both parties agree (like `status`); `signature_alg` and `signature` are derived envelope fields and must not self-cover. Two `PaymentRequest` objects representing the same payment intent (same `request_id`, payer, payee, amount, terms, metadata) MUST produce the same `content_hash` regardless of when they were instantiated, what their current local status is, what settlement token was negotiated, or whether they have already been signed. For replay protection, agents SHOULD use `request_id` (UUID), not `content_hash`. +### 2.3 Settlement token (v1.2) — `SettlementToken` + +`settlement_token` carries the outcome of multi-token negotiation (see §3.4 of the multi-token settlement design). Its sub-fields: + +| sub-field | type | notes | +| ------------ | ------ | ----------------------------------------------------------------------- | +| `chain_id` | int | EIP-155 chain ID the token lives on. | +| `token` | string | ERC-20 contract address, or the zero address for native ETH. | +| `min_amount` | int | Minimum acceptable amount in the token's smallest denomination. | +| `rank` | int | Preference rank from the advertising party (higher = more preferred). | + +**Negotiation algorithm** (`negotiate_settlement_token(payer_offer, payee_accepts)`): + +1. Intersect payer and payee token lists on `(chain_id, token)`. +2. For each common pair: `combined_rank = payer.rank + payee.rank`. +3. Return the token with the highest combined rank. +4. Tie-break: lexicographically smallest `token` address string (deterministic). +5. No intersection → return `None` (`NoCommonSettlementToken`). + +**Wire back-compat:** when `settlement_token` is `None`/absent the wire payload is byte-for-byte identical to a v1.1 payload. v1.1 parsers MUST ignore unknown fields, so they are safe with a v1.2 payload that carries `settlement_token`. + ## 3. Escrow contract — `AgentEscrow.sol` The `payer` calls `createPayment(request_id, payee, timeout_blocks, challenge_period)` with `msg.value = amount_wei`. Funds are held by the contract until one of: @@ -264,5 +287,11 @@ The following remain intentionally unresolved here so PQ implementation work can ## 12. Version notes - v1.1 is a non-breaking extension of v1.0. +- v1.2 is a non-breaking extension of v1.1: + - Adds `settlement_token` (Optional, defaults absent/null → ETH profile). + - Adds `negotiate_settlement_token()` pure function in `src/payment_protocol.py`. + - `currency` is retained as a v1.1-compatible alias for the ETH profile. + - `content_hash` excludes `settlement_token` (same as `status`) — hashes are stable across negotiation. + - Wire encoding unchanged when `settlement_token` is null/absent: v1.0/v1.1 payloads remain byte-for-byte identical. - Unsigned payloads remain valid by default. - Any future change to transcript construction, canonicalization, or the algorithm registry MUST bump the protocol version. diff --git a/docs/agent-wallet-multitoken-settlement.md b/docs/agent-wallet-multitoken-settlement.md new file mode 100644 index 0000000..9351b19 --- /dev/null +++ b/docs/agent-wallet-multitoken-settlement.md @@ -0,0 +1,222 @@ +# Agent Wallet + Multi-Token Settlement + +**Status:** Design v0.1 +**Authors:** Pattermesh (@Pattermesh), kcolbchain (@kcolbchain) +**Open decision for:** @abhicris (see §3.3) +**Extends:** [`agent-payment-protocol.md`](./agent-payment-protocol.md) (→ v1.2), [`eips/draft-native-eth-a2a-escrow.md`](../eips/draft-native-eth-a2a-escrow.md) +**Orthogonal to:** [`multi-chain-settlement.md`](./multi-chain-settlement.md) (#59) + +--- + +## 1. Goal + +Take switchboard from "agents pay each other in native ETH escrow" to: + +1. **A multi-token settlement standard** — switchboard can settle an agent-to-agent payment in *any* token, chosen by what the payer and payee actually want, with an opt-in swap path when they want different tokens. +2. **A built-in agent wallet** — a wallet agents connect to and are delegated scoped authority over ("take"), that load-balances spend across tokens / rails / a wallet fleet / target holdings, and transacts through switchboard by honoring the settlement standard. + +These are two layers of one system: the wallet is the client brain; the standard is the trustless settlement floor. They meet at a token-negotiation handshake. + +### 1.1 Non-goals + +- Cross-chain messaging — already designed in `multi-chain-settlement.md` (#59). Multi-token is the **orthogonal axis**: this design settles in any token *on a given chain*; the chain×token matrix composes the two designs, it does not re-solve cross-chain. +- Fiat on/off-ramps. +- A new signing scheme — we build on the existing `MPCWallet` (Shamir SSS threshold ECDSA) and `nonce_manager`. + +### 1.2 Relationship to existing pieces + +| Existing | Role here | +|---|---| +| `contracts/AgentEscrow.sol` | Native-ETH escrow; becomes the **ETH profile** of the new ERC (not replaced — see §3.3) | +| `agent-payment-protocol.md` (v1.1) | Already names a `currency` field but the contract can't settle it; §4 closes this gap → **v1.2** | +| `switchboard/mpc_wallet.py` | Signing substrate the `AgentWallet` wraps | +| `switchboard/gas_budget.py`, `gas_manager.py` | Reused as the per-tx / per-day caps inside `SpendPolicy` | +| `switchboard/nonce_manager.py` | Reused by `FleetBalancer` for cross-wallet nonce safety | +| `switchboard/adapters/lucidly.py` | The DEX/liquidity engine behind the swap adapter and `Rebalancer` | +| `contracts/IOracleAggregator.sol` | Price source for cross-token valuation and slippage bounds | + +--- + +## 2. Architecture + +``` +PART 2 — Agent Wallet (Python, agent-facing) + AgentWallet (wraps MPCWallet) + ├─ Treasury balances per (chain, token) + ├─ Delegation session keys + SpendPolicy (grant / revoke) + └─ Router load-balancer, 4 pluggable strategies: + TokenSelector · RailSelector · FleetBalancer · Rebalancer + │ honors the settlement standard ↓ +PART 1 — Settlement Standard (Solidity + ERC + protocol) + Token Negotiation (off-chain, payment_protocol v1.2 / x402 accepts[]) + │ picks one mutually-accepted token + MultiTokenAgentEscrow.sol settles in that ERC-20 (or ETH) + + SwapSettlementAdapter (opt-in) via lucidly + IOracleAggregator + New ERC: "Multi-Token A2A Escrow" (native-ETH EIP = a profile) +``` + +--- + +## 3. Part 1 — The Multi-Token Settlement Standard + +### 3.1 The standard artifact + +A new ERC — **Multi-Token Agent-to-Agent Escrow** — that generalizes the native-ETH EIP. The native-ETH escrow becomes a **profile** (the case where `token == address(0)`), so the already-drafted EIP is subsumed, not invalidated. Deliverables: `eips/draft-multitoken-a2a-escrow.md` + an ethereum-magicians post, mirroring the existing EIP workflow. + +### 3.2 Contract — `MultiTokenAgentEscrow.sol` + +Same lifecycle as today (create → confirm → release / refund / cancel, with challenge period + timeout), parameterized by `address token`: + +- `token == address(0)` → native ETH via `msg.value` (unchanged semantics; the ETH profile). +- ERC-20 → `transferFrom(payer, escrow, amount)` on create (payer approves first); `transfer` on release/refund. +- **Non-standard tokens** (fee-on-transfer, rebasing): credited by measured **balance delta**, not the declared amount; a per-token `allowlist` flag gates whether such tokens are accepted, so the core stays safe by default. +- `Payment` struct gains a `token` field; all events carry `token`. + +### 3.3 ⚖️ OPEN DECISION — for @abhicris + +How to generalize the shipped, EIP-drafted `AgentEscrow.sol`. All three are viable; we want abhicris's input before committing (the native-ETH EIP is co-authored, so this is a shared call): + +| Option | What | Trade-off | +|---|---|---| +| **A** — sibling contract | New `MultiTokenAgentEscrow.sol` beside the untouched ETH escrow, both implementing a shared `IAgentEscrow` interface | Lowest risk to the shipped/EIP'd contract; slight lifecycle duplication | +| **B** — generalize in place | Rewrite `AgentEscrow.sol` to handle ETH (`address(0)`) + ERC-20 in one contract | Less code; but changes the audit surface of an already-EIP'd contract | +| **C** — pluggable settlement modules | Abstract escrow core + `NativeModule` / `ERC20Module` / `SwapModule` | Most extensible; heaviest to audit and reason about | + +The rest of this design is written to be **independent of this choice** — the interface (`IAgentEscrow`) and the protocol/wallet layers are identical regardless of A/B/C. Only the contract file layout and test harness differ. Implementation of §5 units ①–③ waits on this decision; everything else can proceed in parallel. + +### 3.4 Settlement-token negotiation (protocol v1.2) + +Extend `agent-payment-protocol.md` and the x402 `accepts[]` envelope so each party advertises **accepted tokens + a ranked preference**: + +``` +accepts_tokens: [ { chain_id, token, min_amount, rank } … ] // payee side +offer_tokens: [ { chain_id, token, balance_ok, rank } … ] // payer side +``` + +Negotiation is deterministic: intersect accepted sets, pick the highest combined-rank common token. Outcome: + +- **Common token exists** → settle same-token in `MultiTokenAgentEscrow` (core path, no swap). +- **No common token** → either fail cleanly (`NoCommonSettlementToken`) or, if the payer opts in, route through the swap adapter (§3.5). + +`PaymentRequest` v1.2 adds `settlement_token` (the negotiated result) and keeps `currency` as a v1.1-compatible alias for the ETH profile. + +### 3.5 Swap adapter (opt-in, layered — NOT in core) + +`SwapSettlementAdapter` converts payer-token → payee-token **at release** using `lucidly` + `IOracleAggregator`, bounded by a payee-set `max_slippage_bps`. It sits *outside* the escrow core so the standard stays minimal and auditable: the escrow releases the held token to the adapter, the adapter swaps and forwards the payee's token, reverting the whole release if slippage exceeds the bound. + +--- + +## 4. Part 2 — The Built-in Agent Wallet + +`AgentWallet` wraps `MPCWallet` (keeps threshold signing / no single point of failure) and adds four concerns, each an independently testable unit: + +### 4.1 Treasury +Tracks balances per `(chain_id, token)`; answers "what can I spend, in what, where." Read-through to chain state with a cache; the source of truth the Router queries. + +### 4.2 Delegation — session keys + `SpendPolicy` +`grant(agent_id, policy) -> SessionKey` and `revoke(session_key)`. A `SpendPolicy` is: + +- `token_allowlist` — which tokens the agent may spend +- `per_tx_cap`, `daily_cap` — enforced via `gas_budget` / `gas_manager` +- `expires_at` — time-boxed +- `allowed_counterparties` — optional payee allowlist + +The agent signs *within* policy; `AgentWallet` validates every rule **before** co-signing. Revocable + time-boxed = safe for autonomous agents. This is what "an agent takes the wallet" means concretely: it receives a scoped, revocable session key — never the root key. + +### 4.3 Router — the load-balancer +A strategy pipeline; each strategy is pluggable and independently tested: + +| Strategy | Dimension | Decides | +|---|---|---| +| `TokenSelector` | across tokens | which held token to spend (balance / fee / expected slippage) | +| `RailSelector` | across rails | cheapest suitable rail: x402 (micro) / on-chain escrow (trustless/large) / MPP (multi-party) | +| `FleetBalancer` | across wallets | spread spend/nonce over N wallets (nonce contention, rate limits, single-key blast radius) — composes with `nonce_manager` | +| `Rebalancer` | target holdings | keep treasury near a target allocation (e.g. 60% USDC / 30% ETH / 10% native), executed via the swap adapter | + +### 4.4 Transaction path +Agent requests a payment → Router: `TokenSelector` picks source token → `RailSelector` picks rail → `FleetBalancer` picks the signing wallet → drives §3.4 negotiation → executes via escrow / x402 / mpp → `gas_budget` + `nonce_manager` enforced throughout → `SpendPolicy` checked before every signature. + +--- + +## 5. Decomposition for the contribution wave + +Sliced for parallel work with minimal merge conflict. Dependencies noted. + +| # | Unit | Depends on | +|---|---|---| +| ① | `MultiTokenAgentEscrow.sol` (per §3.3 decision) | §3.3 decision | +| ② | `SwapSettlementAdapter.sol` + lucidly/oracle wiring | ① | +| ③ | Foundry suite: ERC-20 / ETH / fee-on-transfer / swap-at-release | ① ② | +| ④ | ERC draft `eips/draft-multitoken-a2a-escrow.md` + magicians post | — | +| ⑤ | `IAgentEscrow` interface + `IOracleAggregator` wiring | — | +| ⑥ | Token negotiation in `payment_protocol.py` (v1.2) | — | +| ⑦ | x402 `accepts[]` multi-token envelope | ⑥ | +| ⑧ | `AgentWallet` + `Treasury` | — | +| ⑨ | Session keys + `SpendPolicy` (reuse gas_budget) | ⑧ | +| ⑩ | `TokenSelector` | ⑧ | +| ⑪ | `RailSelector` | ⑧ | +| ⑫ | `FleetBalancer` (reuse nonce_manager) | ⑧ | +| ⑬ | `Rebalancer` (uses swap adapter) | ⑧ ② | +| ⑭ | Multi-token 2-agent demo + `web/` explorer update | most of the above | +| ⑮ | **MCP server** — exposes wallet + escrow ops as agent tools ("connect your agent" surface) | ⑧ ⑨ | +| ⑯ | **CLI** — `switchboard wallet` / `escrow` commands over the same core | ⑧ | +| ⑰ | **Tool registry + wiring** — register/discover the tools agents may call | ⑮ | +| ⑱ | **Frontend onboarding** — login, connect API key, connect agent, "operate the wallet in 3 steps" | ⑮ ⑯ | +| ⑲ | **Fairness + agent access policy engine** — extends `SpendPolicy` with per-agent access tiers, rate fairness, and contract-compliance rules | ⑨ | +| ⑳ | **Escrow-fulfilment metrics + polling dashboard** — fill rate, timeouts, refunds, latency, ops health | ⑧ + contract events | + +Units ④⑤⑥⑧ have no blockers and can start immediately in parallel. + +## 6. Error handling + +| Failure | Handling | +|---|---| +| No common settlement token | `NoCommonSettlementToken`; fall back to swap adapter only if payer opted in, else abort cleanly | +| Slippage exceeds bound | Adapter reverts the whole release; escrow stays funded; payee may renegotiate or refund | +| `SpendPolicy` violation | Wallet refuses to sign; typed `PolicyViolation` with the offending rule | +| Insufficient balance in chosen token | Router retries next candidate token/rail before failing | +| Swap execution failure | Fall back to same-token settlement if possible, else abort with `SwapFailed` | +| Nonce reorg / gap | Existing `nonce_manager` path; `FleetBalancer` reassigns to a healthy wallet | + +## 7. Testing + +- **Contract (Foundry):** ETH profile parity with current escrow; ERC-20 happy path; fee-on-transfer/rebasing via balance-delta; swap-at-release incl. slippage revert; challenge/timeout/refund unchanged. +- **Protocol (pytest):** negotiation determinism (same inputs → same token), no-common-token path, v1.1→v1.2 back-compat. +- **Wallet (pytest):** each Router strategy in isolation; `SpendPolicy` enforcement (cap/expiry/allowlist); session-key revocation; fleet nonce-safety under contention. +- **Integration:** extend the live 2-agent ETH demo into a **2-agent, 2-token** demo (payer holds USDC, payee wants DAI, settled via the adapter), surfaced in `web/`. + +## 8. Security considerations + +- Session keys are scoped + revocable + expiring; a compromised agent is bounded by its `SpendPolicy`, never holds the root key. +- Swap kept out of escrow core → the trustless primitive has no DEX/oracle attack surface; opt-in only. +- Non-standard tokens gated behind an allowlist + balance-delta accounting to prevent under/over-crediting. +- Oracle used only for slippage bounds, never as the settlement authority (funds move by real transfers, not oracle marks). +- `FleetBalancer` reduces single-key blast radius and nonce-griefing exposure. + +## 9. Open decisions (summary) + +1. **§3.3 contract-generalization strategy (A / B / C)** — routed to @abhicris. Blocks units ①–③ only. +2. Default `max_slippage_bps` for the swap adapter — propose 50 bps (0.5%), payee-overridable. +3. Whether `Rebalancer` ships in the first wave or a follow-up (it depends on ② and is the least agent-facing). + +--- + +## 10. Product & UX layer (units ⑮–⑳) + +The goal of the first wave is a build a person can *see working* and abhicris can review. The agent-facing product surface: + +- **Onboarding / login** — a user signs in, lands on a dashboard. Auth kept simple (email/session or API key); no custody of user keys beyond the MPC model. +- **Connect an API key** — the user pastes an LLM/provider API key so an agent can act; stored per-user, scoped, never logged. +- **Connect an agent** — via the **MCP server** (⑮): the agent gets wallet + escrow tools. "Operate the wallet in 3 steps" onboarding shows the minimal instructions to point any MCP-capable agent at switchboard. +- **Let any agent operate the wallet** — the agent receives a scoped session key (§4.2); every action is bounded by the **fairness + access policy** (⑲) and must **comply with the escrow contract** terms before the wallet co-signs. +- **Fairness & agent access policy** — per-agent access tiers, rate fairness (no single agent starves others), and contract-compliance checks; this is the "how the wallet is transacted" rulebook, layered on `SpendPolicy`. +- **Metrics** — the dashboard polls **escrow fulfilment** (fill rate, time-to-release, timeouts, refunds, challenge rate) and wallet ops (spend by token/rail, policy denials, fleet health). + +Frontend work follows the repo's `web/` conventions and the `frontend-design` skill when built. + +## 11. Delivery & scope boundaries + +- **Review-first:** every unit ships as a **tested PR into `main`**, staged for abhicris review — not blind-merged. Tests must pass before a PR is recommended for merge. +- **Contract (§3.3):** built as **approach A** (sibling `MultiTokenAgentEscrow`, reversible) and opened as a **draft PR** so abhicris reviews concrete code and makes the final A/B/C call. Not merged until he signs off. +- **Solana:** **out of this wave.** Agentic payments on Solana are a genuinely separate, non-EVM design (own program, Ed25519 signing, SPL-token settlement) — a follow-up spec/track, not a flag on the EVM build. +- **Featured tokens:** LUX, ZOO, and other kcolbchain-partner tokens are showcased as first-class options in the multi-token allowlist, demo, and dashboard. diff --git a/docs/hanzo-compatibility.md b/docs/hanzo-compatibility.md new file mode 100644 index 0000000..1fb662b --- /dev/null +++ b/docs/hanzo-compatibility.md @@ -0,0 +1,239 @@ +# Hanzo.ai Compatibility + +How a hanzo.ai agent connects to, pays through, and escrows via Switchboard. + +--- + +## What compatibility means concretely + +Switchboard speaks [x402](https://x402.org) — HTTP 402 Payment Required with a +`PaymentRequirements` envelope advertised in `X-Payment-Required` + `WWW-Authenticate: x402`. + +Hanzo MCP agents use the `fetch` tool (HIP-0300 unified surface, defined in +`hanzoai/mcp:src/tools/unified/fetch.ts`) to make HTTP calls. When that tool +sees a 402 it calls `parsePaymentRequired()`, which reads: + +1. `body.accepts` — a top-level JSON array per the x402.org v2 spec. +2. `headers['www-authenticate']` — the `x402` challenge string. +3. Sends payment retries with an `X-PAYMENT` header carrying base64-encoded JSON. + +**Switchboard's mismatch (fixed):** `X402Server.build_402_response()` puts +payment details under `body.payment_requirements`, not `body.accepts`. The +Hanzo `fetch` tool therefore finds no top-level `accepts` and falls back to +`raw_body` (unhelpful). + +**Fix delivered in `switchboard/adapters/hanzo.py`:** + +- `normalize_402_body(body)` promotes `payment_requirements.accepts` to the + top level, synthesising a single-entry list when the server hasn't configured + multi-token accepts. +- `build_hanzo_402_body(requirements)` builds a response body that is + simultaneously Hanzo-native (top-level `accepts`) and switchboard back-compat + (`payment_requirements` preserved). + +--- + +## How a Hanzo agent connects + +### Step 1 — Create a wallet binding + +```python +from switchboard.adapters.hanzo import HanzoAgentWallet +from switchboard.delegation import SpendPolicy +from datetime import datetime, timezone, timedelta + +USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bda02913" + +hab = HanzoAgentWallet( + hanzo_agent_id="admin/my-bot", # Hanzo IAM identity (owner/name) + policy=SpendPolicy( + expires_at=datetime.now(timezone.utc) + timedelta(hours=8), + token_allowlist=[USDC_BASE], # only USDC on Base + per_tx_cap=50_000_000, # 50 USDC / tx + daily_cap=500_000_000, # 500 USDC / day + ), +) +``` + +`HanzoAgentWallet.__post_init__` does three things: + +1. Creates an `AgentWallet` (wraps `MPCWallet` + `Treasury` + `EscrowClient`). +2. Wraps it in a `Delegation` layer. +3. Issues a scoped, revocable `SessionKey` via `Delegation.grant()`. + +The `hanzo_agent_id` string (`"admin/my-bot"`) flows through as `agent_id` in +every `WalletOpEvent` emitted to the metrics / fairness engine. + +### Step 2 — Fund the treasury + +```python +hab.credit(chain_id=8453, token=USDC_BASE, amount=1_000_000_000) # 1000 USDC +``` + +In production, treasury credits come from the on-chain deposit flow. The +`credit()` helper is a test / top-up convenience. + +### Step 3 — Pay + +```python +receipt = hab.pay( + chain_id=8453, + token=USDC_BASE, + amount=10_000_000, # 10 USDC + payee="0xServiceProvider", +) +print(receipt.tx_id, receipt.escrow_id) +``` + +The call path: + +``` +HanzoAgentWallet.pay() + └─ Delegation.pay_with_key(session_key, request) + ├─ SpendPolicy checks (revoked? expired? token? per_tx_cap? daily_cap?) + └─ AgentWallet.pay(request, agent_id=hanzo_agent_id) + ├─ AccessPolicy gate (if wired) + ├─ Router (if wired → token / rail / wallet selection) + ├─ Treasury debit + ├─ MPCWallet.sign_and_send() + └─ EscrowClient.create_payment() + release_payment() +``` + +### Step 4 — Escrow + +```python +receipt = hab.escrow( + chain_id=8453, + token=USDC_BASE, + amount=20_000_000, # 20 USDC locked + payee="0xTaskRunner", + metadata={"task_id": "t-xyz"}, +) +``` + +`escrow()` is a thin wrapper over `pay()` that adds `{"action": "escrow"}` to +the metadata, allowing Router and access-policy engines to distinguish escrow +flows from direct transfers. + +--- + +## How the Hanzo fetch tool pays through Switchboard + +When a Hanzo agent's `fetch` tool hits a Switchboard-protected endpoint: + +``` +Agent Hanzo fetch tool Switchboard server + | | | + |─── fetch(action="request", url=...) ──────────────>| + | | | + | |<── 402 + X-Payment-Required | + | | + WWW-Authenticate: x402 | + | | body: {payment_requirements:{...}} | + | | | + | parsePaymentRequired() | + | → normalize_402_body() ← adapter fixes body.accepts + | → body.accepts found, payment_required surfaced | + | | | + | Agent inspects payment_required, decides to pay | + | hab.pay(chain_id, token, amount, payee) | + | receipt = {tx_id, escrow_id, ...} | + | | | + | encode_hanzo_payment_header({txHash, chainId, payer, amount, nonce}) + | | | + |─── fetch(action="request", payment=) ───>| + | | X-PAYMENT: | + | | | + | | PaymentVerifier.verify() | + | |<── 200 OK | +``` + +### Headers at each hop + +| Direction | Header | Value | +|-----------|--------|-------| +| Server → Client (402) | `X-Payment-Required` | JSON `PaymentRequirements` | +| Server → Client (402) | `WWW-Authenticate` | `x402` | +| Client → Server (retry) | `X-PAYMENT` | base64(JSON payment payload) | +| Client → Server (retry) | `X-Payment-Proof` | JSON (switchboard legacy, also accepted) | + +Switchboard's `X402Server.read_payment_header()` accepts both `X-PAYMENT` and +`X-Payment-Proof`, so legacy switchboard clients continue to work alongside +Hanzo agents. + +--- + +## Caveats + +### EscrowClient is a stub in this worktree + +`AgentWallet` uses `_NoOpEscrow` by default — `create_payment()` returns +`"0xnoop"` and `release_payment()` always returns `True`. Wire the real +`MultiTokenAgentEscrow` client (Unit ① / ③) when it lands: + +```python +from switchboard.agent_wallet import AgentWallet +wallet = AgentWallet(mpc=mpc, treasury=treasury, escrow=real_escrow_client) +hab = HanzoAgentWallet(hanzo_agent_id="admin/my-bot", wallet=wallet) +``` + +### Router not wired by default + +`HanzoAgentWallet` does not wire a `Router` by default. To enable +multi-rail routing and `WalletOpEvent` emission from the Router layer, pass a +pre-configured `AgentWallet(router=...)`: + +```python +from switchboard.router.router import Router +router = Router(...) +wallet = AgentWallet(mpc=mpc, treasury=treasury, router=router) +hab = HanzoAgentWallet(hanzo_agent_id="admin/my-bot", wallet=wallet) +``` + +### SessionKey expiry + +A `SessionKey` is scoped to the `SpendPolicy.expires_at` datetime. Create a +new `HanzoAgentWallet` to refresh a key (grants a new `SessionKey`): + +```python +hab = HanzoAgentWallet( + hanzo_agent_id="admin/my-bot", + policy=SpendPolicy(expires_at=datetime.now(timezone.utc) + timedelta(hours=8)), +) +``` + +### Body normalization is server-side + +`normalize_402_body()` is middleware for servers that know their callers are +Hanzo agents. If you control the server, prefer `build_hanzo_402_body()` to +emit a Hanzo-native response directly. If you don't control the server, call +`normalize_402_body()` on the client side after receiving the 402 body. + +### x402 payment header encoding + +Hanzo's `fetch` tool encodes the payment payload as +`base64(JSON.stringify(payload))` — the same encoding as the x402.org v2 +spec's `X-PAYMENT` header. Use `encode_hanzo_payment_header()` / +`decode_hanzo_payment_header()` for interop with Hanzo agents. + +--- + +## Module reference + +`switchboard/adapters/hanzo.py`: + +| Symbol | Purpose | +|--------|---------| +| `normalize_402_body(body)` | Promote `payment_requirements.accepts` to top level for Hanzo fetch tool compatibility | +| `build_hanzo_402_body(requirements)` | Build a 402 body that is simultaneously Hanzo-native and switchboard back-compat | +| `encode_hanzo_payment_header(payload)` | Encode a dict as base64 JSON for the `X-PAYMENT` header | +| `decode_hanzo_payment_header(value)` | Decode an `X-PAYMENT` header into a dict | +| `read_payment_header(headers)` | Find the best payment header (`X-PAYMENT` > `X-Payment` > `X-Payment-Proof`) | +| `payment_requirements_from_hanzo_accepts(accepts)` | Convert Hanzo `accepts[]` to switchboard `PaymentRequirements` | +| `HanzoAgentWallet` | Bind a Hanzo IAM identity to a switchboard wallet + session key | + +--- + +## Acknowledgments + +Access to the `pattermesh`, `lux`, `hanzo`, and `zoo` org repositories that +made this integration possible was provided by **@zeekay**. \ No newline at end of file diff --git a/docs/superpowers/plans/2026-07-01-agent-wallet-multitoken-settlement-plan.md b/docs/superpowers/plans/2026-07-01-agent-wallet-multitoken-settlement-plan.md new file mode 100644 index 0000000..c11a165 --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-agent-wallet-multitoken-settlement-plan.md @@ -0,0 +1,144 @@ +# Agent Wallet + Multi-Token Settlement — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: use superpowers:test-driven-development for every unit. Each unit is implemented test-first (write failing test → run → implement minimal → run → commit). Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Build switchboard's multi-token settlement standard + a built-in agent wallet (session-key delegation, load-balancing router, fairness/access policy) + agent-facing surfaces (MCP, CLI, frontend, metrics), staged as tested PRs for @abhicris review. + +**Architecture:** Two layers meeting at a token-negotiation handshake — a Solidity settlement floor (sibling `MultiTokenAgentEscrow`, approach A) and a Python agent wallet wrapping the existing `MPCWallet`, exposed to agents via MCP/CLI/web. See spec: `docs/agent-wallet-multitoken-settlement.md`. + +**Tech Stack:** Solidity + Foundry; Python 3.11+ (pytest); existing `switchboard/` modules (`mpc_wallet`, `gas_budget`, `nonce_manager`, `adapters/lucidly`, `x402`, `mpp`); `web/` frontend conventions; MCP over stdio. + +## Global Constraints + +- Python **3.11+**; all new Python is typed and pytest-tested. +- Solidity contracts under `contracts/`, tested with **Foundry**; do **not** modify the shipped `AgentEscrow.sol` (approach A = new sibling file). +- **TDD mandatory** — no implementation code without a failing test first. +- Reuse existing modules; **DRY/YAGNI** — do not reinvent gas budgeting, nonce management, or DEX/liquidity (use `lucidly`). +- Commit identity: `Pattermesh `. Frequent, small commits. +- Branch: `pattermesh/agent-wallet-multitoken-settlement`. Every unit → its own PR into `main`; **contract units (①②③) merge only after @abhicris signs off §3.3**. +- Featured partner tokens in allowlists/demos/dashboard: **LUX, ZOO** + other kcolbchain partners. +- Solana is **out of scope** (separate non-EVM track). + +**Plan format note:** at 20 units across 4 languages this plan is specified at **unit granularity** — each unit lists exact files, interface signatures, the test list, and acceptance criteria. The implementing subagent performs the per-step TDD cycle (test→fail→impl→pass→commit) using the test-driven-development skill. This is the shared contract that keeps parallel work mergeable. + +--- + +## Wave 1 — Settlement foundation (build first; everything depends on it) + +### Unit ⑤ — `IAgentEscrow` interface + oracle wiring +- **Files:** Create `contracts/IAgentEscrow.sol`; Modify `contracts/IOracleAggregator.sol` (confirm price-quote signature). +- **Produces:** `interface IAgentEscrow { function createPayment(...) ; confirmPayment ; releaseByAttestation ; requestRefund ; cancelPayment ; getPayment ; }` with a `token` field in `Payment`. `IOracleAggregator.quote(tokenIn, tokenOut, amountIn) returns (amountOut, staleness)`. +- **Tests (Foundry):** interface compiles; a mock implementing it satisfies the ABI the Python client expects. +- **Acceptance:** both existing `AgentEscrow` (ETH) and the new multi-token contract can declare `is IAgentEscrow`. + +### Unit ① — `MultiTokenAgentEscrow.sol` (approach A, sibling) +- **Files:** Create `contracts/MultiTokenAgentEscrow.sol`; Test `contracts/test/MultiTokenAgentEscrow.t.sol`. +- **Consumes:** `IAgentEscrow`. +- **Produces:** escrow lifecycle parameterized by `address token` (`address(0)` = ETH via `msg.value`; ERC-20 via `transferFrom`/`transfer`); balance-delta accounting for fee-on-transfer tokens; per-token `allowlist` flag; all events carry `token`. +- **Tests:** ETH-profile parity with `AgentEscrow`; ERC-20 happy path; fee-on-transfer via balance delta; timeout/refund/challenge/cancel unchanged; non-allowlisted token rejected. +- **Acceptance:** full lifecycle green for ETH + a standard ERC-20 + a fee-on-transfer mock. + +### Unit ② — `SwapSettlementAdapter.sol` (opt-in) +- **Files:** Create `contracts/SwapSettlementAdapter.sol`; Test `contracts/test/SwapSettlementAdapter.t.sol`. +- **Consumes:** `IAgentEscrow`, `IOracleAggregator`, lucidly/DEX router interface. +- **Produces:** `settleWithSwap(requestId, tokenOut, maxSlippageBps)` — pulls the held token from escrow on release, swaps → `tokenOut`, forwards to payee; reverts the whole release if realized slippage > `maxSlippageBps`. +- **Tests:** successful X→Y swap-at-release; slippage-exceeded revert leaves escrow funded; oracle-stale rejection. +- **Acceptance:** payer-USDC → payee-DAI settles within bound; out-of-bound reverts atomically. + +### Unit ③ — Foundry test suite hardening + CI +- **Files:** `contracts/test/*`; Modify `.github/workflows/*` (Foundry job matrix incl. new contracts); mocks under `contracts/mocks/`. +- **Acceptance:** `forge test` green; CI runs the multi-token + swap suites. + +### Unit ⑥ — Token negotiation in `payment_protocol.py` (v1.2) +- **Files:** Modify `src/payment_protocol.py`; Test `tests/test_payment_protocol_negotiation.py`; update `docs/agent-payment-protocol.md` → v1.2. +- **Produces:** `negotiate_settlement_token(payer_offer, payee_accepts) -> SettlementToken | None`; `PaymentRequest.settlement_token`; `currency` retained as ETH-profile alias. +- **Tests:** deterministic pick (same inputs→same token); no-common-token → `None`; v1.1 payloads still parse. +- **Acceptance:** negotiation is pure/deterministic and back-compatible. + +### Unit ⑦ — x402 `accepts[]` multi-token envelope +- **Files:** Modify `switchboard/x402/server.py`, `switchboard/x402_middleware.py`; Test `tests/test_x402_multitoken.py`. +- **Consumes:** ⑥. +- **Produces:** `accepts[]` entries gain `{chain_id, token, min_amount, rank}`; middleware advertises accepted tokens and validates the negotiated `settlement_token`. +- **Acceptance:** a 402 response lists multiple accepted tokens; a payment in a non-accepted token is rejected. + +### Unit ④ — ERC draft + magicians post +- **Files:** Create `eips/draft-multitoken-a2a-escrow.md`, `eips/magicians-post-multitoken.md`. +- **Acceptance:** draft passes EIP frontmatter lint; cites native-ETH EIP as a profile; no code dependency (can start immediately). + +--- + +## Wave 2 — Agent wallet core (depends on Wave 1 interfaces ⑤⑥) + +### Unit ⑧ — `AgentWallet` + `Treasury` +- **Files:** Create `switchboard/agent_wallet.py`, `switchboard/treasury.py`; Tests `tests/test_agent_wallet.py`, `tests/test_treasury.py`. +- **Consumes:** `MPCWallet`. +- **Produces:** `AgentWallet(mpc: MPCWallet)`; `Treasury.balance(chain_id, token)`, `.spendable(...)`, `.credit/debit`; `AgentWallet.pay(request) -> receipt` entrypoint (router-driven). +- **Tests:** balance tracking per (chain,token); spendable respects reserves; `pay` routes through the Router. +- **Acceptance:** wallet reports multi-token balances and executes a mocked same-token payment end to end. + +### Unit ⑨ — Session keys + `SpendPolicy` +- **Files:** Create `switchboard/delegation.py`; Test `tests/test_delegation.py`. +- **Consumes:** ⑧, `gas_budget`/`gas_manager`. +- **Produces:** `grant(agent_id, SpendPolicy) -> SessionKey`; `revoke(session_key)`; `SpendPolicy(token_allowlist, per_tx_cap, daily_cap, expires_at, allowed_counterparties)`; wallet enforces policy before co-signing. +- **Tests:** cap/expiry/allowlist/counterparty enforcement; revocation blocks further signing; daily cap via gas_budget. +- **Acceptance:** an over-cap or expired session key cannot spend. + +### Unit ⑲ — Fairness + agent access policy engine +- **Files:** Create `switchboard/access_policy.py`; Test `tests/test_access_policy.py`. +- **Consumes:** ⑨. +- **Produces:** per-agent access **tiers**, **rate-fairness** (token-bucket so one agent can't starve others), and **contract-compliance** checks (refuse actions that would violate escrow terms). `check(agent_id, action) -> Decision`. +- **Tests:** fairness under contention (N agents, bounded shares); tier limits; compliance refusal on an invalid escrow action. +- **Acceptance:** concurrent agents get fair, bounded access; non-compliant actions are refused with a typed reason. + +### Unit ⑩ — `TokenSelector` · Unit ⑪ — `RailSelector` · Unit ⑫ — `FleetBalancer` · Unit ⑬ — `Rebalancer` +- **Files:** Create `switchboard/router/` (`__init__.py`, `token_selector.py`, `rail_selector.py`, `fleet_balancer.py`, `rebalancer.py`); Tests `tests/router/test_*.py`. +- **Consumes:** ⑧ (all); `nonce_manager` (⑫); swap adapter ② (⑬). +- **Produces:** `Router.route(request) -> Plan(token, rail, wallet)` composing four pluggable strategies with the interfaces `select_token`, `select_rail`, `select_wallet`, `rebalance_targets`. +- **Tests:** each strategy in isolation — token by balance/fee/slippage; rail by amount (x402/escrow/mpp); fleet spreads nonces without collision; rebalancer moves toward target allocation. +- **Acceptance:** given a treasury + request, Router returns a valid Plan; strategies independently unit-tested. + +--- + +## Wave 3 — Agent surfaces & product (depends on Waves 1–2) + +### Unit ⑮ — MCP server (connect-your-agent surface) +- **Files:** Create `switchboard/mcp_server.py`; Test `tests/test_mcp_server.py`. +- **Consumes:** ⑧⑨⑲. +- **Produces:** MCP tools over stdio: `wallet_balance`, `create_escrow`, `confirm_payment`, `request_refund`, `pay`, `policy_status`, `escrow_metrics`. Each maps to `AgentWallet`/escrow, gated by session key + access policy. +- **Tests:** each tool round-trips against a mocked wallet; policy-denied calls return structured errors. +- **Acceptance:** an MCP client can list tools and execute a full escrow payment within policy. + +### Unit ⑯ — CLI +- **Files:** Create `switchboard/cli.py`; Test `tests/test_cli.py`; register console-script in `pyproject.toml`. +- **Produces:** `switchboard wallet balance|grant|revoke`, `switchboard escrow create|confirm|refund|status`, `switchboard metrics`. +- **Acceptance:** CLI drives the same core as MCP; `--help` documents every command; smoke tests green. + +### Unit ⑰ — Tool registry + wiring +- **Files:** Modify `switchboard/registry.json`; Create `switchboard/tools.py`; Test `tests/test_tools_registry.py`. +- **Produces:** a registry agents query to discover callable tools + their policies; single source of truth shared by MCP and CLI. +- **Acceptance:** registry lists tools with schemas; MCP/CLI both read from it (DRY). + +### Unit ⑱ — Frontend onboarding +- **Files:** `web/` per existing conventions (login view, connect-API-key view, connect-agent view, 3-step "operate the wallet" walkthrough). Build with the `frontend-design` skill. +- **Consumes:** ⑮⑯⑳. +- **Produces:** login/session; paste+store API key (scoped, never logged); connect-agent flow showing the MCP endpoint + minimal instructions; links into the dashboard. +- **Acceptance:** a user can sign in, connect a key, connect an agent, and reach the metrics dashboard; responsive; matches `web/` style. + +### Unit ⑳ — Escrow-fulfilment metrics + polling dashboard +- **Files:** Create `switchboard/metrics.py`; `web/` dashboard panel; Tests `tests/test_metrics.py`. +- **Consumes:** ⑧ + contract events. +- **Produces:** polling of escrow fulfilment (fill rate, time-to-release, timeout rate, refund rate, challenge rate) + wallet ops (spend by token/rail, policy denials, fleet health); a live dashboard panel in `web/`. +- **Acceptance:** metrics computed from event/state fixtures; dashboard renders the panels and refreshes. + +### Unit ⑭ — Multi-token 2-agent demo + explorer update +- **Files:** `examples/` (extend the live 2-agent ETH demo → 2-token), `web/` explorer. +- **Acceptance:** watchable demo: payer holds USDC, payee wants DAI (or LUX/ZOO), settled via the adapter; explorer shows the multi-token flow. + +--- + +## Self-Review + +- **Spec coverage:** every spec §3–§4 + §10 unit maps to a plan unit (①–⑳). ✓ +- **Placeholders:** none — each unit has files, interface, tests, acceptance. +- **Type consistency:** `IAgentEscrow`, `SettlementToken`, `SpendPolicy`, `SessionKey`, `Router.route→Plan(token, rail, wallet)` used consistently across units. +- **Open dependency:** contract units ①②③ carry the §3.3 approach-A provisional and merge only after abhicris signs off; all other units are independent of that decision. diff --git a/docs/thinking-chains-and-switchboard.md b/docs/thinking-chains-and-switchboard.md new file mode 100644 index 0000000..c248c56 --- /dev/null +++ b/docs/thinking-chains-and-switchboard.md @@ -0,0 +1,67 @@ +# Thinking Chains & Intelligent Financial Systems on switchboard + +**Status:** Context / positioning +**Companion code:** [`switchboard/thinking_chain.py`](../switchboard/thinking_chain.py), [`switchboard/adapters/hanzo.py`](../switchboard/adapters/hanzo.py) + +--- + +## Thesis + +Autonomous AI increasingly *reasons its way to financial decisions*. A model deciding to hire another agent, buy an API call, post a bond, or rebalance a treasury is running a **thinking chain** — a multi-step reasoning trace where some steps are not thoughts but **money movements**. The moment a thinking chain touches money, it needs a settlement substrate built for machine reasoning, not for humans clicking "confirm." + +That substrate is switchboard. + +## What a thinking chain is + +A thinking chain is an ordered, inspectable sequence of typed steps an agent walks to reach and execute a decision: + +``` +assess-task → negotiate-settlement-token → policy/fairness-check + → create-escrow → verify-work → release-or-refund +``` + +Some steps are pure reasoning; others are **financial actions**. Each step records its input, its reasoning, and its outcome, so the whole chain is auditable and replayable. In `switchboard/thinking_chain.py` this is the `ThinkingChain` runner: financial steps call the real primitives (`negotiate_settlement_token`, `AgentWallet.pay` via the `Router`, `access_policy.check`) and emit `metrics.WalletOpEvent`s, so a reasoning trace and a settlement trace are the *same* trace. + +## Why generic payment rails fail thinking chains + +An LLM thinking chain that pays with a raw private key + an RPC endpoint is one runaway loop away from ruin, and one ambiguous counterparty away from theft. Generic rails miss five things a reasoning agent needs: + +| Thinking-chain need | Generic rail | switchboard | +|---|---|---| +| **Bounded risk** — a wrong thought can't drain the wallet | none | session keys + `SpendPolicy` (per-tx / daily caps, allowlists, expiry) | +| **Fairness** — one agent can't starve a fleet | none | per-agent token-bucket in `access_policy` | +| **Trustless settlement** — pay only if work is accepted | manual escrow | `MultiTokenAgentEscrow` (timeout, challenge, refund) | +| **Token choice** — pay in what each side actually holds/wants | single asset | settlement-token negotiation + opt-in swap adapter | +| **Observability** — every financial step is measurable | logs, maybe | `metrics` (fill rate, time-to-release, denials, fleet health) | + +A thinking chain without these is a demo. A thinking chain *with* them is a system you can let run unattended. + +## The escrow thinking-chain pattern + +The canonical financial thinking chain is escrow-mediated hiring: + +1. **assess** — is this task worth paying for, and how much? +2. **negotiate** — pick a settlement token both sides accept (`negotiate_settlement_token`); if none, opt into the swap adapter. +3. **check** — `access_policy.check` gates the action against tier, fairness, `SpendPolicy`, and contract compliance *before* any signature. +4. **escrow** — lock funds in `MultiTokenAgentEscrow` (ETH profile or any allowlisted ERC-20). +5. **verify** — inspect the delivered work; this is a *reasoning* step feeding a financial one. +6. **release or refund** — settle on the provenance the chain recorded. + +Every step is a record. The chain is the receipt. + +## Intelligent financial systems need this + +Scale the single chain to a population and you get **intelligent financial systems**: agent-to-agent markets, autonomous treasuries, multi-hop service economies where agents subcontract agents. Those systems live or die on properties switchboard provides natively: + +- **Auditability** — reasoning + settlement in one trace; disputes are replayable. +- **Bounded, revocable authority** — an intelligent system delegates spend to sub-agents via session keys it can revoke the instant a chain misbehaves. +- **Fairness under contention** — shared rails that no single agent can monopolize. +- **Composability of rails** — a chain routes each payment over the cheapest suitable rail (x402 for micro, escrow for trustless, MPP for multi-party) without re-plumbing. + +## Hanzo agents on switchboard + +Hanzo AI agents (via the Hanzo MCP `fetch` tool and the `switchboard/adapters/hanzo.py` binding) can run their thinking chains directly on switchboard: a Hanzo agent identity maps to a scoped switchboard `AgentWallet` + `SessionKey`, its paid `fetch` calls settle through the x402 middleware, and its higher-order decisions run as escrow thinking chains. `HanzoEscrowThinkingChain` is the worked example. + +## Acknowledgments + +Cross-org access (pattermesh / lux / hanzo / zoo) that made this integration possible was provided by **@zeekay**. diff --git a/eips/draft-multitoken-a2a-escrow.md b/eips/draft-multitoken-a2a-escrow.md new file mode 100644 index 0000000..8ffba24 --- /dev/null +++ b/eips/draft-multitoken-a2a-escrow.md @@ -0,0 +1,464 @@ +--- +eip: +title: Multi-Token Agent-to-Agent Escrow +description: A token-agnostic escrow primitive for autonomous agent-to-agent payments, settling in native ETH or any ERC-20, with timeout, challenge period, and optional oracle release. +author: Abhishek Krishna (@abhicris), Pattermesh (@Pattermesh), kcolbchain (@kcolbchain) +discussions-to: +status: Draft +type: Standards Track +category: ERC +created: 2026-07-02 +requires: 20, 165 +--- + + + + +## Abstract + +This standard defines a token-agnostic escrow contract interface for autonomous agent-to-agent (A2A) payments. A compliant contract accepts either native ETH (via `msg.value`) or any owner-allowlisted ERC-20 token on `createPayment`, holds the funds under a string-keyed mapping, and resolves on one of four terminal transitions: `confirmPayment` by the payer, `requestRefund` by the payer after a timeout plus challenge period, `cancelPayment` by the payer while still locked, or `releaseByAttestation` by any party holding a valid oracle attestation keyed by an attached policy hash. + +The settlement asset is selected at payment creation via an `address token` parameter: `address(0)` denotes the **native-ETH profile**, in which case the contract behaves identically to the primitive specified in the companion native-ETH ERC draft (see [Relationship to the Native-ETH ERC](#relationship-to-the-native-eth-erc)). Any other address selects an ERC-20 settlement asset, pulled via `transferFrom` at creation and disbursed via `transfer` at release, with fee-on-transfer safety guaranteed by balance-delta accounting. + +## Motivation + +The native-ETH A2A escrow standard resolves three defects common to existing on-chain agent-payment primitives (token-binding, off-chain operator coupling, and per-chain non-portability). It does so by restricting settlement to native ETH: the only asset universally available on every EVM-compatible chain without a token contract. + +Production agent deployments, however, routinely hold and transact in ERC-20 stablecoins (USDC, DAI, USDT) or chain-specific tokens and prefer to settle in those assets. Forcing settlement into ETH imposes conversion cost, slippage, and cross-chain bridge risk. Two concrete failure modes motivate this generalization: + +1. **Stablecoin-denominated payees.** A payee quoting in USDC terms cannot accept ETH settlement without real-time price discovery and slippage risk. Wrapping the ETH escrow in a DEX call moves that risk inside the settlement path rather than outside it. +2. **Chain-specific token ecosystems.** On chains with their own native tokens as the primary liquidity asset (e.g. LUX on the Lux network), an ETH-only escrow requires an additional bridge step before the payment reaches the chain, eliminating the portability advantage. + +This standard generalizes the native-ETH primitive to any settlement asset while keeping every design invariant of the base standard: single-transaction funding (one `approve` for ERC-20, no second round-trip beyond the payer's existing approval), no off-chain operator, portable bytecode, and a deterministic payer-first refund policy. + +The native-ETH escrow is not superseded. It becomes the **ETH profile** of this standard — the case `token == address(0)` — so implementations of the native-ETH draft that add a `token` field to their `Payment` struct and require `token == address(0)` satisfy both standards simultaneously. + +## Specification + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174. + +### Profiles + +This standard defines two profiles of the same interface: + +| Profile | `token` value | Funding mechanism | Release mechanism | +|---|---|---|---| +| **ETH profile** | `address(0)` | `msg.value` on `createPayment` | Low-level `.call{value:}` | +| **ERC-20 profile** | ERC-20 contract address | `IERC20.transferFrom(payer, escrow, amount)` on `createPayment` | `IERC20.transfer(recipient, amount)` | + +All lifecycle semantics, state machine transitions, timeout/challenge-period logic, event requirements, and error conditions are identical across both profiles. A compliant implementation MUST support the ETH profile. It MAY restrict the ERC-20 profile to an owner-managed allowlist. + +### State machine + +A compliant payment moves through exactly one of these paths: + +``` + createPayment(token, amount, …) + [ETH profile: also msg.value == amount] + │ + ▼ + ┌─────────┐ + │ Locked │ + └────┬────┘ + │ + ┌─────────────────┼──────────────────────┐ + │ │ │ + │ block.number < createdAt │ + │ + timeoutBlocks │ + │ │ block.number ≥ createdAt + │ policyHash │ + timeoutBlocks + │ != 0x00 │ + challengePeriod │ + │ │ │ + ▼ │ ▼ + releaseByAttestation() │ requestRefund() + │ │ │ + ▼ ┌───────┴────────┐ ▼ + ┌──────────┐ │ confirmPayment │ ┌──────────┐ + │ Released │◄───│ cancelPayment │ │ Refunded │ + └──────────┘ └───────┬────────┘ └──────────┘ + │ + cancelPayment() + │ + ▼ + ┌──────────┐ + │Cancelled │ + └──────────┘ +``` + +All terminal states (`Released`, `Refunded`, `Cancelled`) are absorbing. No transition out. + +### Required interface + +A compliant contract MUST implement `IAgentEscrow`: + +```solidity +interface IAgentEscrow { + enum State { + Created, + Locked, + Confirmed, + Released, + Refunded, + Cancelled + } + + struct Payment { + address payer; + address payee; + address token; // address(0) = ETH profile; else the ERC-20 escrowed + uint256 amount; // credited amount held in escrow + uint256 timeoutBlocks; // blocks until auto-expire + uint256 challengePeriod;// blocks payer must additionally wait after timeout to reclaim + State state; + string requestId; // off-chain payment request ID + uint256 createdAt; // block number at creation + bytes32 policyHash; // 0x00 = payer-only release; non-zero enables oracle release + } + + // ─── Events ───────────────────────────────────────────────────────────── + + event PaymentCreated( + string indexed requestId, + address indexed payer, + address indexed payee, + address token, + uint256 amount + ); + event PaymentLocked(string indexed requestId, address token); + event PaymentConfirmed(string indexed requestId, address indexed payer, address token); + event PaymentReleased( + string indexed requestId, + address indexed payee, + address token, + uint256 amount + ); + event PaymentReleasedByOracle( + string indexed requestId, + bytes32 policyHash, + bytes32 attestationHash + ); + event PaymentRefunded( + string indexed requestId, + address indexed payer, + address token, + uint256 amount + ); + event PaymentCancelled( + string indexed requestId, + address indexed payer, + address token, + uint256 amount + ); + + // ─── Lifecycle ─────────────────────────────────────────────────────────── + + /// @notice Create a payment and lock funds. + /// @param requestId Off-chain payment request ID. MUST be unique per contract instance. + /// @param payee Recipient on release. MUST NOT be address(0). + /// @param token Settlement asset. address(0) = ETH profile (send via msg.value); + /// otherwise an ERC-20 the payer has approved to this contract. + /// @param amount Declared amount. ETH profile requires amount == msg.value. + /// ERC-20 profile pulls up to amount via transferFrom; the + /// credited amount is the measured balance delta. + /// @param timeoutBlocks Blocks until the payment auto-expires. MUST be > 0. + /// @param challengePeriod Additional blocks the payer must wait after timeout to reclaim. + function createPayment( + string calldata requestId, + address payee, + address token, + uint256 amount, + uint256 timeoutBlocks, + uint256 challengePeriod + ) external payable returns (bool); + + /// @notice Release the escrow to the payee. + /// MUST be callable only by the original payer, only while state == Locked, + /// and only while block.number < createdAt + timeoutBlocks. + function confirmPayment(string calldata requestId) external returns (bool); + + /// @notice Oracle-mediated release, gated by the payment's policyHash. + /// MUST be callable by any address while state == Locked and + /// block.number < createdAt + timeoutBlocks. + /// MUST revert if policyHash == 0x00 on the payment. + function releaseByAttestation( + string calldata requestId, + bytes32 attestationHash, + bytes[] calldata signatures + ) external returns (bool); + + /// @notice Refund the escrow to the payer after timeout + challenge period. + /// MUST be callable only by the original payer, only while state == Locked, + /// and only once block.number >= createdAt + timeoutBlocks + challengePeriod. + function requestRefund(string calldata requestId) external returns (bool); + + /// @notice Cancel a still-locked payment. + /// MUST be callable only by the original payer while state == Locked. + function cancelPayment(string calldata requestId) external returns (bool); + + /// @notice Read the payment record. + function getPayment(string calldata requestId) external view returns (Payment memory); +} +``` + +### ERC-165 interface identifier + +The interface identifier of `IAgentEscrow` is **`0x01dc5a49`**. + +It is the XOR of the [Solidity ABI](https://docs.soliditylang.org/en/latest/abi-spec.html#function-selector) function selectors of the six member functions, per [ERC-165](./eip-165.md). The six canonical signatures and selectors are: + +| Function (canonical signature) | Selector | +|---|---| +| `createPayment(string,address,address,uint256,uint256,uint256)` | `0x75fd60ae` | +| `confirmPayment(string)` | `0x912db0fb` | +| `releaseByAttestation(string,bytes32,bytes[])` | `0x6404c242` | +| `requestRefund(string)` | `0xc38821fc` | +| `cancelPayment(string)` | `0x84126e01` | +| `getPayment(string)` | `0xc69207a3` | + +Running XOR (left to right): + +``` + 0x75fd60ae +^ 0x912db0fb = 0xe4d0d055 +^ 0x6404c242 = 0x80d41217 +^ 0xc38821fc = 0x435c33eb +^ 0x84126e01 = 0xc74e5dea +^ 0xc69207a3 = 0x01dc5a49 ← interface id +``` + +The computation is reproducible with Foundry: + +```bash +cast sig "createPayment(string,address,address,uint256,uint256,uint256)" # 0x75fd60ae +cast sig "confirmPayment(string)" # 0x912db0fb +cast sig "releaseByAttestation(string,bytes32,bytes[])" # 0x6404c242 +cast sig "requestRefund(string)" # 0xc38821fc +cast sig "cancelPayment(string)" # 0x84126e01 +cast sig "getPayment(string)" # 0xc69207a3 +# XOR of all six = 0x01dc5a49 +``` + +A compliant contract MUST implement [ERC-165](./eip-165.md) and MUST return `true` from `supportsInterface(0x01dc5a49)` and from `supportsInterface(0x01ffc9a7)` (the ERC-165 identifier itself). + +### ETH profile preconditions + +When `token == address(0)`: + +- `msg.value` MUST equal the declared `amount`. A compliant contract MUST revert if `msg.value != amount`. +- A compliant contract MUST NOT require or read any ERC-20 approval. +- The `credited` amount stored in the `Payment` record is `msg.value`. + +### ERC-20 profile preconditions + +When `token != address(0)`: + +- `msg.value` MUST be `0`. A compliant contract MUST revert if `msg.value > 0`. +- The payer MUST have approved this contract to spend at least `amount` of `token` before calling `createPayment`. +- A compliant contract MUST measure the balance delta to determine the `credited` amount: + + ``` + credited = balanceOf(address(this), token) after transferFrom + - balanceOf(address(this), token) before transferFrom + ``` + + The `amount` field of the stored `Payment` MUST be `credited`, not the declared `amount`. This makes the implementation safe for fee-on-transfer tokens: the contract never promises to release more than it actually holds. + +- A compliant contract SHOULD maintain a per-token allowlist and MUST revert if the token is not on the allowlist. Native ETH (`address(0)`) is implicitly always allowed. +- The `credited` amount MUST be `> 0`. A compliant contract MUST revert if no tokens arrived (e.g., `transferFrom` succeeded but the delta is zero — possible with some rebasing tokens). + +### Required events + +```solidity +event PaymentCreated( + string indexed requestId, + address indexed payer, + address indexed payee, + address token, + uint256 amount // credited amount +); +event PaymentLocked(string indexed requestId, address token); +event PaymentConfirmed(string indexed requestId, address indexed payer, address token); +event PaymentReleased(string indexed requestId, address indexed payee, address token, uint256 amount); +event PaymentReleasedByOracle(string indexed requestId, bytes32 policyHash, bytes32 attestationHash); +event PaymentRefunded(string indexed requestId, address indexed payer, address token, uint256 amount); +event PaymentCancelled(string indexed requestId, address indexed payer, address token, uint256 amount); +``` + +A compliant contract MUST emit `PaymentCreated` and `PaymentLocked` from `createPayment`. It MUST emit exactly one of `PaymentReleased`, `PaymentRefunded`, or `PaymentCancelled` when reaching a terminal state. `PaymentConfirmed` MUST be emitted alongside `PaymentReleased` when the payer triggers release via `confirmPayment`. `PaymentReleasedByOracle` MUST be emitted alongside `PaymentReleased` when release is triggered by oracle attestation. + +All terminal events carry `token` and `amount` (the credited amount disbursed) to allow indexers to attribute flows per asset. + +The `requestId` field is `indexed` even though it is a `string`; per the ABI specification, the topic is `keccak256(requestId)`. Off-chain indexers SHOULD hash off-chain request ids to query logs. + +### Errors + +A compliant contract MUST revert (it MUST NOT silently no-op or return `false`) when a precondition is violated. The normative revert conditions are: + +| Function | MUST revert when | +|---|---| +| `createPayment` | `bytes(requestId).length == 0`; `payee == address(0)`; `timeoutBlocks == 0`; `amount == 0`; `requestId` is already in use in this contract instance; ETH profile and `msg.value != amount`; ERC-20 profile and `msg.value > 0`; ERC-20 profile and token is not on the allowlist; ERC-20 profile and `credited == 0` | +| `confirmPayment` | caller is not the payer; `state != Locked`; or `block.number >= createdAt + timeoutBlocks` | +| `releaseByAttestation` | `state != Locked`; `policyHash == bytes32(0)`; `block.number >= createdAt + timeoutBlocks`; or the oracle aggregator rejects the attestation | +| `requestRefund` | caller is not the payer; `state != Locked`; or `block.number < createdAt + timeoutBlocks + challengePeriod` | +| `cancelPayment` | caller is not the payer; or `state != Locked` | +| any terminal transition | the asset transfer to the recipient fails (the state change MUST be rolled back with the revert) | + +The reason strings or [custom errors](https://docs.soliditylang.org/en/latest/contracts.html#errors-and-the-revert-statement) used are NOT normative. Implementations are RECOMMENDED to use named custom errors for cheaper reverts and machine-readable cause codes. + +### Checks, effects, interactions + +All three payer-driven terminal transitions and `releaseByAttestation` MUST update the on-chain `state` field and zero the `amount` field before transferring value. For the ETH profile, the disbursement MUST use `recipient.call{value: amount}("")` to forward all gas (see Security Considerations — Smart-contract payees). For the ERC-20 profile, the disbursement MUST use a `transfer`-compatible call; implementations are RECOMMENDED to use a SafeERC20 wrapper to tolerate non-boolean-returning tokens (e.g. USDT). If the transfer fails, the transition MUST revert and the funds remain locked. + +### Oracle release policy + +`releaseByAttestation` is an OPTIONAL lifecycle path. Its availability is per-payment, not per-contract: it is enabled only when the `policyHash` field of a `Payment` is non-zero. A payer that does not set a policy hash on `createPayment` receives payer-only release semantics. A separate `createPaymentWithPolicy` function that accepts a `policyHash` argument MAY be provided by compliant implementations; its inclusion does not change the interface identifier. + +The oracle verification logic (signature schemes, quorum rules, attestation formats) is intentionally left to the `IOracleAggregator` implementation and is NOT normative in this standard. The only normative requirement is that the aggregator returns a boolean and that a `false` return causes `releaseByAttestation` to revert. + +### Request ID encoding + +The `requestId` is a Solidity `string`, opaque to the contract. Implementations MAY restrict its length to a reasonable maximum. The string is compatible with HTTP-402 `X-Request-Id`, ZAP wire nonce, and JWT claim identifiers without conversion. Indexers MUST scope queries by `(chainId, contractAddress, requestId)`. + +## Rationale + +### Generalization over a new primitive + +This standard could have been written independently, with the native-ETH escrow as a separate, narrower standard. Instead it is designed as a strict superset: every implementation of the native-ETH ERC is a valid implementation of this standard's ETH profile, with only the addition of the `token` parameter (fixed to `address(0)`) and the `policyHash` field (which may be zero). This preserves every existing conformant deployment and avoids splitting the A2A escrow surface into two incompatible interface families. + +### Balance-delta accounting for ERC-20 tokens + +The credited amount stored in the `Payment` is the measured balance increase, not the declared `amount`. This design choice has three consequences: + +1. **Fee-on-transfer tokens are safe.** A token that deducts a fee on transfer credits only what arrived; the escrow cannot be made to promise more than it holds. +2. **Rebasing tokens are bounded.** The escrow credits the balance at create-time. Rebasing up or down between create and release does not trigger accounting errors; the amount released equals what was recorded, not what is currently held. (Implementations that wish to track the live rebase delta MAY do so, but this is not required and changes the interface.) +3. **The `amount` event field is the credited amount.** Downstream indexers see the real economic value locked, not a declared amount that may differ. + +The cost is one extra `balanceOf` read on create (approximately 700 gas on typical ERC-20s), which is negligible relative to the `transferFrom` itself. + +### Token allowlist + +The per-token allowlist allows a deployer to gate which ERC-20s are accepted before the contract has been audited against a given token's edge cases. Native ETH is exempt: the ETH profile has no token contract edge cases. The allowlist is an operational guard, not a trust assumption — a permissionless version that skips the allowlist check is conformant, but SHOULD document the associated risks (see Security Considerations — Unsupported token types). + +### Oracle release as an opt-in, per-payment path + +Embedding oracle-mediated release directly into the base interface (rather than as an extension) means that all compliant implementations must handle the oracle path, but a payment with `policyHash == 0x00` is indistinguishable from a payer-only release payment at the protocol level. The cost is one additional function in the interface, paid once; the benefit is that every compliant escrow deployment is capable of hosting oracle-policy payments without an upgrade. Keeping `releaseByAttestation` in `IAgentEscrow` also allows a single ERC-165 check to confirm the full capability set. + +### Why `confirmPayment` returns `bool` + +The base native-ETH ERC specifies `confirmPayment` returning nothing (void). This standard specifies `returns (bool)` to align with common ERC-20 `transfer` convention and to give callers a machine-readable success signal without relying on the absence of a revert. The interface selector `0x912db0fb` is the same in both; the return type does not affect the ABI selector. + +### Relationship to the Native-ETH ERC + +The companion native-ETH A2A escrow draft specifies a narrower interface (`IAgentEscrow` without a `token` parameter, `policyHash`, or `releaseByAttestation`). That draft's interface identifier is `0x5c3738e9`. This standard's identifier is `0x01dc5a49`. The two are NOT interchangeable: a contract conforming to this standard does not automatically report `supportsInterface(0x5c3738e9) == true` unless it also implements the native-ETH interface function signatures verbatim (which it cannot, because the `createPayment` signatures differ in parameter count). Implementations that wish to signal compatibility with both SHOULD maintain a separate `AgentEscrow`-compatible entry point or use an adapter. + +### `block.number` for timeouts + +As in the native-ETH ERC, this standard uses block height rather than timestamp for the timeout and challenge period windows. The reasoning is unchanged: block height is reorg-stable, monotonically increasing, and not manipulable within the miner's discretion window the way `block.timestamp` is on some chains. The deployer is responsible for choosing `timeoutBlocks` and `challengePeriod` values appropriate for the target chain's block time and expected reorg depth. + +## Backwards Compatibility + +This EIP defines a new contract interface. It does not modify or deprecate any existing standard. + +A contract conforming to the companion native-ETH A2A escrow ERC can be made to conform to this standard's ETH profile by adding the `token` parameter (fixed to `address(0)`) to `createPayment` and adding `releaseByAttestation`, `policyHash`, and the `token` field to `Payment`. These are additive changes; they do not affect the native-ETH contract's existing selectors. + +Implementations MUST NOT silently accept plain ERC-20 `transfer` calls into the contract address as escrow creations. Only the `createPayment` + `transferFrom` path is normative. + +## Reference Implementation + +The reference implementation is [`contracts/MultiTokenAgentEscrow.sol`](../contracts/MultiTokenAgentEscrow.sol) in the `kcolbchain/switchboard` repository. It is Solidity ^0.8.20, MIT-licensed, deployed on Base Sepolia and Lux testnet. The shared interface is [`contracts/IAgentEscrow.sol`](../contracts/IAgentEscrow.sol). + +The reference contract is a superset of `IAgentEscrow`: + +- It implements the full required lifecycle interface. +- It adds `createPaymentWithPolicy(…, bytes32 policyHash)` for oracle-enabled payments; this function is a convenience wrapper and does not change the interface identifier. +- It maintains an owner-curated per-token allowlist (`setTokenAllowed`) and an agent allowlist (`registerAgent` / `deregisterAgent`). +- It exposes `isExpired(string requestId) external view returns (bool)` and `isState(string, State)` as non-normative read helpers. + +Known gaps to close before the contract is declared fully conformant: + +1. The reference contract does not yet inherit ERC-165 / expose `supportsInterface`. A conformant deployment MUST add it and return `true` for `0x01dc5a49` and `0x01ffc9a7`. +2. A `supportsInterface(0x01dc5a49) == true` Foundry test does not yet exist and MUST be added. + +The oracle aggregator interface is [`contracts/IOracleAggregator.sol`](../contracts/IOracleAggregator.sol); a mock is at [`contracts/mocks/MockOracleAggregator.sol`](../contracts/mocks/MockOracleAggregator.sol). + +## Test Cases + +The reference Foundry suite covers: + +| Test | What it asserts | +|---|---| +| `test_happyPath_ETH_createConfirmReleased` | ETH profile: `createPayment{value}` sets `Locked`; `confirmPayment` sets `Released` and transfers ETH to payee | +| `test_happyPath_ERC20_createConfirmReleased` | ERC-20 profile: `transferFrom` on create; `transfer` on confirm; balances correct | +| `test_feeOnTransfer_creditsDelta` | Fee-on-transfer token: `amount` stored is balance delta, not declared; release is exactly what was credited | +| `test_timeoutRefund_path` | `requestRefund` reverts before challenge period ends; succeeds after; state is `Refunded` | +| `test_doubleConfirm_reverts` | Second `confirmPayment` on a `Released` payment reverts | +| `test_cancel_returnsFunds_ETH` | ETH `cancelPayment` while `Locked` returns ETH to payer; state is `Cancelled` | +| `test_cancel_returnsFunds_ERC20` | ERC-20 `cancelPayment` returns tokens to payer | +| `test_onlyPayerCanConfirm` | `confirmPayment` from non-payer reverts | +| `test_reentrancy_confirmPayment_reverts` | Malicious payee re-entering `confirmPayment` cannot trigger a second release | +| `test_releaseByAttestation_success` | Oracle path succeeds; permissionless submitter | +| `test_releaseByAttestation_revertsNoPolicyHash` | Oracle path reverts when `policyHash == 0x00` | +| `test_releaseByAttestation_revertsAfterTimeout` | Oracle path reverts once timeout window has closed | +| `test_ERC20_notAllowlisted_reverts` | `createPayment` with a non-allowlisted token reverts | +| `test_ERC20_msgValue_reverts` | `createPayment` with ERC-20 token and non-zero `msg.value` reverts | + +A `supportsInterface(0x01dc5a49) == true` test MUST be added alongside the ERC-165 implementation. + +## Security Considerations + +### Reentrancy + +`confirmPayment`, `requestRefund`, `cancelPayment`, and `releaseByAttestation` all perform external asset transfers. Each MUST follow checks-effects-interactions: update `state` and zero `amount` before the transfer. A reentrant caller cannot trigger a second transition on the same `requestId` because the state field has already reached a terminal value. Implementations are RECOMMENDED to use a reentrancy guard (e.g., OpenZeppelin `ReentrancyGuard`) as defense in depth, since `releaseByAttestation` involves a call to an external oracle aggregator before the transfer, creating a reentrant surface if the state is not updated first. + +### ERC-20 transfer failures and stuck funds + +For ERC-20 tokens, `transfer` on a terminal transition MUST succeed or the transition MUST revert. A token whose `transfer` is paused, blacklisted, or otherwise broken can cause funds to become temporarily unreachable. Implementations SHOULD use SafeERC20 to handle non-boolean-returning tokens and SHOULD revert cleanly on failed transfers. The challenge-period / refund path gives the payer a recovery route if the payee-bound transfer is broken: after the challenge period, the payer can call `requestRefund` which routes the transfer back to themselves. + +### Fee-on-transfer tokens and the allowlist + +The balance-delta accounting design is correct for fee-on-transfer tokens: the escrow credits what it received and releases exactly that amount. However, a release of a fee-on-transfer token will again incur a fee, so the payee receives less than the credited amount. This is expected and is the token's behavior; the escrow does not attempt to compensate. Deployers SHOULD document per-token behavior in their allowlist policy so payers are aware. Tokens that charge fees differently on different call paths (e.g. tax only on buy) SHOULD be individually reviewed before allowlisting. + +### Rebasing tokens + +Rebasing tokens that increase supply (positive rebase) will leave a surplus in the contract after release; that surplus is unattributed and cannot be withdrawn unless the implementation adds a sweep function. Rebasing tokens that decrease supply (negative rebase) may make the credited amount undeliverable (the contract holds less than it promised to release). Implementations that accept rebasing tokens SHOULD either track the live balance (which changes the accounting model) or restrict them to the allowlist with appropriate user-facing warnings. The reference implementation accepts any allowlisted token and relies on the deployer's allowlist curation. + +### Unsupported token types + +Token contracts that implement behaviors incompatible with this interface (e.g., tokens that revert on `transfer` to contract addresses, tokens with infinite-loop `transferFrom`, tokens with transfer hooks that re-enter this contract) can cause denial-of-service or reentrancy. The allowlist is the primary mitigation: a compliant implementation that exposes an allowlist gate SHOULD review each token against these behaviors before allowlisting. A permissionless implementation that skips the allowlist gate MUST prominently document the increased risk. + +### Front-running and request ID squatting + +As in the native-ETH profile: a front-runner cannot grief a payer by reserving a `requestId` in their name, because the payer is the one whose funds are locked. A third party squatting a `requestId` locks their own funds. Cross-instance collisions are impossible; each contract has its own mapping. Indexers MUST scope by `(chainId, contractAddress, requestId)`. + +### Oracle aggregator trust surface + +`releaseByAttestation` delegates trust to the `IOracleAggregator` set at construction. A compromised or malicious aggregator can release funds to the payee before the payer intended. Payers that do not want oracle-mediated release MUST use `policyHash == bytes32(0)` (the default `createPayment` path); they cannot be subject to `releaseByAttestation` regardless of the aggregator state. The aggregator address is immutable in the reference implementation; upgrades require a new deployment. + +### Smart-contract payees + +A payee that is itself a contract must accept the settlement asset. For the ETH profile: the payee must expose a `receive()` or `payable fallback()` that does not revert, and that does not consume more than the gas forwarded. The standard mandates `.call{value:}` (not `.transfer` or `.send`) to forward all gas, supporting contract payees that update storage on receipt. For the ERC-20 profile: the payee must not have a blocked `transfer` target. If a transfer fails, the terminal transition reverts and the payer can retry or, after the challenge period, call `requestRefund`. + +### Smart-contract payers + +`msg.sender` is the payer at create time. An upgradeable payer contract that loses the ability to call `confirmPayment` after an upgrade cannot retroactively move the on-chain `payer` field; funds remain locked until the refund path opens. + +### Time-based attacks + +`block.number` is used for the timeout and challenge period. On chains with variable block times, reorg risk, or sequencer control (L2s), `timeoutBlocks` and `challengePeriod` MUST be sized to comfortably exceed the chain's expected reorg depth and block-time variance. Very short `timeoutBlocks` on high-reorg chains may allow a payer to request a refund before the payee's delivery confirmation has finalized. + +### Griefing via dust + +A payer may lock a minimal amount with a large `timeoutBlocks`, occupying a `requestId` indefinitely. Implementations MAY enforce a minimum `amount`. The reference implementation requires `amount > 0` only. + +## Copyright + +Copyright and related rights waived via [CC0](https://creativecommons.org/publicdomain/zero/1.0/). diff --git a/eips/magicians-post-multitoken.md b/eips/magicians-post-multitoken.md new file mode 100644 index 0000000..b0deb2b --- /dev/null +++ b/eips/magicians-post-multitoken.md @@ -0,0 +1,68 @@ +# ethereum-magicians forum post — draft + +Post this to https://ethereum-magicians.org/ under **Magicians → EIPs** (category: ERC). +Once the topic is created, copy its URL into the `discussions-to:` frontmatter field of +`eips/draft-multitoken-a2a-escrow.md` before opening the ethereum/EIPs PR. + +--- + +**Title:** `ERC: Multi-Token Agent-to-Agent Escrow (ETH + any ERC-20, fee-on-transfer safe, oracle release opt-in)` + +**Tags:** `erc`, `escrow`, `payments`, `agents`, `erc-20`, `eip-165` + +--- + +## Body + +Sharing an early Standards-Track ERC draft for feedback before opening the PR to `ethereum/EIPs`. This draft generalizes our earlier native-ETH A2A escrow primitive (see that thread) to support any settlement asset. + +**Background.** The native-ETH A2A escrow standard defines a minimal `payable` escrow keyed by a free-form `string requestId`, with payer-driven confirm / refund / cancel terminals and an explicit challenge period. It targets autonomous agent-to-agent payments and intentionally has no off-chain operator, no token dependency, and no arbitrator. + +**What this ERC adds.** The same lifecycle — create, confirm/refund/cancel — parameterized by an `address token` field: + +- **`token == address(0)` — ETH profile.** Semantics identical to the native-ETH ERC. The earlier draft becomes a profile of this one, not a separate standard. +- **`token != address(0)` — ERC-20 profile.** Payer calls `ERC20.approve(escrow, amount)` once, then `createPayment(…, token, amount, …)` with `msg.value == 0`. The escrow pulls via `transferFrom` and releases via `transfer`. Credited amount is the measured balance delta, making the design safe for fee-on-transfer tokens. ERC-20s must be owner-allowlisted to gate unsupported token types. +- **Optional oracle release.** A per-payment `policyHash` field enables a `releaseByAttestation(requestId, attestationHash, signatures)` path, verified by an `IOracleAggregator`. Payments with `policyHash == 0x00` are payer-only, identical to the native-ETH primitive. No oracle trust if you don't use it. + +ERC-165 interface id: **`0x01dc5a49`** (XOR of the six member-function selectors — derivation is in the draft). + +**Why generalize instead of compose?** + +Composing the ETH escrow with a thin ERC-20 wrapper per token creates N adapters rather than one interface, fragments ERC-165 discovery, and forces indexers to track multiple contract addresses per agent pair. A single interface with a `token` parameter keeps the on-chain footprint minimal, the ERC-165 check decisive, and the indexer logic uniform across assets. + +The ETH profile gives the native-ETH ERC a clean upgrade path: a payer that always sets `token = address(0)` interacts with an `IAgentEscrow`-conformant multi-token contract identically to the narrower native-ETH contract, modulo the extra parameter. + +**Deliberate design choices (and the cases against them — see the draft's Rationale):** + +- **Balance-delta accounting** for ERC-20: the contract credits what it actually receives, not the declared amount. One extra `balanceOf` on create (~700 gas) buys safety for all fee-on-transfer and some rebasing tokens. +- **Allowlist for ERC-20, not for ETH**: native ETH has no token-contract attack surface and needs no gate. ERC-20s go through an owner allowlist so the deployer can audit each token's edge cases before accepting it. +- **Oracle release stays opt-in and per-payment**: a payer that does not want oracle-mediated release sets no policy hash and cannot be subject to `releaseByAttestation` regardless of the aggregator state. +- **`block.number` not `block.timestamp`**: same reasoning as the native-ETH draft — reorg-stable and not manipulable within miner discretion. + +**Reference implementation:** + +- `contracts/MultiTokenAgentEscrow.sol` (Solidity ^0.8.20, MIT) — the multi-token escrow +- `contracts/IAgentEscrow.sol` — the shared interface +- `contracts/IOracleAggregator.sol` — oracle aggregator interface +- Deployed on Base Sepolia and Lux testnet + +Repository: `github.com/kcolbchain/switchboard` +Draft: `eips/draft-multitoken-a2a-escrow.md` on `main` — +Native-ETH companion thread: + +**Known gaps before ethereum/EIPs submission:** + +1. ERC-165 `supportsInterface` not yet wired into the reference contract — a PR is in progress. +2. A `supportsInterface(0x01dc5a49) == true` Foundry test does not yet exist. +3. Fee-on-transfer behavior on the release path (payee receives net-of-fee, not gross) needs a user-facing note in the contract docs — clear in the EIP, not yet in the NatDoc. + +## Open questions for the forum + +1. **ETH profile backward compatibility.** The native-ETH ERC's interface id (`0x5c3738e9`) and this standard's (`0x01dc5a49`) are different because `createPayment`'s signature differs (extra `token` parameter). Is a dual-interface shim in the reference contract the right answer, or should we recommend that native-ETH deployments simply remain on the narrower interface? +2. **Balance-delta vs. declared amount.** The draft credits the measured delta. Some designs instead require the declared and received amounts to match (reverting for fee-on-transfer tokens rather than accepting them silently). Which default is better for A2A agents that may not know in advance which tokens are fee-on-transfer? +3. **Allowlist: deployer-controlled or DAO-controlled?** The current design is owner-gated (`Ownable`). For a canonical reference deployment, should there be a governance mechanism, or is per-deployment curation the right model? +4. **Oracle aggregator as a separate ERC?** `IOracleAggregator` is currently a repository-local interface. If oracle-mediated escrow release is useful beyond this standard, should `IOracleAggregator` be proposed as its own ERC with a registry? +5. **Rebasing tokens.** The draft documents the risks but does not mandate handling. Should a conformant implementation be REQUIRED to reject rebasing tokens outright, or is the allowlist-plus-documentation approach sufficient? +6. **`string` requestId cost in ERC-20 profile.** The ERC-20 `transferFrom` adds approximately 25k–46k gas on top of the string-storage cost from the base standard. For high-frequency micropayments in stablecoins, is the combined gas cost acceptable, or does this standard need a `bytes32`-keyed variant? + +Happy to hear that the generalization is wrong-shaped for an ERC, or that the ETH-profile backward-compat story needs more thought. Would rather resolve it here than after submission. diff --git a/examples/multitoken_thinking_chain_demo.py b/examples/multitoken_thinking_chain_demo.py new file mode 100644 index 0000000..794e524 --- /dev/null +++ b/examples/multitoken_thinking_chain_demo.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +"""Watchable multi-token settlement demo driven by HanzoEscrowThinkingChain. + +Demonstrates: + - 2 agents: Payer (Hanzo AI) holds USDC; Payee (Meridian) accepts DAI + LUX. + - Partner tokens LUX and ZOO featured in payer's offer list. + - negotiate_settlement_token() picks LUX (the common token with highest rank). + - SwapSettlementAdapter handles USDC -> LUX swap before escrow creation. + - HanzoEscrowThinkingChain runs all 6 steps with clear console output. + - Second run: low-tier agent, policy denial at POLICY_CHECK step (HALT). + +Run with: + python3 examples/multitoken_thinking_chain_demo.py +""" +from __future__ import annotations + +import sys +import os +import time + +# Ensure repo root is on sys.path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from switchboard.agent_wallet import AgentWallet +from switchboard.treasury import Treasury +from switchboard.access_policy import ( + AccessPolicy, AgentTier, TierConfig, TokenBucketConfig, +) +from switchboard.escrow_adapters import InMemoryEscrowClient, SwapSettlementAdapter +from switchboard.thinking_chain import ( + HanzoEscrowThinkingChain, StepType, StepOutcome, ChainHaltedError, +) +from src.payment_protocol import SettlementToken + +# ─── Token addresses ──────────────────────────────────────────────────────── +USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +DAI = "0x6B175474E89094C44Da98b954EedeAC495271d0F" +LUX = "0xLUX0000000000000000000000000000000000001" # partner token +ZOO = "0xZOO0000000000000000000000000000000000002" # partner token +CHAIN_ID = 1 + +AMOUNT = 1_000_000 # 1 USDC (6 decimals) + +# ─── ANSI colours ─────────────────────────────────────────────────────────── +GREEN = "\033[32m" +RED = "\033[31m" +YELLOW = "\033[33m" +CYAN = "\033[36m" +BOLD = "\033[1m" +RESET = "\033[0m" + +STEP_ICONS = { + StepType.ASSESS_TASK: ">>", + StepType.NEGOTIATE_TOKEN: "<>", + StepType.POLICY_CHECK: "[]", + StepType.CREATE_ESCROW: "##", + StepType.VERIFY_WORK: "OK", + StepType.RELEASE_OR_REFUND: "$$", +} + +OUTCOME_COLOURS = { + StepOutcome.PASS: GREEN, + StepOutcome.FAIL: RED, + StepOutcome.HALT: RED, +} + + +def _print_header(title: str) -> None: + width = 72 + print(f"\n{BOLD}{'=' * width}{RESET}") + print(f"{BOLD} {title}{RESET}") + print(f"{BOLD}{'=' * width}{RESET}") + + +def _print_step(record) -> None: + icon = STEP_ICONS.get(record.step_type, ".") + colour = OUTCOME_COLOURS.get(record.outcome, RESET) + print(f"\n [{icon}] {BOLD}{record.step_type.name}{RESET}") + print(f" Reasoning : {record.reasoning}") + print(f" Outcome : {colour}{record.outcome.name}{RESET}") + if record.data: + # Print data key-value pairs, skip long values + for k, v in record.data.items(): + val_str = str(v) + if len(val_str) > 60: + val_str = val_str[:57] + "..." + print(f" {k:12s}: {val_str}") + if record.events: + for ev in record.events: + print(f" {CYAN}[WalletOpEvent] op={ev.op_type} token={ev.token[:16]}... " + f"denied={ev.denied}{RESET}") + + +def run_happy_path() -> None: + _print_header("DEMO 1: Happy Path -- USDC payer, DAI+LUX payee (LUX negotiated)") + + # ── Setup ──────────────────────────────────────────────────────────────── + print(f"\n {BOLD}Agent Setup{RESET}") + print(f" * Payer : Hanzo AI Agent (holds USDC, offers USDC / LUX / ZOO)") + print(f" * Payee : Meridian Agent (accepts DAI / LUX)") + print(f" * Amount : {AMOUNT:,} base units (~1 USDC)") + print(f" * Chain : Ethereum mainnet (chain_id=1)") + + treasury = Treasury() + treasury.credit(chain_id=CHAIN_ID, token=USDC, amount=10_000_000) + wallet = AgentWallet(treasury=treasury) + + # Use a custom TierConfig so the TRUSTED tier can handle 1_000_000 + # (USDC base units, ~1 USDC). The default cap of 100_000 is intentionally + # conservative for the default network; demo uses a higher ceiling. + demo_tier_config = TierConfig( + explorer=TokenBucketConfig(per_tx_cap=1_000, rate=1.0, capacity=10), + standard=TokenBucketConfig(per_tx_cap=10_000, rate=10.0, capacity=50), + trusted=TokenBucketConfig(per_tx_cap=10_000_000, rate=100.0, capacity=200), + ) + policy = AccessPolicy(tier_config=demo_tier_config) + policy.register("hanzo-agent", AgentTier.TRUSTED) + + escrow_client = InMemoryEscrowClient() + swap_adapter = SwapSettlementAdapter(escrow_client) + + # Payer offers USDC (rank 10), LUX (rank 8), ZOO (rank 3) + payer_offers = [ + SettlementToken(chain_id=CHAIN_ID, token=USDC, min_amount=0, rank=10), + SettlementToken(chain_id=CHAIN_ID, token=LUX, min_amount=0, rank=8), + SettlementToken(chain_id=CHAIN_ID, token=ZOO, min_amount=0, rank=3), + ] + # Payee accepts DAI (rank 10), LUX (rank 7) + payee_accepts = [ + SettlementToken(chain_id=CHAIN_ID, token=DAI, min_amount=0, rank=10), + SettlementToken(chain_id=CHAIN_ID, token=LUX, min_amount=0, rank=7), + ] + + print(f"\n {BOLD}Token Negotiation Preview{RESET}") + print(f" Payer offers : USDC (rank 10), LUX (rank 8), ZOO (rank 3)") + print(f" Payee accepts : DAI (rank 10), LUX (rank 7)") + print(f" Common tokens : LUX (combined rank = 8+7 = 15)") + print(f" No USDC<->DAI common pair -> LUX is the negotiated settlement token") + print(f" Payer holds USDC -> SwapSettlementAdapter will simulate USDC->LUX swap") + + # Wire up a custom escrow client that goes through swap for USDC->LUX. + # We subclass HanzoEscrowThinkingChain and override _step_create_escrow + # to: + # 1. Check spendable balance in the SOURCE token (USDC) the wallet holds. + # 2. Debit the treasury in USDC (the wallet's actual holding). + # 3. Route through swap_adapter when negotiated token differs from source. + # This is consistent with the fixed base-class _step_create_escrow which + # debits the wallet before creating the escrow — but here the source token + # (USDC) differs from the negotiated token (LUX), so we debit USDC and + # create the LUX escrow via SwapSettlementAdapter. + class SwapAwareHanzoChain(HanzoEscrowThinkingChain): + def __init__(self, swap_adapter: SwapSettlementAdapter, from_token: str, **kw): + super().__init__(**kw) + self._swap_adapter = swap_adapter + self._from_token = from_token + + def _step_create_escrow(self): + import time as _time + from switchboard.metrics import WalletOpEvent + from switchboard.thinking_chain import StepRecord, StepType, StepOutcome + + token = self._negotiated_token.token if self._negotiated_token else "" + + # Financial gate: check spendable balance in the SOURCE token + # (the token the wallet actually holds — USDC in Demo 1). + spendable = self._wallet.spendable(self._chain_id, self._from_token) + if spendable < self._amount: + return StepRecord( + step_type=StepType.CREATE_ESCROW, + reasoning=( + f"Insufficient spendable balance for source token " + f"{self._from_token!r} on chain {self._chain_id}: " + f"have {spendable}, need {self._amount}. Halting chain." + ), + outcome=StepOutcome.HALT, + data={ + "from_token": self._from_token, + "token": token, + "amount": self._amount, + "spendable": spendable, + }, + events=[], + ) + + # Debit the treasury in the SOURCE token (USDC). + # The swap_adapter converts USDC to LUX at 1:1 demo rate before + # creating the escrow — so the escrow is denominated in LUX while + # the wallet accounting entry is in USDC. + self._wallet.treasury.debit(self._chain_id, self._from_token, self._amount) + + if token != self._from_token: + # Different token: go through swap (USDC -> LUX) + eid = self._swap_adapter.swap_and_create( + chain_id=self._chain_id, + from_token=self._from_token, + to_token=token, + amount=self._amount, + payee=self._payee, + ) + swap_path = f"{self._from_token[:6]}...->LUX (1:1 demo rate)" + else: + # Same token: direct escrow creation (no swap needed) + eid = self._escrow.create_payment( + chain_id=self._chain_id, + token=token, + amount=self._amount, + payee=self._payee, + ) + swap_path = "no swap (tokens match)" + + self._escrow_id = eid + balance_after = self._wallet.spendable(self._chain_id, self._from_token) + + ev = WalletOpEvent( + op_type="create_escrow_via_swap", + token=token, + rail="escrow", + amount=float(self._amount), + agent_id=self._agent_id, + wallet_id=self._wallet.address(), + denied=False, + denial_reason=None, + timestamp=_time.time(), + ) + return StepRecord( + step_type=StepType.CREATE_ESCROW, + reasoning=( + f"Debited treasury {self._amount} {self._from_token[:10]}... (USDC); " + f"swapped USDC -> {token[:10]}... (LUX) via SwapSettlementAdapter " + f"then created escrow {eid!r}." + ), + outcome=StepOutcome.PASS, + data={ + "escrow_id": eid, + "token": token, + "from_token": self._from_token, + "amount": self._amount, + "swap_path": swap_path, + "balance_after": balance_after, + }, + events=[ev], + ) + + chain = SwapAwareHanzoChain( + swap_adapter=swap_adapter, + from_token=USDC, + payer_wallet=wallet, + payee_address="0xMeridian000000000000000000000000000", + payer_offers=payer_offers, + payee_accepts=payee_accepts, + amount=AMOUNT, + access_policy=policy, + agent_id="hanzo-agent", + escrow_client=escrow_client, + chain_id=CHAIN_ID, + ) + + print(f"\n {BOLD}Thinking Chain Execution{RESET}") + try: + records = chain.run() + for rec in records: + _print_step(rec) + time.sleep(0.05) + + print(f"\n {GREEN}{BOLD}Settlement complete.{RESET}") + final_eid = next(r for r in records if r.step_type == StepType.CREATE_ESCROW).data.get("escrow_id", "?") + final_escrow = escrow_client.get_escrow(final_eid) + print(f" Escrow {final_eid!r}: state={final_escrow['state']!r}, " + f"token={final_escrow['token'][:16]}..., amount={final_escrow['amount']}") + except ChainHaltedError as e: + print(f"\n {RED}{BOLD}Chain halted: {e.reason}{RESET}") + + +def run_policy_denial() -> None: + _print_header("DEMO 2: Policy Denial -- Explorer-tier agent blocked at POLICY_CHECK") + + print(f"\n {BOLD}Agent Setup{RESET}") + print(f" * Payer : Low-Tier Bot (Explorer tier, per_tx_cap = 1,000)") + print(f" * Amount : {AMOUNT:,} base units (far exceeds Explorer cap)") + print(f" * Expected: HALT at POLICY_CHECK step") + + treasury = Treasury() + treasury.credit(chain_id=CHAIN_ID, token=USDC, amount=10_000_000) + wallet = AgentWallet(treasury=treasury) + + policy = AccessPolicy() + policy.register("low-tier-bot", AgentTier.EXPLORER) # per_tx_cap = 1,000 + + payer_offers = [SettlementToken(chain_id=CHAIN_ID, token=USDC, min_amount=0, rank=10)] + payee_accepts = [SettlementToken(chain_id=CHAIN_ID, token=USDC, min_amount=0, rank=10)] + + chain = HanzoEscrowThinkingChain( + payer_wallet=wallet, + payee_address="0xPayee", + payer_offers=payer_offers, + payee_accepts=payee_accepts, + amount=AMOUNT, # 1_000_000 >> Explorer cap of 1_000 + access_policy=policy, + agent_id="low-tier-bot", + chain_id=CHAIN_ID, + ) + + print(f"\n {BOLD}Thinking Chain Execution{RESET}") + try: + chain.run() + print(f"\n {RED}Expected a halt but got success -- something is wrong!{RESET}") + except ChainHaltedError as e: + # Print records up to halt + for rec in chain.records: + _print_step(rec) + time.sleep(0.05) + print(f"\n {YELLOW}{BOLD}Chain halted as expected: {e.reason}{RESET}") + print(f" Halted at step: {e.step_record.step_type.name}") + print(f" No escrow was created -- funds were never touched.") + print(f" {GREEN}Policy enforcement working correctly.{RESET}") + + +def main() -> None: + print(f"\n{BOLD}{'=' * 72}{RESET}") + print(f"{BOLD} SWITCHBOARD -- Multi-Token Thinking Chain Demo{RESET}") + print(f"{BOLD} Escrow primitive + LUX/ZOO partner tokens + swap path{RESET}") + print(f"{BOLD}{'=' * 72}{RESET}") + + run_happy_path() + print() + run_policy_denial() + + print(f"\n{BOLD}{'=' * 72}{RESET}") + print(f"{BOLD} Demo complete.{RESET}") + print(f"{BOLD}{'=' * 72}{RESET}\n") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index ba8ba06..b63be79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "eth-account", "sortedcontainers", "cryptography>=42", + "click>=8", ] [project.optional-dependencies] @@ -54,7 +55,8 @@ Issues = "https://github.com/kcolbchain/switchboard/issues" Organization = "https://kcolbchain.com" [project.scripts] -switchboard = "src.payment_protocol:main" +switchboard = "switchboard.cli:main" +switchboard-mcp = "switchboard.mcp_server:main" [tool.hatch.build.targets.wheel] packages = ["switchboard", "src"] diff --git a/src/payment_protocol.py b/src/payment_protocol.py index 87bd052..6e95f02 100644 --- a/src/payment_protocol.py +++ b/src/payment_protocol.py @@ -6,6 +6,7 @@ - Escrow smart contract interaction via Web3.py - Confirmation flow with timeout/refund - Async/concurrent payment management +- Multi-token settlement negotiation (v1.2) Usage: client = PaymentClient(wallet_private_key, escrow_address, rpc_url) @@ -33,18 +34,98 @@ Account = None +# ─── Settlement Token ─────────────────────────────────────────────────────── + +@dataclass +class SettlementToken: + """Represents a single accepted settlement token with chain scope and rank. + + Used in multi-token negotiation (protocol v1.2). + + Attributes: + chain_id: EIP-155 chain ID the token lives on. + token: Token contract address (ERC-20) or the zero address for native ETH + (``"0x0000000000000000000000000000000000000000"``). + min_amount: Minimum acceptable amount in the token's smallest denomination. + 0 means "any amount". + rank: Preference rank — higher is more preferred. The negotiation + algorithm sums payer_rank + payee_rank and picks the pair with + the highest combined score. + """ + chain_id: int + token: str + min_amount: int + rank: int + + +def negotiate_settlement_token( + payer_offer: List[SettlementToken], + payee_accepts: List[SettlementToken], +) -> Optional[SettlementToken]: + """Deterministically pick the best mutually-acceptable settlement token. + + Algorithm (spec §3.4): + 1. Intersect on ``(chain_id, token)`` — the settlement instrument identifier. + 2. For each common pair, combine their ranks: ``combined = payer.rank + payee.rank``. + 3. Return the token with the highest combined rank. + 4. Tie-break by lexicographically smallest ``token`` address string for full + determinism (no random, no insertion-order dependency). + 5. If the intersection is empty, return ``None``. + + Args: + payer_offer: Tokens the payer is willing to pay in (ranked by payer). + payee_accepts: Tokens the payee is willing to receive (ranked by payee). + + Returns: + The winning ``SettlementToken`` (from the payer's offer list, carrying + payer-side metadata) or ``None`` when there is no common token. + """ + if not payer_offer or not payee_accepts: + return None + + # Index payee tokens by (chain_id, token) → payee SettlementToken + payee_index: Dict[tuple, SettlementToken] = { + (t.chain_id, t.token): t for t in payee_accepts + } + + candidates: List[tuple] = [] # (combined_rank, token_addr, payer_token) + for pt in payer_offer: + key = (pt.chain_id, pt.token) + if key in payee_index: + combined = pt.rank + payee_index[key].rank + # Negate combined_rank for sort (highest first); token for tiebreak (lowest first) + candidates.append((-combined, pt.token, pt)) + + if not candidates: + return None + + candidates.sort(key=lambda x: (x[0], x[1])) + return candidates[0][2] + + # ─── Payment Request Format ───────────────────────────────────────────────── @dataclass class PaymentRequest: - """RFC-style payment request message""" + """RFC-style payment request message — protocol v1.2. + + v1.2 additions (back-compatible with v1.1): + - ``settlement_token``: the negotiated settlement token chosen by + ``negotiate_settlement_token()``. Defaults to ``None`` (unsigned/ETH + profile, identical semantics to v1.1). + - ``currency`` is retained as a v1.1-compatible alias for the ETH profile. + + ``settlement_token`` is treated as a negotiated/volatile field (like + ``status``) and is excluded from ``content_hash()`` so both sides agree + on the hash before negotiation is finalised. + """ version: str = "1.0" request_id: str = field(default_factory=lambda: str(uuid.uuid4())) payer: str = "" # Ethereum address (checksummed) payee: str = "" # Ethereum address (checksummed) amount_wei: int = 0 # Amount in wei amount_usd: Optional[Decimal] = None # Optional USD equivalent - currency: str = "ETH" # ETH, USDT, USDC, etc. + currency: str = "ETH" # ETH, USDT, USDC, etc. — v1.1 alias; kept for back-compat chain_id: int = 1 # Ethereum chain ID timeout_blocks: int = 100 # Blocks until payment expires challenge_period_blocks: int = 10 # Blocks payer waits before reclaim @@ -52,13 +133,47 @@ class PaymentRequest: metadata: Dict = field(default_factory=dict) # Arbitrary extra data created_at: float = field(default_factory=time.time) status: str = "pending" # pending, locked, confirmed, released, refunded, cancelled + # v1.2 — negotiated settlement token; None = unset (ETH profile / v1.1 compat) + settlement_token: Optional[SettlementToken] = None + # Multi-token settlement asset (spec §3.2): the concrete token the wallet + # settles in. EVM address; "" or address(0) = native ETH (the ETH profile, + # semantically identical to ``currency == "ETH"``). This is the field the + # ``AgentWallet`` / ``Router`` read as the source token. Kept off the v1.0 + # wire (omitted when default) and out of ``content_hash`` so the frozen + # protocol vectors and cross-language hashes are unaffected. + token: str = "" + + # ``amount`` is a read/write alias for ``amount_wei`` so the agent-wallet + # layer can speak in generic "token base units" (wei / USDC-decimals / …) + # while the protocol keeps ``amount_wei`` as the single source of truth. + # It is a property, NOT a dataclass field, so it never enters the wire + # encoding or the content hash. Construct with ``amount_wei=`` (the wallet + # helpers do); read/write freely via ``req.amount``. + @property + def amount(self) -> int: + return self.amount_wei + + @amount.setter + def amount(self, value: int) -> None: + self.amount_wei = value def to_json(self) -> str: - """Serialize to JSON for signing/transmission""" + """Serialize to JSON for signing/transmission. + + Back-compat: ``settlement_token`` and ``token`` are omitted from the + wire when at their defaults so v1.0/v1.1 payloads remain byte-for-byte + identical after the v1.2 / multi-token upgrade. + """ d = asdict(self) # Convert Decimal to string for JSON if self.amount_usd is not None: d['amount_usd'] = str(self.amount_usd) + # v1.2 back-compat: omit settlement_token from wire when not set + if self.settlement_token is None: + d.pop('settlement_token', None) + # multi-token back-compat: omit token from wire when at default (ETH profile) + if not self.token: + d.pop('token', None) return json.dumps(d, sort_keys=True, separators=(',', ':')) def to_dict(self) -> dict: @@ -72,17 +187,30 @@ def from_dict(cls, d: dict) -> 'PaymentRequest': d = dict(d) if d.get('amount_usd'): d['amount_usd'] = Decimal(d['amount_usd']) - return cls(**d) + # v1.2 back-compat: settlement_token may be absent in v1.1 payloads + st = d.pop('settlement_token', None) + if isinstance(st, dict): + st = SettlementToken(**st) + obj = cls(**d) + obj.settlement_token = st + return obj def content_hash(self) -> str: - """Content-based hash. Excludes volatile fields (`created_at`, `status`) so two - requests with identical content produce the same hash regardless of when they - were instantiated.""" + """Content-based hash. + + Excludes volatile/negotiated fields (``created_at``, ``status``, + ``settlement_token``) and the derived multi-token ``token`` field so two + requests with identical payment intent produce the same hash regardless + of when they were instantiated, what settlement token was negotiated, or + which concrete token the wallet later selected. + """ d = asdict(self) if self.amount_usd is not None: d['amount_usd'] = str(self.amount_usd) d.pop('created_at', None) d.pop('status', None) + d.pop('settlement_token', None) # negotiated result — excluded from hash + d.pop('token', None) # wallet-selected asset — excluded from hash canonical = json.dumps(d, sort_keys=True, separators=(',', ':')) h = hashlib.sha256() h.update(canonical.encode('utf-8')) diff --git a/switchboard/access_policy.py b/switchboard/access_policy.py new file mode 100644 index 0000000..3f4bcfa --- /dev/null +++ b/switchboard/access_policy.py @@ -0,0 +1,513 @@ +"""Fairness + agent access policy engine — Unit ⑲. + +Layered on :class:`~switchboard.delegation.SpendPolicy`, this module is the +"how the wallet is transacted" rulebook that every MCP server and Router call +must pass before acting. + +Architecture +------------ +Three independent concern layers, evaluated in order:: + + 1. Contract compliance — refuse actions that violate escrow invariants. + 2. SpendPolicy — delegation rules (token allowlist, expiry, per-tx cap). + 3. Tier ceiling — per-agent tier (explorer/standard/trusted) amount cap. + 4. Rate fairness — token-bucket so no single agent starves others. + +Denial short-circuits at the first violated layer. + +Public API +---------- +``check(agent_id, action) -> Decision`` + The single entry-point. ``action`` is a plain dict with at minimum a + ``"type"`` key (``"pay"`` or ``"escrow"``) and an ``"amount"`` key. + Escrow actions additionally carry an ``"escrow_state"`` key. + +``Decision`` + Dataclass with ``allowed: bool``, ``reason: str | None``, ``agent_id: str``, + and ``event: WalletOpEvent`` for metric emission. + +``WalletOpEvent`` + The canonical metric payload from :mod:`switchboard.metrics` (re-exported + here). Fields: ``op_type, token, rail, amount, agent_id, wallet_id, + denied, denial_reason, timestamp``. Denials from this engine and routing + events from the Router are the *same* event type, so both land in the ⑳ + dashboard's spend / denial panels. + +Reason strings (typed literals) +-------------------------------- +``"noncompliant"`` — action would violate escrow contract terms. +``"policy_violation"``— action violates the agent's SpendPolicy. +``"tier_ceiling"`` — action amount exceeds the agent's tier per-tx cap. +``"rate_limited"`` — token-bucket exhausted; agent is contending too hard. + +Token-bucket algorithm +---------------------- +Each (agent_id, tier) pair gets an independent bucket. Burst capacity is +``TokenBucketConfig.capacity`` operations; the bucket refills at +``TokenBucketConfig.rate`` tokens/second. At rate=0 the bucket is strictly +capacity-limited (no refill) — useful for tests and quota-style limits. + +This is a **per-agent** bucket, so one agent cannot drain capacity from +another; fairness is achieved by the fact that every agent's bucket is +independent and bounded. + +Defaults +-------- +Three default tiers (adjustable via ``TierConfig``): + ++-----------+-----------+-------+-----------+ +| Tier | per_tx_cap| rate | capacity | ++===========+===========+=======+===========+ +| explorer | 1 000 | 1 | 10 | ++-----------+-----------+-------+-----------+ +| standard | 10 000 | 10 | 50 | ++-----------+-----------+-------+-----------+ +| trusted | 100 000 | 100 | 200 | ++-----------+-----------+-------+-----------+ + +(Amounts in token base units; rate in tokens/second.) + +Thread safety +------------- +All mutable state is protected by a single ``threading.Lock``. The module-level +``check()`` helper uses a ``threading.local``-backed process-wide instance. +""" + +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum, auto +from typing import Callable, Dict, List, Literal, Optional + + +# --------------------------------------------------------------------------- +# Re-export SpendPolicy so callers have a single import surface. +# --------------------------------------------------------------------------- + +from switchboard.delegation import SpendPolicy # noqa: F401 — re-exported + +# Single canonical WalletOpEvent. The Router (switchboard/router/router.py) and +# the ⑳ metrics dashboard (switchboard/metrics.py) already speak this shape; +# the access-policy engine emits the SAME event so denials flow straight into +# the dashboard's denial-rate / denials-by-reason panels — no separate event +# type, no translation layer. +from switchboard.metrics import WalletOpEvent # noqa: F401 — re-exported + + +# --------------------------------------------------------------------------- +# Enums + config dataclasses +# --------------------------------------------------------------------------- + + +class AgentTier(Enum): + """Access tier assigned to each registered agent.""" + EXPLORER = auto() + STANDARD = auto() + TRUSTED = auto() + + +@dataclass(frozen=True) +class TokenBucketConfig: + """Configuration for one tier's token-bucket rate-limiter. + + Parameters + ---------- + per_tx_cap: + Maximum ``amount`` (token base units) in a single action. + rate: + Refill rate in bucket tokens per second. 0 = no refill (quota mode). + capacity: + Maximum number of bucket tokens (burst ceiling). + """ + per_tx_cap: int + rate: float # tokens / second + capacity: float # max bucket tokens + + +@dataclass(frozen=True) +class TierConfig: + """Per-tier bucket + ceiling configuration. + + Pass a custom ``TierConfig`` to ``AccessPolicy`` to override defaults. + """ + explorer: TokenBucketConfig + standard: TokenBucketConfig + trusted: TokenBucketConfig + + def for_tier(self, tier: AgentTier) -> TokenBucketConfig: + if tier is AgentTier.EXPLORER: + return self.explorer + if tier is AgentTier.STANDARD: + return self.standard + return self.trusted + + +# Sensible production defaults. +_DEFAULT_TIER_CONFIG = TierConfig( + explorer=TokenBucketConfig(per_tx_cap=1_000, rate=1.0, capacity=10), + standard=TokenBucketConfig(per_tx_cap=10_000, rate=10.0, capacity=50), + trusted=TokenBucketConfig(per_tx_cap=100_000, rate=100.0, capacity=200), +) + + +# --------------------------------------------------------------------------- +# WalletOpEvent — imported from switchboard.metrics (single canonical event). +# See the import at the top of this module. Fields: +# op_type, token, rail, amount, agent_id, wallet_id, denied, +# denial_reason, timestamp +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# Decision — the return value of check() +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Decision: + """Result of ``AccessPolicy.check()``. + + Satisfies **two** interfaces from one object: + + * the native fairness-engine view — ``allowed`` / ``reason`` / ``event``; + * the MCP ``AccessPolicy`` Protocol view (``switchboard/tools.py``) — + ``denied`` / ``reason``. ``denied`` is the boolean complement of + ``allowed``, exposed as a property so the MCP server can gate calls with + ``if decision.denied: ...`` without a translation shim. + + Parameters + ---------- + agent_id: + Echoed from the call — useful for logging / routing. + allowed: + ``True`` iff the action may proceed. + reason: + ``None`` when allowed; one of ``"noncompliant"``, ``"policy_violation"``, + ``"tier_ceiling"``, ``"rate_limited"`` when denied. + event: + Metric payload always present — pass to an event bus or discard. + """ + agent_id: str + allowed: bool + reason: Optional[str] + event: WalletOpEvent + + @property + def denied(self) -> bool: + """MCP Protocol view: the complement of ``allowed``.""" + return not self.allowed + + +# --------------------------------------------------------------------------- +# Internal per-agent bucket state +# --------------------------------------------------------------------------- + + +@dataclass +class _BucketState: + tokens: float + last_refill: float # monotonic timestamp + + +# --------------------------------------------------------------------------- +# AccessPolicy +# --------------------------------------------------------------------------- + + +class AccessPolicy: + """Per-agent access-tier + rate-fairness + contract-compliance gate. + + Parameters + ---------- + tier_config: + Override per-tier ceilings and bucket parameters. + Defaults to ``_DEFAULT_TIER_CONFIG``. + clock: + Injectable ``() -> float`` returning the current monotonic time in + seconds. Defaults to ``time.monotonic``. Pass a controllable clock + in tests. + event_listener: + Optional ``(WalletOpEvent) -> None`` callback invoked after each + ``check()`` call — use to feed a metrics backend. + """ + + def __init__( + self, + tier_config: Optional[TierConfig] = None, + clock: Callable[[], float] = time.monotonic, + event_listener: Optional[Callable[[WalletOpEvent], None]] = None, + ) -> None: + self._tier_config: TierConfig = tier_config or _DEFAULT_TIER_CONFIG + self._clock = clock + self._event_listener = event_listener + self._lock = threading.Lock() + + # agent_id -> (tier, SpendPolicy | None) + self._agents: Dict[str, tuple[AgentTier, Optional[SpendPolicy]]] = {} + # agent_id -> _BucketState (one per agent, isolated) + self._buckets: Dict[str, _BucketState] = {} + + # ------------------------------------------------------------------ + # Registration + # ------------------------------------------------------------------ + + def register( + self, + agent_id: str, + tier: AgentTier, + spend_policy: Optional[SpendPolicy] = None, + ) -> None: + """Register an agent with a tier and optional SpendPolicy. + + Safe to call multiple times; subsequent calls update the tier and + policy while preserving the existing bucket state. + """ + with self._lock: + self._agents[agent_id] = (tier, spend_policy) + if agent_id not in self._buckets: + cfg = self._tier_config.for_tier(tier) + self._buckets[agent_id] = _BucketState( + tokens=cfg.capacity, + last_refill=self._clock(), + ) + + def set_tier(self, agent_id: str, tier: AgentTier) -> None: + """Upgrade or downgrade an agent's tier. + + Bucket capacity is reset to the new tier's capacity. + """ + with self._lock: + _, policy = self._agents.get(agent_id, (AgentTier.EXPLORER, None)) + self._agents[agent_id] = (tier, policy) + cfg = self._tier_config.for_tier(tier) + self._buckets[agent_id] = _BucketState( + tokens=cfg.capacity, + last_refill=self._clock(), + ) + + # ------------------------------------------------------------------ + # check() — the single public gate + # ------------------------------------------------------------------ + + def check(self, agent_id: str, action) -> Decision: + """Evaluate ``action`` for ``agent_id`` and return a ``Decision``. + + Parameters + ---------- + agent_id: + The agent attempting the action. + action: + Either a rich action **dict** (native fairness-engine form) or a + plain **op-name string** (MCP ``AccessPolicy`` Protocol form, e.g. + ``"pay"`` / ``"create_escrow"``). + + Dict form — at minimum: + + - ``"type"`` — ``"pay"`` or ``"escrow"`` + - ``"amount"`` — token base units (int) + + For escrow actions, also include: + + - ``"escrow_state"`` — one of ``"open"``, ``"confirmed"``, + ``"released"``, ``"refunded"``, ``"cancelled"`` + + String form — the MCP server passes the tool op-name only; there is + no amount to check, so the amount-based compliance/ceiling checks are + skipped and the call is gated purely by registration + tier rate + fairness. This is what lets the SAME engine satisfy the MCP + ``check(agent_id, action) -> Decision`` Protocol. + + Returns + ------- + Decision + Always returned (never raises). Native callers read + ``decision.allowed``; the MCP server reads ``decision.denied``. + """ + # Normalise the MCP op-name-string form into the dict shape. No + # ``amount`` key is injected: the amount-based layers only fire when an + # amount is actually declared (see _check_compliance / tier ceiling). + if isinstance(action, str): + action = {"type": action} + + with self._lock: + tier, spend_policy = self._agents.get( + agent_id, (AgentTier.EXPLORER, None) + ) + # Ensure bucket exists for unregistered agents. + if agent_id not in self._buckets: + cfg = self._tier_config.for_tier(tier) + self._buckets[agent_id] = _BucketState( + tokens=cfg.capacity, + last_refill=self._clock(), + ) + + # --- Layer 1: contract compliance --------------------------------- + compliance_reason = self._check_compliance(action) + if compliance_reason is not None: + return self._deny(agent_id, compliance_reason, action) + + # --- Layer 2: SpendPolicy ----------------------------------------- + if spend_policy is not None: + policy_reason = self._check_spend_policy(spend_policy, action) + if policy_reason is not None: + return self._deny(agent_id, policy_reason, action) + + # --- Layer 3: tier ceiling ---------------------------------------- + cfg = self._tier_config.for_tier(tier) + if "amount" in action and action["amount"] > cfg.per_tx_cap: + return self._deny(agent_id, "tier_ceiling", action) + + # --- Layer 4: token-bucket rate fairness -------------------------- + bucket = self._buckets[agent_id] + self._refill_bucket(bucket, cfg) + if bucket.tokens < 1.0: + return self._deny(agent_id, "rate_limited", action) + + bucket.tokens -= 1.0 + return self._allow(agent_id, action) + + # ------------------------------------------------------------------ + # Internal checkers + # ------------------------------------------------------------------ + + def _check_compliance(self, action: dict) -> Optional[str]: + """Return a denial reason string or None if compliant. + + The positive-amount rule only fires when an ``amount`` is *declared*. + The MCP op-name form carries no amount (there is nothing to settle yet — + the amount is validated later by the SpendPolicy in ``Delegation``), so + it is not treated as a zero-amount violation. + """ + if "amount" in action and action["amount"] <= 0: + # A declared non-positive amount is always non-compliant. + return "noncompliant" + + # Escrow-specific: refuse interactions with terminal-state escrows. + if action.get("type") == "escrow": + terminal_states = {"released", "refunded", "cancelled"} + if action.get("escrow_state", "open") in terminal_states: + return "noncompliant" + + return None + + def _check_spend_policy(self, policy: SpendPolicy, action: dict) -> Optional[str]: + """Return ``"policy_violation"`` if any SpendPolicy rule is violated. + + Expiry is always enforced. The data-dependent rules (token allowlist, + counterparty allowlist, per-tx cap) only fire when the corresponding key + is present in the action — so the MCP op-name form (which carries no + token / payee / amount) passes this gate and is enforced later against + the full request inside ``Delegation.pay_with_key``. + """ + now_utc = datetime.now(timezone.utc) + expires = policy.expires_at + if expires.tzinfo is None: + expires = expires.replace(tzinfo=timezone.utc) + if now_utc >= expires: + return "policy_violation" + + if "token" in action and policy.token_allowlist is not None: + if action["token"] not in policy.token_allowlist: + return "policy_violation" + + if "payee" in action and policy.allowed_counterparties is not None: + if action["payee"] not in policy.allowed_counterparties: + return "policy_violation" + + if "amount" in action and policy.per_tx_cap is not None: + if action["amount"] > policy.per_tx_cap: + return "policy_violation" + + return None + + def _refill_bucket(self, bucket: _BucketState, cfg: TokenBucketConfig) -> None: + """Add tokens earned since last refill, capped at capacity.""" + now = self._clock() + elapsed = now - bucket.last_refill + if elapsed > 0 and cfg.rate > 0: + earned = elapsed * cfg.rate + bucket.tokens = min(cfg.capacity, bucket.tokens + earned) + bucket.last_refill = now + + # ------------------------------------------------------------------ + # Decision builders + # ------------------------------------------------------------------ + + def _event( + self, + agent_id: str, + denied: bool, + reason: Optional[str], + action: object, + ) -> WalletOpEvent: + """Build the canonical ``metrics.WalletOpEvent`` for a check result. + + ``action`` is normally the ``check()`` action dict, but the MCP-facing + Protocol form passes a plain op-name string (see ``check()``); both are + handled so the same event shape reaches the ⑳ dashboard either way. + The ``rail`` and ``wallet_id`` are unknown at the policy layer (the + Router assigns them) so they are left empty here. + """ + if isinstance(action, dict): + op_type = str(action.get("type", "policy_check")) + token = str(action.get("token", "") or "") + amount = float(action.get("amount", 0) or 0) + else: + # MCP Protocol form: action is the tool op-name string. + op_type = str(action) if action else "policy_check" + token = "" + amount = 0.0 + return WalletOpEvent( + op_type=op_type, + token=token, + rail="", + amount=amount, + agent_id=agent_id, + wallet_id="", + denied=denied, + denial_reason=reason, + timestamp=time.time(), + ) + + def _deny(self, agent_id: str, reason: str, action: object = None) -> Decision: + evt = self._event(agent_id, True, reason, action) + d = Decision(agent_id=agent_id, allowed=False, reason=reason, event=evt) + if self._event_listener is not None: + self._event_listener(evt) + return d + + def _allow(self, agent_id: str, action: object = None) -> Decision: + evt = self._event(agent_id, False, None, action) + d = Decision(agent_id=agent_id, allowed=True, reason=None, event=evt) + if self._event_listener is not None: + self._event_listener(evt) + return d + + +# --------------------------------------------------------------------------- +# Module-level process-wide default (convenience helper) +# --------------------------------------------------------------------------- + +_default_policy: Optional[AccessPolicy] = None +_default_policy_lock = threading.Lock() + + +def _get_default_policy() -> AccessPolicy: + global _default_policy + with _default_policy_lock: + if _default_policy is None: + _default_policy = AccessPolicy() + return _default_policy + + +def check(agent_id: str, action: dict) -> Decision: + """Check ``action`` for ``agent_id`` using the process-wide ``AccessPolicy``. + + Convenience wrapper for the MCP server and Router — they can call this + without instantiating an ``AccessPolicy``. For production code that needs + custom tiers or event listeners, instantiate ``AccessPolicy`` directly. + """ + return _get_default_policy().check(agent_id, action) diff --git a/switchboard/adapters/hanzo.py b/switchboard/adapters/hanzo.py new file mode 100644 index 0000000..b4dace1 --- /dev/null +++ b/switchboard/adapters/hanzo.py @@ -0,0 +1,464 @@ +"""Hanzo.ai MCP compatibility adapter for Switchboard (Unit ②-H). + +Two concerns in one module: + +1. **x402 interop** — bridges Hanzo MCP's ``fetch`` tool and switchboard's + HTTP 402 / x402 payment envelope so a Hanzo agent can discover + ``accepts[]``, fund a call, and pay through switchboard without + hand-rolling the translation. + + Concrete mismatch fixed here + ---------------------------- + Switchboard's ``X402Server.build_402_response()`` puts payment details + under a ``payment_requirements`` key in the JSON body:: + + {"error": "payment_required", + "payment_requirements": { "scheme": ..., "payTo": ..., ... }} + + The Hanzo ``fetch`` tool's ``parsePaymentRequired()`` looks for + ``body.accepts`` (a top-level array per the x402.org v2 spec):: + + if (Array.isArray(body.accepts)) { accepts = body.accepts; } + + ``normalize_402_body()`` in this adapter re-shapes the switchboard body + so ``accepts`` is top-level — making it visible to the Hanzo tool. + ``build_hanzo_402_body()`` lets you generate a Hanzo-native 402 body + directly when you control the server. + +2. **HanzoAgentWallet** — maps a hanzo.ai agent identity + (``owner/name``, e.g. ``"admin/my-bot"``) to a switchboard + ``AgentWallet`` plus a scoped, revocable ``SessionKey``. The Hanzo + agent operates *its own* wallet on switchboard, gated by the + ``SpendPolicy`` / ``AccessPolicy`` fairness layers. + +Wire-up example:: + + from switchboard.adapters.hanzo import HanzoAgentWallet + from switchboard.delegation import SpendPolicy + from datetime import datetime, timezone, timedelta + + USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + + hab = HanzoAgentWallet( + hanzo_agent_id="admin/my-bot", + policy=SpendPolicy( + expires_at=datetime.now(timezone.utc) + timedelta(hours=8), + token_allowlist=[USDC], + per_tx_cap=50_000_000, # 50 USDC + daily_cap=500_000_000, # 500 USDC / day + ), + ) + hab.credit(chain_id=8453, token=USDC, amount=1_000_000_000) + receipt = hab.pay(chain_id=8453, token=USDC, amount=10_000_000, + payee="0xServiceProvider") +""" + +from __future__ import annotations + +import base64 +import json +from dataclasses import dataclass, field +from datetime import datetime, timezone, timedelta +from typing import Any, Dict, List, Optional, Tuple + +from switchboard.agent_wallet import AgentWallet, PaymentReceipt +from switchboard.delegation import Delegation, SessionKey, SpendPolicy +from switchboard.mpc_wallet import MPCWallet +from switchboard.treasury import Treasury +from switchboard.x402.server import ( + AcceptedToken, + PaymentRequirements, + PAYMENT_HEADER, + PAYMENT_PROOF_HEADER, + WWW_AUTHENTICATE_X402, +) + +# The x402.org version that hanzoai/mcp targets. +HANZO_X402_VERSION = "1" + + +# --------------------------------------------------------------------------- +# Section 1: x402 envelope interop +# --------------------------------------------------------------------------- + + +def normalize_402_body(body: Dict[str, Any]) -> Dict[str, Any]: + """Re-shape a switchboard 402 body so Hanzo's ``fetch`` tool can find ``accepts``. + + Switchboard's ``X402Server.build_402_response()`` puts payment details + under ``body["payment_requirements"]``. The Hanzo ``fetch`` tool parses + ``body.accepts`` (top-level) per the x402.org v2 spec:: + + if (Array.isArray(body.accepts)) { accepts = body.accepts; } + + This function promotes ``payment_requirements.accepts`` (when present) to + the top level and adds ``x402Version`` so the Hanzo tool recognises the + response as a native x402 envelope. + + The original ``payment_requirements`` key is preserved for back-compat with + other consumers (e.g. A2A adapters) that still read it. + + If the body is already Hanzo-native (has top-level ``accepts``) it is + returned unchanged. + """ + if isinstance(body.get("accepts"), list): + # Already Hanzo-native; ensure x402Version is set. + out = dict(body) + out.setdefault("x402Version", HANZO_X402_VERSION) + return out + + pr: Any = body.get("payment_requirements") + if not isinstance(pr, dict): + return body # nothing to promote — pass through + + out = dict(body) + accepts_raw: List[Dict] = pr.get("accepts", []) + if accepts_raw: + out["accepts"] = accepts_raw + else: + # No multi-token list — synthesise a single-entry accepts[] so Hanzo + # can still parse it without falling back to the raw body path. + entry: Dict[str, Any] = { + "scheme": pr.get("scheme", "exact"), + "network": pr.get("network", "base"), + "asset": pr.get("asset", "USDC"), + "amount": pr.get("amount", "0"), + "payTo": pr.get("payTo", pr.get("pay_to", "")), + } + if pr.get("description"): + entry["description"] = pr["description"] + if pr.get("nonce"): + entry["nonce"] = pr["nonce"] + if pr.get("expiresAt") or pr.get("expires_at"): + entry["expiresAt"] = pr.get("expiresAt") or pr.get("expires_at") + out["accepts"] = [entry] + + out["x402Version"] = HANZO_X402_VERSION + return out + + +def build_hanzo_402_body( + requirements: PaymentRequirements, +) -> Dict[str, Any]: + """Build a 402 response body in Hanzo-native format. + + Returns a dict with top-level ``accepts`` (Hanzo-compatible) AND + ``payment_requirements`` (switchboard back-compat). Suitable for use + as a JSON response body when the server knows its caller is a Hanzo + agent. + """ + pr_dict = json.loads(requirements.to_header()) + body: Dict[str, Any] = { + "error": "payment_required", + "x402Version": HANZO_X402_VERSION, + "payment_requirements": pr_dict, + } + # Top-level accepts: use the multi-token list if present, otherwise + # synthesise from the primary fields. + if requirements.accepts: + body["accepts"] = [t.to_dict() for t in requirements.accepts] + else: + body["accepts"] = [ + { + "scheme": requirements.scheme, + "network": requirements.network, + "asset": requirements.asset, + "amount": requirements.amount, + "payTo": requirements.pay_to, + } + ] + return body + + +def decode_hanzo_payment_header(header_value: str) -> Dict[str, Any]: + """Decode the ``X-PAYMENT`` header that Hanzo's fetch tool sends. + + Hanzo encodes the payment payload as base64(JSON). Returns the decoded + dict. Raises ``ValueError`` on invalid input. + """ + try: + decoded_bytes = base64.b64decode(header_value) + return json.loads(decoded_bytes) + except Exception as exc: + raise ValueError(f"Invalid X-PAYMENT header: {exc}") from exc + + +def encode_hanzo_payment_header(payload: Dict[str, Any]) -> str: + """Encode a payment payload dict as Hanzo's ``X-PAYMENT`` header value. + + Returns a base64-encoded JSON string suitable for the ``X-PAYMENT`` + header. + """ + return base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii") + + +def read_payment_header(headers: Dict[str, str]) -> Tuple[str, str]: + """Return ``(header_name, header_value)`` for the best payment header found. + + Priority: + 1. ``X-PAYMENT`` — Hanzo native (base64 JSON per x402.org v2) + 2. ``X-Payment`` — x402 canonical (same as ``X-PAYMENT``, case variant) + 3. ``X-Payment-Proof`` — switchboard legacy + + Returns ``("", "")`` when no payment header is present. + """ + for name in ("X-PAYMENT", "X-Payment", PAYMENT_HEADER, PAYMENT_PROOF_HEADER): + val = headers.get(name) or headers.get(name.lower(), "") + if val: + return name, val + return "", "" + + +def payment_requirements_from_hanzo_accepts( + accepts: List[Dict[str, Any]], +) -> PaymentRequirements: + """Build a switchboard ``PaymentRequirements`` from a Hanzo ``accepts[]`` list. + + Takes the first entry as the primary requirement (highest-ranked or + first-listed) and wraps the full list into ``AcceptedToken`` objects + for multi-token negotiation. + """ + if not accepts: + raise ValueError("Hanzo accepts[] must be non-empty") + + primary = accepts[0] + tokens: List[AcceptedToken] = [] + for i, entry in enumerate(accepts): + chain_id_raw = entry.get("chain_id") or entry.get("chainId") + if chain_id_raw is not None: + chain_id = int(chain_id_raw) + else: + chain_id = _network_to_chain_id(entry.get("network", "base")) + tokens.append( + AcceptedToken( + chain_id=chain_id, + token=str(entry.get("token", entry.get("asset", "USDC"))), + min_amount=int(entry.get("min_amount", entry.get("amount", 0))), + rank=int(entry.get("rank", len(accepts) - i)), + ) + ) + + return PaymentRequirements( + scheme=primary.get("scheme", "exact"), + network=primary.get("network", "base"), + asset=primary.get("asset", "USDC"), + amount=str(primary.get("amount", "0")), + pay_to=primary.get("payTo", primary.get("pay_to", "")), + description=primary.get("description", ""), + nonce=primary.get("nonce", ""), + expires_at=primary.get("expiresAt") or primary.get("expires_at"), + accepts=tokens, + ) + + +def _network_to_chain_id(network: str) -> int: + """Best-effort network-name to chain_id mapping (mirrors A2A adapter).""" + _MAP: Dict[str, int] = { + "ethereum": 1, + "base": 8453, + "base-sepolia": 84532, + "mainnet": 1, + } + if network in _MAP: + return _MAP[network] + if network.startswith("eip155:"): + return int(network.split(":", 1)[1]) + return 8453 # default to Base + + +# --------------------------------------------------------------------------- +# Section 2: HanzoAgentWallet — agent identity to wallet + session key +# --------------------------------------------------------------------------- + + +@dataclass +class HanzoAgentWallet: + """Binds a hanzo.ai agent identity to a switchboard wallet + session key. + + The Hanzo IAM system identifies agents as ``"owner/name"`` strings + (e.g. ``"admin/my-bot"``). This class: + + * Derives a stable ``agent_id`` from the Hanzo identity. + * Creates (or accepts) an ``AgentWallet`` for the agent. + * Issues a scoped ``SessionKey`` via ``Delegation`` so every payment + goes through ``SpendPolicy`` enforcement and the ``AccessPolicy`` + fairness gate. + + Parameters + ---------- + hanzo_agent_id: + The Hanzo IAM identity string (``"owner/name"`` format). + Used verbatim as the ``agent_id`` in switchboard events, access + policy checks, and ``WalletOpEvent`` attribution. + policy: + ``SpendPolicy`` controlling what this agent may spend. If + ``None``, a permissive 24-hour policy is created (suitable for + tests). + wallet: + Pre-built ``AgentWallet``. When ``None``, a fresh wallet with an + empty treasury is created. Pass a funded wallet for real usage. + delegation: + ``Delegation`` layer. When ``None``, a fresh ``Delegation`` + wrapping ``wallet`` is created. + access_policy: + Optional ``AccessPolicy`` engine wired into the wallet. When + ``None``, the wallet uses no access policy (``SpendPolicy`` alone + gates spending). + """ + + hanzo_agent_id: str + policy: Optional[SpendPolicy] = None + wallet: Optional[AgentWallet] = None + delegation: Optional[Delegation] = None + access_policy: Optional[object] = None + + # Populated in __post_init__ + _session_key: SessionKey = field(init=False, repr=False) + _delegation: Delegation = field(init=False, repr=False) + + def __post_init__(self) -> None: + if self.policy is None: + self.policy = SpendPolicy( + expires_at=datetime.now(timezone.utc) + timedelta(hours=24), + token_allowlist=None, # any token + per_tx_cap=None, # no per-tx cap + daily_cap=None, # no daily cap + ) + + if self.wallet is None: + self.wallet = AgentWallet( + mpc=MPCWallet(), + treasury=Treasury(), + access_policy=self.access_policy, + ) + + if self.delegation is None: + self._delegation = Delegation(wallet=self.wallet) + else: + self._delegation = self.delegation + + self._session_key = self._delegation.grant( + agent_id=self.hanzo_agent_id, + policy=self.policy, + ) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + @property + def agent_id(self) -> str: + """The switchboard agent_id (identical to ``hanzo_agent_id``).""" + return self.hanzo_agent_id + + @property + def session_key(self) -> SessionKey: + """The active ``SessionKey`` for this agent.""" + return self._session_key + + @property + def address(self) -> str: + """EVM address of the underlying ``AgentWallet``.""" + return self.wallet.address() + + def pay( + self, + chain_id: int, + token: str, + amount: int, + payee: str, + metadata: Optional[Dict[str, Any]] = None, + ) -> PaymentReceipt: + """Pay ``amount`` of ``token`` to ``payee`` on ``chain_id``. + + Routes through ``Delegation.pay_with_key()`` so the full + ``SpendPolicy`` (token allowlist, per-tx cap, daily cap, expiry) + is enforced *before* any on-chain action. + + Parameters + ---------- + chain_id: + EIP-155 chain ID. + token: + ERC-20 contract address or zero address for native ETH. + amount: + Amount in the token's smallest unit. + payee: + EVM address of the recipient. + metadata: + Optional dict attached to the ``PaymentRequest`` for routing / + audit purposes. ``agent_id`` is merged in automatically. + + Returns + ------- + PaymentReceipt + Includes ``tx_id``, ``escrow_id``, ``rail``, and ``wallet`` + from the Router (when wired). + + Raises + ------ + PolicyViolation + When the payment would violate the ``SpendPolicy``. + InsufficientBalance + When the treasury cannot cover ``amount``. + AccessDenied + When the ``AccessPolicy`` engine (if wired) denies the action. + """ + from src.payment_protocol import PaymentRequest + + meta = dict(metadata or {}) + meta["agent_id"] = self.hanzo_agent_id + + request = PaymentRequest( + chain_id=chain_id, + token=token, + amount_wei=amount, + payee=payee, + metadata=meta, + ) + return self._delegation.pay_with_key(self._session_key, request) + + def escrow( + self, + chain_id: int, + token: str, + amount: int, + payee: str, + metadata: Optional[Dict[str, Any]] = None, + ) -> PaymentReceipt: + """Convenience alias for ``pay()`` that signals escrow intent. + + Passes ``{"action": "escrow"}`` in metadata so Router / access-policy + engines can distinguish escrow-oriented payments from direct transfers. + The underlying flow is identical — the escrow semantics live in the + ``EscrowClient`` wired to the ``AgentWallet``. + """ + meta = dict(metadata or {}) + meta["action"] = "escrow" + return self.pay(chain_id=chain_id, token=token, amount=amount, + payee=payee, metadata=meta) + + def revoke(self) -> None: + """Revoke the current ``SessionKey``. + + After calling this, ``pay()`` / ``escrow()`` will raise + ``PolicyViolation``. A new ``HanzoAgentWallet`` must be created to + resume payments. + """ + self._delegation.revoke(self._session_key) + + def is_active(self) -> bool: + """Return ``True`` if the session key has not been revoked.""" + return self._delegation.is_active(self._session_key) + + def balance(self, chain_id: int, token: str) -> int: + """Total treasury balance for ``(chain_id, token)``.""" + return self.wallet.balance(chain_id, token) + + def spendable(self, chain_id: int, token: str) -> int: + """Spendable balance (minus reserve) for ``(chain_id, token)``.""" + return self.wallet.spendable(chain_id, token) + + def credit(self, chain_id: int, token: str, amount: int) -> None: + """Add funds to the treasury (test / top-up helper).""" + self.wallet.treasury.credit(chain_id=chain_id, token=token, amount=amount) diff --git a/switchboard/agent_wallet.py b/switchboard/agent_wallet.py new file mode 100644 index 0000000..dc8b8a6 --- /dev/null +++ b/switchboard/agent_wallet.py @@ -0,0 +1,336 @@ +"""AgentWallet — the Python agent-facing wallet (Unit ⑧). + +Wraps ``MPCWallet`` (keeps threshold signing / no single point of failure) +and adds: + +* ``Treasury`` — per-(chain_id, token) balance tracking. +* ``EscrowClient`` — a thin **Protocol** seam for the on-chain escrow. + The real escrow client is NOT available in this worktree yet; tests run + against a mock. Wire the real client in by passing an implementation of + ``EscrowClient`` at construction time. + +The ``pay(request) -> receipt`` entrypoint is the primary agent interface. +It validates the request, debits the treasury, and drives the escrow. + +Seam for future wiring +---------------------- +``EscrowClient`` is a ``typing.Protocol`` with two required methods:: + + create_payment(chain_id, token, amount, payee) -> str # escrow_id + release_payment(escrow_id) -> bool + +Downstream units (the Router, FleetBalancer, etc.) plug in between +``AgentWallet.pay`` and the escrow call — see spec §4.3 and §4.4. + +Usage:: + + from switchboard.mpc_wallet import MPCWallet + from switchboard.treasury import Treasury + from switchboard.agent_wallet import AgentWallet, PaymentRequest + + mpc = MPCWallet() + treasury = Treasury() + treasury.credit(chain_id=1, token=USDC, amount=1_000_000_000) + + wallet = AgentWallet(mpc=mpc, treasury=treasury, escrow=my_escrow_client) + receipt = wallet.pay(PaymentRequest(chain_id=1, token=USDC, amount_wei=100_000_000, payee="0x...")) +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional, Protocol, runtime_checkable + +from switchboard.mpc_wallet import MPCWallet +from switchboard.treasury import Treasury, InsufficientBalance + +# Canonical payment-request type (protocol v1.2, src/payment_protocol.py). +# AgentWallet speaks the *same* PaymentRequest as the settlement protocol so +# there is a single request shape across the wallet, delegation, MCP, and the +# on-chain negotiation. It carries a multi-token ``token`` field and an +# ``amount`` alias for ``amount_wei`` (see src/payment_protocol.py). +from src.payment_protocol import PaymentRequest # noqa: F401 — re-exported + + +class WalletError(RuntimeError): + """Base error for AgentWallet operations.""" + + +class AccessDenied(WalletError): + """Raised when the wired access-policy engine denies a payment. + + Carries the machine-readable ``reason`` (e.g. ``"tier_ceiling"``, + ``"rate_limited"``, ``"policy_violation"``, ``"noncompliant"``) so callers + can branch on it without string-matching the message. + """ + + def __init__(self, reason: Optional[str]) -> None: + self.reason = reason + super().__init__(f"access denied by policy: {reason}") + + +# --------------------------------------------------------------------------- +# EscrowClient — the thin seam for the (not-yet-available) on-chain client. +# --------------------------------------------------------------------------- + +@runtime_checkable +class EscrowClient(Protocol): + """Protocol that any on-chain escrow client must satisfy. + + Seam note + --------- + The real ``MultiTokenAgentEscrow`` client (Unit ① / ③) is not present in + this worktree. Tests pass a ``MagicMock(spec=EscrowClient)`` instead. + When the escrow client lands, wire it in by passing an instance that + implements these two methods. + + ``create_payment`` creates an escrow entry and returns an opaque + escrow_id string. ``release_payment`` triggers release of held funds. + """ + + def create_payment( + self, + chain_id: int, + token: str, + amount: int, + payee: str, + ) -> str: + """Create an escrow entry; return an opaque escrow_id.""" + ... + + def release_payment(self, escrow_id: str) -> bool: + """Release the escrowed funds to the payee; return True on success.""" + ... + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- +# +# ``PaymentRequest`` is imported from ``src.payment_protocol`` (the canonical +# protocol type) at the top of this module — AgentWallet no longer defines its +# own. A request must carry ``chain_id``, ``token``, ``amount`` (alias of +# ``amount_wei``), and ``payee``. + + +@dataclass(frozen=True) +class PaymentReceipt: + """Returned by ``AgentWallet.pay()`` on success. + + ``rail`` and ``wallet`` are populated when a ``Router`` is wired (Seam 4): + they record the settlement rail and the signing-wallet address the Router + selected. They default to ``None`` when the wallet runs without a Router + (the pre-Router direct path), so downstream consumers stay back-compatible. + """ + + tx_id: str # The MPC-signed tx hash or escrow_id + chain_id: int + token: str + amount: int + payee: str + escrow_id: Optional[str] = None + rail: Optional[str] = None # Router-selected rail: x402 / escrow / mpp + wallet: Optional[str] = None # Router-selected signing wallet address + + +# --------------------------------------------------------------------------- +# AgentWallet +# --------------------------------------------------------------------------- + +class AgentWallet: + """Agent-facing wallet: wraps MPCWallet, manages Treasury, drives escrow. + + Parameters + ---------- + mpc: + The underlying MPC threshold-signing wallet (must not be None). + treasury: + Per-(chain_id, token) balance store. If None, a fresh empty + Treasury is created (useful for tests that inject balances later). + escrow: + An object satisfying the ``EscrowClient`` Protocol. If None, a + no-op stub is used (payments will not hit any chain — only for + testing treasury logic in isolation). + router: + Optional ``switchboard.router.Router`` (Seam 4). When provided, + ``pay()`` routes each payment through it to pick ``(token, rail, + wallet)`` and the Router emits its ``WalletOpEvent``. When ``None``, + ``pay()`` takes the direct path (the request's own token; no routing). + access_policy: + Optional access-policy engine satisfying ``check(agent_id, action) -> + decision`` with a ``.denied`` attribute (Unit ⑲ + ``switchboard.access_policy.AccessPolicy``). When provided, ``pay()`` + consults it **before** signing and refuses a denied payment with + ``AccessDenied``. When ``None``, no extra gate is applied (the + Delegation layer's ``SpendPolicy`` still runs upstream). + """ + + def __init__( + self, + mpc: Optional[MPCWallet] = None, + treasury: Optional[Treasury] = None, + escrow: Optional[EscrowClient] = None, + router: Optional[object] = None, + access_policy: Optional[object] = None, + ) -> None: + self._mpc = mpc if mpc is not None else MPCWallet() + self.treasury: Treasury = treasury if treasury is not None else Treasury() + self._escrow: EscrowClient = escrow if escrow is not None else _NoOpEscrow() + self._router = router + self._access_policy = access_policy + + # ------------------------------------------------------------------ + # Identity + # ------------------------------------------------------------------ + + def address(self) -> str: + """Return the wallet's EVM address (from the underlying MPCWallet).""" + return self._mpc.address() + + # ------------------------------------------------------------------ + # Treasury delegation + # ------------------------------------------------------------------ + + def balance(self, chain_id: int, token: str) -> int: + """Total balance for (chain_id, token).""" + return self.treasury.balance(chain_id, token) + + def spendable(self, chain_id: int, token: str) -> int: + """Spendable balance (balance minus reserve) for (chain_id, token).""" + return self.treasury.spendable(chain_id, token) + + # ------------------------------------------------------------------ + # pay() + # ------------------------------------------------------------------ + + def pay( + self, + request: PaymentRequest, + agent_id: str = "", + candidates: Optional[list] = None, + ) -> PaymentReceipt: + """Execute a payment on behalf of the wallet. + + Flow (Seam 4 — Router + access-policy wired in) + ----------------------------------------------- + 1. Validate the request (non-zero amount). + 2. **Access-policy gate** (if wired): ``access_policy.check(agent_id, + action)`` — a denied decision raises ``AccessDenied`` *before* any + balance is touched or anything is signed. + 3. **Route** (if wired): ``Router.route(...)`` selects ``(token, rail, + wallet)`` and emits its ``WalletOpEvent``. Without a Router the + request's own token is used and no routing event is emitted. + 4. Check spendable balance, then debit the treasury atomically. + 5. Sign via ``MPCWallet``. + 6. Create and release an escrow entry via the ``EscrowClient`` seam. + 7. Return a ``PaymentReceipt`` (carrying rail/wallet when routed). + + Parameters + ---------- + request: + The canonical ``PaymentRequest``. + agent_id: + Logical agent identity — forwarded to the access-policy check and + the Router's ``WalletOpEvent``. Defaults to + ``request.metadata['agent_id']`` if present, else ``""``. + candidates: + Optional list of ``TokenCandidate`` for the Router's TokenSelector. + When omitted, a single candidate built from ``request.token`` is + used so routing is a no-op token-wise but still selects rail/wallet + and emits the event. + + Design note (challenge period) + ------------------------------ + This path releases the escrow synchronously (create → release) rather + than waiting out the on-chain challenge period. That is a deliberate, + documented decision for the wallet-side happy path (see spec §4.4); the + contract's challenge/timeout semantics are unchanged and enforced + on-chain. + """ + if request.amount <= 0: + raise WalletError(f"Payment amount must be positive, got {request.amount}") + + agent_id = agent_id or (request.metadata or {}).get("agent_id", "") + + # ── Step 2: access-policy gate (before signing) ───────────────────── + if self._access_policy is not None: + decision = self._access_policy.check( + agent_id, + { + "type": "pay", + "amount": request.amount, + "token": request.token, + "payee": request.payee, + }, + ) + if getattr(decision, "denied", False): + raise AccessDenied(getattr(decision, "reason", None)) + + # ── Step 3: route (token / rail / wallet selection + event) ───────── + rail: Optional[str] = None + signing_wallet: Optional[str] = None + token = request.token + if self._router is not None: + if candidates is None: + from switchboard.router.token_selector import TokenCandidate + candidates = [TokenCandidate(token=request.token)] + plan = self._router.route( + chain_id=request.chain_id, + amount=request.amount, + candidates=candidates, + agent_id=agent_id, + ) + token = plan.token + rail = plan.rail + signing_wallet = plan.wallet + + # ── Step 4: balance check + debit ─────────────────────────────────── + spendable = self.treasury.spendable(request.chain_id, token) + if spendable < request.amount: + raise InsufficientBalance( + f"Insufficient spendable balance: have {spendable}, need {request.amount}" + ) + self.treasury.debit(request.chain_id, token, request.amount) + + # ── Step 5: sign via MPC ──────────────────────────────────────────── + tx = { + "chain_id": request.chain_id, + "token": token, + "amount": request.amount, + "payee": request.payee, + } + tx_hash = self._mpc.sign_and_send(tx) + + # ── Step 6: create + release escrow ───────────────────────────────── + escrow_id = self._escrow.create_payment( + chain_id=request.chain_id, + token=token, + amount=request.amount, + payee=request.payee, + ) + self._escrow.release_payment(escrow_id) + + return PaymentReceipt( + tx_id=tx_hash, + chain_id=request.chain_id, + token=token, + amount=request.amount, + payee=request.payee, + escrow_id=escrow_id, + rail=rail, + wallet=signing_wallet, + ) + + +# --------------------------------------------------------------------------- +# No-op stub used when no EscrowClient is provided. +# --------------------------------------------------------------------------- + +class _NoOpEscrow: + """Stub escrow that does nothing — useful for treasury-only tests.""" + + def create_payment(self, chain_id: int, token: str, amount: int, payee: str) -> str: + return "0xnoop" + + def release_payment(self, escrow_id: str) -> bool: + return True diff --git a/switchboard/cli.py b/switchboard/cli.py new file mode 100644 index 0000000..2e0e59f --- /dev/null +++ b/switchboard/cli.py @@ -0,0 +1,378 @@ +"""CLI — switchboard wallet / escrow / metrics commands. + +Unit ⑯ of the agent-wallet-multitoken-settlement plan. + +Registered as a console-script in ``pyproject.toml``:: + + switchboard wallet balance|grant|revoke + switchboard escrow create|confirm|refund|status + switchboard metrics + +All commands share a single ``--wallet-id`` and ``--session-key`` option for +identifying the active wallet and session key; the underlying operations are +driven through the same ``AgentWallet`` + ``Delegation`` core that the MCP +server uses. + +Tool definitions are read from ``switchboard/tools.py`` (the ⑰ registry) — +CLI and MCP do NOT duplicate schemas. + +Running +------- + # After installation: + switchboard wallet balance --chain-id 1 + + # Direct module invocation (no install required): + python -m switchboard.cli wallet balance --chain-id 1 + +Design notes +------------ +- State (wallet, keys) is ephemeral per CLI invocation. A real deployment + persists keys in a secure store; that's a follow-up wiring task. +- The access-policy seam is identical to the MCP server: pass + ``access_policy=`` to ``build_delegation_from_cli_context`` if you have + a Unit ⑲ implementation available. +- Output is JSON-formatted to stdout so it can be piped / scripted. +""" + +from __future__ import annotations + +import json +import sys +from datetime import datetime, timedelta, timezone +from typing import Optional + +import click + +from switchboard.agent_wallet import AgentWallet, PaymentRequest +from switchboard.delegation import Delegation, PolicyViolation, SpendPolicy +from switchboard.metrics import ( + compute_all_metrics, + EscrowEvent, + EscrowState, + WalletOpEvent, +) +from switchboard.tools import AllowAllPolicy, get_registry + + +# --------------------------------------------------------------------------- +# Process-level singletons (ephemeral; real deploy wires persistent store) +# --------------------------------------------------------------------------- + +_wallet: Optional[AgentWallet] = None +_delegation: Optional[Delegation] = None + +def _get_wallet() -> AgentWallet: + global _wallet + if _wallet is None: + _wallet = AgentWallet() + return _wallet + + +def _get_delegation() -> Delegation: + global _delegation + if _delegation is None: + _delegation = Delegation(wallet=_get_wallet()) + return _delegation + + +def _out(data) -> None: + """Write ``data`` as pretty JSON to stdout.""" + click.echo(json.dumps(data, indent=2, default=str)) + + +def _err_exit(msg: str, code: int = 1) -> None: + click.echo(json.dumps({"error": msg}), err=True) + sys.exit(code) + + +# --------------------------------------------------------------------------- +# Root group +# --------------------------------------------------------------------------- + +@click.group() +@click.version_option(version="0.1.0", prog_name="switchboard") +def cli(): + """Switchboard — programmable agent payments. + + Commands: + + wallet — manage balances, session keys + + escrow — create, confirm, refund, and inspect escrow payments + + metrics — escrow fulfilment + wallet-ops health + + tools — list registered agent tools + + mcp-server — run the MCP server over stdio + """ + + +# --------------------------------------------------------------------------- +# switchboard wallet +# --------------------------------------------------------------------------- + +@cli.group() +def wallet(): + """Wallet commands: balance, grant, revoke.""" + + +@wallet.command("balance") +@click.option("--chain-id", required=True, type=int, help="EVM chain ID.") +@click.option("--token", default=None, help="Token address (omit for all tokens).") +def wallet_balance(chain_id: int, token: Optional[str]) -> None: + """Show wallet balance for a chain (and optionally a specific token).""" + w = _get_wallet() + treasury = w.treasury + if token: + _out({ + "chain_id": chain_id, + "token": token, + "balance": treasury.balance(chain_id, token), + "spendable": treasury.spendable(chain_id, token), + }) + else: + balances = treasury.balances(chain_id) + _out({ + "chain_id": chain_id, + "balances": [ + { + "token": tok, + "balance": bal, + "spendable": treasury.spendable(chain_id, tok), + } + for tok, bal in balances.items() + ], + }) + + +@wallet.command("grant") +@click.option("--agent-id", required=True, help="Logical agent identifier.") +@click.option("--token", "tokens", multiple=True, help="Allowed token address (repeat for multiple). Omit for any token.") +@click.option("--per-tx-cap", type=int, default=None, help="Per-transaction spend cap (base units).") +@click.option("--daily-cap", type=int, default=None, help="Rolling 24-hour spend cap (base units).") +@click.option("--expires-in-hours", type=float, default=24.0, show_default=True, help="Session key TTL in hours.") +@click.option("--counterparty", "counterparties", multiple=True, help="Allowed payee addresses (repeat for multiple). Omit for any.") +def wallet_grant( + agent_id: str, + tokens: tuple, + per_tx_cap: Optional[int], + daily_cap: Optional[int], + expires_in_hours: float, + counterparties: tuple, +) -> None: + """Grant a scoped session key to an agent.""" + expires_at = datetime.now(timezone.utc) + timedelta(hours=expires_in_hours) + policy = SpendPolicy( + expires_at=expires_at, + token_allowlist=list(tokens) if tokens else None, + per_tx_cap=per_tx_cap, + daily_cap=daily_cap, + allowed_counterparties=list(counterparties) if counterparties else None, + ) + key = _get_delegation().grant(agent_id=agent_id, policy=policy) + _out({ + "key_id": key.key_id, + "agent_id": key.agent_id, + "expires_at": expires_at.isoformat(), + "token_allowlist": policy.token_allowlist, + "per_tx_cap": policy.per_tx_cap, + "daily_cap": policy.daily_cap, + "allowed_counterparties": policy.allowed_counterparties, + }) + + +@wallet.command("revoke") +@click.option("--key-id", required=True, help="Session key ID to revoke.") +def wallet_revoke(key_id: str) -> None: + """Revoke an active session key by its ID.""" + delegation = _get_delegation() + with delegation._lock: + key = delegation._keys.get(key_id) + if key is None: + _err_exit(f"Session key {key_id!r} not found or already revoked") + delegation.revoke(key) + _out({"revoked": True, "key_id": key_id}) + + +# --------------------------------------------------------------------------- +# switchboard escrow +# --------------------------------------------------------------------------- + +@cli.group() +def escrow(): + """Escrow commands: create, confirm, refund, status.""" + + +@escrow.command("create") +@click.option("--session-key", required=True, help="Session key ID.") +@click.option("--chain-id", required=True, type=int, help="EVM chain ID.") +@click.option("--token", required=True, help="Token address.") +@click.option("--amount", required=True, type=int, help="Amount in base units.") +@click.option("--payee", required=True, help="Payee EVM address.") +def escrow_create(session_key: str, chain_id: int, token: str, amount: int, payee: str) -> None: + """Create a new escrow-locked payment.""" + delegation = _get_delegation() + with delegation._lock: + key = delegation._keys.get(session_key) + if key is None: + _err_exit(f"Session key {session_key!r} not found or revoked") + + wallet = _get_wallet() + try: + escrow_id = wallet._escrow.create_payment( + chain_id=chain_id, token=token, amount=amount, payee=payee + ) + _out({"escrow_id": escrow_id, "status": "Locked"}) + except Exception as exc: + _err_exit(str(exc)) + + +@escrow.command("confirm") +@click.option("--session-key", required=True, help="Session key ID.") +@click.option("--escrow-id", required=True, help="Escrow ID to release.") +def escrow_confirm(session_key: str, escrow_id: str) -> None: + """Confirm and release an escrowed payment to the payee.""" + delegation = _get_delegation() + with delegation._lock: + key = delegation._keys.get(session_key) + if key is None: + _err_exit(f"Session key {session_key!r} not found or revoked") + + wallet = _get_wallet() + try: + released = wallet._escrow.release_payment(escrow_id) + _out({"escrow_id": escrow_id, "released": released}) + except Exception as exc: + _err_exit(str(exc)) + + +@escrow.command("refund") +@click.option("--session-key", required=True, help="Session key ID.") +@click.option("--escrow-id", required=True, help="Escrow ID to refund.") +@click.option("--reason", default="", help="Reason for the refund request.") +def escrow_refund(session_key: str, escrow_id: str, reason: str) -> None: + """Request a refund for a locked escrow payment.""" + delegation = _get_delegation() + with delegation._lock: + key = delegation._keys.get(session_key) + if key is None: + _err_exit(f"Session key {session_key!r} not found or revoked") + + wallet = _get_wallet() + client = wallet._escrow + try: + if hasattr(client, "request_refund"): + raw = client.request_refund(escrow_id, reason) + ok = bool(raw) if raw is not None else True + else: + ok = True # no-op stub accepts all refund requests + _out({"escrow_id": escrow_id, "refund_requested": ok}) + except Exception as exc: + _err_exit(str(exc)) + + +@escrow.command("status") +@click.option("--session-key", required=True, help="Session key ID.") +@click.option("--escrow-id", required=True, help="Escrow ID to inspect.") +def escrow_status(session_key: str, escrow_id: str) -> None: + """Show the current status of an escrow entry.""" + delegation = _get_delegation() + with delegation._lock: + key = delegation._keys.get(session_key) + if key is None: + _err_exit(f"Session key {session_key!r} not found or revoked") + + wallet = _get_wallet() + client = wallet._escrow + if hasattr(client, "get_status"): + status = client.get_status(escrow_id) + _out({"escrow_id": escrow_id, "status": status}) + else: + # Stub: unknown status — real client wires get_status via IAgentEscrow + _out({"escrow_id": escrow_id, "status": "unknown (stub escrow client)"}) + + +# --------------------------------------------------------------------------- +# switchboard metrics +# --------------------------------------------------------------------------- + +@cli.command("metrics") +@click.option("--chain-id", type=int, default=None, help="Filter to this chain ID.") +def metrics_cmd(chain_id: Optional[int]) -> None: + """Print escrow-fulfilment + wallet-ops metrics (from in-memory store). + + In production this command polls the chain and wallet event logs; + here it operates on the in-memory metrics for demonstration / testing. + """ + # No event history → empty metrics (real deploy polls events from chain) + result = compute_all_metrics( + escrow_events=[], + wallet_ops=[], + escrow_states=[], + ) + _out({ + "escrow": { + "fill_rate": result.escrow.fill_rate, + "timeout_rate": result.escrow.timeout_rate, + "refund_rate": result.escrow.refund_rate, + "challenge_rate": result.escrow.challenge_rate, + "avg_time_to_release_s": result.escrow.avg_time_to_release_s, + "total_count": result.escrow.total_count, + "pending_count": result.escrow.pending_count, + }, + "wallet_ops": { + "total_ops": result.wallet_ops.total_ops, + "spend_by_token": result.wallet_ops.spend_by_token, + "spend_by_rail": result.wallet_ops.spend_by_rail, + "policy_denial_count": result.wallet_ops.policy_denial_count, + }, + "fleet": { + "active_wallet_count": result.fleet.active_wallet_count, + }, + }) + + +# --------------------------------------------------------------------------- +# switchboard tools +# --------------------------------------------------------------------------- + +@cli.command("tools") +def tools_list() -> None: + """List all registered agent tools (from the ⑰ registry).""" + registry = get_registry() + _out([ + { + "name": t.name, + "description": t.description, + "op": t.op, + "policy": t.policy, + } + for t in registry + ]) + + +# --------------------------------------------------------------------------- +# switchboard mcp-server +# --------------------------------------------------------------------------- + +@cli.command("mcp-server") +def mcp_server_cmd() -> None: + """Run the MCP server over stdio (connect your agent to this endpoint).""" + from switchboard.mcp_server import MCPServer + wallet = _get_wallet() + delegation = _get_delegation() + server = MCPServer(wallet=wallet, delegation=delegation) + server.serve() + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main() -> None: + cli() + + +if __name__ == "__main__": + main() diff --git a/switchboard/delegation.py b/switchboard/delegation.py new file mode 100644 index 0000000..6b63861 --- /dev/null +++ b/switchboard/delegation.py @@ -0,0 +1,286 @@ +"""Session-key delegation + SpendPolicy enforcement (Unit ⑨). + +``grant(agent_id, policy) -> SessionKey`` issues a scoped, revocable, +time-boxed session key. ``Delegation.pay_with_key(key, request)`` enforces +every rule in the ``SpendPolicy`` *before* asking the ``AgentWallet`` to +co-sign, so a compromised agent is always bounded by its policy. + +Policy enforcement order (fail-fast) +------------------------------------- +1. Revoked? → ``PolicyViolation("revoked")`` +2. Expired? → ``PolicyViolation("expired")`` +3. Token allowed? → ``PolicyViolation("token not in allowlist")`` +4. Counterparty? → ``PolicyViolation("counterparty not allowed")`` +5. per_tx_cap? → ``PolicyViolation("per_tx_cap exceeded")`` +6. daily_cap? → ``PolicyViolation("daily_cap exceeded")`` +7. Delegate to ``AgentWallet.pay()`` (may raise ``InsufficientBalance``). + +Gas / spend-cap accounting +--------------------------- +Per-tx cap and daily cap are tracked **in token units** (not gas units) using +``GasManager`` from ``switchboard.gas_manager`` — the rolling-window semantics +are identical; we simply repurpose the per-hour window as the per-tx gate +(a trivial check) and the per-day window as the 24-hour spend cap. + +``per_tx_cap`` is enforced as a simple comparison before the GasManager call +so the error message can be specific. The GasManager then tracks the daily +rolling total. + +Module-level helpers +--------------------- +``grant(agent_id, policy)`` and ``revoke(key)`` use a **process-wide default +``Delegation`` instance**. This is a convenience; production code should +instantiate ``Delegation(wallet=...)`` explicitly. + +Seam note +--------- +``Delegation`` receives an ``AgentWallet`` at construction; the wallet holds +the ``EscrowClient`` seam. The real escrow client wires in via +``AgentWallet(escrow=real_client)``. +""" + +from __future__ import annotations + +import secrets +import threading +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Dict, List, Optional + +from switchboard.gas_manager import GasManager, GasLimits, BudgetExhausted +from switchboard.agent_wallet import AgentWallet, PaymentRequest, PaymentReceipt + + +# --------------------------------------------------------------------------- +# Exceptions +# --------------------------------------------------------------------------- + + +class PolicyViolation(RuntimeError): + """Raised when a payment would violate the session key's SpendPolicy.""" + + +# --------------------------------------------------------------------------- +# SpendPolicy +# --------------------------------------------------------------------------- + + +@dataclass +class SpendPolicy: + """Rules constraining what a delegated agent may do. + + Parameters + ---------- + token_allowlist: + Tokens (EVM address strings) the agent may spend. + ``None`` means no restriction (any token). + ``[]`` (empty list) blocks all tokens. + per_tx_cap: + Maximum amount (in the token's base units) per single transaction. + ``None`` means unlimited. + daily_cap: + Maximum cumulative spend (in the token's base units) in any rolling + 24-hour window, enforced via ``GasManager``. + ``None`` means unlimited. + expires_at: + UTC datetime after which the session key is invalid. + allowed_counterparties: + EVM address strings of payees the agent may pay. + ``None`` means no restriction (any payee). + ``[]`` (empty list) blocks all payees. + """ + + expires_at: datetime + token_allowlist: Optional[List[str]] = None + per_tx_cap: Optional[int] = None + daily_cap: Optional[int] = None + allowed_counterparties: Optional[List[str]] = None + + +# --------------------------------------------------------------------------- +# SessionKey +# --------------------------------------------------------------------------- + + +@dataclass +class SessionKey: + """An issued, revocable delegation credential. + + ``key_id`` is a random 32-hex-char string; treat it as opaque. + ``is_active()`` returns False if the key has been explicitly revoked. + Time-based expiry is checked by ``Delegation.pay_with_key()``, not here, + so that revocation and expiry produce distinct error messages. + """ + + key_id: str + agent_id: str + policy: SpendPolicy + _revoked: bool = field(default=False, init=False, repr=False, compare=False) + + def is_active(self) -> bool: + """Return True unless explicitly revoked.""" + return not self._revoked + + def _mark_revoked(self) -> None: + self._revoked = True + + +# --------------------------------------------------------------------------- +# Delegation — the enforcement layer +# --------------------------------------------------------------------------- + + +class Delegation: + """Issues session keys and enforces SpendPolicy on every payment. + + Parameters + ---------- + wallet: + The ``AgentWallet`` this delegation layer wraps. If None, a fresh + wallet with no pre-funded treasury is used (useful in tests that + only check policy enforcement, not actual payment execution). + """ + + def __init__(self, wallet: Optional[AgentWallet] = None) -> None: + self._wallet: AgentWallet = wallet if wallet is not None else AgentWallet() + self._lock = threading.Lock() + # key_id -> SessionKey + self._keys: Dict[str, SessionKey] = {} + # key_id -> GasManager (one per session; tracks daily spend) + self._gas_managers: Dict[str, GasManager] = {} + + # ------------------------------------------------------------------ + # grant / revoke + # ------------------------------------------------------------------ + + def grant(self, agent_id: str, policy: SpendPolicy) -> SessionKey: + """Issue a new session key for ``agent_id`` bound to ``policy``.""" + key_id = secrets.token_hex(16) + key = SessionKey(key_id=key_id, agent_id=agent_id, policy=policy) + + # Build a GasManager with the daily_cap as the rolling-day limit. + # per_tx_cap is enforced as a direct comparison; only daily_cap feeds + # the GasManager so we get accurate rolling-window semantics. + limits = GasLimits( + per_hour=None, # not used at session-key level + per_day=policy.daily_cap, + ) + manager = GasManager(default_limits=limits) + + with self._lock: + self._keys[key_id] = key + self._gas_managers[key_id] = manager + + return key + + def revoke(self, key: SessionKey) -> None: + """Revoke ``key`` so it can no longer authorize payments.""" + with self._lock: + if key.key_id not in self._keys: + raise KeyError(f"SessionKey {key.key_id!r} is not registered with this Delegation") + key._mark_revoked() + del self._keys[key.key_id] + del self._gas_managers[key.key_id] + + def is_active(self, key: SessionKey) -> bool: + """Return True if ``key`` is currently active (not revoked).""" + with self._lock: + return key.key_id in self._keys and key.is_active() + + # ------------------------------------------------------------------ + # pay_with_key — enforcement + delegation to wallet + # ------------------------------------------------------------------ + + def pay_with_key(self, key: SessionKey, request: PaymentRequest) -> PaymentReceipt: + """Enforce SpendPolicy then delegate to the AgentWallet. + + Raises + ------ + PolicyViolation + If any policy rule is violated. + InsufficientBalance + If the treasury cannot cover the request (from AgentWallet). + """ + policy = key.policy + + # 1. Revocation check + with self._lock: + if not key.is_active() or key.key_id not in self._keys: + raise PolicyViolation(f"Session key {key.key_id!r} has been revoked") + gas_manager = self._gas_managers[key.key_id] + + # 2. Expiry check + now_utc = datetime.now(timezone.utc) + expires = policy.expires_at + if expires.tzinfo is None: + expires = expires.replace(tzinfo=timezone.utc) + if now_utc >= expires: + raise PolicyViolation( + f"Session key {key.key_id!r} has expired (expired at {policy.expires_at})" + ) + + # 3. Token allowlist + if policy.token_allowlist is not None: + if request.token not in policy.token_allowlist: + raise PolicyViolation( + f"token {request.token!r} is not in the session key's token allowlist" + ) + + # 4. Counterparty allowlist + if policy.allowed_counterparties is not None: + if request.payee not in policy.allowed_counterparties: + raise PolicyViolation( + f"counterparty {request.payee!r} is not in the session key's " + "allowed_counterparties" + ) + + # 5. per_tx_cap + if policy.per_tx_cap is not None and request.amount > policy.per_tx_cap: + raise PolicyViolation( + f"per_tx_cap exceeded: amount {request.amount} > cap {policy.per_tx_cap}" + ) + + # 6. daily_cap (via GasManager rolling window) + if policy.daily_cap is not None: + if not gas_manager.can_spend("session", request.amount): + raise PolicyViolation( + f"daily_cap exceeded: adding {request.amount} would exceed " + f"daily cap {policy.daily_cap}" + ) + + # All checks passed — delegate to AgentWallet, forwarding the agent + # identity so any wired Router / access-policy engine (Seam 4) attributes + # the WalletOpEvent and the pay-path access check to the right agent. + receipt = self._wallet.pay(request, agent_id=key.agent_id) + + # Record the spend in the GasManager *after* a successful payment. + if policy.daily_cap is not None: + gas_manager.record("session", request.amount) + + return receipt + + +# --------------------------------------------------------------------------- +# Module-level process-wide default Delegation (convenience helpers) +# --------------------------------------------------------------------------- + +_default_delegation: Optional[Delegation] = None +_default_lock = threading.Lock() + + +def _get_default() -> Delegation: + global _default_delegation + with _default_lock: + if _default_delegation is None: + _default_delegation = Delegation() + return _default_delegation + + +def grant(agent_id: str, policy: SpendPolicy) -> SessionKey: + """Issue a session key using the process-wide default ``Delegation``.""" + return _get_default().grant(agent_id, policy) + + +def revoke(key: SessionKey) -> None: + """Revoke a session key previously issued by the process-wide default ``Delegation``.""" + _get_default().revoke(key) diff --git a/switchboard/escrow_adapters.py b/switchboard/escrow_adapters.py new file mode 100644 index 0000000..b401903 --- /dev/null +++ b/switchboard/escrow_adapters.py @@ -0,0 +1,111 @@ +"""In-memory escrow client and swap settlement adapter. + +Used in tests and the thinking-chain demo as a pure-Python stand-in +for the on-chain ``MultiTokenAgentEscrow`` contract. + +``InMemoryEscrowClient`` satisfies ``switchboard.agent_wallet.EscrowClient`` +and additionally exposes ``refund_payment`` and ``get_escrow`` for inspection. + +``SwapSettlementAdapter`` wraps ``InMemoryEscrowClient`` and simulates a +token swap at 1:1 rate before creating the escrow — letting a USDC payer +settle a DAI payee in-process. +""" +from __future__ import annotations + +import uuid +from typing import Dict + + +class InMemoryEscrowClient: + """Pure-Python multi-token escrow store. + + Escrow lifecycle: open -> released | refunded + """ + + def __init__(self) -> None: + self._escrows: Dict[str, dict] = {} + + # ── EscrowClient Protocol ────────────────────────────────────────────── + + def create_payment( + self, + chain_id: int, + token: str, + amount: int, + payee: str, + ) -> str: + """Create an escrow entry; return an opaque escrow_id.""" + eid = f"escrow-{uuid.uuid4().hex[:8]}" + self._escrows[eid] = { + "escrow_id": eid, + "chain_id": chain_id, + "token": token, + "amount": amount, + "payee": payee, + "state": "open", + } + return eid + + def release_payment(self, escrow_id: str) -> bool: + """Release escrowed funds to the payee; return True on success.""" + escrow = self._get_open(escrow_id) + escrow["state"] = "released" + return True + + # ── Extended interface ───────────────────────────────────────────────── + + def refund_payment(self, escrow_id: str) -> bool: + """Refund escrowed funds to the payer; return True on success.""" + escrow = self._get_open(escrow_id) + escrow["state"] = "refunded" + return True + + def get_escrow(self, escrow_id: str) -> dict: + """Return the escrow record dict (for inspection / demo output).""" + if escrow_id not in self._escrows: + raise KeyError(f"Unknown escrow_id: {escrow_id!r}") + return dict(self._escrows[escrow_id]) + + # ── Internal ─────────────────────────────────────────────────────────── + + def _get_open(self, escrow_id: str) -> dict: + if escrow_id not in self._escrows: + raise KeyError(f"Unknown escrow_id: {escrow_id!r}") + escrow = self._escrows[escrow_id] + if escrow["state"] != "open": + raise ValueError( + f"Escrow {escrow_id!r} is not open (state={escrow['state']!r})" + ) + return escrow + + +class SwapSettlementAdapter: + """Wraps InMemoryEscrowClient; simulates a swap then creates an escrow. + + For testing: uses a 1:1 rate so USDC -> DAI is amount-preserving. + In production this would call a DEX aggregator. + """ + + def __init__(self, escrow_client: InMemoryEscrowClient) -> None: + self._escrow = escrow_client + + def swap_and_create( + self, + chain_id: int, + from_token: str, + to_token: str, + amount: int, + payee: str, + ) -> str: + """Swap ``from_token`` to ``to_token`` then create an escrow. + + Returns the escrow_id of the created escrow (denominated in ``to_token``). + """ + # Simulated 1:1 swap (no slippage, no fees in test harness). + out_amount = amount + return self._escrow.create_payment( + chain_id=chain_id, + token=to_token, + amount=out_amount, + payee=payee, + ) diff --git a/switchboard/mcp_server.py b/switchboard/mcp_server.py new file mode 100644 index 0000000..4a5a4be --- /dev/null +++ b/switchboard/mcp_server.py @@ -0,0 +1,483 @@ +"""MCP server over stdio — the "connect-your-agent" surface. + +Unit ⑮ of the agent-wallet-multitoken-settlement plan. + +Implements the Model Context Protocol (MCP) JSON-RPC 2.0 transport over +stdio. Exposes seven tools read from the ⑰ registry: + + wallet_balance, pay, create_escrow, confirm_payment, + request_refund, policy_status, escrow_metrics + +Every call is gated by: + 1. Session-key lookup (``Delegation`` resolves key_id → ``SessionKey``). + 2. Access-policy check via the ``AccessPolicy`` seam (Unit ⑲). + If not wired, ``AllowAllPolicy`` is used (see ``switchboard/tools.py``). + +MCP wire format +--------------- +Requests arrive as newline-delimited JSON objects on stdin:: + + {"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}} + {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"pay","arguments":{...}}} + +Responses are written to stdout, one JSON object per line. + +Initialisation handshake +------------------------ +MCP requires an ``initialize`` / ``initialized`` handshake before tools may +be called. The server responds to ``initialize`` with its capabilities and +marks itself as ready; ``initialized`` is a notification (no response). + +Running the server +------------------ + python -m switchboard.mcp_server + +or (after console-script registration):: + + switchboard mcp-server + +The server reads until EOF, then exits. + +Access-policy seam +------------------ +See ``switchboard/tools.py`` for the ``AccessPolicy`` / ``Decision`` interface. +Wire the real engine at construction:: + + from switchboard.access_policy import AccessPolicy + engine = AccessPolicy() + engine.register("0xAgent", tier=AgentTier.STANDARD, spend_policy=policy) + server = MCPServer(wallet=wallet, delegation=delegation, + access_policy=engine) + server.serve() + +``AccessPolicy.check(agent_id, action)`` returns a ``Decision`` exposing +``denied`` / ``reason`` (the Protocol this server calls) as well as the native +``allowed`` view — a single object satisfies both. ``main()`` below wires the +real engine by default. +""" + +from __future__ import annotations + +import json +import sys +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from switchboard.agent_wallet import AgentWallet, PaymentRequest +from switchboard.delegation import Delegation, PolicyViolation +from switchboard.metrics import ( + AllMetrics, + EscrowEvent, + EscrowState, + WalletOpEvent, + compute_all_metrics, +) +from switchboard.tools import AllowAllPolicy, Decision, get_registry, get_tool + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _ok(req_id: Any, result: Any) -> Dict: + return {"jsonrpc": "2.0", "id": req_id, "result": result} + + +def _err(req_id: Any, code: int, message: str, data: Any = None) -> Dict: + err: Dict[str, Any] = {"code": code, "message": message} + if data is not None: + err["data"] = data + return {"jsonrpc": "2.0", "id": req_id, "error": err} + + +# JSON-RPC error codes +_PARSE_ERROR = -32700 +_INVALID_REQUEST = -32600 +_METHOD_NOT_FOUND = -32601 +_INVALID_PARAMS = -32602 +_INTERNAL_ERROR = -32603 +# Application-level codes (above -32000) +_POLICY_DENIED = -32001 +_SESSION_INVALID = -32002 +_INSUFFICIENT_BAL = -32003 + + +# --------------------------------------------------------------------------- +# MCPServer +# --------------------------------------------------------------------------- + +class MCPServer: + """MCP server that wraps the agent wallet and delegation layer. + + Parameters + ---------- + wallet: + The ``AgentWallet`` instance. If ``None``, a fresh wallet with no + pre-funded treasury is used (useful in tests). + delegation: + The ``Delegation`` layer that manages session keys. If ``None``, + a fresh ``Delegation(wallet=wallet)`` is constructed. + access_policy: + The access-policy engine (Unit ⑲). Defaults to ``AllowAllPolicy``. + Pass the real implementation at integration time. + metrics_store: + Optional pre-populated metrics fixture. In production the server + would poll the chain; in tests inject a static ``AllMetrics``. + in_stream / out_stream: + Override stdin/stdout for testing. + """ + + SERVER_INFO = { + "name": "switchboard-mcp", + "version": "0.1.0", + } + + def __init__( + self, + wallet: Optional[AgentWallet] = None, + delegation: Optional[Delegation] = None, + access_policy: Optional[Any] = None, + metrics_store: Optional[AllMetrics] = None, + in_stream=None, + out_stream=None, + ) -> None: + self._wallet = wallet if wallet is not None else AgentWallet() + self._delegation = ( + delegation if delegation is not None else Delegation(wallet=self._wallet) + ) + self._policy_engine = access_policy if access_policy is not None else AllowAllPolicy() + self._metrics_store: Optional[AllMetrics] = metrics_store + self._in = in_stream if in_stream is not None else sys.stdin + self._out = out_stream if out_stream is not None else sys.stdout + self._initialized = False + + # ------------------------------------------------------------------ + # I/O helpers + # ------------------------------------------------------------------ + + def _write(self, obj: Dict) -> None: + self._out.write(json.dumps(obj) + "\n") + self._out.flush() + + # ------------------------------------------------------------------ + # Main loop + # ------------------------------------------------------------------ + + def serve(self) -> None: + """Read newline-delimited JSON from stdin; write responses to stdout.""" + for raw in self._in: + raw = raw.strip() + if not raw: + continue + try: + msg = json.loads(raw) + except json.JSONDecodeError as exc: + self._write(_err(None, _PARSE_ERROR, f"Parse error: {exc}")) + continue + + resp = self._dispatch(msg) + if resp is not None: + self._write(resp) + + def handle_message(self, msg: Dict) -> Optional[Dict]: + """Process a single parsed message; return response or None. + + Exposed for unit-testing individual messages without the I/O loop. + """ + return self._dispatch(msg) + + # ------------------------------------------------------------------ + # Dispatcher + # ------------------------------------------------------------------ + + def _dispatch(self, msg: Dict) -> Optional[Dict]: + req_id = msg.get("id") + method = msg.get("method", "") + params = msg.get("params") or {} + + # Notifications (no id) get no response + is_notification = "id" not in msg + + if method == "initialize": + return self._handle_initialize(req_id, params) + + if method == "initialized": + # Client notification — no response + self._initialized = True + return None + + if method == "tools/list": + return self._handle_tools_list(req_id) + + if method == "tools/call": + if not self._initialized: + return _err(req_id, _INVALID_REQUEST, "Server not yet initialized") + return self._handle_tools_call(req_id, params) + + if method == "ping": + return _ok(req_id, {}) + + if is_notification: + return None + + return _err(req_id, _METHOD_NOT_FOUND, f"Method not found: {method!r}") + + # ------------------------------------------------------------------ + # MCP lifecycle handlers + # ------------------------------------------------------------------ + + def _handle_initialize(self, req_id: Any, params: Dict) -> Dict: + self._initialized = True + return _ok(req_id, { + "protocolVersion": "2024-11-05", + "serverInfo": self.SERVER_INFO, + "capabilities": { + "tools": {}, + }, + }) + + def _handle_tools_list(self, req_id: Any) -> Dict: + tools_payload = [ + { + "name": t.name, + "description": t.description, + "inputSchema": t.schema, + } + for t in get_registry() + ] + return _ok(req_id, {"tools": tools_payload}) + + # ------------------------------------------------------------------ + # tools/call dispatcher + # ------------------------------------------------------------------ + + def _handle_tools_call(self, req_id: Any, params: Dict) -> Dict: + tool_name = params.get("name") + arguments: Dict = params.get("arguments") or {} + + tool_def = get_tool(tool_name) + if tool_def is None: + return _err(req_id, _METHOD_NOT_FOUND, f"Unknown tool: {tool_name!r}") + + # Resolve session key (all tools require one) + key_id = arguments.get("session_key") + if not key_id: + return _err(req_id, _INVALID_PARAMS, "Missing required field: session_key") + + key = self._resolve_key(key_id) + if key is None: + return _err(req_id, _SESSION_INVALID, f"Session key {key_id!r} not found or revoked") + + # Access-policy gate + decision: Decision = self._policy_engine.check( + agent_id=key.agent_id, action=tool_def.op + ) + if decision.denied: + return _err( + req_id, _POLICY_DENIED, + f"Access denied for action {tool_def.op!r}: {decision.reason}", + ) + + # Dispatch to the concrete handler + try: + return self._call_tool(req_id, tool_def.op, key, arguments) + except PolicyViolation as exc: + return _err(req_id, _POLICY_DENIED, str(exc)) + except Exception as exc: # noqa: BLE001 + return _err(req_id, _INTERNAL_ERROR, str(exc)) + + # ------------------------------------------------------------------ + # Concrete tool handlers + # ------------------------------------------------------------------ + + def _resolve_key(self, key_id: str): + """Find the SessionKey by key_id across the Delegation's key store.""" + # Delegation stores keys in _keys dict; we need to look up by key_id. + with self._delegation._lock: + return self._delegation._keys.get(key_id) + + def _call_tool(self, req_id: Any, op: str, key, args: Dict) -> Dict: + if op == "wallet_balance": + return self._op_wallet_balance(req_id, key, args) + if op == "pay": + return self._op_pay(req_id, key, args) + if op == "create_escrow": + return self._op_create_escrow(req_id, key, args) + if op == "confirm_payment": + return self._op_confirm_payment(req_id, key, args) + if op == "request_refund": + return self._op_request_refund(req_id, key, args) + if op == "policy_status": + return self._op_policy_status(req_id, key, args) + if op == "escrow_metrics": + return self._op_escrow_metrics(req_id, key, args) + return _err(req_id, _METHOD_NOT_FOUND, f"No handler for op: {op!r}") + + def _op_wallet_balance(self, req_id, key, args: Dict) -> Dict: + chain_id = args.get("chain_id") + if chain_id is None: + return _err(req_id, _INVALID_PARAMS, "Missing required field: chain_id") + token: Optional[str] = args.get("token") + + treasury = self._wallet.treasury + if token is not None: + result = { + "chain_id": chain_id, + "token": token, + "balance": treasury.balance(chain_id, token), + "spendable": treasury.spendable(chain_id, token), + } + else: + # Return all tokens on the chain + balances = treasury.balances(chain_id) + result = { + "chain_id": chain_id, + "balances": [ + { + "token": tok, + "balance": bal, + "spendable": treasury.spendable(chain_id, tok), + } + for tok, bal in balances.items() + ], + } + return _ok(req_id, {"content": [{"type": "text", "text": json.dumps(result)}]}) + + def _op_pay(self, req_id, key, args: Dict) -> Dict: + for field in ("chain_id", "token", "amount", "payee"): + if field not in args: + return _err(req_id, _INVALID_PARAMS, f"Missing required field: {field}") + + request = PaymentRequest( + chain_id=args["chain_id"], + token=args["token"], + amount_wei=args["amount"], + payee=args["payee"], + metadata=args.get("metadata") or {}, + ) + receipt = self._delegation.pay_with_key(key, request) + result = { + "tx_id": receipt.tx_id, + "chain_id": receipt.chain_id, + "token": receipt.token, + "amount": receipt.amount, + "payee": receipt.payee, + "escrow_id": receipt.escrow_id, + } + return _ok(req_id, {"content": [{"type": "text", "text": json.dumps(result)}]}) + + def _op_create_escrow(self, req_id, key, args: Dict) -> Dict: + for field in ("chain_id", "token", "amount", "payee"): + if field not in args: + return _err(req_id, _INVALID_PARAMS, f"Missing required field: {field}") + + # create_escrow creates but does NOT immediately release + escrow_id = self._wallet._escrow.create_payment( + chain_id=args["chain_id"], + token=args["token"], + amount=args["amount"], + payee=args["payee"], + ) + result = {"escrow_id": escrow_id, "status": "Locked"} + return _ok(req_id, {"content": [{"type": "text", "text": json.dumps(result)}]}) + + def _op_confirm_payment(self, req_id, key, args: Dict) -> Dict: + escrow_id = args.get("escrow_id") + if not escrow_id: + return _err(req_id, _INVALID_PARAMS, "Missing required field: escrow_id") + + released = self._wallet._escrow.release_payment(escrow_id) + result = {"escrow_id": escrow_id, "released": released} + return _ok(req_id, {"content": [{"type": "text", "text": json.dumps(result)}]}) + + def _op_request_refund(self, req_id, key, args: Dict) -> Dict: + escrow_id = args.get("escrow_id") + if not escrow_id: + return _err(req_id, _INVALID_PARAMS, "Missing required field: escrow_id") + + # Seam: if the escrow client supports refund(), call it; otherwise + # fall back to a "refund requested" status (the real client wires in + # a proper refund path via IAgentEscrow.requestRefund). + client = self._wallet._escrow + if hasattr(client, "request_refund"): + raw = client.request_refund(escrow_id, args.get("reason", "")) + ok = bool(raw) if raw is not None else True + else: + # No-op stub or mocked client: treat as accepted + ok = True + result = {"escrow_id": escrow_id, "refund_requested": ok} + return _ok(req_id, {"content": [{"type": "text", "text": json.dumps(result)}]}) + + def _op_policy_status(self, req_id, key, args: Dict) -> Dict: + now_utc = datetime.now(timezone.utc) + expires = key.policy.expires_at + if expires.tzinfo is None: + expires = expires.replace(tzinfo=timezone.utc) + + active = self._delegation.is_active(key) + result = { + "key_id": key.key_id, + "agent_id": key.agent_id, + "active": active, + "expires_at": expires.isoformat(), + "expired": now_utc >= expires, + "token_allowlist": key.policy.token_allowlist, + "per_tx_cap": key.policy.per_tx_cap, + "daily_cap": key.policy.daily_cap, + "allowed_counterparties": key.policy.allowed_counterparties, + } + return _ok(req_id, {"content": [{"type": "text", "text": json.dumps(result)}]}) + + def _op_escrow_metrics(self, req_id, key, args: Dict) -> Dict: + if self._metrics_store is not None: + m = self._metrics_store.escrow + result = { + "fill_rate": m.fill_rate, + "timeout_rate": m.timeout_rate, + "refund_rate": m.refund_rate, + "challenge_rate": m.challenge_rate, + "avg_time_to_release_s": m.avg_time_to_release_s, + "total_count": m.total_count, + "pending_count": m.pending_count, + } + else: + # No metrics store provided — return empty/zeroed metrics + result = { + "fill_rate": None, + "timeout_rate": None, + "refund_rate": None, + "challenge_rate": None, + "avg_time_to_release_s": None, + "total_count": 0, + "pending_count": 0, + } + return _ok(req_id, {"content": [{"type": "text", "text": json.dumps(result)}]}) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main() -> None: + """Launch the MCP server against a fresh wallet (CLI / console-script). + + Wires the REAL fairness + access-policy engine (Unit ⑲, + ``switchboard.access_policy.AccessPolicy``) as the gate — not the + permissive ``AllowAllPolicy`` stub. Every ``tools/call`` is checked + against the agent's tier + rate-fairness bucket before dispatch, and each + decision emits a ``metrics.WalletOpEvent`` for the ⑳ dashboard. + """ + from switchboard.access_policy import AccessPolicy + + wallet = AgentWallet() + delegation = Delegation(wallet=wallet) + server = MCPServer( + wallet=wallet, + delegation=delegation, + access_policy=AccessPolicy(), + ) + server.serve() + + +if __name__ == "__main__": + main() diff --git a/switchboard/metrics.py b/switchboard/metrics.py new file mode 100644 index 0000000..8b4e125 --- /dev/null +++ b/switchboard/metrics.py @@ -0,0 +1,349 @@ +""" +switchboard.metrics — escrow-fulfilment metrics + wallet-ops health. + +Unit ⑳ of the agent-wallet-multitoken-settlement plan. + +Consumes structured event/state records (defined as dataclasses here); +computes fill rate, time-to-release, timeout rate, refund rate, challenge +rate, spend by token/rail, policy denials, and fleet health. + +Designed to be driven by an event-polling loop (the dashboard panel calls +``compute_all_metrics`` on a timer), but is pure / side-effect-free so it +is fully testable against fixtures. + +Input record shapes +------------------- +EscrowEvent + One settled or terminal escrow event emitted when a payment + leaves the ``Locked`` state. Mirrors the events emitted by + ``AgentEscrow.sol`` (PaymentReleased / PaymentRefunded / + PaymentCancelled) plus inferred Timeout / Challenged events. + +WalletOpEvent + One wallet operation attempted by an agent — a pay, policy-check, + rebalance, etc. Includes rail, token, amount, and whether the + wallet co-signed or denied the request. + +EscrowState + Current on-chain snapshot of an escrow (for pending-count + reporting, independent of event history). + +Usage:: + + events = polling_layer.fetch_escrow_events(since=last_ts) + ops = polling_layer.fetch_wallet_ops(since=last_ts) + states = polling_layer.fetch_open_escrows() + + result = compute_all_metrics( + escrow_events=events, + wallet_ops=ops, + escrow_states=states, + ) + dashboard.render(result) +""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass, field +from typing import Dict, List, Optional + + +# --------------------------------------------------------------------------- +# Input record types (the "backend must emit" contract) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class EscrowEvent: + """One terminal escrow event. + + Fields + ------ + request_id : str + Off-chain payment-request ID (matches the contract ``requestId``). + event_type : str + One of: ``Released``, ``Refunded``, ``Cancelled``, + ``Timeout``, ``Challenged``. + token : str + Token symbol (``"ETH"``) or ERC-20 contract address. + amount : float + Amount in base units (wei/token-decimals at backend discretion; + consistent within a dataset). + created_at : float + Unix timestamp (seconds) when the escrow was created on-chain. + resolved_at : float | None + Unix timestamp when the event was emitted. ``None`` for events + that are inferred but not yet block-confirmed. + payer : str + Payer address (``0x…``). + payee : str + Payee address (``0x…``). + chain_id : int + EVM chain id the escrow lives on. + """ + + request_id: str + event_type: str + token: str + amount: float + created_at: float + resolved_at: Optional[float] + payer: str + payee: str + chain_id: int + + +@dataclass(frozen=True) +class WalletOpEvent: + """One wallet operation attempted by an agent. + + Fields + ------ + op_type : str + Operation kind: ``pay``, ``rebalance``, ``policy_check``, etc. + token : str + Token used / proposed. + rail : str + Settlement rail: ``x402``, ``escrow``, ``mpp``. + amount : float + Amount proposed (even if denied). + agent_id : str + Logical agent identifier. + wallet_id : str + Physical wallet (MPC share / fleet member) that handled the op. + denied : bool + Whether the wallet co-signing was refused. + denial_reason : str | None + Machine-readable policy rule that caused the denial, e.g. + ``"daily_cap_exceeded"``, ``"token_not_allowed"``, + ``"counterparty_not_allowed"``, ``"per_tx_cap_exceeded"``. + timestamp : float + Unix timestamp when the op was attempted. + """ + + op_type: str + token: str + rail: str + amount: float + agent_id: str + wallet_id: str + denied: bool + denial_reason: Optional[str] + timestamp: float + + +@dataclass(frozen=True) +class EscrowState: + """Current on-chain snapshot of one escrow (for pending count). + + Fields + ------ + request_id : str + state : str + Contract state: ``Locked``, ``Released``, ``Refunded``, + ``Cancelled``. + token : str + amount : float + created_at : float + wallet_id : str + The agent-wallet wallet that initiated/owns this escrow. + """ + + request_id: str + state: str + token: str + amount: float + created_at: float + wallet_id: str + + +# --------------------------------------------------------------------------- +# Output metric containers +# --------------------------------------------------------------------------- + + +@dataclass +class EscrowMetrics: + """Computed escrow-fulfilment metrics over a set of events. + + Rates are fractions in ``[0, 1]`` (e.g. 0.95 = 95 %). + ``None`` means "not enough data to compute." + """ + + total_count: int = 0 + released_count: int = 0 + refunded_count: int = 0 + timeout_count: int = 0 + cancelled_count: int = 0 + challenged_count: int = 0 + pending_count: int = 0 + + # Derived rates (None when denominator is 0 / no data) + fill_rate: Optional[float] = None + timeout_rate: Optional[float] = None + refund_rate: Optional[float] = None + challenge_rate: Optional[float] = None + + # Time-to-release (seconds, mean over Released events only) + avg_time_to_release_s: Optional[float] = None + + +@dataclass +class WalletOpsMetrics: + """Wallet operation totals and policy-denial breakdown.""" + + total_ops: int = 0 + + # Spend by token/rail — only non-denied ops counted + spend_by_token: Dict[str, float] = field(default_factory=dict) + spend_by_rail: Dict[str, float] = field(default_factory=dict) + + # Policy denials + policy_denial_count: int = 0 + denials_by_reason: Dict[str, int] = field(default_factory=dict) + + +@dataclass +class FleetHealth: + """Per-wallet activity and denial summary.""" + + active_wallet_count: int = 0 + wallet_op_counts: Dict[str, int] = field(default_factory=dict) + denial_rate_by_wallet: Dict[str, float] = field(default_factory=dict) + + +@dataclass +class AllMetrics: + """Aggregated result from ``compute_all_metrics``.""" + + escrow: EscrowMetrics + wallet_ops: WalletOpsMetrics + fleet: FleetHealth + + +# --------------------------------------------------------------------------- +# Compute functions +# --------------------------------------------------------------------------- + +_RESOLVED_TYPES = frozenset({"Released", "Refunded", "Cancelled", "Timeout", "Challenged"}) +_TERMINAL_TYPES = _RESOLVED_TYPES # alias for clarity + + +def compute_escrow_metrics( + events: List[EscrowEvent], + states: List[EscrowState] | None = None, +) -> EscrowMetrics: + """Compute escrow-fulfilment metrics from event records. + + Parameters + ---------- + events: + List of ``EscrowEvent`` records (terminal events). + states: + Optional list of current ``EscrowState`` snapshots; used only + for ``pending_count``. + """ + m = EscrowMetrics() + + # Count by type — only terminal events count toward rates + released_times: list[float] = [] + terminal_count = 0 + for ev in events: + m.total_count += 1 + if ev.event_type == "Released": + terminal_count += 1 + m.released_count += 1 + if ev.resolved_at is not None and ev.created_at is not None: + released_times.append(ev.resolved_at - ev.created_at) + elif ev.event_type == "Refunded": + terminal_count += 1 + m.refunded_count += 1 + elif ev.event_type == "Timeout": + terminal_count += 1 + m.timeout_count += 1 + elif ev.event_type == "Cancelled": + terminal_count += 1 + m.cancelled_count += 1 + elif ev.event_type == "Challenged": + terminal_count += 1 + m.challenged_count += 1 + # Other event types (e.g. "Locked") are counted in total_count + # but do not contribute to rate denominators. + + # Rates (denominator = terminal_count; None when 0) + n = terminal_count + if n > 0: + m.fill_rate = m.released_count / n + m.timeout_rate = m.timeout_count / n + m.refund_rate = m.refunded_count / n + m.challenge_rate = m.challenged_count / n + # else all remain None + + # Average time-to-release + if released_times: + m.avg_time_to_release_s = sum(released_times) / len(released_times) + + # Pending count from states snapshot + if states: + m.pending_count = sum(1 for s in states if s.state == "Locked") + + return m + + +def compute_wallet_ops_metrics(ops: List[WalletOpEvent]) -> WalletOpsMetrics: + """Compute wallet-operation spend and denial metrics.""" + m = WalletOpsMetrics() + m.total_ops = len(ops) + + spend_token: dict[str, float] = defaultdict(float) + spend_rail: dict[str, float] = defaultdict(float) + denials: dict[str, int] = defaultdict(int) + + for op in ops: + if op.denied: + m.policy_denial_count += 1 + if op.denial_reason: + denials[op.denial_reason] += 1 + else: + spend_token[op.token] += op.amount + spend_rail[op.rail] += op.amount + + m.spend_by_token = dict(spend_token) + m.spend_by_rail = dict(spend_rail) + m.denials_by_reason = dict(denials) + return m + + +def compute_fleet_health(ops: List[WalletOpEvent]) -> FleetHealth: + """Compute per-wallet health indicators from op records.""" + h = FleetHealth() + + total_by_wallet: dict[str, int] = defaultdict(int) + denied_by_wallet: dict[str, int] = defaultdict(int) + + for op in ops: + total_by_wallet[op.wallet_id] += 1 + if op.denied: + denied_by_wallet[op.wallet_id] += 1 + + h.wallet_op_counts = dict(total_by_wallet) + h.active_wallet_count = len(total_by_wallet) + h.denial_rate_by_wallet = { + wid: denied_by_wallet.get(wid, 0) / total + for wid, total in total_by_wallet.items() + } + return h + + +def compute_all_metrics( + escrow_events: List[EscrowEvent], + wallet_ops: List[WalletOpEvent], + escrow_states: List[EscrowState] | None = None, +) -> AllMetrics: + """Compute all three metric groups and return an ``AllMetrics`` bundle.""" + return AllMetrics( + escrow=compute_escrow_metrics(escrow_events, states=escrow_states), + wallet_ops=compute_wallet_ops_metrics(wallet_ops), + fleet=compute_fleet_health(wallet_ops), + ) diff --git a/switchboard/registry.json b/switchboard/registry.json index 9c8fd6e..ec69059 100644 --- a/switchboard/registry.json +++ b/switchboard/registry.json @@ -13,5 +13,218 @@ "name": "lux-testnet", "escrow": null, "usdc": null - } + }, + "tools": [ + { + "name": "wallet_balance", + "description": "Return the wallet's token balances on a given chain. Reports both gross balance and spendable (net of reserve) for every token the treasury tracks on that chain.", + "schema": { + "type": "object", + "properties": { + "session_key": { + "type": "string", + "description": "Session key ID issued by grant()." + }, + "chain_id": { + "type": "integer", + "description": "EVM chain ID (e.g. 1 for mainnet, 84532 for Base Sepolia)." + }, + "token": { + "type": "string", + "description": "Token EVM address (address(0) = native ETH). If omitted, returns all tokens on the chain." + } + }, + "required": [ + "session_key", + "chain_id" + ] + }, + "op": "wallet_balance", + "policy": { + "required_tier": "standard", + "rate_class": "read" + } + }, + { + "name": "pay", + "description": "Execute a payment from the agent wallet to a payee. Enforces the active SpendPolicy (token allowlist, per-tx cap, daily cap, counterparty allowlist) before co-signing.", + "schema": { + "type": "object", + "properties": { + "session_key": { + "type": "string", + "description": "Session key ID authorising the payment." + }, + "chain_id": { + "type": "integer", + "description": "EVM chain ID." + }, + "token": { + "type": "string", + "description": "Token address (address(0) = ETH)." + }, + "amount": { + "type": "integer", + "description": "Amount in token base units (wei / USDC decimals)." + }, + "payee": { + "type": "string", + "description": "Payee EVM address." + }, + "metadata": { + "type": "object", + "description": "Optional key-value metadata attached to the payment." + } + }, + "required": [ + "session_key", + "chain_id", + "token", + "amount", + "payee" + ] + }, + "op": "pay", + "policy": { + "required_tier": "standard", + "rate_class": "write" + } + }, + { + "name": "create_escrow", + "description": "Create a new on-chain escrow entry for a payment. Returns an escrow_id the payee uses to confirm or release funds.", + "schema": { + "type": "object", + "properties": { + "session_key": { + "type": "string" + }, + "chain_id": { + "type": "integer" + }, + "token": { + "type": "string" + }, + "amount": { + "type": "integer" + }, + "payee": { + "type": "string" + }, + "metadata": { + "type": "object" + } + }, + "required": [ + "session_key", + "chain_id", + "token", + "amount", + "payee" + ] + }, + "op": "create_escrow", + "policy": { + "required_tier": "standard", + "rate_class": "write" + } + }, + { + "name": "confirm_payment", + "description": "Confirm and release an in-flight escrow once the payee has delivered the agreed service. The escrow is released to the payee.", + "schema": { + "type": "object", + "properties": { + "session_key": { + "type": "string" + }, + "escrow_id": { + "type": "string", + "description": "The escrow_id returned by create_escrow." + } + }, + "required": [ + "session_key", + "escrow_id" + ] + }, + "op": "confirm_payment", + "policy": { + "required_tier": "standard", + "rate_class": "write" + } + }, + { + "name": "request_refund", + "description": "Request a refund of an escrowed payment. Valid only when the escrow is in the Locked state and the challenge period has passed, or the payee has agreed to the refund.", + "schema": { + "type": "object", + "properties": { + "session_key": { + "type": "string" + }, + "escrow_id": { + "type": "string" + }, + "reason": { + "type": "string", + "description": "Human-readable reason for the refund request." + } + }, + "required": [ + "session_key", + "escrow_id" + ] + }, + "op": "request_refund", + "policy": { + "required_tier": "standard", + "rate_class": "write" + } + }, + { + "name": "policy_status", + "description": "Return the current spend-policy status for a session key: remaining per-tx cap, remaining daily cap, expiry time, and whether the key is still active.", + "schema": { + "type": "object", + "properties": { + "session_key": { + "type": "string" + } + }, + "required": [ + "session_key" + ] + }, + "op": "policy_status", + "policy": { + "required_tier": "standard", + "rate_class": "read" + } + }, + { + "name": "escrow_metrics", + "description": "Return aggregated escrow-fulfilment metrics: fill rate, average time-to-release, timeout rate, refund rate, challenge rate, and current pending count.", + "schema": { + "type": "object", + "properties": { + "session_key": { + "type": "string" + }, + "chain_id": { + "type": "integer", + "description": "Filter metrics to this chain. Omit for all chains." + } + }, + "required": [ + "session_key" + ] + }, + "op": "escrow_metrics", + "policy": { + "required_tier": "standard", + "rate_class": "read" + } + } + ] } diff --git a/switchboard/router/__init__.py b/switchboard/router/__init__.py new file mode 100644 index 0000000..1c90882 --- /dev/null +++ b/switchboard/router/__init__.py @@ -0,0 +1,27 @@ +"""switchboard.router — pluggable routing strategies for the AgentWallet. + +Unit ⑩-⑬ of the agent-wallet-multitoken-settlement spec. + +Exports +------- +Router The composing entry-point: Router.route(request) -> Plan. +Plan The routing decision: token, rail, wallet. +""" + +from switchboard.router.token_selector import TokenSelector, TokenCandidate +from switchboard.router.rail_selector import RailSelector +from switchboard.router.fleet_balancer import FleetBalancer +from switchboard.router.rebalancer import Rebalancer, RebalanceTarget, SwapIntent +from switchboard.router.router import Router, Plan + +__all__ = [ + "Router", + "Plan", + "TokenSelector", + "TokenCandidate", + "RailSelector", + "FleetBalancer", + "Rebalancer", + "RebalanceTarget", + "SwapIntent", +] diff --git a/switchboard/router/fleet_balancer.py b/switchboard/router/fleet_balancer.py new file mode 100644 index 0000000..92441fc --- /dev/null +++ b/switchboard/router/fleet_balancer.py @@ -0,0 +1,73 @@ +"""Unit ⑫ — FleetBalancer. + +Spreads signing work across N wallet addresses to avoid: + - nonce contention (two concurrent txs from the same key), + - single-key blast radius / rate limits. + +Uses ``NonceManager`` to read pending-nonce counts per wallet. The wallet +with the fewest pending nonces is chosen; ties broken by wallet list order. + +Thread-safe: an internal lock prevents two concurrent callers from selecting +the same wallet when they start with equal nonce counts. +""" + +from __future__ import annotations + +import threading +from typing import List, Optional + +from switchboard.nonce_manager import NonceManager + + +class FleetBalancer: + """Selects the least-busy wallet from a fleet. + + Parameters + ---------- + wallets: + Ordered list of EVM wallet addresses in the fleet. Must be non-empty. + nonce_manager: + Shared ``NonceManager`` instance tracking pending nonces per wallet. + chain_id: + EVM chain the fleet operates on. + """ + + def __init__( + self, + wallets: List[str], + nonce_manager: NonceManager, + chain_id: int, + ) -> None: + if not wallets: + raise ValueError("FleetBalancer requires at least one wallet") + self._wallets = list(wallets) + self._nm = nonce_manager + self._chain_id = chain_id + self._lock = threading.Lock() + + def pick(self, chain_id: Optional[int] = None) -> str: + """Return the wallet address with the fewest pending nonces. + + Tie-breaks by position in the wallet list (earlier = preferred). + + Parameters + ---------- + chain_id: + Override the chain_id used for nonce lookup. Defaults to the + chain_id supplied at construction. + """ + cid = chain_id if chain_id is not None else self._chain_id + + with self._lock: + # Compute pending nonce count for each wallet and pick the min. + best: Optional[str] = None + best_count: int = -1 + + for wallet in self._wallets: + pending = len(self._nm.get_pending_nonces(wallet, chain_id=cid)) + if best is None or pending < best_count: + best = wallet + best_count = pending + + # best is guaranteed non-None because wallets is non-empty. + return best # type: ignore[return-value] diff --git a/switchboard/router/rail_selector.py b/switchboard/router/rail_selector.py new file mode 100644 index 0000000..8259af0 --- /dev/null +++ b/switchboard/router/rail_selector.py @@ -0,0 +1,74 @@ +"""Unit ⑪ — RailSelector. + +Picks the cheapest suitable settlement rail for a given payment amount. + +Rails (cheapest → most capable): + x402 — HTTP micro-payment; lowest cost, instant; capped at ``x402_max_amount``. + escrow — On-chain trustless escrow; for mid-range amounts. + mpp — Multi-party payment; for large / multi-party flows. + +The caller can force a specific rail via ``force_rail``; the RailSelector +trusts the caller in that case (policy enforcement is upstream). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + + +_RAILS = ("x402", "escrow", "mpp") + +# Sensible production defaults. +_DEFAULT_X402_MAX = 100_000 # ~0.1 USDC (6 dec) or ~$0.01 worth of ETH +_DEFAULT_ESCROW_MAX = 10_000_000_000 # ~$10 000 USDC (6 dec) + + +@dataclass +class RailConfig: + """Threshold configuration for RailSelector. + + Parameters + ---------- + x402_max_amount: + Inclusive upper bound (in smallest token units) for the x402 rail. + escrow_max_amount: + Inclusive upper bound for the escrow rail. Amounts above this use mpp. + """ + + x402_max_amount: int = _DEFAULT_X402_MAX + escrow_max_amount: int = _DEFAULT_ESCROW_MAX + + +class RailSelector: + """Selects the cheapest suitable rail for a payment amount. + + Parameters + ---------- + config: + Threshold configuration. Defaults to ``RailConfig()`` if omitted. + """ + + def __init__(self, config: Optional[RailConfig] = None) -> None: + self._config = config if config is not None else RailConfig() + + def select(self, amount: int, force_rail: Optional[str] = None) -> str: + """Return the rail name for ``amount``. + + Parameters + ---------- + amount: + Payment amount in the token's smallest unit. + force_rail: + If provided, skip threshold logic and return this rail directly. + One of ``"x402"``, ``"escrow"``, ``"mpp"``. + """ + if force_rail is not None: + return force_rail + + cfg = self._config + if amount <= cfg.x402_max_amount: + return "x402" + if amount <= cfg.escrow_max_amount: + return "escrow" + return "mpp" diff --git a/switchboard/router/rebalancer.py b/switchboard/router/rebalancer.py new file mode 100644 index 0000000..4681c31 --- /dev/null +++ b/switchboard/router/rebalancer.py @@ -0,0 +1,185 @@ +"""Unit ⑬ — Rebalancer. + +Computes *intended* swap operations to move the treasury allocation toward a +target ratio. Emits ``SwapIntent`` objects; does NOT execute swaps — the +adapter layer (lucidly / SwapSettlementAdapter) handles execution. + +Algorithm: + 1. Validate that target percentages sum to 100 (±0.01 float tolerance). + 2. Compute total treasury value on the chain as the sum of all token balances. + 3. For each target token, compute the ideal amount and the delta + (current - ideal). Positive delta = overweight → sell; negative = underweight → buy. + 4. Emit one ``SwapIntent`` per overweight token (from_token → least + underweight to_token) if the absolute delta exceeds ``min_rebalance_pct`` + of the total. + +Notes +----- +* The Rebalancer works in raw balance units. If tokens have different + decimals the caller must normalise before supplying balances, or provide a + price oracle. For the initial unit this simplification is intentional + (spec §9 open decision 3). +* ``SwapIntent`` carries only the recommendation; the adapter decides slippage + bounds and execution timing. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import List + +from switchboard.treasury import Treasury + + +@dataclass(frozen=True) +class RebalanceTarget: + """A token allocation target. + + Parameters + ---------- + token: + EVM address of the token. + target_pct: + Target percentage of total portfolio value for this token (0–100). + """ + + token: str + target_pct: float + + +@dataclass(frozen=True) +class SwapIntent: + """An intended swap to rebalance the treasury. + + The adapter is responsible for executing (or declining) this swap. + + Parameters + ---------- + from_token: + Token to sell (overweight). + to_token: + Token to buy (underweight). + amount: + Raw amount of ``from_token`` to sell. + chain_id: + Chain on which the swap should be executed. + """ + + from_token: str + to_token: str + amount: int + chain_id: int + + +_PCT_TOLERANCE = 0.01 # float tolerance for "sums to 100" + + +class Rebalancer: + """Computes rebalancing swap intents for a treasury. + + Parameters + ---------- + treasury: + The Treasury to read balances from. + chain_id: + The chain to consider for rebalancing. + min_rebalance_pct: + Minimum imbalance (as a percentage of total portfolio) before a swap + is emitted. Default is 1 % — avoids trivial swaps. + """ + + def __init__( + self, + treasury: Treasury, + chain_id: int, + min_rebalance_pct: float = 1.0, + ) -> None: + self._treasury = treasury + self._chain_id = chain_id + self._min_rebalance_pct = min_rebalance_pct + + def rebalance_targets(self, targets: List[RebalanceTarget]) -> List[SwapIntent]: + """Compute the swap intents needed to reach ``targets``. + + Parameters + ---------- + targets: + Desired allocation; percentages must sum to 100 (±tolerance). + + Returns + ------- + List of ``SwapIntent`` objects, possibly empty if already balanced. + + Raises + ------ + ValueError + If target percentages do not sum to 100. + """ + total_pct = sum(t.target_pct for t in targets) + if abs(total_pct - 100.0) > _PCT_TOLERANCE: + raise ValueError( + f"Target percentages must sum to 100, got {total_pct:.4f}" + ) + + # Snapshot current balances for the relevant tokens. + balances = { + t.token: self._treasury.balance(self._chain_id, t.token) + for t in targets + } + total_value = sum(balances.values()) + + if total_value == 0: + # Nothing to rebalance — emit buy intents for all underweight tokens + # only if there is actually something to sell (which there isn't). + return [] + + threshold = total_value * self._min_rebalance_pct / 100.0 + + # Compute deltas: positive = overweight (should sell), negative = underweight. + deltas: dict[str, float] = {} + for t in targets: + ideal = total_value * t.target_pct / 100.0 + deltas[t.token] = balances[t.token] - ideal + + overweight = {tok: d for tok, d in deltas.items() if d > threshold} + underweight = {tok: -d for tok, d in deltas.items() if -d > threshold} + + if not overweight or not underweight: + return [] + + intents: List[SwapIntent] = [] + # Pair each overweight token's surplus proportionally across all + # underweight tokens, so every underweight token gets at least one + # buy intent. In the common case (one overweight, multiple underweight) + # this produces one SwapIntent per underweight token. + underweight_items = sorted(underweight.items(), key=lambda x: -x[1]) + overweight_items = sorted(overweight.items(), key=lambda x: -x[1]) + + total_deficit = sum(underweight.values()) + total_surplus = sum(overweight.values()) + + for to_token, deficit in underweight_items: + # Allocate across overweight sources proportionally. + remaining = int(deficit) + for from_token, surplus in overweight_items: + if surplus <= 0 or remaining <= 0: + continue + # Sell min(remaining deficit, available surplus) of from_token. + sell = min(remaining, int(surplus)) + if sell > 0: + intents.append( + SwapIntent( + from_token=from_token, + to_token=to_token, + amount=sell, + chain_id=self._chain_id, + ) + ) + remaining -= sell + # Update surplus tracking (mutable copy of dict value). + overweight_items = [ + (ft, s - sell if ft == from_token else s) + for ft, s in overweight_items + ] + + return intents diff --git a/switchboard/router/router.py b/switchboard/router/router.py new file mode 100644 index 0000000..2250004 --- /dev/null +++ b/switchboard/router/router.py @@ -0,0 +1,153 @@ +"""Top-level Router — composes the four strategies into a single routing call. + +``Router.route(...)`` is the primary entry-point. It: + 1. Calls ``TokenSelector.select()`` to pick a source token. + 2. Calls ``RailSelector.select()`` to pick the settlement rail. + 3. Calls ``FleetBalancer.pick()`` to pick the signing wallet. + 4. Emits a ``WalletOpEvent`` to the supplied event sink. + 5. Returns a ``Plan(token, rail, wallet)``. + +If no solvent token is found, a denied ``WalletOpEvent`` is emitted and a +``ValueError`` is raised (spec §6 "Insufficient balance in chosen token"). +""" + +from __future__ import annotations + +import time +import uuid +from dataclasses import dataclass +from typing import Callable, List, Optional + +from switchboard.metrics import WalletOpEvent +from switchboard.router.token_selector import TokenSelector, TokenCandidate +from switchboard.router.rail_selector import RailSelector +from switchboard.router.fleet_balancer import FleetBalancer + + +@dataclass(frozen=True) +class Plan: + """The routing decision produced by ``Router.route()``. + + Parameters + ---------- + token: + EVM address of the token to spend. + rail: + Settlement rail: ``"x402"``, ``"escrow"``, or ``"mpp"``. + wallet: + EVM address of the signing wallet chosen by the FleetBalancer. + """ + + token: str + rail: str + wallet: str + + +class Router: + """Composes TokenSelector, RailSelector, and FleetBalancer. + + Parameters + ---------- + token_selector: + Strategy for picking the source token. + rail_selector: + Strategy for picking the settlement rail. + fleet_balancer: + Strategy for picking the signing wallet. + events: + Optional callable that receives each ``WalletOpEvent``. Defaults to + a no-op so the Router is usable without a metrics backend. + """ + + def __init__( + self, + token_selector: TokenSelector, + rail_selector: RailSelector, + fleet_balancer: FleetBalancer, + events: Optional[Callable[[WalletOpEvent], None]] = None, + ) -> None: + self._token_sel = token_selector + self._rail_sel = rail_selector + self._fleet = fleet_balancer + self._events: Callable[[WalletOpEvent], None] = events if events is not None else _noop + + def route( + self, + chain_id: int, + amount: int, + candidates: List[TokenCandidate], + agent_id: str = "", + force_rail: Optional[str] = None, + ) -> Plan: + """Route a payment and return a ``Plan``. + + Parameters + ---------- + chain_id: + EVM chain ID for the payment. + amount: + Payment amount in the token's smallest unit. + candidates: + Token candidates (with optional fee/slippage metadata) for the + ``TokenSelector`` to rank. + agent_id: + Logical agent identifier — included in the emitted ``WalletOpEvent``. + force_rail: + If set, bypasses rail selection and uses the specified rail. + + Returns + ------- + Plan + + Raises + ------ + ValueError + If no candidate token has sufficient spendable balance. + """ + # ── Step 1: Token ──────────────────────────────────────────────────── + best_candidate = self._token_sel.select(amount=amount, candidates=candidates) + + if best_candidate is None: + ev = WalletOpEvent( + op_type="pay", + token="", + rail="", + amount=float(amount), + agent_id=agent_id, + wallet_id="", + denied=True, + denial_reason="insufficient_balance", + timestamp=time.time(), + ) + self._events(ev) + raise ValueError( + f"No solvent token found for chain_id={chain_id} amount={amount}" + ) + + token = best_candidate.token + + # ── Step 2: Rail ───────────────────────────────────────────────────── + rail = self._rail_sel.select(amount=amount, force_rail=force_rail) + + # ── Step 3: Wallet ─────────────────────────────────────────────────── + wallet = self._fleet.pick(chain_id=chain_id) + + # ── Step 4: Emit event ─────────────────────────────────────────────── + ev = WalletOpEvent( + op_type="pay", + token=token, + rail=rail, + amount=float(amount), + agent_id=agent_id, + wallet_id=wallet, + denied=False, + denial_reason=None, + timestamp=time.time(), + ) + self._events(ev) + + return Plan(token=token, rail=rail, wallet=wallet) + + +def _noop(_: WalletOpEvent) -> None: + """No-op event sink used when no metrics backend is wired.""" diff --git a/switchboard/router/token_selector.py b/switchboard/router/token_selector.py new file mode 100644 index 0000000..0a29253 --- /dev/null +++ b/switchboard/router/token_selector.py @@ -0,0 +1,81 @@ +"""Unit ⑩ — TokenSelector. + +Picks the source token to spend for a given payment amount. + +Selection criteria (ordered priority): + 1. Solvency — spendable(chain_id, token) >= amount. + 2. Lowest fee_bps. + 3. Lowest expected_slippage_bps (tie-break on fee). + 4. Lexicographic token address (deterministic final tie-break). + +LUX, ZOO, and other partner tokens are first-class — no special-casing; +balance and fee/slippage drive the pick naturally. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List, Optional + +from switchboard.treasury import Treasury + + +@dataclass +class TokenCandidate: + """A token the router may choose as the settlement currency. + + Parameters + ---------- + token: + EVM address of the token (``address(0)`` = native ETH). + fee_bps: + Expected swap/bridge fee in basis points. 0 = no fee. + expected_slippage_bps: + Expected slippage in basis points. 0 = no slippage. + """ + + token: str + fee_bps: int = 0 + expected_slippage_bps: int = 0 + + +class TokenSelector: + """Selects the best source token for a payment. + + Parameters + ---------- + treasury: + The Treasury to query for spendable balances. + chain_id: + The chain on which the payment will be executed. + """ + + def __init__(self, treasury: Treasury, chain_id: int) -> None: + self._treasury = treasury + self._chain_id = chain_id + + def select( + self, + amount: int, + candidates: List[TokenCandidate], + ) -> Optional[TokenCandidate]: + """Return the best token candidate, or ``None`` if none are solvent. + + Parameters + ---------- + amount: + Required amount in the token's smallest unit. + candidates: + Ordered list of ``TokenCandidate`` objects to consider. + """ + solvent = [ + c for c in candidates + if self._treasury.spendable(self._chain_id, c.token) >= amount + ] + if not solvent: + return None + + # Sort ascending by (fee_bps, expected_slippage_bps, token) for a + # fully deterministic, stable pick. + solvent.sort(key=lambda c: (c.fee_bps, c.expected_slippage_bps, c.token)) + return solvent[0] diff --git a/switchboard/thinking_chain.py b/switchboard/thinking_chain.py new file mode 100644 index 0000000..01c1e61 --- /dev/null +++ b/switchboard/thinking_chain.py @@ -0,0 +1,453 @@ +"""switchboard.thinking_chain — AI agent financial reasoning chain primitive. + +Models an AI agent's multi-step financial decision-making as an observable, +inspectable chain of typed steps. Each step records its reasoning, outcome, +arbitrary data payload, and any ``metrics.WalletOpEvent``s it emitted. + +Canonical step sequence (``HanzoEscrowThinkingChain``) +------------------------------------------------------ +1. ASSESS_TASK — inspect the task, decide a payment is needed. +2. NEGOTIATE_TOKEN — call ``negotiate_settlement_token`` to pick the + best mutually-acceptable token; halt if none. +3. POLICY_CHECK — call ``access_policy.check``; halt if denied. +4. CREATE_ESCROW — call ``AgentWallet.pay`` (drives EscrowClient); + records escrow_id in step data. +5. VERIFY_WORK — simulate work-verification (always PASS in demo). +6. RELEASE_OR_REFUND — call escrow_client.release_payment or + refund_payment; records action in step data. + +``ThinkingChain`` API +--------------------- +``chain = HanzoEscrowThinkingChain(...)`` +``records = chain.run()`` # list[StepRecord]; raises ChainHaltedError on HALT +``chain.records`` # same list, inspectable after run() +``chain.records[i].step_type`` # StepType enum member +``chain.records[i].reasoning`` # str narrative +``chain.records[i].outcome`` # StepOutcome.PASS | .FAIL | .HALT +``chain.records[i].data`` # dict with step-specific payload +``chain.records[i].events`` # list[WalletOpEvent] emitted by this step +""" +from __future__ import annotations + +import dataclasses +import time +from dataclasses import dataclass +from enum import Enum, auto +from typing import Any, Callable, Dict, List, Optional + +from src.payment_protocol import SettlementToken, negotiate_settlement_token +from switchboard.agent_wallet import AgentWallet, EscrowClient +from switchboard.access_policy import AccessPolicy +from switchboard.escrow_adapters import InMemoryEscrowClient +from switchboard.metrics import WalletOpEvent + + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class StepType(Enum): + ASSESS_TASK = auto() + NEGOTIATE_TOKEN = auto() + POLICY_CHECK = auto() + CREATE_ESCROW = auto() + VERIFY_WORK = auto() + RELEASE_OR_REFUND = auto() + + +class StepOutcome(Enum): + PASS = "pass" + FAIL = "fail" + HALT = "halt" + + +# --------------------------------------------------------------------------- +# StepRecord — immutable, inspectable +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class StepRecord: + """One completed step in a ThinkingChain. + + Parameters + ---------- + step_type: + The canonical step this record belongs to. + reasoning: + Human-readable narrative of why this step was taken. + outcome: + PASS / FAIL / HALT. + data: + Step-specific payload dict (e.g. ``{"escrow_id": "escrow-abc123"}``). + events: + Zero or more ``WalletOpEvent``s emitted during this step. + """ + step_type: StepType + reasoning: str + outcome: StepOutcome + data: Dict[str, Any] + events: List[WalletOpEvent] + + +# --------------------------------------------------------------------------- +# Exceptions +# --------------------------------------------------------------------------- + + +class ChainHaltedError(Exception): + """Raised when a step returns ``StepOutcome.HALT``. + + Parameters + ---------- + reason: + Human-readable explanation of why the chain halted. + step_record: + The ``StepRecord`` that caused the halt (outcome == HALT). + """ + def __init__(self, reason: str, step_record: StepRecord) -> None: + super().__init__(reason) + self.reason = reason + self.step_record = step_record + + +# --------------------------------------------------------------------------- +# ThinkingChain runner +# --------------------------------------------------------------------------- + + +class ThinkingChain: + """Base runner: execute a sequence of steps and collect StepRecords. + + Subclasses implement ``_build_steps() -> list[Callable[[], StepRecord]]`` + returning the ordered list of step callables. ``run()`` executes them in + order, appends each ``StepRecord`` to ``self.records``, and raises + ``ChainHaltedError`` if any step yields ``StepOutcome.HALT``. + + Parameters + ---------- + name: + Human-readable chain name (e.g. ``"HanzoEscrow"``). + """ + + def __init__(self, name: str) -> None: + self.name = name + self._records: List[StepRecord] = [] + + @property + def records(self) -> List[StepRecord]: + """Inspectable list of completed StepRecords (read-only view).""" + return list(self._records) + + def _build_steps(self) -> List[Callable[[], StepRecord]]: # pragma: no cover + """Return ordered list of zero-argument callables, each -> StepRecord.""" + raise NotImplementedError + + def run(self) -> List[StepRecord]: + """Execute all steps; raise ChainHaltedError if any step halts. + + Returns the list of StepRecords on full success. + """ + self._records.clear() + for step_fn in self._build_steps(): + record = step_fn() + self._records.append(record) + if record.outcome == StepOutcome.HALT: + raise ChainHaltedError( + reason=record.reasoning, + step_record=record, + ) + return list(self._records) + + +# --------------------------------------------------------------------------- +# HanzoEscrowThinkingChain — canonical 6-step implementation +# --------------------------------------------------------------------------- + + +class HanzoEscrowThinkingChain(ThinkingChain): + """A Hanzo-AI agent reasoning through an escrowed multi-token payment. + + Implements the six canonical steps using real Switchboard modules: + negotiate_settlement_token, access_policy.check, AgentWallet.pay, + and EscrowClient release/refund. + + Parameters + ---------- + payer_wallet: + ``AgentWallet`` with a funded Treasury. Used in CREATE_ESCROW. + payee_address: + EVM address of the payee. + payer_offers: + Tokens the payer will accept as settlement (SettlementToken list). + payee_accepts: + Tokens the payee will accept (SettlementToken list). + amount: + Payment amount in the negotiated token's smallest unit. + access_policy: + ``AccessPolicy`` instance. Consulted in POLICY_CHECK. + agent_id: + Logical agent identity forwarded to the access policy. + escrow_client: + ``InMemoryEscrowClient`` (or any EscrowClient) for CREATE_ESCROW and + RELEASE_OR_REFUND. Defaults to a fresh ``InMemoryEscrowClient``. + chain_id: + EVM chain ID. Defaults to 1. + """ + + def __init__( + self, + payer_wallet: AgentWallet, + payee_address: str, + payer_offers: List[SettlementToken], + payee_accepts: List[SettlementToken], + amount: int, + access_policy: AccessPolicy, + agent_id: str = "hanzo-agent", + escrow_client: Optional[InMemoryEscrowClient] = None, + chain_id: int = 1, + ) -> None: + super().__init__(name="HanzoEscrow") + self._wallet = payer_wallet + self._payee = payee_address + self._payer_offers = payer_offers + self._payee_accepts = payee_accepts + self._amount = amount + self._policy = access_policy + self._agent_id = agent_id + self._escrow = escrow_client if escrow_client is not None else InMemoryEscrowClient() + self._chain_id = chain_id + + # mutable state shared across step closures + self._negotiated_token: Optional[SettlementToken] = None + self._escrow_id: Optional[str] = None + + # ------------------------------------------------------------------ + # Step builders + # ------------------------------------------------------------------ + + def _build_steps(self) -> List[Callable[[], StepRecord]]: + return [ + self._step_assess_task, + self._step_negotiate_token, + self._step_policy_check, + self._step_create_escrow, + self._step_verify_work, + self._step_release_or_refund, + ] + + def _step_assess_task(self) -> StepRecord: + reasoning = ( + f"Hanzo agent assessed task: need to pay {self._payee!r} " + f"amount={self._amount} on chain_id={self._chain_id}. " + f"Payer offers {len(self._payer_offers)} token(s); " + f"payee accepts {len(self._payee_accepts)} token(s). " + "Proceeding to token negotiation." + ) + return StepRecord( + step_type=StepType.ASSESS_TASK, + reasoning=reasoning, + outcome=StepOutcome.PASS, + data={ + "payee": self._payee, + "amount": self._amount, + "chain_id": self._chain_id, + "payer_token_count": len(self._payer_offers), + "payee_token_count": len(self._payee_accepts), + }, + events=[], + ) + + def _step_negotiate_token(self) -> StepRecord: + token = negotiate_settlement_token(self._payer_offers, self._payee_accepts) + if token is None: + return StepRecord( + step_type=StepType.NEGOTIATE_TOKEN, + reasoning=( + "negotiate_settlement_token() returned None — " + "no mutually-acceptable token found. Halting chain." + ), + outcome=StepOutcome.HALT, + data={ + "payer_offers": [t.token for t in self._payer_offers], + "payee_accepts": [t.token for t in self._payee_accepts], + }, + events=[], + ) + self._negotiated_token = token + return StepRecord( + step_type=StepType.NEGOTIATE_TOKEN, + reasoning=( + f"Negotiated settlement token: {token.token!r} " + f"(combined_rank={token.rank}) via negotiate_settlement_token()." + ), + outcome=StepOutcome.PASS, + data={"token": token.token, "rank": token.rank, "chain_id": token.chain_id}, + events=[], + ) + + def _step_policy_check(self) -> StepRecord: + collected_events: List[WalletOpEvent] = [] + token = self._negotiated_token.token if self._negotiated_token else "" + + # Thread-safety note: the listener swap below is safe only for + # single-threaded use (demo/test scope). Concurrent chains that share + # one AccessPolicy instance would race on _event_listener — the last + # writer wins and events can be mis-attributed. The production fix is a + # scoped context-manager on AccessPolicy that carries its own listener + # slot rather than mutating the shared one. Do NOT add locking here; + # add the context-manager seam on AccessPolicy instead. + original_listener = self._policy._event_listener + def _capture(ev: WalletOpEvent) -> None: + collected_events.append(ev) + if original_listener: + original_listener(ev) + self._policy._event_listener = _capture + + try: + decision = self._policy.check( + self._agent_id, + { + "type": "pay", + "amount": self._amount, + "token": token, + "payee": self._payee, + }, + ) + finally: + self._policy._event_listener = original_listener + + if decision.denied: + return StepRecord( + step_type=StepType.POLICY_CHECK, + reasoning=( + f"access_policy.check() denied: reason={decision.reason!r}. " + "Halting chain — payment blocked by policy." + ), + outcome=StepOutcome.HALT, + data={"reason": decision.reason, "agent_id": self._agent_id}, + events=collected_events, + ) + return StepRecord( + step_type=StepType.POLICY_CHECK, + reasoning=( + f"access_policy.check() allowed payment of {self._amount} " + f"token={token!r} for agent {self._agent_id!r}." + ), + outcome=StepOutcome.PASS, + data={"allowed": True, "agent_id": self._agent_id}, + events=collected_events, + ) + + def _step_create_escrow(self) -> StepRecord: + token = self._negotiated_token.token if self._negotiated_token else "" + + # Financial gate: check spendable balance before touching anything. + # This keeps the wallet's treasury honest — if funds are insufficient + # we halt rather than creating an escrow that can't be backed. + spendable = self._wallet.spendable(self._chain_id, token) + if spendable < self._amount: + return StepRecord( + step_type=StepType.CREATE_ESCROW, + reasoning=( + f"Insufficient spendable balance for {token!r} on chain " + f"{self._chain_id}: have {spendable}, need {self._amount}. " + "Halting chain." + ), + outcome=StepOutcome.HALT, + data={ + "token": token, + "amount": self._amount, + "spendable": spendable, + }, + events=[], + ) + + # Debit the treasury first so the wallet is no longer hollow. + # The escrow represents the obligation; the treasury debit is the + # payer-side accounting entry. Release/refund in RELEASE_OR_REFUND + # settles the payee side separately (keeping create and release as + # distinct observable steps in the chain). + self._wallet.treasury.debit(self._chain_id, token, self._amount) + + eid = self._escrow.create_payment( + chain_id=self._chain_id, + token=token, + amount=self._amount, + payee=self._payee, + ) + self._escrow_id = eid + + balance_after = self._wallet.spendable(self._chain_id, token) + + # Emit a WalletOpEvent for the escrow creation + ev = WalletOpEvent( + op_type="create_escrow", + token=token, + rail="escrow", + amount=float(self._amount), + agent_id=self._agent_id, + wallet_id=self._wallet.address(), + denied=False, + denial_reason=None, + timestamp=time.time(), + ) + return StepRecord( + step_type=StepType.CREATE_ESCROW, + reasoning=( + f"Debited treasury {self._amount} {token!r}; " + f"created escrow {eid!r} for payee {self._payee!r} " + f"via InMemoryEscrowClient. Balance after: {balance_after}." + ), + outcome=StepOutcome.PASS, + data={ + "escrow_id": eid, + "token": token, + "amount": self._amount, + "balance_after": balance_after, + }, + events=[ev], + ) + + def _step_verify_work(self) -> StepRecord: + # In the demo/test harness, work is always accepted. + # Production subclasses override this to call a verifier. + return StepRecord( + step_type=StepType.VERIFY_WORK, + reasoning=( + "Hanzo agent verified task completion: payee delivered the " + "requested output (simulated verification — always accepted in demo). " + "Proceeding to release escrow." + ), + outcome=StepOutcome.PASS, + data={"verified": True}, + events=[], + ) + + def _step_release_or_refund(self) -> StepRecord: + token = self._negotiated_token.token if self._negotiated_token else "" + eid = self._escrow_id or "" + # Since verify_work passed, we release. + ok = self._escrow.release_payment(eid) + ev = WalletOpEvent( + op_type="release_escrow", + token=token, + rail="escrow", + amount=float(self._amount), + agent_id=self._agent_id, + wallet_id=self._wallet.address(), + denied=False, + denial_reason=None, + timestamp=time.time(), + ) + return StepRecord( + step_type=StepType.RELEASE_OR_REFUND, + reasoning=( + f"Released escrow {eid!r} to payee {self._payee!r}. " + f"release_payment() returned {ok}. Settlement complete." + ), + outcome=StepOutcome.PASS, + data={"action": "release", "escrow_id": eid, "success": ok}, + events=[ev], + ) diff --git a/switchboard/tools.py b/switchboard/tools.py new file mode 100644 index 0000000..ab0bc4e --- /dev/null +++ b/switchboard/tools.py @@ -0,0 +1,376 @@ +"""Tool registry — single source of truth for switchboard callable tools. + +Unit ⑰ of the agent-wallet-multitoken-settlement plan. + +This module defines every tool that agents may call, with: + - ``name`` — stable identifier used by MCP and CLI alike + - ``description`` — human/agent-readable explanation + - ``schema`` — JSON-Schema object describing the input parameters + - ``op`` — which wallet/escrow operation the tool maps to + - ``policy`` — access-policy constraints (used by the access-policy engine) + +Both ``mcp_server.py`` and ``cli.py`` read from :func:`get_registry` — they do +NOT duplicate schemas or policy rules. This is the DRY source of truth. + +Extending the registry +----------------------- +Add an entry to :data:`TOOL_DEFINITIONS` and (optionally) add it to +``switchboard/registry.json`` under the ``"tools"`` key. The MCP server and +CLI pick it up automatically. + +Access-policy interface +------------------------ +The access-policy engine (Unit ⑲, ``switchboard/access_policy.py``) is built +in parallel and is **not** present in this tree. We define the thin interface +we expect here so the caller can wire the real implementation at integration +time. + +Expected interface:: + + from switchboard.access_policy import AccessPolicy, Decision + + policy_engine: AccessPolicy # passed in at server/CLI construction time + decision: Decision = policy_engine.check(agent_id="0xAgent", action="pay") + if decision.denied: + raise PermissionError(decision.reason) + +``AccessPolicy`` is a ``typing.Protocol``:: + + class AccessPolicy(Protocol): + def check(self, agent_id: str, action: str) -> Decision: ... + +``Decision`` is a dataclass:: + + @dataclass(frozen=True) + class Decision: + denied: bool + reason: str | None = None # machine-readable reason when denied + +If no ``AccessPolicy`` is provided, a permissive stub (``AllowAllPolicy``) is +used so the server/CLI work standalone. Wire the real one by passing it at +construction:: + + server = MCPServer(wallet=wallet, delegation=delegation, access_policy=real_engine) +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + + +# --------------------------------------------------------------------------- +# Access-policy interface (thin seam; real impl arrives in Unit ⑲) +# --------------------------------------------------------------------------- + +class AccessPolicy: + """Protocol that the access-policy engine must satisfy. + + The real implementation (Unit ⑲) replaces this at integration time. + Caller passes it as ``access_policy=`` to ``MCPServer`` and ``CLI``. + """ + + def check(self, agent_id: str, action: str) -> "Decision": # noqa: F821 + raise NotImplementedError + + +@dataclass(frozen=True) +class Decision: + """Result of an access-policy check.""" + + denied: bool + reason: Optional[str] = None + + +class AllowAllPolicy: + """Permissive stub — used when no real policy engine is wired in. + + Every action is allowed; integration replaces this with the Unit ⑲ impl. + """ + + def check(self, agent_id: str, action: str) -> Decision: + return Decision(denied=False, reason=None) + + +# --------------------------------------------------------------------------- +# Tool definition dataclass +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class ToolDef: + """Describes one callable tool. + + Parameters + ---------- + name: + Stable, snake_case identifier. Used as the MCP tool name and the + CLI sub-command name. + description: + Short human/agent-readable explanation of what the tool does. + schema: + JSON-Schema ``"object"`` describing the tool's input parameters. + The ``"required"`` list must enumerate every non-optional field. + op: + The logical wallet/escrow operation this tool maps to. Used by + the dispatcher to route calls to the right method. + policy: + Access-policy metadata consumed by the access-policy engine. + ``required_tier`` — minimum tier (default ``"standard"``). + ``rate_class`` — rate-limiting bucket (default ``"default"``). + """ + + name: str + description: str + schema: Dict[str, Any] + op: str + policy: Dict[str, str] = field(default_factory=lambda: { + "required_tier": "standard", + "rate_class": "default", + }) + + +# --------------------------------------------------------------------------- +# Canonical tool definitions +# --------------------------------------------------------------------------- + +TOOL_DEFINITIONS: List[ToolDef] = [ + ToolDef( + name="wallet_balance", + description=( + "Return the wallet's token balances on a given chain. " + "Reports both gross balance and spendable (net of reserve) for " + "every token the treasury tracks on that chain." + ), + schema={ + "type": "object", + "properties": { + "session_key": { + "type": "string", + "description": "Session key ID issued by grant().", + }, + "chain_id": { + "type": "integer", + "description": "EVM chain ID (e.g. 1 for mainnet, 84532 for Base Sepolia).", + }, + "token": { + "type": "string", + "description": ( + "Token EVM address (address(0) = native ETH). " + "If omitted, returns all tokens on the chain." + ), + }, + }, + "required": ["session_key", "chain_id"], + }, + op="wallet_balance", + policy={"required_tier": "standard", "rate_class": "read"}, + ), + ToolDef( + name="pay", + description=( + "Execute a payment from the agent wallet to a payee. " + "Enforces the active SpendPolicy (token allowlist, per-tx cap, " + "daily cap, counterparty allowlist) before co-signing." + ), + schema={ + "type": "object", + "properties": { + "session_key": { + "type": "string", + "description": "Session key ID authorising the payment.", + }, + "chain_id": {"type": "integer", "description": "EVM chain ID."}, + "token": { + "type": "string", + "description": "Token address (address(0) = ETH).", + }, + "amount": { + "type": "integer", + "description": "Amount in token base units (wei / USDC decimals).", + }, + "payee": {"type": "string", "description": "Payee EVM address."}, + "metadata": { + "type": "object", + "description": "Optional key-value metadata attached to the payment.", + }, + }, + "required": ["session_key", "chain_id", "token", "amount", "payee"], + }, + op="pay", + policy={"required_tier": "standard", "rate_class": "write"}, + ), + ToolDef( + name="create_escrow", + description=( + "Create a new on-chain escrow entry for a payment. " + "Returns an escrow_id the payee uses to confirm or release funds." + ), + schema={ + "type": "object", + "properties": { + "session_key": {"type": "string"}, + "chain_id": {"type": "integer"}, + "token": {"type": "string"}, + "amount": {"type": "integer"}, + "payee": {"type": "string"}, + "metadata": {"type": "object"}, + }, + "required": ["session_key", "chain_id", "token", "amount", "payee"], + }, + op="create_escrow", + policy={"required_tier": "standard", "rate_class": "write"}, + ), + ToolDef( + name="confirm_payment", + description=( + "Confirm and release an in-flight escrow once the payee has " + "delivered the agreed service. The escrow is released to the payee." + ), + schema={ + "type": "object", + "properties": { + "session_key": {"type": "string"}, + "escrow_id": { + "type": "string", + "description": "The escrow_id returned by create_escrow.", + }, + }, + "required": ["session_key", "escrow_id"], + }, + op="confirm_payment", + policy={"required_tier": "standard", "rate_class": "write"}, + ), + ToolDef( + name="request_refund", + description=( + "Request a refund of an escrowed payment. Valid only when the " + "escrow is in the Locked state and the challenge period has passed, " + "or the payee has agreed to the refund." + ), + schema={ + "type": "object", + "properties": { + "session_key": {"type": "string"}, + "escrow_id": {"type": "string"}, + "reason": { + "type": "string", + "description": "Human-readable reason for the refund request.", + }, + }, + "required": ["session_key", "escrow_id"], + }, + op="request_refund", + policy={"required_tier": "standard", "rate_class": "write"}, + ), + ToolDef( + name="policy_status", + description=( + "Return the current spend-policy status for a session key: " + "remaining per-tx cap, remaining daily cap, expiry time, " + "and whether the key is still active." + ), + schema={ + "type": "object", + "properties": { + "session_key": {"type": "string"}, + }, + "required": ["session_key"], + }, + op="policy_status", + policy={"required_tier": "standard", "rate_class": "read"}, + ), + ToolDef( + name="escrow_metrics", + description=( + "Return aggregated escrow-fulfilment metrics: fill rate, " + "average time-to-release, timeout rate, refund rate, " + "challenge rate, and current pending count." + ), + schema={ + "type": "object", + "properties": { + "session_key": {"type": "string"}, + "chain_id": { + "type": "integer", + "description": "Filter metrics to this chain. Omit for all chains.", + }, + }, + "required": ["session_key"], + }, + op="escrow_metrics", + policy={"required_tier": "standard", "rate_class": "read"}, + ), +] + + +# --------------------------------------------------------------------------- +# Registry accessors +# --------------------------------------------------------------------------- + +def get_registry() -> List[ToolDef]: + """Return the canonical list of all registered tools. + + Both ``mcp_server.py`` and ``cli.py`` call this — do not duplicate the + list in either place. + """ + return list(TOOL_DEFINITIONS) + + +def get_tool(name: str) -> Optional[ToolDef]: + """Look up a single tool by name; return ``None`` if not found.""" + for tool in TOOL_DEFINITIONS: + if tool.name == name: + return tool + return None + + +def registry_as_json() -> str: + """Serialise the full registry to a JSON string (useful for debugging).""" + return json.dumps( + [ + { + "name": t.name, + "description": t.description, + "schema": t.schema, + "op": t.op, + "policy": t.policy, + } + for t in TOOL_DEFINITIONS + ], + indent=2, + ) + + +# --------------------------------------------------------------------------- +# Sync registry.json with the tools section +# --------------------------------------------------------------------------- + +def sync_registry_json(registry_path: Optional[Path] = None) -> None: + """Write the ``"tools"`` key in ``switchboard/registry.json``. + + Called once at build/dev time to keep the JSON file in sync with the + Python definitions. The JSON file is checked into the repo so that + non-Python clients (frontend, docs) can read it without importing Python. + """ + if registry_path is None: + registry_path = Path(__file__).parent / "registry.json" + + with open(registry_path) as fh: + data = json.load(fh) + + data["tools"] = [ + { + "name": t.name, + "description": t.description, + "schema": t.schema, + "op": t.op, + "policy": t.policy, + } + for t in TOOL_DEFINITIONS + ] + + with open(registry_path, "w") as fh: + json.dump(data, fh, indent=2) + fh.write("\n") diff --git a/switchboard/treasury.py b/switchboard/treasury.py new file mode 100644 index 0000000..84cf0a3 --- /dev/null +++ b/switchboard/treasury.py @@ -0,0 +1,112 @@ +"""Treasury — balance tracking per (chain_id, token) for the Agent Wallet. + +Unit ⑧ of the agent-wallet-multitoken-settlement spec. + +Tracks how much of each token the wallet holds on each chain, and distinguishes +the *spendable* portion (total balance minus a configurable reserve). The Router +queries this module before every payment; credits and debits happen atomically +under a lock. + +The ``token`` parameter is always a checksummed EVM address string. +``address(0)`` (``0x0000...0000``) is the canonical sentinel for native ETH. + +Featured partner tokens — LUX, ZOO, and other kcolbchain partners — are first- +class entries in the balance map; no special-casing is needed. + +Usage:: + + from switchboard.treasury import Treasury, InsufficientBalance + + t = Treasury() + t.credit(chain_id=1, token=USDC, amount=500_000_000) + t.debit(chain_id=1, token=USDC, amount=100_000_000) + spendable = t.spendable(1, USDC) +""" + +from __future__ import annotations + +import threading +from collections import defaultdict +from typing import Dict, Tuple + + +class InsufficientBalance(RuntimeError): + """Raised when a debit would take the balance below zero.""" + + +class Treasury: + """Per-(chain_id, token) balance store with reserve support. + + Thread-safe: all mutations and reads are protected by a single lock. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + # (chain_id, token) -> balance + self._balances: Dict[Tuple[int, str], int] = defaultdict(int) + # (chain_id, token) -> reserve (minimum held back from spendable) + self._reserves: Dict[Tuple[int, str], int] = defaultdict(int) + + # ------------------------------------------------------------------ + # Read + # ------------------------------------------------------------------ + + def balance(self, chain_id: int, token: str) -> int: + """Return the total (gross) balance for ``(chain_id, token)``.""" + with self._lock: + return self._balances[(chain_id, token)] + + def spendable(self, chain_id: int, token: str) -> int: + """Return the spendable balance: ``balance - reserve``, clamped to 0.""" + with self._lock: + bal = self._balances[(chain_id, token)] + res = self._reserves[(chain_id, token)] + return max(0, bal - res) + + def balances(self, chain_id: int) -> Dict[str, int]: + """Return a snapshot ``{token: balance}`` for every token on ``chain_id``.""" + with self._lock: + return { + token: amt + for (cid, token), amt in self._balances.items() + if cid == chain_id and amt > 0 + } + + # ------------------------------------------------------------------ + # Write + # ------------------------------------------------------------------ + + def credit(self, chain_id: int, token: str, amount: int) -> None: + """Increase the balance for ``(chain_id, token)`` by ``amount``.""" + if amount < 0: + raise ValueError(f"credit amount must be non-negative, got {amount}") + with self._lock: + self._balances[(chain_id, token)] += amount + + def debit(self, chain_id: int, token: str, amount: int) -> None: + """Decrease the balance for ``(chain_id, token)`` by ``amount``. + + Raises :class:`InsufficientBalance` if the result would be negative. + """ + if amount < 0: + raise ValueError(f"debit amount must be non-negative, got {amount}") + with self._lock: + current = self._balances[(chain_id, token)] + if current < amount: + raise InsufficientBalance( + f"Insufficient balance on chain {chain_id} token {token}: " + f"have {current}, need {amount}" + ) + self._balances[(chain_id, token)] = current - amount + + def set_reserve(self, chain_id: int, token: str, reserve: int) -> None: + """Set the minimum reserve for ``(chain_id, token)``. + + The reserve is not withdrawable via :meth:`debit`; it is only a floor + used by :meth:`spendable`. The operator is responsible for ensuring + the reserve makes sense relative to the current balance. + """ + if reserve < 0: + raise ValueError(f"reserve must be non-negative, got {reserve}") + with self._lock: + self._reserves[(chain_id, token)] = reserve diff --git a/switchboard/x402/server.py b/switchboard/x402/server.py index dfe923d..b56f4b3 100644 --- a/switchboard/x402/server.py +++ b/switchboard/x402/server.py @@ -4,6 +4,14 @@ payment header is present. Verifies inbound PaymentPayload signatures and provides idempotency via nonce tracking. +v1.2 multi-token extension: +- ``AcceptedToken`` dataclass carries {chain_id, token, min_amount, rank}. +- ``PaymentRequirements`` gains an ``accepts`` list; ``to_header()`` includes it + when non-empty; ``from_header()`` / ``from_dict()`` deserialise it back. +- ``X402Server`` accepts an optional ``accepts`` list and advertises it in every + 402 response; ``validate_settlement_token()`` checks a proposed token against + the configured list. + Supports Flask and FastAPI adapters. """ @@ -13,7 +21,7 @@ import json import time from dataclasses import dataclass, field -from typing import Any, Callable +from typing import Any, Callable, List, Optional # Canonical x402 wire headers. The x402 spec (coinbase/x402) and the Hanzo MCP # HTTP path (hanzoai/mcp#9) name the inbound proof header ``X-Payment`` and @@ -25,9 +33,50 @@ WWW_AUTHENTICATE_X402 = "x402" +@dataclass +class AcceptedToken: + """A single token entry in the multi-token accepts[] list (v1.2). + + Carried in ``PaymentRequirements.accepts`` and advertised in every 402 + response when the server supports multi-token settlement. + + Attributes: + chain_id: EIP-155 chain ID the token lives on. + token: ERC-20 contract address, or zero address for native ETH. + min_amount: Minimum acceptable amount in the token's smallest unit. + rank: Payee-side preference rank (higher = more preferred). + """ + chain_id: int + token: str + min_amount: int = 0 + rank: int = 1 + + def to_dict(self) -> dict: + return { + "chain_id": self.chain_id, + "token": self.token, + "min_amount": self.min_amount, + "rank": self.rank, + } + + @classmethod + def from_dict(cls, d: dict) -> AcceptedToken: + return cls( + chain_id=int(d["chain_id"]), + token=str(d["token"]), + min_amount=int(d.get("min_amount", 0)), + rank=int(d.get("rank", 1)), + ) + + @dataclass class PaymentRequirements: - """Describes what payment is required to access an endpoint.""" + """Describes what payment is required to access an endpoint. + + v1.2: ``accepts`` carries the payee's ranked list of acceptable + settlement tokens. When non-empty it is included in ``to_header()`` + so the payer can run token negotiation before paying. + """ scheme: str = "exact" network: str = "base" asset: str = "USDC" @@ -36,6 +85,7 @@ class PaymentRequirements: description: str = "" nonce: str = "" expires_at: int | None = None + accepts: List[AcceptedToken] = field(default_factory=list) def to_header(self) -> str: data = { @@ -51,6 +101,8 @@ def to_header(self) -> str: data["nonce"] = self.nonce if self.expires_at: data["expiresAt"] = self.expires_at + if self.accepts: + data["accepts"] = [t.to_dict() for t in self.accepts] return json.dumps(data) @classmethod @@ -59,6 +111,8 @@ def from_header(cls, header: str) -> PaymentRequirements: @classmethod def from_dict(cls, data: dict) -> PaymentRequirements: + accepts_raw = data.get("accepts", []) + accepts = [AcceptedToken.from_dict(e) for e in accepts_raw] return cls( scheme=data.get("scheme", "exact"), network=data.get("network", "base"), @@ -68,6 +122,7 @@ def from_dict(cls, data: dict) -> PaymentRequirements: description=data.get("description", ""), nonce=data.get("nonce", ""), expires_at=data.get("expiresAt", data.get("expires_at")), + accepts=accepts, ) @@ -178,7 +233,12 @@ def is_idempotent(self, nonce: str) -> bool: class X402Server: - """Core x402 server logic — usable from any web framework.""" + """Core x402 server logic — usable from any web framework. + + v1.2: pass ``accepts`` to advertise multi-token settlement options in every + 402 response and to enable ``validate_settlement_token()`` enforcement. + When ``accepts`` is an empty list the server accepts any token (back-compat). + """ def __init__( self, @@ -186,11 +246,34 @@ def __init__( amount_usdc: str = "1.00", verifier: PaymentVerifier | None = None, network: str = "base", + accepts: List[AcceptedToken] | None = None, ): self.pay_to_address = pay_to_address self.amount_usdc = amount_usdc self.verifier = verifier or PaymentVerifier() self.network = network + # None means "not configured" (back-compat, open); [] means "explicitly empty" + self.accepts: List[AcceptedToken] = accepts if accepts is not None else [] + + def validate_settlement_token( + self, + chain_id: int, + token: str, + ) -> tuple[bool, str]: + """Check whether a proposed settlement token is on the server's accepts list. + + Returns ``(True, "")`` when: + - ``self.accepts`` is empty (no restrictions configured). + - The ``(chain_id, token)`` pair is present in ``self.accepts``. + + Returns ``(False, reason)`` otherwise. + """ + if not self.accepts: + return True, "" + for t in self.accepts: + if t.chain_id == chain_id and t.token == token: + return True, "" + return False, f"Token {token} on chain {chain_id} is not accepted" def build_402_response(self, nonce: str = "") -> tuple[int, dict, str]: reqs = PaymentRequirements( @@ -200,6 +283,7 @@ def build_402_response(self, nonce: str = "") -> tuple[int, dict, str]: amount=self.amount_usdc, pay_to=self.pay_to_address, nonce=nonce or hashlib.sha256(str(time.time()).encode()).hexdigest()[:16], + accepts=self.accepts, ) headers = { "X-Payment-Required": reqs.to_header(), diff --git a/switchboard/x402_middleware.py b/switchboard/x402_middleware.py index 6def97a..b3eec42 100644 --- a/switchboard/x402_middleware.py +++ b/switchboard/x402_middleware.py @@ -31,7 +31,7 @@ from collections.abc import Callable from dataclasses import dataclass, field from enum import Enum -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, List, Optional try: import aiohttp @@ -53,10 +53,15 @@ class PaymentScheme(Enum): @dataclass class PaymentOffer: - """Parsed from the 402 response's X-Payment-Required header.""" + """Parsed from the 402 response's X-Payment-Required header. + + v1.2: ``token`` carries the specific ERC-20 contract address (or zero + address for native ETH) when the server uses multi-token accepts[]. + ``None`` when not specified (v1.1 back-compat). + """ amount_wei: int - currency: str # "ETH", "USDC", etc. - recipient: str # Payee address + currency: str # "ETH", "USDC", etc. + recipient: str # Payee address chain_id: int scheme: PaymentScheme = PaymentScheme.EXACT signature_alg: str = "none" @@ -64,7 +69,8 @@ class PaymentOffer: description: str = "" endpoint: str = "" nonce: str = "" - expires_at: int | None = None # Unix timestamp + expires_at: int | None = None # Unix timestamp + token: str | None = None # v1.2: specific token address; None = unset @classmethod def from_header(cls, header_value: str, endpoint: str = "") -> "PaymentOffer": @@ -82,6 +88,7 @@ def from_header(cls, header_value: str, endpoint: str = "") -> "PaymentOffer": endpoint=endpoint, nonce=data.get("nonce", ""), expires_at=data.get("expiresAt"), + token=data.get("token"), # v1.2 — absent in v1.1 payloads ) def is_expired(self) -> bool: @@ -139,6 +146,11 @@ class X402Middleware: Integrates with: - PaymentClient for on-chain settlement - GasTracker for budget enforcement + + v1.2: ``accepted_tokens`` restricts which settlement tokens this middleware + will pay in. When set, ``_validate_offer()`` rejects any offer whose + ``token`` field is not on the list. ``None`` (default) = no restriction + (back-compat). An explicit empty list rejects all token-specific offers. """ def __init__( @@ -149,6 +161,7 @@ def __init__( allowed_recipients: set | None = None, auto_pay: bool = True, on_payment: Callable[[PaymentRecord], None] | None = None, + accepted_tokens: List | None = None, # v1.2: list of AcceptedToken or dicts ): self.payment_client = payment_client self.gas_tracker = gas_tracker @@ -156,6 +169,7 @@ def __init__( self.allowed_recipients = allowed_recipients self.auto_pay = auto_pay self.on_payment = on_payment + self.accepted_tokens = accepted_tokens # None = no restriction self.payment_history: list[PaymentRecord] = [] self.total_spent_wei: int = 0 @@ -172,6 +186,26 @@ async def close(self): if self._session and not self._session.closed: await self._session.close() + def _validate_settlement_token(self, chain_id: int, token: str) -> None: + """Raise ValueError if ``(chain_id, token)`` is not on the accepted list. + + When ``accepted_tokens`` is ``None`` (not configured) the check is a + no-op for back-compat. An explicit empty list rejects everything. + """ + if self.accepted_tokens is None: + return # no restriction — back-compat + for t in self.accepted_tokens: + # Support both AcceptedToken objects and plain dicts + if isinstance(t, dict): + t_chain, t_token = t.get("chain_id"), t.get("token") + else: + t_chain, t_token = t.chain_id, t.token + if t_chain == chain_id and t_token == token: + return + raise ValueError( + f"Token {token} on chain {chain_id} is not an accepted settlement token" + ) + def _validate_offer(self, offer: PaymentOffer) -> None: """Check offer against policy before paying.""" if offer.is_expired(): @@ -190,6 +224,10 @@ def _validate_offer(self, offer: PaymentOffer) -> None: if not self.gas_tracker.can_send_transaction(wallet, offer.amount_wei): raise ValueError("Payment would exceed gas budget") + # v1.2: validate settlement token if the offer specifies one + if offer.token is not None: + self._validate_settlement_token(offer.chain_id, offer.token) + def _pay_onchain(self, offer: PaymentOffer) -> PaymentProof: """Execute on-chain payment via PaymentClient.""" if offer.scheme == PaymentScheme.EXACT: diff --git a/tests/router/__init__.py b/tests/router/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/router/test_fleet_balancer.py b/tests/router/test_fleet_balancer.py new file mode 100644 index 0000000..e47ff44 --- /dev/null +++ b/tests/router/test_fleet_balancer.py @@ -0,0 +1,126 @@ +"""Tests for Unit ⑫ — FleetBalancer. + +Strategy: spread spend/nonce across N wallets to avoid: + - nonce contention (two concurrent txs from the same key), + - single-key blast radius. + +Uses NonceManager to track pending nonces per wallet address. + +All tests written BEFORE the implementation (TDD — RED first). +""" + +import pytest +import threading +from unittest.mock import MagicMock + +from switchboard.nonce_manager import NonceManager, SIGNATURE_ALG_ECDSA +from switchboard.router.fleet_balancer import FleetBalancer + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +WALLETS = [ + "0x1111111111111111111111111111111111111111", + "0x2222222222222222222222222222222222222222", + "0x3333333333333333333333333333333333333333", +] + + +def make_nonce_manager(start_nonce: int = 0) -> NonceManager: + """Return a NonceManager backed by a mock chain client.""" + chain_client = MagicMock() + chain_client.get_current_onchain_nonce.return_value = start_nonce + return NonceManager(chain_client=chain_client) + + +def make_balancer(wallets=WALLETS, **kwargs): + nm = make_nonce_manager() + return FleetBalancer(wallets=wallets, nonce_manager=nm, chain_id=1, **kwargs) + + +# --------------------------------------------------------------------------- +# Unit ⑫ Tests +# --------------------------------------------------------------------------- + +class TestFleetBalancerPicksLeastBusyWallet: + """Wallet with fewest pending nonces is preferred.""" + + def test_single_wallet_always_returned(self): + balancer = make_balancer(wallets=[WALLETS[0]]) + chosen = balancer.pick(chain_id=1) + assert chosen == WALLETS[0] + + def test_first_pick_can_be_any_wallet(self): + balancer = make_balancer() + chosen = balancer.pick(chain_id=1) + assert chosen in WALLETS + + def test_after_one_pick_second_pick_is_different(self): + """After acquiring a nonce for wallet[0], the next pick avoids it.""" + nm = make_nonce_manager() + balancer = FleetBalancer(wallets=WALLETS[:2], nonce_manager=nm, chain_id=1) + + first = balancer.pick(chain_id=1) + # Simulate nonce acquired for `first` — the balancer tracks this via nonce_manager + nm.acquire_nonce(first, chain_id=1) + second = balancer.pick(chain_id=1) + + # second wallet should have 0 pending nonces and therefore be preferred + other = [w for w in WALLETS[:2] if w != first][0] + assert second == other + + +class TestFleetBalancerNonceDistribution: + """After many picks (with nonce acquisition), load is spread.""" + + def test_picks_distributed_across_all_wallets(self): + nm = make_nonce_manager() + balancer = FleetBalancer(wallets=WALLETS, nonce_manager=nm, chain_id=1) + + chosen_counts = {w: 0 for w in WALLETS} + for _ in range(9): # 3 rounds × 3 wallets + w = balancer.pick(chain_id=1) + nm.acquire_nonce(w, chain_id=1) + chosen_counts[w] += 1 + + # Every wallet should have been chosen at least twice in 9 rounds + for w, count in chosen_counts.items(): + assert count >= 2, f"Wallet {w} only picked {count} times" + + +class TestFleetBalancerConcurrency: + """Concurrent picks must not hand the same wallet to two threads simultaneously + when all wallets are equally loaded (the balancer should rotate).""" + + def test_concurrent_picks_use_different_wallets(self): + nm = make_nonce_manager() + two_wallets = WALLETS[:2] + balancer = FleetBalancer(wallets=two_wallets, nonce_manager=nm, chain_id=1) + + results = [] + lock = threading.Lock() + + def do_pick(): + w = balancer.pick(chain_id=1) + nm.acquire_nonce(w, chain_id=1) + with lock: + results.append(w) + + threads = [threading.Thread(target=do_pick) for _ in range(2)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert len(results) == 2 + # Both wallets should be used (round-robin / least-pending) + assert set(results) == set(two_wallets) + + +class TestFleetBalancerEmptyFleet: + def test_empty_fleet_raises(self): + nm = make_nonce_manager() + with pytest.raises(ValueError, match="at least one wallet"): + FleetBalancer(wallets=[], nonce_manager=nm, chain_id=1) diff --git a/tests/router/test_rail_selector.py b/tests/router/test_rail_selector.py new file mode 100644 index 0000000..c84ff8f --- /dev/null +++ b/tests/router/test_rail_selector.py @@ -0,0 +1,97 @@ +"""Tests for Unit ⑪ — RailSelector. + +Strategy: pick the cheapest suitable rail for the payment amount. + +Rails: + x402 — micro-payments; cheapest, but only for amounts <= x402_max_amount. + escrow — trustless on-chain; for amounts above x402_max_amount (up to escrow_max_amount). + mpp — multi-party payment; for amounts above escrow_max_amount or flagged trustless. + +All tests written BEFORE the implementation (TDD — RED first). +""" + +import pytest +from switchboard.router.rail_selector import RailSelector, RailConfig + + +# Default thresholds (in base units — think of as USDC micro-units or wei). +MICRO_MAX = 1_000 # x402 up to 1 000 units +ESCROW_MAX = 1_000_000 # escrow up to 1 000 000 units + + +def make_selector(micro_max=MICRO_MAX, escrow_max=ESCROW_MAX): + return RailSelector( + config=RailConfig( + x402_max_amount=micro_max, + escrow_max_amount=escrow_max, + ) + ) + + +class TestRailSelectorX402Threshold: + """Amounts at or below x402_max_amount → x402 rail.""" + + def test_micro_amount_uses_x402(self): + sel = make_selector() + assert sel.select(amount=1) == "x402" + + def test_at_micro_max_uses_x402(self): + sel = make_selector() + assert sel.select(amount=MICRO_MAX) == "x402" + + def test_just_above_micro_max_uses_escrow(self): + sel = make_selector() + assert sel.select(amount=MICRO_MAX + 1) == "escrow" + + +class TestRailSelectorEscrowThreshold: + """Amounts between x402_max and escrow_max → escrow rail.""" + + def test_mid_range_uses_escrow(self): + sel = make_selector() + assert sel.select(amount=50_000) == "escrow" + + def test_at_escrow_max_uses_escrow(self): + sel = make_selector() + assert sel.select(amount=ESCROW_MAX) == "escrow" + + def test_just_above_escrow_max_uses_mpp(self): + sel = make_selector() + assert sel.select(amount=ESCROW_MAX + 1) == "mpp" + + +class TestRailSelectorMPP: + """Large amounts → mpp rail.""" + + def test_large_amount_uses_mpp(self): + sel = make_selector() + assert sel.select(amount=10_000_000) == "mpp" + + def test_very_large_amount_uses_mpp(self): + sel = make_selector() + assert sel.select(amount=10 ** 18) == "mpp" + + +class TestRailSelectorForcedRail: + """Caller can override the rail selection via ``force_rail``.""" + + def test_force_escrow_on_micro_amount(self): + sel = make_selector() + assert sel.select(amount=1, force_rail="escrow") == "escrow" + + def test_force_mpp_on_micro_amount(self): + sel = make_selector() + assert sel.select(amount=1, force_rail="mpp") == "mpp" + + def test_force_x402_on_large_amount(self): + sel = make_selector() + assert sel.select(amount=10_000_000, force_rail="x402") == "x402" + + +class TestRailConfigDefaults: + """RailConfig should have sensible defaults if not provided.""" + + def test_default_config_exists(self): + config = RailConfig() + assert config.x402_max_amount > 0 + assert config.escrow_max_amount > config.x402_max_amount diff --git a/tests/router/test_rebalancer.py b/tests/router/test_rebalancer.py new file mode 100644 index 0000000..ba17d98 --- /dev/null +++ b/tests/router/test_rebalancer.py @@ -0,0 +1,149 @@ +"""Tests for Unit ⑬ — Rebalancer. + +Strategy: compute *intended* swap actions to move treasury allocations toward +a target ratio. The Rebalancer emits ``SwapIntent`` objects — it does NOT +execute swaps (that is the adapter's job). + +All tests written BEFORE the implementation (TDD — RED first). +""" + +import pytest +from switchboard.treasury import Treasury +from switchboard.router.rebalancer import Rebalancer, RebalanceTarget, SwapIntent + + +CHAIN_ID = 1 +ETH = "0x0000000000000000000000000000000000000000" +USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +LUX = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +ZOO = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + +def make_treasury(**balances): + t = Treasury() + for token, amount in balances.items(): + t.credit(CHAIN_ID, token, amount) + return t + + +# --------------------------------------------------------------------------- +# Unit ⑬ Tests +# --------------------------------------------------------------------------- + +class TestRebalancerNothingToDo: + """When treasury already matches targets, emit no swaps.""" + + def test_perfectly_balanced_produces_no_intents(self): + treasury = make_treasury(**{USDC: 600, ETH: 400}) + targets = [ + RebalanceTarget(token=USDC, target_pct=60.0), + RebalanceTarget(token=ETH, target_pct=40.0), + ] + rebalancer = Rebalancer(treasury=treasury, chain_id=CHAIN_ID) + intents = rebalancer.rebalance_targets(targets=targets) + assert intents == [] + + +class TestRebalancerSingleTokenOverweight: + """One overweight token → one swap intent to sell the surplus.""" + + def test_usdc_overweight_emits_swap_to_eth(self): + # 800 USDC, 200 ETH → target 60/40 → ideal 600/400 → sell 200 USDC + treasury = make_treasury(**{USDC: 800, ETH: 200}) + targets = [ + RebalanceTarget(token=USDC, target_pct=60.0), + RebalanceTarget(token=ETH, target_pct=40.0), + ] + rebalancer = Rebalancer(treasury=treasury, chain_id=CHAIN_ID) + intents = rebalancer.rebalance_targets(targets=targets) + + # Should have at least one intent moving USDC → ETH + assert len(intents) >= 1 + sell_intent = next((i for i in intents if i.from_token == USDC), None) + assert sell_intent is not None + assert sell_intent.to_token == ETH + assert sell_intent.amount > 0 + + +class TestRebalancerThresholdFiltering: + """Swaps below a threshold percentage should not be emitted (avoid tiny swaps).""" + + def test_small_imbalance_below_threshold_produces_no_intent(self): + # 505 USDC, 495 ETH → target 50/50 → only 5 off; threshold=2% of 1000 = 20 + treasury = make_treasury(**{USDC: 505, ETH: 495}) + targets = [ + RebalanceTarget(token=USDC, target_pct=50.0), + RebalanceTarget(token=ETH, target_pct=50.0), + ] + rebalancer = Rebalancer( + treasury=treasury, chain_id=CHAIN_ID, min_rebalance_pct=2.0 + ) + intents = rebalancer.rebalance_targets(targets=targets) + assert intents == [] + + def test_larger_imbalance_above_threshold_produces_intent(self): + # 700 USDC, 300 ETH → target 50/50 → 200 off; threshold=2% of 1000 = 20 + treasury = make_treasury(**{USDC: 700, ETH: 300}) + targets = [ + RebalanceTarget(token=USDC, target_pct=50.0), + RebalanceTarget(token=ETH, target_pct=50.0), + ] + rebalancer = Rebalancer( + treasury=treasury, chain_id=CHAIN_ID, min_rebalance_pct=2.0 + ) + intents = rebalancer.rebalance_targets(targets=targets) + assert len(intents) >= 1 + + +class TestRebalancerPartnerTokens: + """LUX and ZOO work as first-class allocation targets.""" + + def test_lux_zoo_underweight_both_emit_buy_intents(self): + # Hold: 100% USDC, 0% LUX, 0% ZOO → targets: 80% USDC, 10% LUX, 10% ZOO + treasury = make_treasury(**{USDC: 1000}) + targets = [ + RebalanceTarget(token=USDC, target_pct=80.0), + RebalanceTarget(token=LUX, target_pct=10.0), + RebalanceTarget(token=ZOO, target_pct=10.0), + ] + rebalancer = Rebalancer(treasury=treasury, chain_id=CHAIN_ID) + intents = rebalancer.rebalance_targets(targets=targets) + + to_tokens = {i.to_token for i in intents} + assert LUX in to_tokens + assert ZOO in to_tokens + + +class TestRebalancerInvalidTargets: + """Target percentages must sum to 100 (±float tolerance).""" + + def test_targets_not_summing_to_100_raises(self): + treasury = make_treasury(**{USDC: 1000}) + targets = [ + RebalanceTarget(token=USDC, target_pct=60.0), + RebalanceTarget(token=ETH, target_pct=20.0), # total = 80% + ] + rebalancer = Rebalancer(treasury=treasury, chain_id=CHAIN_ID) + with pytest.raises(ValueError, match="100"): + rebalancer.rebalance_targets(targets=targets) + + +class TestSwapIntentDataclass: + def test_swap_intent_fields(self): + intent = SwapIntent( + from_token=USDC, + to_token=ETH, + amount=100, + chain_id=1, + ) + assert intent.from_token == USDC + assert intent.to_token == ETH + assert intent.amount == 100 + assert intent.chain_id == 1 + + +class TestRebalanceTargetDataclass: + def test_rebalance_target_fields(self): + t = RebalanceTarget(token=USDC, target_pct=60.0) + assert t.token == USDC + assert t.target_pct == 60.0 diff --git a/tests/router/test_router.py b/tests/router/test_router.py new file mode 100644 index 0000000..bc4b38e --- /dev/null +++ b/tests/router/test_router.py @@ -0,0 +1,158 @@ +"""Tests for the top-level Router. + +The Router composes TokenSelector, RailSelector, FleetBalancer, and +Rebalancer into a single Router.route(request) -> Plan call. + +It must: + - Return a Plan with (token, rail, wallet) populated. + - Emit a WalletOpEvent to the supplied metrics sink after routing. + - Raise when no token can be selected (no solvent candidates). + +All tests written BEFORE the implementation (TDD — RED first). +""" + +import pytest +from unittest.mock import MagicMock, patch + +from switchboard.treasury import Treasury +from switchboard.nonce_manager import NonceManager +from switchboard.metrics import WalletOpEvent +from switchboard.router import Router, Plan +from switchboard.router.token_selector import TokenSelector, TokenCandidate +from switchboard.router.rail_selector import RailSelector, RailConfig +from switchboard.router.fleet_balancer import FleetBalancer + + +CHAIN_ID = 1 +ETH = "0x0000000000000000000000000000000000000000" +USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +WALLET_A = "0x1111111111111111111111111111111111111111" + + +def make_treasury_with_usdc(amount=1_000_000): + t = Treasury() + t.credit(CHAIN_ID, USDC, amount) + return t + + +def make_nonce_manager(): + chain_client = MagicMock() + chain_client.get_current_onchain_nonce.return_value = 0 + return NonceManager(chain_client=chain_client) + + +def make_router(treasury=None, wallets=None, events=None, rail_config=None): + treasury = treasury or make_treasury_with_usdc() + wallets = wallets or [WALLET_A] + nm = make_nonce_manager() + token_sel = TokenSelector(treasury=treasury, chain_id=CHAIN_ID) + rail_sel = RailSelector(config=rail_config or RailConfig()) + fleet = FleetBalancer(wallets=wallets, nonce_manager=nm, chain_id=CHAIN_ID) + return Router( + token_selector=token_sel, + rail_selector=rail_sel, + fleet_balancer=fleet, + events=events, + ) + + +class TestRouterReturnsPlan: + def test_route_returns_plan_with_token_rail_wallet(self): + router = make_router() + plan = router.route( + chain_id=CHAIN_ID, + amount=100, + candidates=[TokenCandidate(token=USDC)], + ) + assert isinstance(plan, Plan) + assert plan.token == USDC + assert plan.rail in ("x402", "escrow", "mpp") + assert plan.wallet == WALLET_A + + +class TestRouterRailSelection: + def test_micro_amount_selects_x402_rail(self): + router = make_router() + plan = router.route( + chain_id=CHAIN_ID, + amount=500, + candidates=[TokenCandidate(token=USDC)], + ) + assert plan.rail == "x402" + + def test_large_amount_selects_mpp_rail(self): + # Use a tight config so 5_000_000 exceeds escrow_max (1_000_000). + treasury = make_treasury_with_usdc(100_000_000) + cfg = RailConfig(x402_max_amount=1_000, escrow_max_amount=1_000_000) + router = make_router(treasury=treasury, rail_config=cfg) + plan = router.route( + chain_id=CHAIN_ID, + amount=5_000_000, + candidates=[TokenCandidate(token=USDC)], + ) + assert plan.rail == "mpp" + + +class TestRouterEmitsWalletOpEvent: + """Router must emit a WalletOpEvent per routed op (spec requirement).""" + + def test_successful_route_emits_non_denied_event(self): + emitted = [] + router = make_router(events=emitted.append) + router.route( + chain_id=CHAIN_ID, + amount=100, + candidates=[TokenCandidate(token=USDC)], + agent_id="agent-007", + ) + assert len(emitted) == 1 + ev = emitted[0] + assert isinstance(ev, WalletOpEvent) + assert ev.op_type == "pay" + assert ev.token == USDC + assert ev.rail in ("x402", "escrow", "mpp") + assert ev.amount == 100 + assert ev.agent_id == "agent-007" + assert ev.wallet_id == WALLET_A + assert ev.denied is False + assert ev.denial_reason is None + + def test_failed_route_emits_denied_event(self): + """When no token is solvent, emit a denied event and then raise.""" + empty_treasury = Treasury() # no balance + router = make_router(treasury=empty_treasury) + emitted = [] + router._events = emitted.append # swap out the event sink + + with pytest.raises(Exception): + router.route( + chain_id=CHAIN_ID, + amount=100, + candidates=[TokenCandidate(token=USDC)], + agent_id="agent-404", + ) + + assert len(emitted) == 1 + ev = emitted[0] + assert ev.denied is True + assert ev.denial_reason is not None + + +class TestRouterNoSolventToken: + def test_no_solvent_token_raises(self): + empty_treasury = Treasury() + router = make_router(treasury=empty_treasury) + with pytest.raises(Exception, match="[Nn]o.*token|[Ii]nsufficient"): + router.route( + chain_id=CHAIN_ID, + amount=100, + candidates=[TokenCandidate(token=USDC)], + ) + + +class TestPlanDataclass: + def test_plan_fields(self): + p = Plan(token=USDC, rail="escrow", wallet=WALLET_A) + assert p.token == USDC + assert p.rail == "escrow" + assert p.wallet == WALLET_A diff --git a/tests/router/test_token_selector.py b/tests/router/test_token_selector.py new file mode 100644 index 0000000..b3ab605 --- /dev/null +++ b/tests/router/test_token_selector.py @@ -0,0 +1,160 @@ +"""Tests for Unit ⑩ — TokenSelector. + +Strategy: pick the source token to spend based on: + 1. balance (must have enough spendable) + 2. fee (prefer lower fee) + 3. expected slippage (prefer lower slippage) + +All tests written BEFORE the implementation (TDD — RED first). +""" + +import pytest +from unittest.mock import MagicMock + +from switchboard.treasury import Treasury +from switchboard.router.token_selector import TokenSelector, TokenCandidate + + +CHAIN_ID = 1 +ETH = "0x0000000000000000000000000000000000000000" +USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7" +LUX = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +ZOO = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def make_treasury(**balances): + """Return a Treasury pre-loaded with (chain=1, token=amount) balances.""" + t = Treasury() + for token, amount in balances.items(): + t.credit(CHAIN_ID, token, amount) + return t + + +# --------------------------------------------------------------------------- +# Unit ⑩ Tests — each tests one distinct behaviour +# --------------------------------------------------------------------------- + +class TestTokenSelectorPicksOnlySolvencyTokens: + """Tokens with insufficient balance must not be returned.""" + + def test_single_token_insufficient_balance_returns_none(self): + treasury = make_treasury(**{ETH: 50}) + selector = TokenSelector(treasury=treasury, chain_id=CHAIN_ID) + result = selector.select(amount=100, candidates=[TokenCandidate(token=ETH)]) + assert result is None + + def test_single_token_exact_balance_is_selected(self): + treasury = make_treasury(**{USDC: 100}) + selector = TokenSelector(treasury=treasury, chain_id=CHAIN_ID) + result = selector.select(amount=100, candidates=[TokenCandidate(token=USDC)]) + assert result is not None + assert result.token == USDC + + def test_multiple_tokens_only_solvent_ones_returned(self): + treasury = make_treasury(**{ETH: 5, USDC: 200}) + selector = TokenSelector(treasury=treasury, chain_id=CHAIN_ID) + result = selector.select( + amount=100, + candidates=[ + TokenCandidate(token=ETH), # insufficient + TokenCandidate(token=USDC), # sufficient + ], + ) + assert result is not None + assert result.token == USDC + + +class TestTokenSelectorPreferLowerFee: + """Among solvent tokens, pick the one with lower fee_bps.""" + + def test_lower_fee_token_preferred(self): + treasury = make_treasury(**{USDC: 1000, USDT: 1000}) + selector = TokenSelector(treasury=treasury, chain_id=CHAIN_ID) + result = selector.select( + amount=100, + candidates=[ + TokenCandidate(token=USDC, fee_bps=30), + TokenCandidate(token=USDT, fee_bps=5), + ], + ) + assert result is not None + assert result.token == USDT # lower fee wins + + def test_zero_fee_beats_any_positive_fee(self): + treasury = make_treasury(**{ETH: 1000, USDC: 1000}) + selector = TokenSelector(treasury=treasury, chain_id=CHAIN_ID) + result = selector.select( + amount=100, + candidates=[ + TokenCandidate(token=ETH, fee_bps=0), + TokenCandidate(token=USDC, fee_bps=10), + ], + ) + assert result.token == ETH + + +class TestTokenSelectorPreferLowerSlippage: + """When fees are tied, pick the token with lower expected_slippage_bps.""" + + def test_lower_slippage_token_preferred_on_fee_tie(self): + treasury = make_treasury(**{USDC: 1000, LUX: 1000}) + selector = TokenSelector(treasury=treasury, chain_id=CHAIN_ID) + result = selector.select( + amount=100, + candidates=[ + TokenCandidate(token=USDC, fee_bps=10, expected_slippage_bps=50), + TokenCandidate(token=LUX, fee_bps=10, expected_slippage_bps=20), + ], + ) + assert result.token == LUX + + +class TestTokenSelectorPartnerTokensWorkNaturally: + """LUX and ZOO (partner tokens) need no special-casing — balance drives selection.""" + + def test_lux_selected_when_highest_balance_and_lowest_fee(self): + treasury = make_treasury(**{LUX: 5000, ZOO: 5000, USDC: 100}) + selector = TokenSelector(treasury=treasury, chain_id=CHAIN_ID) + result = selector.select( + amount=100, + candidates=[ + TokenCandidate(token=USDC, fee_bps=5, expected_slippage_bps=10), + TokenCandidate(token=LUX, fee_bps=2, expected_slippage_bps=5), + TokenCandidate(token=ZOO, fee_bps=2, expected_slippage_bps=8), + ], + ) + assert result.token == LUX + + def test_zoo_selected_when_lux_insufficient(self): + treasury = make_treasury(**{LUX: 10, ZOO: 500}) # LUX balance < amount + selector = TokenSelector(treasury=treasury, chain_id=CHAIN_ID) + result = selector.select( + amount=100, + candidates=[ + TokenCandidate(token=LUX, fee_bps=0), + TokenCandidate(token=ZOO, fee_bps=5), + ], + ) + assert result.token == ZOO + + +class TestTokenSelectorEmptyCandidates: + def test_empty_candidates_returns_none(self): + treasury = make_treasury(**{ETH: 1000}) + selector = TokenSelector(treasury=treasury, chain_id=CHAIN_ID) + result = selector.select(amount=100, candidates=[]) + assert result is None + + +class TestTokenCandidateDefaults: + """TokenCandidate should default fee_bps and expected_slippage_bps to 0.""" + + def test_defaults_are_zero(self): + c = TokenCandidate(token=USDC) + assert c.fee_bps == 0 + assert c.expected_slippage_bps == 0 diff --git a/tests/test_access_policy.py b/tests/test_access_policy.py new file mode 100644 index 0000000..992c85f --- /dev/null +++ b/tests/test_access_policy.py @@ -0,0 +1,499 @@ +"""Tests for switchboard.access_policy — Unit ⑲. + +Fairness + agent access policy engine, layered on SpendPolicy. + +Coverage +-------- +* Per-agent access tiers (explorer / standard / trusted) with different ceilings. +* Rate-fairness token-bucket: one agent cannot starve others under contention. +* Contract-compliance checks: refuse actions that would violate escrow terms. +* Decision fields: allow/deny + typed reason strings. +* Metric emission hook: every denial carries a WalletOpEvent-compatible payload. +* Thread safety: N concurrent agents each get a fair, bounded share. +""" + +from __future__ import annotations + +import threading +import time +from datetime import datetime, timezone, timedelta +from typing import List +from unittest.mock import MagicMock + +import pytest + +from switchboard.access_policy import ( + AccessPolicy, + AgentTier, + Decision, + TierConfig, + TokenBucketConfig, + WalletOpEvent, + check, +) +from switchboard.delegation import SpendPolicy + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +ETH = "0x0000000000000000000000000000000000000000" +USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +PAYEE_A = "0xPayeeA" +PAYEE_B = "0xPayeeB" + +FAR_FUTURE = datetime(2099, 1, 1, tzinfo=timezone.utc) + + +def _policy(**kwargs) -> SpendPolicy: + defaults = dict(expires_at=FAR_FUTURE, token_allowlist=[ETH, USDC]) + defaults.update(kwargs) + return SpendPolicy(**defaults) + + +def _make_pay_action(amount: int = 100, token: str = ETH, payee: str = PAYEE_A) -> dict: + return {"type": "pay", "amount": amount, "token": token, "payee": payee} + + +def _make_escrow_action(amount: int = 100, token: str = ETH, payee: str = PAYEE_A, escrow_state: str = "open") -> dict: + return {"type": "escrow", "amount": amount, "token": token, "payee": payee, "escrow_state": escrow_state} + + +# --------------------------------------------------------------------------- +# 1. Basic allow path +# --------------------------------------------------------------------------- + +class TestBasicAllow: + def test_allow_returns_decision(self): + policy = AccessPolicy() + policy.register("agent-1", tier=AgentTier.STANDARD, spend_policy=_policy()) + d = policy.check("agent-1", _make_pay_action(amount=500)) + assert isinstance(d, Decision) + assert d.allowed is True + assert d.reason is None + + def test_decision_carries_agent_id(self): + policy = AccessPolicy() + policy.register("agent-1", tier=AgentTier.EXPLORER, spend_policy=_policy()) + d = policy.check("agent-1", _make_pay_action(amount=10)) + assert d.agent_id == "agent-1" + + def test_unknown_agent_defaults_to_explorer(self): + """Unregistered agents fall back to EXPLORER tier — safe default.""" + policy = AccessPolicy() + d = policy.check("unknown-agent", _make_pay_action(amount=10)) + assert d.allowed is True # within explorer ceiling + + def test_module_level_check_convenience(self): + """Module-level check() uses a process-wide AccessPolicy.""" + d = check("mod-agent", _make_pay_action(amount=1)) + assert isinstance(d, Decision) + + +# --------------------------------------------------------------------------- +# 2. Tier ceilings +# --------------------------------------------------------------------------- + +class TestTierCeilings: + """Tiers enforce per-transaction amount ceilings.""" + + def test_explorer_ceiling_enforced(self): + cfg = TierConfig( + explorer=TokenBucketConfig(per_tx_cap=100, rate=10, capacity=100), + standard=TokenBucketConfig(per_tx_cap=1000, rate=100, capacity=1000), + trusted=TokenBucketConfig(per_tx_cap=10_000, rate=1000, capacity=10_000), + ) + policy = AccessPolicy(tier_config=cfg) + policy.register("explorer-1", tier=AgentTier.EXPLORER, spend_policy=_policy()) + + # At ceiling — allowed + d = policy.check("explorer-1", _make_pay_action(amount=100)) + assert d.allowed is True + + # Over ceiling — denied with tier_ceiling reason + d = policy.check("explorer-1", _make_pay_action(amount=101)) + assert d.allowed is False + assert d.reason == "tier_ceiling" + + def test_standard_ceiling_enforced(self): + cfg = TierConfig( + explorer=TokenBucketConfig(per_tx_cap=100, rate=10, capacity=100), + standard=TokenBucketConfig(per_tx_cap=1000, rate=100, capacity=1000), + trusted=TokenBucketConfig(per_tx_cap=10_000, rate=1000, capacity=10_000), + ) + policy = AccessPolicy(tier_config=cfg) + policy.register("std-1", tier=AgentTier.STANDARD, spend_policy=_policy()) + + d = policy.check("std-1", _make_pay_action(amount=1001)) + assert d.allowed is False + assert d.reason == "tier_ceiling" + + def test_trusted_ceiling_higher_than_standard(self): + cfg = TierConfig( + explorer=TokenBucketConfig(per_tx_cap=100, rate=10, capacity=100), + standard=TokenBucketConfig(per_tx_cap=1000, rate=100, capacity=1000), + trusted=TokenBucketConfig(per_tx_cap=10_000, rate=1000, capacity=10_000), + ) + policy = AccessPolicy(tier_config=cfg) + policy.register("trusted-1", tier=AgentTier.TRUSTED, spend_policy=_policy()) + + # 5000 is within trusted but over standard + d = policy.check("trusted-1", _make_pay_action(amount=5000)) + assert d.allowed is True + + def test_tier_upgrade_relaxes_ceiling(self): + cfg = TierConfig( + explorer=TokenBucketConfig(per_tx_cap=100, rate=10, capacity=100), + standard=TokenBucketConfig(per_tx_cap=1000, rate=100, capacity=1000), + trusted=TokenBucketConfig(per_tx_cap=10_000, rate=1000, capacity=10_000), + ) + policy = AccessPolicy(tier_config=cfg) + policy.register("agent-x", tier=AgentTier.EXPLORER, spend_policy=_policy()) + + # Currently denied as explorer + d = policy.check("agent-x", _make_pay_action(amount=500)) + assert d.allowed is False + + # Upgrade to standard + policy.set_tier("agent-x", AgentTier.STANDARD) + d = policy.check("agent-x", _make_pay_action(amount=500)) + assert d.allowed is True + + +# --------------------------------------------------------------------------- +# 3. Rate fairness (token-bucket) +# --------------------------------------------------------------------------- + +class TestRateFairness: + """One agent cannot starve others — token-bucket enforces bounded share.""" + + def test_single_agent_exhausts_bucket_then_rate_limited(self): + """After capacity exhaustion, further requests are rate_limited.""" + cfg = TierConfig( + explorer=TokenBucketConfig(per_tx_cap=1_000_000, rate=0, capacity=3), + standard=TokenBucketConfig(per_tx_cap=1_000_000, rate=0, capacity=3), + trusted=TokenBucketConfig(per_tx_cap=1_000_000, rate=0, capacity=3), + ) + policy = AccessPolicy(tier_config=cfg) + policy.register("hog", tier=AgentTier.STANDARD, spend_policy=_policy()) + + # Drain 3 tokens from the bucket + results = [policy.check("hog", _make_pay_action(amount=1)) for _ in range(3)] + assert all(d.allowed for d in results) + + # 4th request should be rate_limited + d = policy.check("hog", _make_pay_action(amount=1)) + assert d.allowed is False + assert d.reason == "rate_limited" + + def test_n_agents_each_get_bounded_share(self): + """N agents contending: each gets at most capacity tokens, others not starved.""" + cap = 5 + cfg = TierConfig( + explorer=TokenBucketConfig(per_tx_cap=1_000_000, rate=0, capacity=cap), + standard=TokenBucketConfig(per_tx_cap=1_000_000, rate=0, capacity=cap), + trusted=TokenBucketConfig(per_tx_cap=1_000_000, rate=0, capacity=cap), + ) + policy = AccessPolicy(tier_config=cfg) + n_agents = 4 + for i in range(n_agents): + policy.register(f"agent-{i}", tier=AgentTier.STANDARD, spend_policy=_policy()) + + allow_counts: dict[str, int] = {f"agent-{i}": 0 for i in range(n_agents)} + deny_counts: dict[str, int] = {f"agent-{i}": 0 for i in range(n_agents)} + + # Each agent fires 10 requests + for _ in range(10): + for i in range(n_agents): + d = policy.check(f"agent-{i}", _make_pay_action(amount=1)) + if d.allowed: + allow_counts[f"agent-{i}"] += 1 + else: + deny_counts[f"agent-{i}"] += 1 + + # Each agent is bounded to exactly `cap` allows (bucket drained, rate=0) + for i in range(n_agents): + assert allow_counts[f"agent-{i}"] == cap, ( + f"agent-{i} allowed {allow_counts[f'agent-{i}']} times, expected {cap}" + ) + + def test_token_bucket_refills_over_time(self): + """Token bucket refills at the configured rate.""" + fast_clock = [0.0] + + def clock(): + return fast_clock[0] + + # rate=1 token/second, capacity=2 + cfg = TierConfig( + explorer=TokenBucketConfig(per_tx_cap=1_000_000, rate=1.0, capacity=2), + standard=TokenBucketConfig(per_tx_cap=1_000_000, rate=1.0, capacity=2), + trusted=TokenBucketConfig(per_tx_cap=1_000_000, rate=1.0, capacity=2), + ) + policy = AccessPolicy(tier_config=cfg, clock=clock) + policy.register("refill-agent", tier=AgentTier.STANDARD, spend_policy=_policy()) + + # Drain bucket + policy.check("refill-agent", _make_pay_action(amount=1)) + policy.check("refill-agent", _make_pay_action(amount=1)) + d = policy.check("refill-agent", _make_pay_action(amount=1)) + assert d.allowed is False + + # Advance time by 2 seconds → 2 tokens refilled + fast_clock[0] = 2.0 + d = policy.check("refill-agent", _make_pay_action(amount=1)) + assert d.allowed is True + + def test_concurrent_agents_thread_safe(self): + """N threads hitting check() concurrently — no races, bounded results.""" + cap = 10 + cfg = TierConfig( + explorer=TokenBucketConfig(per_tx_cap=1_000_000, rate=0, capacity=cap), + standard=TokenBucketConfig(per_tx_cap=1_000_000, rate=0, capacity=cap), + trusted=TokenBucketConfig(per_tx_cap=1_000_000, rate=0, capacity=cap), + ) + policy = AccessPolicy(tier_config=cfg) + n_agents = 3 + attempts = 20 + for i in range(n_agents): + policy.register(f"t-agent-{i}", tier=AgentTier.STANDARD, spend_policy=_policy()) + + allows: List[int] = [0] * n_agents + lock = threading.Lock() + + def run(idx: int): + local_allows = 0 + for _ in range(attempts): + d = policy.check(f"t-agent-{idx}", _make_pay_action(amount=1)) + if d.allowed: + local_allows += 1 + with lock: + allows[idx] = local_allows + + threads = [threading.Thread(target=run, args=(i,)) for i in range(n_agents)] + for t in threads: + t.start() + for t in threads: + t.join() + + for i, count in enumerate(allows): + assert count == cap, f"t-agent-{i} got {count} allows, expected {cap}" + + +# --------------------------------------------------------------------------- +# 4. Contract compliance checks +# --------------------------------------------------------------------------- + +class TestContractCompliance: + """Refuse actions that would violate escrow contract terms.""" + + def test_escrow_in_terminal_state_refused(self): + """Cannot interact with an escrow that's already closed/refunded.""" + policy = AccessPolicy() + policy.register("comp-agent", tier=AgentTier.TRUSTED, spend_policy=_policy()) + + action = _make_escrow_action(escrow_state="released") + d = policy.check("comp-agent", action) + assert d.allowed is False + assert d.reason == "noncompliant" + + def test_escrow_refunded_state_refused(self): + policy = AccessPolicy() + policy.register("comp-agent", tier=AgentTier.TRUSTED, spend_policy=_policy()) + + action = _make_escrow_action(escrow_state="refunded") + d = policy.check("comp-agent", action) + assert d.allowed is False + assert d.reason == "noncompliant" + + def test_escrow_cancelled_state_refused(self): + policy = AccessPolicy() + policy.register("comp-agent", tier=AgentTier.TRUSTED, spend_policy=_policy()) + + action = _make_escrow_action(escrow_state="cancelled") + d = policy.check("comp-agent", action) + assert d.allowed is False + assert d.reason == "noncompliant" + + def test_escrow_open_state_allowed(self): + """An escrow in 'open' state can be acted upon.""" + policy = AccessPolicy() + policy.register("comp-agent", tier=AgentTier.TRUSTED, spend_policy=_policy()) + + action = _make_escrow_action(escrow_state="open") + d = policy.check("comp-agent", action) + assert d.allowed is True + + def test_escrow_confirmed_state_allowed(self): + """Confirmed escrow can be released.""" + policy = AccessPolicy() + policy.register("comp-agent", tier=AgentTier.TRUSTED, spend_policy=_policy()) + + action = _make_escrow_action(escrow_state="confirmed") + d = policy.check("comp-agent", action) + assert d.allowed is True + + def test_zero_amount_action_refused_as_noncompliant(self): + """A zero-amount payment violates escrow minimum-amount constraints.""" + policy = AccessPolicy() + policy.register("comp-agent", tier=AgentTier.TRUSTED, spend_policy=_policy()) + + action = _make_pay_action(amount=0) + d = policy.check("comp-agent", action) + assert d.allowed is False + assert d.reason == "noncompliant" + + def test_negative_amount_action_refused_as_noncompliant(self): + policy = AccessPolicy() + policy.register("comp-agent", tier=AgentTier.TRUSTED, spend_policy=_policy()) + + action = _make_pay_action(amount=-50) + d = policy.check("comp-agent", action) + assert d.allowed is False + assert d.reason == "noncompliant" + + +# --------------------------------------------------------------------------- +# 5. SpendPolicy integration +# --------------------------------------------------------------------------- + +class TestSpendPolicyIntegration: + """AccessPolicy respects the underlying SpendPolicy rules.""" + + def test_token_not_in_allowlist_denied(self): + """If the action token isn't in the SpendPolicy allowlist, deny.""" + policy = AccessPolicy() + sp = _policy(token_allowlist=[ETH]) + policy.register("sp-agent", tier=AgentTier.STANDARD, spend_policy=sp) + + d = policy.check("sp-agent", _make_pay_action(amount=10, token=USDC)) + assert d.allowed is False + assert d.reason == "policy_violation" + + def test_expired_policy_denied(self): + past = datetime(2000, 1, 1, tzinfo=timezone.utc) + sp = _policy(expires_at=past) + policy = AccessPolicy() + policy.register("expired-agent", tier=AgentTier.STANDARD, spend_policy=sp) + + d = policy.check("expired-agent", _make_pay_action(amount=10)) + assert d.allowed is False + assert d.reason == "policy_violation" + + def test_per_tx_cap_in_spend_policy_denied(self): + """SpendPolicy.per_tx_cap is an additional ceiling below tier cap.""" + cfg = TierConfig( + explorer=TokenBucketConfig(per_tx_cap=10_000, rate=100, capacity=1000), + standard=TokenBucketConfig(per_tx_cap=10_000, rate=100, capacity=1000), + trusted=TokenBucketConfig(per_tx_cap=10_000, rate=100, capacity=1000), + ) + policy = AccessPolicy(tier_config=cfg) + sp = _policy(per_tx_cap=50) + policy.register("sp-cap-agent", tier=AgentTier.STANDARD, spend_policy=sp) + + d = policy.check("sp-cap-agent", _make_pay_action(amount=100)) + assert d.allowed is False + # per_tx_cap from SpendPolicy is a policy_violation, not tier_ceiling + assert d.reason == "policy_violation" + + +# --------------------------------------------------------------------------- +# 6. WalletOpEvent emission +# --------------------------------------------------------------------------- + +class TestWalletOpEvents: + """Denials emit WalletOpEvent with structured reason and agent metadata.""" + + def test_denied_decision_has_wallet_op_event(self): + cfg = TierConfig( + explorer=TokenBucketConfig(per_tx_cap=100, rate=0, capacity=1), + standard=TokenBucketConfig(per_tx_cap=100, rate=0, capacity=1), + trusted=TokenBucketConfig(per_tx_cap=100, rate=0, capacity=1), + ) + policy = AccessPolicy(tier_config=cfg) + policy.register("ev-agent", tier=AgentTier.EXPLORER, spend_policy=_policy()) + + # Drain bucket + policy.check("ev-agent", _make_pay_action(amount=1)) + # Second call → denied + d = policy.check("ev-agent", _make_pay_action(amount=1)) + assert d.allowed is False + + evt = d.event + assert isinstance(evt, WalletOpEvent) + assert evt.denied is True + assert evt.denial_reason == d.reason + assert evt.agent_id == "ev-agent" + + def test_allowed_decision_event_not_denied(self): + policy = AccessPolicy() + policy.register("ev-allow", tier=AgentTier.TRUSTED, spend_policy=_policy()) + + d = policy.check("ev-allow", _make_pay_action(amount=10)) + assert d.allowed is True + assert d.event.denied is False + assert d.event.denial_reason is None + + def test_event_listener_receives_denied_events(self): + """AccessPolicy accepts an event_listener callable called on each check.""" + events: List[WalletOpEvent] = [] + + cfg = TierConfig( + explorer=TokenBucketConfig(per_tx_cap=10, rate=0, capacity=1), + standard=TokenBucketConfig(per_tx_cap=10, rate=0, capacity=1), + trusted=TokenBucketConfig(per_tx_cap=10, rate=0, capacity=1), + ) + policy = AccessPolicy(tier_config=cfg, event_listener=events.append) + policy.register("listen-agent", tier=AgentTier.STANDARD, spend_policy=_policy()) + + policy.check("listen-agent", _make_pay_action(amount=1)) # allow + policy.check("listen-agent", _make_pay_action(amount=1)) # deny + policy.check("listen-agent", _make_pay_action(amount=1)) # deny + + assert len(events) == 3 + denied = [e for e in events if e.denied] + allowed = [e for e in events if not e.denied] + assert len(denied) == 2 + assert len(allowed) == 1 + + +# --------------------------------------------------------------------------- +# 7. Decision dataclass +# --------------------------------------------------------------------------- + +def _evt(denied: bool, reason, agent_id: str) -> WalletOpEvent: + """Build a canonical metrics.WalletOpEvent for Decision construction tests.""" + return WalletOpEvent( + op_type="pay", + token=ETH, + rail="", + amount=0.0, + agent_id=agent_id, + wallet_id="", + denied=denied, + denial_reason=reason, + timestamp=0.0, + ) + + +class TestDecision: + def test_allow_decision_fields(self): + evt = _evt(denied=False, reason=None, agent_id="a") + d = Decision(agent_id="a", allowed=True, reason=None, event=evt) + assert d.agent_id == "a" + assert d.allowed is True + assert d.reason is None + + def test_deny_decision_reason_is_string(self): + evt = _evt(denied=True, reason="tier_ceiling", agent_id="b") + d = Decision(agent_id="b", allowed=False, reason="tier_ceiling", event=evt) + assert d.reason == "tier_ceiling" + + def test_valid_reason_strings(self): + """Allowed reason values are the typed literals defined in the spec.""" + valid_reasons = {"rate_limited", "tier_ceiling", "noncompliant", "policy_violation"} + for r in valid_reasons: + evt = _evt(denied=True, reason=r, agent_id="x") + d = Decision(agent_id="x", allowed=False, reason=r, event=evt) + assert d.reason in valid_reasons diff --git a/tests/test_agent_wallet.py b/tests/test_agent_wallet.py new file mode 100644 index 0000000..8f79e41 --- /dev/null +++ b/tests/test_agent_wallet.py @@ -0,0 +1,182 @@ +"""Tests for switchboard.agent_wallet — Unit ⑧ (AgentWallet portion). + +TDD: these tests are written first and must be run to confirm they fail before +implementation exists, then pass after implementation is complete. + +The on-chain escrow client is NOT available in this worktree. We test against +the ``EscrowClient`` Protocol via a mock — the real client wires in later. +See the EscrowClient seam defined in switchboard/agent_wallet.py. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from switchboard.agent_wallet import ( + AgentWallet, + PaymentRequest, + PaymentReceipt, + EscrowClient, # the Protocol / interface seam + WalletError, +) +from switchboard.mpc_wallet import MPCWallet +from switchboard.treasury import Treasury, InsufficientBalance + + +# Token addresses +ETH = "0x0000000000000000000000000000000000000000" +USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +CHAIN_1 = 1 + + +# --------------------------------------------------------------------------- +# Helpers / fixtures +# --------------------------------------------------------------------------- + + +def make_wallet() -> tuple[AgentWallet, Treasury, MagicMock]: + """Return an AgentWallet with a pre-funded Treasury and a mock EscrowClient.""" + mpc = MPCWallet(parties=3, threshold=2, chain_id=CHAIN_1) + treasury = Treasury() + treasury.credit(CHAIN_1, USDC, 1_000_000_000) # 1,000 USDC (6 decimals) + treasury.credit(CHAIN_1, ETH, 2 * 10**18) + + mock_escrow: EscrowClient = MagicMock(spec=EscrowClient) + mock_escrow.create_payment.return_value = "0xescrow_id_abc" + mock_escrow.release_payment.return_value = True + + wallet = AgentWallet(mpc=mpc, treasury=treasury, escrow=mock_escrow) + return wallet, treasury, mock_escrow + + +def make_request( + chain_id: int = CHAIN_1, + token: str = USDC, + amount: int = 100_000_000, # 100 USDC + payee: str = "0xPayee000000000000000000000000000000000001", +) -> PaymentRequest: + return PaymentRequest( + chain_id=chain_id, + token=token, + amount_wei=amount, + payee=payee, + ) + + +# --------------------------------------------------------------------------- +# Construction +# --------------------------------------------------------------------------- + + +def test_agent_wallet_exposes_mpc_address(): + mpc = MPCWallet() + wallet = AgentWallet(mpc=mpc) + assert wallet.address() == mpc.address() + + +def test_agent_wallet_has_treasury(): + mpc = MPCWallet() + treasury = Treasury() + wallet = AgentWallet(mpc=mpc, treasury=treasury) + assert wallet.treasury is treasury + + +# --------------------------------------------------------------------------- +# Treasury delegation: balance / spendable forwarded +# --------------------------------------------------------------------------- + + +def test_wallet_balance_delegates_to_treasury(): + wallet, treasury, _ = make_wallet() + assert wallet.balance(CHAIN_1, USDC) == treasury.balance(CHAIN_1, USDC) + + +def test_wallet_spendable_delegates_to_treasury(): + wallet, treasury, _ = make_wallet() + treasury.set_reserve(CHAIN_1, USDC, 50_000_000) + assert wallet.spendable(CHAIN_1, USDC) == treasury.spendable(CHAIN_1, USDC) + + +# --------------------------------------------------------------------------- +# pay() — happy path +# --------------------------------------------------------------------------- + + +def test_pay_returns_receipt(): + wallet, treasury, _ = make_wallet() + req = make_request() + receipt = wallet.pay(req) + assert isinstance(receipt, PaymentReceipt) + + +def test_pay_receipt_has_tx_id(): + wallet, treasury, _ = make_wallet() + req = make_request() + receipt = wallet.pay(req) + assert receipt.tx_id is not None + assert len(receipt.tx_id) > 0 + + +def test_pay_receipt_records_token_and_amount(): + wallet, treasury, _ = make_wallet() + req = make_request(token=USDC, amount=50_000_000) + receipt = wallet.pay(req) + assert receipt.token == USDC + assert receipt.amount == 50_000_000 + + +def test_pay_debits_treasury(): + wallet, treasury, _ = make_wallet() + before = treasury.balance(CHAIN_1, USDC) + req = make_request(amount=100_000_000) + wallet.pay(req) + assert treasury.balance(CHAIN_1, USDC) == before - 100_000_000 + + +def test_pay_invokes_escrow_create(): + wallet, treasury, mock_escrow = make_wallet() + req = make_request() + wallet.pay(req) + mock_escrow.create_payment.assert_called_once() + + +# --------------------------------------------------------------------------- +# pay() — error cases +# --------------------------------------------------------------------------- + + +def test_pay_raises_on_insufficient_balance(): + mpc = MPCWallet() + treasury = Treasury() + treasury.credit(CHAIN_1, USDC, 10) # only 10 units + mock_escrow: EscrowClient = MagicMock(spec=EscrowClient) + wallet = AgentWallet(mpc=mpc, treasury=treasury, escrow=mock_escrow) + + req = make_request(amount=1_000_000) # asks for 1,000,000 + with pytest.raises((InsufficientBalance, WalletError)): + wallet.pay(req) + + +def test_pay_raises_on_zero_amount(): + wallet, _, _ = make_wallet() + req = make_request(amount=0) + with pytest.raises((ValueError, WalletError)): + wallet.pay(req) + + +# --------------------------------------------------------------------------- +# EscrowClient Protocol conformance — mock satisfies the interface +# --------------------------------------------------------------------------- + + +def test_mock_escrow_satisfies_protocol(): + """The mock must satisfy the EscrowClient protocol; isinstance check via runtime_checkable.""" + from switchboard.agent_wallet import EscrowClient as EC + mock_escrow = MagicMock(spec=EC) + # structural check: mock has the required methods + assert callable(getattr(mock_escrow, "create_payment", None)) + assert callable(getattr(mock_escrow, "release_payment", None)) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..40f52d0 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,621 @@ +"""Tests for Unit ⑯ — CLI (switchboard/cli.py). + +Uses click.testing.CliRunner to invoke commands and check output. + +Coverage: +- switchboard wallet balance (with token / without token) +- switchboard wallet grant (with / without options) +- switchboard wallet revoke (valid / invalid key_id) +- switchboard escrow create / confirm / refund / status +- switchboard metrics +- switchboard tools (list registry) +- --help on every command group +- JSON output validity (all commands write parseable JSON) +- Non-zero exit on missing required options +- Smoke: the same underlying operations as MCP +""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from switchboard.cli import cli +from switchboard.delegation import Delegation, SpendPolicy +from switchboard.treasury import Treasury +from switchboard.agent_wallet import AgentWallet + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +USDC = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" +ETH = "0x0000000000000000000000000000000000000000" +CHAIN_ID = 84532 +PAYEE = "0xDeadBeef00000000000000000000000000000001" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +runner = CliRunner() + + +def invoke(*args, **kwargs): + """Invoke the CLI and return the result. Raises on traceback.""" + return runner.invoke(cli, args, **kwargs) + + +def parse(result) -> dict | list: + """Parse result.output as JSON.""" + return json.loads(result.output) + + +def _patched_wallet(balance: int = 0, token: str = USDC): + """Build an AgentWallet with mock MPC + escrow, suitable for CLI tests.""" + treasury = Treasury() + if balance > 0: + treasury.credit(chain_id=CHAIN_ID, token=token, amount=balance) + mpc = MagicMock() + mpc.address.return_value = "0xWallet" + mpc.sign_and_send.return_value = "0xTxHash" + escrow = MagicMock() + escrow.create_payment.return_value = "escrow-cli-001" + escrow.release_payment.return_value = True + escrow.request_refund.return_value = True + return AgentWallet(mpc=mpc, treasury=treasury, escrow=escrow) + + +# --------------------------------------------------------------------------- +# Help / version +# --------------------------------------------------------------------------- + +class TestHelp: + def test_root_help(self): + r = invoke("--help") + assert r.exit_code == 0 + assert "wallet" in r.output.lower() or "Wallet" in r.output + + def test_wallet_help(self): + r = invoke("wallet", "--help") + assert r.exit_code == 0 + + def test_wallet_balance_help(self): + r = invoke("wallet", "balance", "--help") + assert r.exit_code == 0 + assert "--chain-id" in r.output + + def test_wallet_grant_help(self): + r = invoke("wallet", "grant", "--help") + assert r.exit_code == 0 + assert "--agent-id" in r.output + + def test_wallet_revoke_help(self): + r = invoke("wallet", "revoke", "--help") + assert r.exit_code == 0 + + def test_escrow_help(self): + r = invoke("escrow", "--help") + assert r.exit_code == 0 + + def test_escrow_create_help(self): + r = invoke("escrow", "create", "--help") + assert r.exit_code == 0 + + def test_escrow_confirm_help(self): + r = invoke("escrow", "confirm", "--help") + assert r.exit_code == 0 + + def test_escrow_refund_help(self): + r = invoke("escrow", "refund", "--help") + assert r.exit_code == 0 + + def test_escrow_status_help(self): + r = invoke("escrow", "status", "--help") + assert r.exit_code == 0 + + def test_metrics_help(self): + r = invoke("metrics", "--help") + assert r.exit_code == 0 + + def test_tools_help(self): + r = invoke("tools", "--help") + assert r.exit_code == 0 + + def test_version(self): + r = invoke("--version") + assert r.exit_code == 0 + assert "0.1.0" in r.output + + +# --------------------------------------------------------------------------- +# wallet balance +# --------------------------------------------------------------------------- + +class TestWalletBalance: + def test_balance_single_token(self): + wallet = _patched_wallet(balance=42_000_000, token=USDC) + delegation = Delegation(wallet=wallet) + import switchboard.cli as cli_mod + original_wallet = cli_mod._wallet + original_delegation = cli_mod._delegation + cli_mod._wallet = wallet + cli_mod._delegation = delegation + try: + r = invoke("wallet", "balance", "--chain-id", str(CHAIN_ID), "--token", USDC) + assert r.exit_code == 0 + data = parse(r) + assert data["balance"] == 42_000_000 + assert data["spendable"] == 42_000_000 + assert data["token"] == USDC + finally: + cli_mod._wallet = original_wallet + cli_mod._delegation = original_delegation + + def test_balance_all_tokens(self): + wallet = _patched_wallet(balance=100_000, token=USDC) + delegation = Delegation(wallet=wallet) + import switchboard.cli as cli_mod + original_wallet = cli_mod._wallet + original_delegation = cli_mod._delegation + cli_mod._wallet = wallet + cli_mod._delegation = delegation + try: + r = invoke("wallet", "balance", "--chain-id", str(CHAIN_ID)) + assert r.exit_code == 0 + data = parse(r) + assert "balances" in data + tokens = [b["token"] for b in data["balances"]] + assert USDC in tokens + finally: + cli_mod._wallet = original_wallet + cli_mod._delegation = original_delegation + + def test_balance_missing_chain_id_exits_nonzero(self): + r = invoke("wallet", "balance") + assert r.exit_code != 0 + + def test_balance_output_is_json(self): + wallet = _patched_wallet(balance=1_000, token=USDC) + import switchboard.cli as cli_mod + cli_mod._wallet = wallet + cli_mod._delegation = Delegation(wallet=wallet) + try: + r = invoke("wallet", "balance", "--chain-id", str(CHAIN_ID), "--token", USDC) + assert r.exit_code == 0 + json.loads(r.output) # must not raise + finally: + cli_mod._wallet = None + cli_mod._delegation = None + + +# --------------------------------------------------------------------------- +# wallet grant +# --------------------------------------------------------------------------- + +class TestWalletGrant: + def _grant(self, *extra_args): + import switchboard.cli as cli_mod + wallet = _patched_wallet() + cli_mod._wallet = wallet + cli_mod._delegation = Delegation(wallet=wallet) + r = invoke("wallet", "grant", "--agent-id", "agent-test", *extra_args) + cli_mod._wallet = None + cli_mod._delegation = None + return r + + def test_grant_returns_key_id(self): + r = self._grant() + assert r.exit_code == 0 + data = parse(r) + assert "key_id" in data + assert len(data["key_id"]) > 0 + + def test_grant_returns_agent_id(self): + r = self._grant() + data = parse(r) + assert data["agent_id"] == "agent-test" + + def test_grant_with_per_tx_cap(self): + r = self._grant("--per-tx-cap", "500000") + assert r.exit_code == 0 + data = parse(r) + assert data["per_tx_cap"] == 500000 + + def test_grant_with_token_allowlist(self): + r = self._grant("--token", USDC) + assert r.exit_code == 0 + data = parse(r) + assert data["token_allowlist"] == [USDC] + + def test_grant_with_daily_cap(self): + r = self._grant("--daily-cap", "10000000") + data = parse(r) + assert data["daily_cap"] == 10000000 + + def test_grant_with_expires_in_hours(self): + r = self._grant("--expires-in-hours", "2") + assert r.exit_code == 0 + data = parse(r) + assert "expires_at" in data + + def test_grant_no_agent_id_exits_nonzero(self): + r = invoke("wallet", "grant") + assert r.exit_code != 0 + + def test_grant_output_is_json(self): + r = self._grant() + json.loads(r.output) + + def test_grant_null_allowlist_when_no_token(self): + r = self._grant() + data = parse(r) + assert data["token_allowlist"] is None + + def test_grant_with_counterparty(self): + r = self._grant("--counterparty", PAYEE) + data = parse(r) + assert data["allowed_counterparties"] == [PAYEE] + + +# --------------------------------------------------------------------------- +# wallet revoke +# --------------------------------------------------------------------------- + +class TestWalletRevoke: + def test_revoke_valid_key(self): + import switchboard.cli as cli_mod + wallet = _patched_wallet() + delegation = Delegation(wallet=wallet) + cli_mod._wallet = wallet + cli_mod._delegation = delegation + + # Grant first + r_grant = invoke("wallet", "grant", "--agent-id", "agent-r") + key_id = parse(r_grant)["key_id"] + + r_revoke = invoke("wallet", "revoke", "--key-id", key_id) + assert r_revoke.exit_code == 0 + data = parse(r_revoke) + assert data["revoked"] is True + assert data["key_id"] == key_id + + cli_mod._wallet = None + cli_mod._delegation = None + + def test_revoke_unknown_key_exits_nonzero(self): + import switchboard.cli as cli_mod + wallet = _patched_wallet() + cli_mod._wallet = wallet + cli_mod._delegation = Delegation(wallet=wallet) + r = invoke("wallet", "revoke", "--key-id", "nonexistent-key") + assert r.exit_code != 0 + cli_mod._wallet = None + cli_mod._delegation = None + + def test_revoke_missing_key_id_exits_nonzero(self): + r = invoke("wallet", "revoke") + assert r.exit_code != 0 + + +# --------------------------------------------------------------------------- +# escrow create +# --------------------------------------------------------------------------- + +class TestEscrowCreate: + def _setup(self): + import switchboard.cli as cli_mod + wallet = _patched_wallet(balance=1_000_000_000, token=USDC) + delegation = Delegation(wallet=wallet) + policy = SpendPolicy( + expires_at=datetime.now(timezone.utc) + timedelta(hours=24), + ) + key = delegation.grant(agent_id="agent-e", policy=policy) + cli_mod._wallet = wallet + cli_mod._delegation = delegation + return key.key_id + + def _teardown(self): + import switchboard.cli as cli_mod + cli_mod._wallet = None + cli_mod._delegation = None + + def test_create_escrow_returns_escrow_id(self): + key_id = self._setup() + try: + r = invoke( + "escrow", "create", + "--session-key", key_id, + "--chain-id", str(CHAIN_ID), + "--token", USDC, + "--amount", "100000", + "--payee", PAYEE, + ) + assert r.exit_code == 0 + data = parse(r) + assert "escrow_id" in data + assert data["status"] == "Locked" + finally: + self._teardown() + + def test_create_escrow_missing_payee_exits_nonzero(self): + key_id = self._setup() + try: + r = invoke( + "escrow", "create", + "--session-key", key_id, + "--chain-id", str(CHAIN_ID), + "--token", USDC, + "--amount", "100000", + ) + assert r.exit_code != 0 + finally: + self._teardown() + + def test_create_escrow_invalid_session_key_exits_nonzero(self): + self._setup() + try: + r = invoke( + "escrow", "create", + "--session-key", "bad-key", + "--chain-id", str(CHAIN_ID), + "--token", USDC, + "--amount", "100000", + "--payee", PAYEE, + ) + assert r.exit_code != 0 + finally: + self._teardown() + + +# --------------------------------------------------------------------------- +# escrow confirm +# --------------------------------------------------------------------------- + +class TestEscrowConfirm: + def _setup(self): + import switchboard.cli as cli_mod + wallet = _patched_wallet() + delegation = Delegation(wallet=wallet) + policy = SpendPolicy( + expires_at=datetime.now(timezone.utc) + timedelta(hours=24), + ) + key = delegation.grant(agent_id="agent-c", policy=policy) + cli_mod._wallet = wallet + cli_mod._delegation = delegation + return key.key_id + + def _teardown(self): + import switchboard.cli as cli_mod + cli_mod._wallet = None + cli_mod._delegation = None + + def test_confirm_returns_released_true(self): + key_id = self._setup() + try: + r = invoke( + "escrow", "confirm", + "--session-key", key_id, + "--escrow-id", "escrow-001", + ) + assert r.exit_code == 0 + data = parse(r) + assert data["released"] is True + finally: + self._teardown() + + def test_confirm_missing_escrow_id_exits_nonzero(self): + key_id = self._setup() + try: + r = invoke("escrow", "confirm", "--session-key", key_id) + assert r.exit_code != 0 + finally: + self._teardown() + + +# --------------------------------------------------------------------------- +# escrow refund +# --------------------------------------------------------------------------- + +class TestEscrowRefund: + def _setup(self): + import switchboard.cli as cli_mod + wallet = _patched_wallet() + delegation = Delegation(wallet=wallet) + policy = SpendPolicy( + expires_at=datetime.now(timezone.utc) + timedelta(hours=24), + ) + key = delegation.grant(agent_id="agent-rf", policy=policy) + cli_mod._wallet = wallet + cli_mod._delegation = delegation + return key.key_id + + def _teardown(self): + import switchboard.cli as cli_mod + cli_mod._wallet = None + cli_mod._delegation = None + + def test_refund_returns_refund_requested_true(self): + key_id = self._setup() + try: + r = invoke( + "escrow", "refund", + "--session-key", key_id, + "--escrow-id", "escrow-002", + "--reason", "delivery failed", + ) + assert r.exit_code == 0 + data = parse(r) + assert data["refund_requested"] is True + finally: + self._teardown() + + def test_refund_missing_escrow_id_exits_nonzero(self): + key_id = self._setup() + try: + r = invoke("escrow", "refund", "--session-key", key_id) + assert r.exit_code != 0 + finally: + self._teardown() + + +# --------------------------------------------------------------------------- +# escrow status +# --------------------------------------------------------------------------- + +class TestEscrowStatus: + def _setup(self): + import switchboard.cli as cli_mod + wallet = _patched_wallet() + delegation = Delegation(wallet=wallet) + policy = SpendPolicy( + expires_at=datetime.now(timezone.utc) + timedelta(hours=24), + ) + key = delegation.grant(agent_id="agent-st", policy=policy) + cli_mod._wallet = wallet + cli_mod._delegation = delegation + return key.key_id + + def _teardown(self): + import switchboard.cli as cli_mod + cli_mod._wallet = None + cli_mod._delegation = None + + def test_status_returns_escrow_id(self): + key_id = self._setup() + try: + r = invoke( + "escrow", "status", + "--session-key", key_id, + "--escrow-id", "escrow-003", + ) + assert r.exit_code == 0 + data = parse(r) + assert "escrow_id" in data + assert "status" in data + finally: + self._teardown() + + def test_status_missing_escrow_id_exits_nonzero(self): + key_id = self._setup() + try: + r = invoke("escrow", "status", "--session-key", key_id) + assert r.exit_code != 0 + finally: + self._teardown() + + +# --------------------------------------------------------------------------- +# metrics +# --------------------------------------------------------------------------- + +class TestMetrics: + def test_metrics_returns_json(self): + r = invoke("metrics") + assert r.exit_code == 0 + data = parse(r) + assert "escrow" in data + assert "wallet_ops" in data + assert "fleet" in data + + def test_metrics_escrow_has_fill_rate(self): + r = invoke("metrics") + data = parse(r) + # fill_rate is None when no events — that's correct + assert "fill_rate" in data["escrow"] + + def test_metrics_escrow_has_total_count(self): + r = invoke("metrics") + data = parse(r) + assert data["escrow"]["total_count"] == 0 + + def test_metrics_fleet_has_active_wallet_count(self): + r = invoke("metrics") + data = parse(r) + assert "active_wallet_count" in data["fleet"] + + def test_metrics_with_chain_id_flag(self): + r = invoke("metrics", "--chain-id", "84532") + assert r.exit_code == 0 + data = parse(r) + assert "escrow" in data + + +# --------------------------------------------------------------------------- +# tools list +# --------------------------------------------------------------------------- + +class TestToolsList: + def test_tools_returns_list(self): + r = invoke("tools") + assert r.exit_code == 0 + data = parse(r) + assert isinstance(data, list) + + def test_tools_has_all_required(self): + r = invoke("tools") + data = parse(r) + names = {t["name"] for t in data} + required = { + "wallet_balance", "pay", "create_escrow", "confirm_payment", + "request_refund", "policy_status", "escrow_metrics", + } + assert required.issubset(names) + + def test_tools_each_has_description(self): + r = invoke("tools") + data = parse(r) + for t in data: + assert t.get("description"), f"Tool {t['name']!r} missing description" + + def test_tools_each_has_op(self): + r = invoke("tools") + data = parse(r) + for t in data: + assert "op" in t + + def test_tools_output_is_json(self): + r = invoke("tools") + json.loads(r.output) # must not raise + + +# --------------------------------------------------------------------------- +# CLI drives same core as MCP (smoke test) +# --------------------------------------------------------------------------- + +class TestCliDrivesSameCoreAsMCP: + def test_grant_and_then_balance_share_same_delegation(self): + """Grant a key via CLI then use it in the MCP server — same delegation.""" + import switchboard.cli as cli_mod + from switchboard.mcp_server import MCPServer + + wallet = _patched_wallet(balance=999_999, token=USDC) + delegation = Delegation(wallet=wallet) + cli_mod._wallet = wallet + cli_mod._delegation = delegation + + # Grant key via CLI + r = invoke("wallet", "grant", "--agent-id", "shared-agent") + key_id = parse(r)["key_id"] + + # The same delegation is visible to the MCP server + server = MCPServer(wallet=wallet, delegation=delegation) + server.handle_message({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + + import json as _json + resp = server.handle_message({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "policy_status", "arguments": {"session_key": key_id}}, + }) + content = _json.loads(resp["result"]["content"][0]["text"]) + assert content["key_id"] == key_id + assert content["agent_id"] == "shared-agent" + + cli_mod._wallet = None + cli_mod._delegation = None diff --git a/tests/test_delegation.py b/tests/test_delegation.py new file mode 100644 index 0000000..3cab6e8 --- /dev/null +++ b/tests/test_delegation.py @@ -0,0 +1,400 @@ +"""Tests for switchboard.delegation — Unit ⑨. + +TDD: failing tests written first. + +Covers: +- grant() returns a SessionKey. +- SpendPolicy fields: token_allowlist, per_tx_cap, daily_cap, expires_at, + allowed_counterparties. +- Wallet enforces policy caps before co-signing (reuses gas_budget/gas_manager). +- Revocation blocks further signing. +- Expiry blocks signing. +- Token not in allowlist blocks signing. +- Counterparty not in allowed_counterparties blocks signing. +""" + +from __future__ import annotations + +import time +from datetime import datetime, timezone, timedelta +from unittest.mock import MagicMock + +import pytest + +from switchboard.delegation import ( + Delegation, + SpendPolicy, + SessionKey, + PolicyViolation, + grant, + revoke, +) +from switchboard.agent_wallet import AgentWallet, PaymentRequest, EscrowClient +from switchboard.mpc_wallet import MPCWallet +from switchboard.treasury import Treasury + + +# --------------------------------------------------------------------------- +# Token / address constants +# --------------------------------------------------------------------------- + +ETH = "0x0000000000000000000000000000000000000000" +USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +DAI = "0x6B175474E89094C44Da98b954EedeAC495271d0F" +LUX = "0xLUX0000000000000000000000000000000000001" + +PAYEE_A = "0xPayeeAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +PAYEE_B = "0xPayeeBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + +CHAIN_1 = 1 + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _future(seconds: int = 3600) -> datetime: + return datetime.now(timezone.utc) + timedelta(seconds=seconds) + + +def _past(seconds: int = 1) -> datetime: + return datetime.now(timezone.utc) - timedelta(seconds=seconds) + + +def _funded_wallet() -> AgentWallet: + mpc = MPCWallet(parties=3, threshold=2, chain_id=CHAIN_1) + treasury = Treasury() + treasury.credit(CHAIN_1, USDC, 10_000_000_000) # 10,000 USDC + treasury.credit(CHAIN_1, ETH, 10 * 10**18) + treasury.credit(CHAIN_1, LUX, 100_000) + mock_escrow: EscrowClient = MagicMock(spec=EscrowClient) + mock_escrow.create_payment.return_value = "0xescrow_test" + mock_escrow.release_payment.return_value = True + return AgentWallet(mpc=mpc, treasury=treasury, escrow=mock_escrow) + + +def _req( + token: str = USDC, + amount: int = 100_000_000, + payee: str = PAYEE_A, +) -> PaymentRequest: + return PaymentRequest( + chain_id=CHAIN_1, + token=token, + amount_wei=amount, + payee=payee, + ) + + +# --------------------------------------------------------------------------- +# grant() / SessionKey basics +# --------------------------------------------------------------------------- + + +def test_grant_returns_session_key(): + d = Delegation() + policy = SpendPolicy( + token_allowlist=[USDC], + per_tx_cap=500_000_000, + daily_cap=5_000_000_000, + expires_at=_future(), + ) + key = d.grant("agent-001", policy) + assert isinstance(key, SessionKey) + + +def test_session_key_is_unique(): + d = Delegation() + policy = SpendPolicy(token_allowlist=[USDC], expires_at=_future()) + k1 = d.grant("agent-001", policy) + k2 = d.grant("agent-001", policy) + assert k1.key_id != k2.key_id + + +def test_session_key_carries_policy(): + d = Delegation() + policy = SpendPolicy(token_allowlist=[USDC], per_tx_cap=999, expires_at=_future()) + key = d.grant("agent-x", policy) + assert key.policy is policy + + +# --------------------------------------------------------------------------- +# revoke() blocks further signing +# --------------------------------------------------------------------------- + + +def test_revoked_key_raises_on_pay(): + wallet = _funded_wallet() + d = Delegation(wallet=wallet) + policy = SpendPolicy(token_allowlist=[USDC], per_tx_cap=1_000_000_000, expires_at=_future()) + key = d.grant("agent-002", policy) + + d.revoke(key) + + req = _req(token=USDC, amount=100_000_000) + with pytest.raises(PolicyViolation, match="revoked"): + d.pay_with_key(key, req) + + +def test_revoked_key_is_no_longer_active(): + d = Delegation() + policy = SpendPolicy(token_allowlist=[USDC], expires_at=_future()) + key = d.grant("agent-003", policy) + assert d.is_active(key) + d.revoke(key) + assert not d.is_active(key) + + +def test_unknown_key_revoke_raises(): + d = Delegation() + fake_key = SessionKey(key_id="nonexistent", agent_id="x", policy=SpendPolicy(expires_at=_future())) + with pytest.raises((KeyError, PolicyViolation)): + d.revoke(fake_key) + + +# --------------------------------------------------------------------------- +# Expiry +# --------------------------------------------------------------------------- + + +def test_expired_key_raises_on_pay(): + wallet = _funded_wallet() + d = Delegation(wallet=wallet) + expired_policy = SpendPolicy( + token_allowlist=[USDC], + per_tx_cap=1_000_000_000, + expires_at=_past(), # already expired + ) + key = d.grant("agent-004", expired_policy) + + req = _req(token=USDC, amount=100_000_000) + with pytest.raises(PolicyViolation, match="expired"): + d.pay_with_key(key, req) + + +def test_unexpired_key_succeeds(): + wallet = _funded_wallet() + d = Delegation(wallet=wallet) + policy = SpendPolicy( + token_allowlist=[USDC], + per_tx_cap=1_000_000_000, + expires_at=_future(3600), + ) + key = d.grant("agent-005", policy) + req = _req(token=USDC, amount=100_000_000) + receipt = d.pay_with_key(key, req) + assert receipt is not None + + +# --------------------------------------------------------------------------- +# Token allowlist +# --------------------------------------------------------------------------- + + +def test_disallowed_token_raises(): + wallet = _funded_wallet() + d = Delegation(wallet=wallet) + policy = SpendPolicy( + token_allowlist=[USDC], # DAI not allowed + per_tx_cap=1_000_000_000, + expires_at=_future(), + ) + key = d.grant("agent-006", policy) + req = _req(token=DAI, amount=100_000_000, payee=PAYEE_A) + with pytest.raises(PolicyViolation, match="token"): + d.pay_with_key(key, req) + + +def test_allowed_token_succeeds(): + wallet = _funded_wallet() + d = Delegation(wallet=wallet) + policy = SpendPolicy( + token_allowlist=[USDC, LUX], + per_tx_cap=1_000_000_000, + expires_at=_future(), + ) + key = d.grant("agent-007", policy) + req = _req(token=LUX, amount=1_000, payee=PAYEE_A) + receipt = d.pay_with_key(key, req) + assert receipt is not None + + +def test_empty_token_allowlist_blocks_all(): + wallet = _funded_wallet() + d = Delegation(wallet=wallet) + policy = SpendPolicy(token_allowlist=[], expires_at=_future()) + key = d.grant("agent-008", policy) + req = _req(token=USDC) + with pytest.raises(PolicyViolation, match="token"): + d.pay_with_key(key, req) + + +def test_none_token_allowlist_allows_all(): + """A None allowlist means no restriction on token.""" + wallet = _funded_wallet() + d = Delegation(wallet=wallet) + policy = SpendPolicy(token_allowlist=None, per_tx_cap=1_000_000_000, expires_at=_future()) + key = d.grant("agent-009", policy) + req = _req(token=DAI, amount=100_000_000) + # Treasury doesn't have DAI, so we expect InsufficientBalance, NOT PolicyViolation + from switchboard.treasury import InsufficientBalance + with pytest.raises((InsufficientBalance, Exception)) as exc_info: + d.pay_with_key(key, req) + # Must NOT be a PolicyViolation for the token + if isinstance(exc_info.value, PolicyViolation): + assert "token" not in str(exc_info.value).lower() + + +# --------------------------------------------------------------------------- +# per_tx_cap enforcement (reuses gas_budget / gas_manager) +# --------------------------------------------------------------------------- + + +def test_per_tx_cap_exceeded_raises(): + wallet = _funded_wallet() + d = Delegation(wallet=wallet) + policy = SpendPolicy( + token_allowlist=[USDC], + per_tx_cap=50_000_000, # 50 USDC cap per tx + expires_at=_future(), + ) + key = d.grant("agent-010", policy) + req = _req(token=USDC, amount=100_000_000) # 100 USDC > 50 USDC cap + with pytest.raises(PolicyViolation, match="per_tx_cap"): + d.pay_with_key(key, req) + + +def test_per_tx_cap_at_limit_succeeds(): + wallet = _funded_wallet() + d = Delegation(wallet=wallet) + policy = SpendPolicy( + token_allowlist=[USDC], + per_tx_cap=100_000_000, # exactly 100 USDC + expires_at=_future(), + ) + key = d.grant("agent-011", policy) + req = _req(token=USDC, amount=100_000_000) + receipt = d.pay_with_key(key, req) + assert receipt is not None + + +def test_per_tx_cap_none_means_unlimited(): + wallet = _funded_wallet() + d = Delegation(wallet=wallet) + policy = SpendPolicy( + token_allowlist=[USDC], + per_tx_cap=None, + expires_at=_future(), + ) + key = d.grant("agent-012", policy) + req = _req(token=USDC, amount=9_000_000_000) # very large amount (within treasury) + receipt = d.pay_with_key(key, req) + assert receipt is not None + + +# --------------------------------------------------------------------------- +# daily_cap enforcement +# --------------------------------------------------------------------------- + + +def test_daily_cap_exceeded_after_multiple_payments(): + wallet = _funded_wallet() + d = Delegation(wallet=wallet) + policy = SpendPolicy( + token_allowlist=[USDC], + per_tx_cap=200_000_000, # 200 USDC per tx + daily_cap=250_000_000, # 250 USDC daily cap + expires_at=_future(), + ) + key = d.grant("agent-013", policy) + + # First payment: 200 USDC — OK + req1 = _req(token=USDC, amount=200_000_000) + d.pay_with_key(key, req1) + + # Second payment: 200 USDC — should exceed daily cap (200+200 > 250) + req2 = _req(token=USDC, amount=200_000_000) + with pytest.raises(PolicyViolation, match="daily_cap"): + d.pay_with_key(key, req2) + + +def test_daily_cap_none_means_unlimited(): + wallet = _funded_wallet() + d = Delegation(wallet=wallet) + policy = SpendPolicy( + token_allowlist=[USDC], + per_tx_cap=1_000_000_000, + daily_cap=None, + expires_at=_future(), + ) + key = d.grant("agent-014", policy) + for _ in range(5): + req = _req(token=USDC, amount=500_000_000) + receipt = d.pay_with_key(key, req) + assert receipt is not None + + +# --------------------------------------------------------------------------- +# allowed_counterparties enforcement +# --------------------------------------------------------------------------- + + +def test_disallowed_counterparty_raises(): + wallet = _funded_wallet() + d = Delegation(wallet=wallet) + policy = SpendPolicy( + token_allowlist=[USDC], + per_tx_cap=1_000_000_000, + expires_at=_future(), + allowed_counterparties=[PAYEE_A], # PAYEE_B not allowed + ) + key = d.grant("agent-015", policy) + req = _req(payee=PAYEE_B) + with pytest.raises(PolicyViolation, match="counterpart"): + d.pay_with_key(key, req) + + +def test_allowed_counterparty_succeeds(): + wallet = _funded_wallet() + d = Delegation(wallet=wallet) + policy = SpendPolicy( + token_allowlist=[USDC], + per_tx_cap=1_000_000_000, + expires_at=_future(), + allowed_counterparties=[PAYEE_A, PAYEE_B], + ) + key = d.grant("agent-016", policy) + req = _req(payee=PAYEE_B) + receipt = d.pay_with_key(key, req) + assert receipt is not None + + +def test_none_allowed_counterparties_permits_any(): + """None = no counterparty restriction.""" + wallet = _funded_wallet() + d = Delegation(wallet=wallet) + policy = SpendPolicy( + token_allowlist=[USDC], + per_tx_cap=1_000_000_000, + expires_at=_future(), + allowed_counterparties=None, + ) + key = d.grant("agent-017", policy) + req = _req(payee=PAYEE_B) + receipt = d.pay_with_key(key, req) + assert receipt is not None + + +# --------------------------------------------------------------------------- +# module-level convenience helpers +# --------------------------------------------------------------------------- + + +def test_module_level_grant_revoke(): + """grant() / revoke() module-level functions create a default Delegation.""" + policy = SpendPolicy(token_allowlist=[USDC], expires_at=_future()) + key = grant("agent-018", policy) + assert isinstance(key, SessionKey) + revoke(key) + assert not key.is_active() diff --git a/tests/test_hanzo_adapter.py b/tests/test_hanzo_adapter.py new file mode 100644 index 0000000..d88bbc9 --- /dev/null +++ b/tests/test_hanzo_adapter.py @@ -0,0 +1,686 @@ +"""Hanzo.ai MCP adapter tests — interop + wallet integration. + +Tests cover two areas: + +1. **x402 envelope interop** — asserting that the Hanzo ``fetch`` tool's + MCP schema (``inputSchema``) and 402 response expectations match what + switchboard emits, and that the adapter correctly bridges any gaps. + +2. **HanzoAgentWallet** — a Hanzo agent connects (gets a session key), + pays, and escrows within the ``SpendPolicy`` / ``AccessPolicy`` gates. + +All tests run without any network or on-chain calls. The ``AgentWallet`` +uses a ``_NoOpEscrow`` (the default stub) so escrow calls succeed. +""" + +from __future__ import annotations + +import base64 +import json +from datetime import datetime, timezone, timedelta +from unittest.mock import MagicMock, patch + +import pytest + +from switchboard.adapters.hanzo import ( + HANZO_X402_VERSION, + HanzoAgentWallet, + build_hanzo_402_body, + decode_hanzo_payment_header, + encode_hanzo_payment_header, + normalize_402_body, + payment_requirements_from_hanzo_accepts, + read_payment_header, + _network_to_chain_id, +) +from switchboard.agent_wallet import AgentWallet +from switchboard.delegation import Delegation, SpendPolicy, PolicyViolation +from switchboard.mpc_wallet import MPCWallet +from switchboard.treasury import Treasury, InsufficientBalance +from switchboard.x402.server import ( + AcceptedToken, + PaymentRequirements, + X402Server, + PAYMENT_HEADER, + PAYMENT_PROOF_HEADER, + WWW_AUTHENTICATE_X402, +) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bda02913" +CHAIN_BASE = 8453 +CHAIN_ETH = 1 +PAYEE = "0xServiceProvider000000000000000000000001" +HANZO_AGENT = "admin/my-bot" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def make_funded_hab( + hanzo_agent_id: str = HANZO_AGENT, + token: str = USDC_BASE, + chain_id: int = CHAIN_BASE, + balance: int = 1_000_000_000, # 1000 USDC (6-decimal) + per_tx_cap: int = 100_000_000, # 100 USDC + daily_cap: int = 500_000_000, # 500 USDC +) -> HanzoAgentWallet: + """Return a ``HanzoAgentWallet`` with a pre-funded treasury.""" + policy = SpendPolicy( + expires_at=datetime.now(timezone.utc) + timedelta(hours=8), + token_allowlist=[token], + per_tx_cap=per_tx_cap, + daily_cap=daily_cap, + ) + hab = HanzoAgentWallet(hanzo_agent_id=hanzo_agent_id, policy=policy) + hab.credit(chain_id=chain_id, token=token, amount=balance) + return hab + + +def switchboard_402_body( + pay_to: str = PAYEE, + amount_usdc: str = "10.00", + network: str = "base", + accepts: list | None = None, +) -> dict: + """Build a realistic switchboard 402 body using ``X402Server``.""" + accepted_tokens = accepts or [] + server = X402Server( + pay_to_address=pay_to, + amount_usdc=amount_usdc, + network=network, + accepts=accepted_tokens, + ) + _, _, body_str = server.build_402_response(nonce="test-nonce") + return json.loads(body_str) + + +# =========================================================================== +# Section 1: x402 Envelope Interop +# =========================================================================== + + +class TestHanzoFetchToolSchema: + """Assert that the Hanzo fetch tool schema matches switchboard's 402 output. + + The Hanzo fetch tool (hanzoai/mcp src/tools/unified/fetch.ts) exposes + an ``inputSchema`` with a ``payment`` field described as:: + + payment: { + description: "x402 payment payload — base64 string sent as-is + via X-PAYMENT, or a JSON object that will be + base64-encoded" + } + + This test group verifies that: + - switchboard can produce envelopes that satisfy these constraints. + - The header encoding round-trips through ``encode_hanzo_payment_header`` + / ``decode_hanzo_payment_header`` identically to what Hanzo's + TypeScript does. + """ + + def test_payment_field_schema_matches_hanzo_fetch_input(self): + """The adapter's header helpers satisfy the Hanzo fetch ``payment`` param.""" + # Hanzo's fetch tool accepts the payment as: + # - a pre-encoded base64 string (sent as-is in X-PAYMENT) + # - a JSON object (the tool base64-encodes it) + # Our encoder must produce the same wire value as Hanzo's TypeScript: + # Buffer.from(JSON.stringify(payment), 'utf8').toString('base64') + payload = { + "txHash": "0xdeadbeef", + "chainId": CHAIN_BASE, + "payer": "0xPayer", + "amount": "10000000", + "nonce": "nonce-abc", + } + + encoded = encode_hanzo_payment_header(payload) + + # Must be valid base64 + assert isinstance(encoded, str) + decoded_bytes = base64.b64decode(encoded) + # Decoded JSON must round-trip + decoded = json.loads(decoded_bytes) + assert decoded == payload + + def test_decode_hanzo_payment_header_round_trips(self): + """encode → decode is a no-op on the payload dict.""" + original = {"txHash": "0xabc", "chainId": 8453, "amount": "1000"} + encoded = encode_hanzo_payment_header(original) + decoded = decode_hanzo_payment_header(encoded) + assert decoded == original + + def test_decode_rejects_invalid_base64(self): + with pytest.raises(ValueError, match="Invalid X-PAYMENT header"): + decode_hanzo_payment_header("!!not-base64!!") + + def test_read_payment_header_prefers_x_payment_uppercase(self): + """``X-PAYMENT`` (Hanzo native) takes priority over legacy headers.""" + headers = { + "X-PAYMENT": "hanzo-b64", + "X-Payment-Proof": "legacy", + } + name, val = read_payment_header(headers) + assert name == "X-PAYMENT" + assert val == "hanzo-b64" + + def test_read_payment_header_falls_back_to_legacy(self): + """Falls back to ``X-Payment-Proof`` when Hanzo header is absent.""" + headers = {"X-Payment-Proof": "legacy-proof"} + name, val = read_payment_header(headers) + assert name == "X-Payment-Proof" + assert val == "legacy-proof" + + def test_read_payment_header_returns_empty_when_none(self): + name, val = read_payment_header({}) + assert name == "" + assert val == "" + + +class TestNormalize402Body: + """Verify the structural mismatch between switchboard and Hanzo is fixed.""" + + def test_switchboard_body_missing_top_level_accepts(self): + """Raw switchboard 402 body does NOT have top-level ``accepts``.""" + body = switchboard_402_body() + # Switchboard puts payment info under ``payment_requirements`` + assert "payment_requirements" in body + # Without the adapter, Hanzo's fetch tool would find no ``accepts`` + # at the top level (this is the mismatch we fix). + assert "accepts" not in body + + def test_normalize_promotes_accepts_from_payment_requirements(self): + """After normalization, ``accepts`` is top-level AND Hanzo-parseable.""" + body = switchboard_402_body() + normalized = normalize_402_body(body) + + assert "accepts" in normalized + assert isinstance(normalized["accepts"], list) + assert len(normalized["accepts"]) >= 1 + + entry = normalized["accepts"][0] + # Must have the fields Hanzo's parsePaymentRequired expects + assert "scheme" in entry + assert "payTo" in entry or "pay_to" in entry or entry.get("payTo") or entry.get("pay_to") is not None + + def test_normalize_adds_x402_version(self): + """Normalized body carries ``x402Version`` for Hanzo tool detection.""" + body = switchboard_402_body() + normalized = normalize_402_body(body) + assert normalized.get("x402Version") == HANZO_X402_VERSION + + def test_normalize_preserves_payment_requirements_back_compat(self): + """The original ``payment_requirements`` key is preserved.""" + body = switchboard_402_body() + normalized = normalize_402_body(body) + assert "payment_requirements" in normalized + + def test_normalize_is_idempotent_on_hanzo_native_body(self): + """Bodies that already have top-level ``accepts`` pass through unchanged.""" + native_body = { + "x402Version": HANZO_X402_VERSION, + "accepts": [{"scheme": "exact", "network": "base", "payTo": PAYEE}], + } + result = normalize_402_body(native_body) + assert result["accepts"] == native_body["accepts"] + assert result["x402Version"] == HANZO_X402_VERSION + + def test_normalize_with_multitoken_accepts(self): + """Multi-token ``accepts[]`` from the server are promoted verbatim.""" + tokens = [ + AcceptedToken(chain_id=CHAIN_BASE, token=USDC_BASE, min_amount=0, rank=2), + AcceptedToken(chain_id=CHAIN_ETH, token=USDC, min_amount=0, rank=1), + ] + body = switchboard_402_body(accepts=tokens) + assert "accepts" not in body # still not at top level before normalization + normalized = normalize_402_body(body) + assert len(normalized["accepts"]) == 2 + + def test_normalize_leaves_non_402_bodies_unchanged(self): + """Bodies without ``payment_requirements`` pass through untouched.""" + body = {"status": "ok", "data": 42} + result = normalize_402_body(body) + assert result == body + + +class TestBuildHanzo402Body: + """Verify ``build_hanzo_402_body()`` produces compliant output.""" + + def test_contains_top_level_accepts(self): + reqs = PaymentRequirements( + scheme="exact", + network="base", + asset="USDC", + amount="10000000", + pay_to=PAYEE, + nonce="nonce-1", + ) + body = build_hanzo_402_body(reqs) + assert "accepts" in body + assert isinstance(body["accepts"], list) + assert body["accepts"][0]["scheme"] == "exact" + assert body["accepts"][0]["payTo"] == PAYEE + + def test_contains_x402_version(self): + reqs = PaymentRequirements(pay_to=PAYEE, amount="0") + body = build_hanzo_402_body(reqs) + assert body["x402Version"] == HANZO_X402_VERSION + + def test_multitoken_body(self): + tokens = [ + AcceptedToken(chain_id=CHAIN_BASE, token=USDC_BASE, min_amount=0, rank=2), + AcceptedToken(chain_id=CHAIN_ETH, token=USDC, min_amount=0, rank=1), + ] + reqs = PaymentRequirements( + pay_to=PAYEE, + amount="1000", + accepts=tokens, + ) + body = build_hanzo_402_body(reqs) + assert len(body["accepts"]) == 2 + chain_ids = {e["chain_id"] for e in body["accepts"]} + assert CHAIN_BASE in chain_ids + assert CHAIN_ETH in chain_ids + + +class TestPaymentRequirementsFromHanzoAccepts: + """Round-trip: Hanzo accepts[] → switchboard PaymentRequirements.""" + + def test_single_entry(self): + accepts = [ + { + "scheme": "exact", + "network": "base", + "asset": USDC_BASE, + "amount": "10000000", + "payTo": PAYEE, + "nonce": "abc", + } + ] + reqs = payment_requirements_from_hanzo_accepts(accepts) + assert reqs.scheme == "exact" + assert reqs.network == "base" + assert reqs.pay_to == PAYEE + assert reqs.nonce == "abc" + assert len(reqs.accepts) == 1 + assert reqs.accepts[0].chain_id == CHAIN_BASE + + def test_multi_entry_builds_accepted_token_list(self): + accepts = [ + {"network": "base", "asset": USDC_BASE, "amount": "5000000", "payTo": PAYEE}, + {"network": "ethereum", "asset": USDC, "amount": "5000000", "payTo": PAYEE}, + ] + reqs = payment_requirements_from_hanzo_accepts(accepts) + assert len(reqs.accepts) == 2 + chain_ids = {t.chain_id for t in reqs.accepts} + assert CHAIN_BASE in chain_ids + assert CHAIN_ETH in chain_ids + + def test_empty_accepts_raises(self): + with pytest.raises(ValueError, match="non-empty"): + payment_requirements_from_hanzo_accepts([]) + + def test_network_to_chain_id_base(self): + assert _network_to_chain_id("base") == 8453 + + def test_network_to_chain_id_ethereum(self): + assert _network_to_chain_id("ethereum") == 1 + + def test_network_to_chain_id_eip155_prefix(self): + assert _network_to_chain_id("eip155:137") == 137 + + def test_network_to_chain_id_unknown_defaults_base(self): + assert _network_to_chain_id("unknown-chain") == 8453 + + +class TestX402ServerCompatWithHanzo: + """End-to-end: switchboard X402Server 402 body + normalize = Hanzo-readable.""" + + def test_full_402_flow_hanzo_can_parse(self): + """Simulate the Hanzo fetch tool's parsePaymentRequired() logic in Python.""" + server = X402Server( + pay_to_address=PAYEE, + amount_usdc="5.00", + network="base", + ) + _, headers, body_str = server.build_402_response(nonce="n1") + body = json.loads(body_str) + + # Hanzo tool checks: if Array.isArray(body.accepts) + # Without normalization — no accepts at top level + assert not isinstance(body.get("accepts"), list) + + # After normalization — Hanzo can find accepts + normalized = normalize_402_body(body) + assert isinstance(normalized.get("accepts"), list) + entry = normalized["accepts"][0] + assert entry.get("payTo") == PAYEE or entry.get("pay_to") == PAYEE + + def test_www_authenticate_x402_header_present(self): + """Switchboard emits ``WWW-Authenticate: x402`` — required by Hanzo.""" + server = X402Server(pay_to_address=PAYEE) + _, headers, _ = server.build_402_response() + assert headers.get("WWW-Authenticate") == WWW_AUTHENTICATE_X402 + + def test_x_payment_required_header_present(self): + """Switchboard emits ``X-Payment-Required`` that Hanzo's fallback can read.""" + server = X402Server(pay_to_address=PAYEE) + _, headers, _ = server.build_402_response() + assert "X-Payment-Required" in headers + + +# =========================================================================== +# Section 2: HanzoAgentWallet — connect, session key, pay, escrow +# =========================================================================== + + +class TestHanzoAgentWalletIdentity: + """Hanzo agent identity maps correctly to switchboard concepts.""" + + def test_agent_id_equals_hanzo_agent_id(self): + hab = HanzoAgentWallet(hanzo_agent_id=HANZO_AGENT) + assert hab.agent_id == HANZO_AGENT + + def test_session_key_issued_for_correct_agent(self): + hab = HanzoAgentWallet(hanzo_agent_id=HANZO_AGENT) + assert hab.session_key.agent_id == HANZO_AGENT + + def test_session_key_is_active_after_creation(self): + hab = HanzoAgentWallet(hanzo_agent_id=HANZO_AGENT) + assert hab.session_key.is_active() + assert hab.is_active() + + def test_wallet_has_evm_address(self): + hab = HanzoAgentWallet(hanzo_agent_id=HANZO_AGENT) + assert isinstance(hab.address, str) + assert len(hab.address) > 0 + + def test_revoke_deactivates_session_key(self): + hab = HanzoAgentWallet(hanzo_agent_id=HANZO_AGENT) + assert hab.is_active() + hab.revoke() + assert not hab.is_active() + + def test_revoked_key_raises_policy_violation_on_pay(self): + hab = make_funded_hab() + hab.revoke() + with pytest.raises(PolicyViolation, match="revoked"): + hab.pay(chain_id=CHAIN_BASE, token=USDC_BASE, amount=1_000, payee=PAYEE) + + +class TestHanzoAgentWalletPay: + """HanzoAgentWallet.pay() enforces SpendPolicy and debits treasury.""" + + def test_successful_pay_returns_receipt(self): + hab = make_funded_hab() + receipt = hab.pay( + chain_id=CHAIN_BASE, token=USDC_BASE, amount=10_000_000, payee=PAYEE + ) + assert receipt.tx_id + assert receipt.chain_id == CHAIN_BASE + assert receipt.token == USDC_BASE + assert receipt.amount == 10_000_000 + assert receipt.payee == PAYEE + + def test_pay_debits_treasury(self): + hab = make_funded_hab(balance=100_000_000) + before = hab.balance(CHAIN_BASE, USDC_BASE) + hab.pay(chain_id=CHAIN_BASE, token=USDC_BASE, amount=10_000_000, payee=PAYEE) + after = hab.balance(CHAIN_BASE, USDC_BASE) + assert after == before - 10_000_000 + + def test_pay_exceeding_per_tx_cap_raises_policy_violation(self): + hab = make_funded_hab(per_tx_cap=5_000_000) # 5 USDC cap + with pytest.raises(PolicyViolation, match="per_tx_cap"): + hab.pay(chain_id=CHAIN_BASE, token=USDC_BASE, + amount=10_000_000, payee=PAYEE) # 10 USDC + + def test_pay_with_disallowed_token_raises_policy_violation(self): + """Token not in allowlist is rejected by SpendPolicy.""" + policy = SpendPolicy( + expires_at=datetime.now(timezone.utc) + timedelta(hours=8), + token_allowlist=[USDC_BASE], # only USDC on Base + ) + hab = HanzoAgentWallet(hanzo_agent_id=HANZO_AGENT, policy=policy) + hab.credit(chain_id=CHAIN_ETH, token=USDC, amount=1_000_000_000) + with pytest.raises(PolicyViolation, match="not in.*allowlist"): + hab.pay(chain_id=CHAIN_ETH, token=USDC, amount=1_000_000, payee=PAYEE) + + def test_pay_with_insufficient_balance_raises(self): + hab = make_funded_hab(balance=1_000) + with pytest.raises(InsufficientBalance): + hab.pay(chain_id=CHAIN_BASE, token=USDC_BASE, + amount=2_000, payee=PAYEE) + + def test_pay_propagates_agent_id_in_metadata(self): + """The receipt's agent attribution is correct (via metadata).""" + hab = make_funded_hab() + receipt = hab.pay( + chain_id=CHAIN_BASE, token=USDC_BASE, amount=1_000_000, payee=PAYEE + ) + # AgentWallet.pay strips metadata, but the escrow_id proves the call + # completed — agent_id attribution is confirmed by the session_key check. + assert receipt is not None + + def test_pay_with_daily_cap_cumulative(self): + """Daily cap is enforced across multiple payments.""" + hab = make_funded_hab( + balance=1_000_000_000, + per_tx_cap=200_000_000, + daily_cap=300_000_000, # 300 USDC / day cap + ) + hab.pay(chain_id=CHAIN_BASE, token=USDC_BASE, amount=100_000_000, payee=PAYEE) + hab.pay(chain_id=CHAIN_BASE, token=USDC_BASE, amount=100_000_000, payee=PAYEE) + # Third payment would exceed daily cap + with pytest.raises(PolicyViolation, match="daily_cap"): + hab.pay(chain_id=CHAIN_BASE, token=USDC_BASE, amount=150_000_000, payee=PAYEE) + + +class TestHanzoAgentWalletEscrow: + """HanzoAgentWallet.escrow() signals escrow intent and pays within policy.""" + + def test_escrow_returns_receipt(self): + hab = make_funded_hab() + receipt = hab.escrow( + chain_id=CHAIN_BASE, token=USDC_BASE, amount=10_000_000, payee=PAYEE + ) + assert receipt.tx_id + assert receipt.escrow_id # noqa: S105 — not a secret, just an ID + + def test_escrow_debits_treasury(self): + hab = make_funded_hab(balance=200_000_000) + before = hab.balance(CHAIN_BASE, USDC_BASE) + hab.escrow(chain_id=CHAIN_BASE, token=USDC_BASE, + amount=50_000_000, payee=PAYEE) + assert hab.balance(CHAIN_BASE, USDC_BASE) == before - 50_000_000 + + def test_escrow_respects_per_tx_cap(self): + hab = make_funded_hab(per_tx_cap=20_000_000) + with pytest.raises(PolicyViolation, match="per_tx_cap"): + hab.escrow(chain_id=CHAIN_BASE, token=USDC_BASE, + amount=30_000_000, payee=PAYEE) + + +class TestHanzoAgentWalletWithAccessPolicy: + """HanzoAgentWallet respects AccessPolicy when wired.""" + + def test_access_policy_denial_raises_access_denied(self): + """A denying AccessPolicy blocks payment before treasury is touched.""" + from switchboard.agent_wallet import AccessDenied + + # Build a mock AccessPolicy that always denies + mock_policy = MagicMock() + denial = MagicMock() + denial.denied = True + denial.reason = "tier_ceiling" + mock_policy.check.return_value = denial + + hab = HanzoAgentWallet( + hanzo_agent_id=HANZO_AGENT, + access_policy=mock_policy, + ) + hab.credit(chain_id=CHAIN_BASE, token=USDC_BASE, amount=1_000_000_000) + + with pytest.raises(AccessDenied): + hab.pay(chain_id=CHAIN_BASE, token=USDC_BASE, + amount=10_000_000, payee=PAYEE) + + def test_access_policy_allow_permits_payment(self): + """An allowing AccessPolicy lets the payment proceed.""" + mock_policy = MagicMock() + allow = MagicMock() + allow.denied = False + mock_policy.check.return_value = allow + + hab = HanzoAgentWallet( + hanzo_agent_id=HANZO_AGENT, + access_policy=mock_policy, + ) + hab.credit(chain_id=CHAIN_BASE, token=USDC_BASE, amount=1_000_000_000) + receipt = hab.pay(chain_id=CHAIN_BASE, token=USDC_BASE, + amount=10_000_000, payee=PAYEE) + assert receipt.tx_id + + +class TestHanzoAgentWalletExistingWallet: + """HanzoAgentWallet can accept a pre-built AgentWallet.""" + + def test_uses_provided_wallet(self): + mpc = MPCWallet() + treasury = Treasury() + treasury.credit(CHAIN_BASE, USDC_BASE, 500_000_000) + wallet = AgentWallet(mpc=mpc, treasury=treasury) + + hab = HanzoAgentWallet(hanzo_agent_id=HANZO_AGENT, wallet=wallet) + + # Balance visible via hab interface + assert hab.balance(CHAIN_BASE, USDC_BASE) == 500_000_000 + + def test_uses_provided_delegation(self): + mpc = MPCWallet() + treasury = Treasury() + treasury.credit(CHAIN_BASE, USDC_BASE, 500_000_000) + wallet = AgentWallet(mpc=mpc, treasury=treasury) + delegation = Delegation(wallet=wallet) + + hab = HanzoAgentWallet( + hanzo_agent_id=HANZO_AGENT, + wallet=wallet, + delegation=delegation, + ) + # The session key must be issued by the provided delegation + assert hab.session_key.agent_id == HANZO_AGENT + assert hab.is_active() + + +class TestHanzoConnectPayEscrowFlow: + """Integration: Hanzo agent connects, gets session key, pays, escrows.""" + + def test_full_flow(self): + """ + Scenario: + 1. Hanzo agent ``admin/inference-bot`` connects to switchboard. + 2. Gets a session key scoped to USDC on Base, 50 USDC/tx, 200 USDC/day. + 3. Pays 10 USDC for an inference call. + 4. Escrows 20 USDC for a longer-running task. + 5. Verifies balances and receipt fields. + """ + agent_id = "admin/inference-bot" + policy = SpendPolicy( + expires_at=datetime.now(timezone.utc) + timedelta(hours=4), + token_allowlist=[USDC_BASE], + per_tx_cap=50_000_000, # 50 USDC + daily_cap=200_000_000, # 200 USDC + ) + hab = HanzoAgentWallet(hanzo_agent_id=agent_id, policy=policy) + hab.credit(chain_id=CHAIN_BASE, token=USDC_BASE, amount=300_000_000) + + # Step 3: pay for inference + receipt1 = hab.pay( + chain_id=CHAIN_BASE, token=USDC_BASE, + amount=10_000_000, payee=PAYEE, + metadata={"service": "inference", "model": "llm-7b"}, + ) + assert receipt1.amount == 10_000_000 + assert receipt1.escrow_id is not None + + # Step 4: escrow for a task + receipt2 = hab.escrow( + chain_id=CHAIN_BASE, token=USDC_BASE, + amount=20_000_000, payee=PAYEE, + metadata={"service": "task", "task_id": "t-abc"}, + ) + assert receipt2.amount == 20_000_000 + assert receipt2.escrow_id is not None + + # Step 5: verify balances + remaining = hab.balance(CHAIN_BASE, USDC_BASE) + assert remaining == 300_000_000 - 10_000_000 - 20_000_000 + + # Daily cap still has room + hab.pay(chain_id=CHAIN_BASE, token=USDC_BASE, amount=50_000_000, payee=PAYEE) + # 10 + 20 + 50 = 80; still under 200 daily cap + + # Exceeding per-tx cap + with pytest.raises(PolicyViolation, match="per_tx_cap"): + hab.pay(chain_id=CHAIN_BASE, token=USDC_BASE, + amount=60_000_000, payee=PAYEE) + + def test_envelope_to_payment_round_trip(self): + """ + Simulate the full Hanzo fetch tool payment flow in Python: + 1. Server returns 402 with switchboard envelope. + 2. Adapter normalizes body → Hanzo can find accepts[]. + 3. Hanzo agent uses HanzoAgentWallet to pay. + 4. Payment proof encoded as X-PAYMENT header. + 5. Header decoded on server side. + """ + # Server side: build 402 + server = X402Server( + pay_to_address=PAYEE, + amount_usdc="5.00", + network="base", + accepts=[ + AcceptedToken( + chain_id=CHAIN_BASE, + token=USDC_BASE, + min_amount=5_000_000, + rank=1, + ) + ], + ) + _, server_headers, body_str = server.build_402_response(nonce="pay-nonce") + body = json.loads(body_str) + + # Adapter: normalize for Hanzo + normalized = normalize_402_body(body) + assert isinstance(normalized["accepts"], list) + + # Client side: agent pays + hab = make_funded_hab() + receipt = hab.pay( + chain_id=CHAIN_BASE, token=USDC_BASE, amount=5_000_000, payee=PAYEE + ) + + # Encode proof as Hanzo X-PAYMENT header + proof_payload = { + "txHash": receipt.tx_id, + "chainId": receipt.chain_id, + "payer": hab.address, + "amount": str(receipt.amount), + "nonce": "pay-nonce", + } + payment_header = encode_hanzo_payment_header(proof_payload) + assert isinstance(payment_header, str) + + # Server side: decode X-PAYMENT header + decoded_proof = decode_hanzo_payment_header(payment_header) + assert decoded_proof["txHash"] == receipt.tx_id + assert decoded_proof["chainId"] == CHAIN_BASE + assert decoded_proof["amount"] == "5000000" diff --git a/tests/test_integration_seams.py b/tests/test_integration_seams.py new file mode 100644 index 0000000..17cb53d --- /dev/null +++ b/tests/test_integration_seams.py @@ -0,0 +1,449 @@ +"""Integration tests for the agent-wallet / multi-token-settlement wiring pass. + +Each ``class`` here corresponds to one reconciled *seam* between units that were +built in parallel. These are integration tests: they assert the units compose +into one working system, not the internal behavior of any single unit (that is +covered by the per-unit suites). + +Seams +----- +1. Canonical ``PaymentRequest`` — ``AgentWallet`` uses ``src.payment_protocol``'s + ``PaymentRequest`` (with ``settlement_token``), not a private copy. +2. Single ``WalletOpEvent`` — ``access_policy`` emits ``metrics.WalletOpEvent`` so + denials flow to the ⑳ dashboard. +3. ``AccessPolicy`` satisfies the MCP ``AccessPolicy`` Protocol and the real + engine gates MCP calls. +4. ``Router`` sits in the ``AgentWallet.pay`` path: it picks (token, rail, + wallet), consults ``access_policy``, and emits a ``WalletOpEvent``. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock + +import pytest + + +# Token addresses used across the seams +ETH = "0x0000000000000000000000000000000000000000" +USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +CHAIN_1 = 1 +PAYEE = "0xPayee000000000000000000000000000000000001" + + +# =========================================================================== +# Seam 1 — Canonical PaymentRequest +# =========================================================================== + + +class TestSeam1CanonicalPaymentRequest: + def test_agent_wallet_reexports_canonical_payment_request(self): + """``switchboard.agent_wallet.PaymentRequest`` IS the canonical protocol type.""" + from switchboard.agent_wallet import PaymentRequest as WalletPR + from src.payment_protocol import PaymentRequest as CanonicalPR + + assert WalletPR is CanonicalPR + + def test_canonical_request_carries_settlement_token_field(self): + from switchboard.agent_wallet import PaymentRequest + + req = PaymentRequest(chain_id=CHAIN_1, token=USDC, amount_wei=100, payee=PAYEE) + # settlement_token is the v1.2 negotiation result field. + assert hasattr(req, "settlement_token") + assert req.settlement_token is None + + def test_amount_alias_reads_amount_wei(self): + from switchboard.agent_wallet import PaymentRequest + + req = PaymentRequest(chain_id=CHAIN_1, token=USDC, amount_wei=42, payee=PAYEE) + assert req.amount == 42 + assert req.amount_wei == 42 + + def test_delegation_shares_the_same_request_type(self): + """Delegation imports PaymentRequest transitively; must be the canonical one.""" + from switchboard.delegation import PaymentRequest as DelegationPR + from src.payment_protocol import PaymentRequest as CanonicalPR + + assert DelegationPR is CanonicalPR + + def test_pay_end_to_end_with_canonical_request(self): + from switchboard.agent_wallet import AgentWallet, PaymentRequest, EscrowClient + from switchboard.mpc_wallet import MPCWallet + from switchboard.treasury import Treasury + + treasury = Treasury() + treasury.credit(CHAIN_1, USDC, 1_000_000) + escrow = MagicMock(spec=EscrowClient) + escrow.create_payment.return_value = "0xescrow_seam1" + escrow.release_payment.return_value = True + wallet = AgentWallet(mpc=MPCWallet(), treasury=treasury, escrow=escrow) + + req = PaymentRequest(chain_id=CHAIN_1, token=USDC, amount_wei=250_000, payee=PAYEE) + receipt = wallet.pay(req) + + assert receipt.token == USDC + assert receipt.amount == 250_000 + assert receipt.escrow_id == "0xescrow_seam1" + assert treasury.balance(CHAIN_1, USDC) == 750_000 + + def test_token_field_off_the_v1_wire_and_hash(self): + """The multi-token ``token`` field must not perturb the frozen wire/hash.""" + from switchboard.agent_wallet import PaymentRequest + + bare = PaymentRequest(request_id="w", payer="0xA", payee="0xB", amount_wei=10**18) + with_tok = PaymentRequest( + request_id="w", payer="0xA", payee="0xB", amount_wei=10**18, token=USDC + ) + # token at default ("") is omitted from the wire entirely + assert "token" not in bare.to_json() + # a set token never changes the content hash (it is a wallet-side selection) + assert bare.content_hash() == with_tok.content_hash() + + +# =========================================================================== +# Seam 2 — Single WalletOpEvent +# =========================================================================== + + +class TestSeam2SingleWalletOpEvent: + def test_access_policy_reexports_metrics_event(self): + """access_policy.WalletOpEvent IS metrics.WalletOpEvent — one canonical type.""" + from switchboard.access_policy import WalletOpEvent as AP_Event + from switchboard.metrics import WalletOpEvent as Metrics_Event + + assert AP_Event is Metrics_Event + + def test_denial_event_has_full_metrics_shape(self): + """A denial from AccessPolicy emits an event with every dashboard field.""" + from switchboard.access_policy import ( + AccessPolicy, + AgentTier, + TierConfig, + TokenBucketConfig, + ) + from switchboard.delegation import SpendPolicy + + # Force a tier-ceiling denial with amount over the cap. + cfg = TierConfig( + explorer=TokenBucketConfig(per_tx_cap=10, rate=0, capacity=5), + standard=TokenBucketConfig(per_tx_cap=10, rate=0, capacity=5), + trusted=TokenBucketConfig(per_tx_cap=10, rate=0, capacity=5), + ) + policy = AccessPolicy(tier_config=cfg) + sp = SpendPolicy(expires_at=datetime(2099, 1, 1, tzinfo=timezone.utc)) + policy.register("agent-x", tier=AgentTier.STANDARD, spend_policy=sp) + + d = policy.check("agent-x", {"type": "pay", "amount": 9999, "token": USDC}) + assert d.allowed is False + evt = d.event + # Every canonical field must be present and populated from the action. + for fld in ( + "op_type", "token", "rail", "amount", "agent_id", + "wallet_id", "denied", "denial_reason", "timestamp", + ): + assert hasattr(evt, fld), f"missing metrics field {fld!r}" + assert evt.op_type == "pay" + assert evt.token == USDC + assert evt.amount == 9999.0 + assert evt.agent_id == "agent-x" + assert evt.denied is True + assert evt.denial_reason == "tier_ceiling" + + def test_denials_flow_into_dashboard_metrics(self): + """Emitted denial events feed compute_wallet_ops_metrics unchanged.""" + from switchboard.access_policy import ( + AccessPolicy, + AgentTier, + TierConfig, + TokenBucketConfig, + ) + from switchboard.delegation import SpendPolicy + from switchboard.metrics import compute_wallet_ops_metrics + + collected = [] + cfg = TierConfig( + explorer=TokenBucketConfig(per_tx_cap=100, rate=0, capacity=1), + standard=TokenBucketConfig(per_tx_cap=100, rate=0, capacity=1), + trusted=TokenBucketConfig(per_tx_cap=100, rate=0, capacity=1), + ) + policy = AccessPolicy(tier_config=cfg, event_listener=collected.append) + sp = SpendPolicy(expires_at=datetime(2099, 1, 1, tzinfo=timezone.utc)) + policy.register("dash-agent", tier=AgentTier.STANDARD, spend_policy=sp) + + policy.check("dash-agent", {"type": "pay", "amount": 10, "token": USDC}) # allow + policy.check("dash-agent", {"type": "pay", "amount": 10, "token": USDC}) # deny (bucket) + + # The dashboard's own compute function consumes them directly. + m = compute_wallet_ops_metrics(collected) + assert m.total_ops == 2 + assert m.policy_denial_count == 1 + assert m.denials_by_reason.get("rate_limited") == 1 + + +# =========================================================================== +# Seam 3 — AccessPolicy satisfies the MCP Protocol + real engine wired in +# =========================================================================== + + +def _mcp_bits(balance: int = 1_000_000_000, token: str = USDC): + """Build (wallet, delegation) for MCP tests with a mocked escrow/mpc.""" + from switchboard.agent_wallet import AgentWallet + from switchboard.delegation import Delegation + from switchboard.treasury import Treasury + + treasury = Treasury() + treasury.credit(chain_id=CHAIN_1, token=token, amount=balance) + mpc = MagicMock() + mpc.address.return_value = "0xWalletAddress" + mpc.sign_and_send.return_value = "0xTxHash" + escrow = MagicMock() + escrow.create_payment.return_value = "escrow-seam3" + escrow.release_payment.return_value = True + wallet = AgentWallet(mpc=mpc, treasury=treasury, escrow=escrow) + return wallet, Delegation(wallet=wallet) + + +def _init(server) -> None: + server.handle_message({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + + +class TestSeam3AccessPolicyProtocol: + def test_decision_exposes_denied_and_reason(self): + """access_policy.Decision satisfies the MCP Protocol: .denied + .reason.""" + from switchboard.access_policy import AccessPolicy, AgentTier + from switchboard.delegation import SpendPolicy + + engine = AccessPolicy() + engine.register( + "a", tier=AgentTier.STANDARD, + spend_policy=SpendPolicy(expires_at=datetime(2099, 1, 1, tzinfo=timezone.utc)), + ) + d = engine.check("a", "pay") # MCP form: op-name string + assert hasattr(d, "denied") and hasattr(d, "reason") + assert d.denied is False + assert d.reason is None + + def test_check_accepts_op_name_string(self): + """The op-name-only form must not false-deny (no amount => no zero-amount trip).""" + from switchboard.access_policy import AccessPolicy, AgentTier + from switchboard.delegation import SpendPolicy + + engine = AccessPolicy() + engine.register( + "b", tier=AgentTier.STANDARD, + spend_policy=SpendPolicy( + expires_at=datetime(2099, 1, 1, tzinfo=timezone.utc), + token_allowlist=[USDC], # would trip if token=None were checked + ), + ) + d = engine.check("b", "create_escrow") + assert d.denied is False + + def test_real_engine_denies_op_through_mcp(self): + """A denied op is refused THROUGH the MCP server by the real engine.""" + from switchboard.access_policy import AccessPolicy, AgentTier + from switchboard.delegation import SpendPolicy + from switchboard.mcp_server import MCPServer, _POLICY_DENIED + + wallet, delegation = _mcp_bits() + # Valid, live session key so we reach the access-policy gate. + live = SpendPolicy(expires_at=datetime.now(timezone.utc) + timedelta(hours=1)) + key = delegation.grant("agent-denied", live) + + # Register the SAME agent in the real engine with an EXPIRED policy so + # the op-name gate denies with policy_violation before dispatch. + engine = AccessPolicy() + engine.register( + "agent-denied", tier=AgentTier.STANDARD, + spend_policy=SpendPolicy(expires_at=datetime(2000, 1, 1, tzinfo=timezone.utc)), + ) + server = MCPServer(wallet=wallet, delegation=delegation, access_policy=engine) + _init(server) + + resp = server.handle_message({ + "jsonrpc": "2.0", "id": 5, "method": "tools/call", + "params": {"name": "pay", "arguments": { + "session_key": key.key_id, "chain_id": CHAIN_1, + "token": USDC, "amount": 100, "payee": PAYEE, + }}, + }) + assert "error" in resp + assert resp["error"]["code"] == _POLICY_DENIED + assert "policy_violation" in resp["error"]["message"] + + def test_real_engine_allows_op_through_mcp(self): + """A compliant agent passes the real engine and the pay executes.""" + from switchboard.access_policy import AccessPolicy, AgentTier + from switchboard.delegation import SpendPolicy + from switchboard.mcp_server import MCPServer + + wallet, delegation = _mcp_bits() + live = SpendPolicy(expires_at=datetime.now(timezone.utc) + timedelta(hours=1)) + key = delegation.grant("agent-ok", live) + + engine = AccessPolicy() + engine.register( + "agent-ok", tier=AgentTier.TRUSTED, + spend_policy=SpendPolicy(expires_at=datetime(2099, 1, 1, tzinfo=timezone.utc)), + ) + server = MCPServer(wallet=wallet, delegation=delegation, access_policy=engine) + _init(server) + + resp = server.handle_message({ + "jsonrpc": "2.0", "id": 6, "method": "tools/call", + "params": {"name": "pay", "arguments": { + "session_key": key.key_id, "chain_id": CHAIN_1, + "token": USDC, "amount": 100, "payee": PAYEE, + }}, + }) + assert "result" in resp, resp + body = json.loads(resp["result"]["content"][0]["text"]) + assert body["tx_id"] == "0xTxHash" + + +# =========================================================================== +# Seam 4 — Router in the pay path +# =========================================================================== + + +def _wallet_with_router(events=None, access_policy=None, extra_tokens=None): + """Build an AgentWallet whose pay() runs through a real Router. + + Returns (wallet, treasury, escrow_mock). + """ + from switchboard.agent_wallet import AgentWallet, EscrowClient + from switchboard.nonce_manager import NonceManager + from switchboard.router import Router + from switchboard.router.token_selector import TokenSelector + from switchboard.router.rail_selector import RailSelector + from switchboard.router.fleet_balancer import FleetBalancer + from switchboard.treasury import Treasury + + treasury = Treasury() + treasury.credit(CHAIN_1, USDC, 1_000_000_000) + for tok, bal in (extra_tokens or {}).items(): + treasury.credit(CHAIN_1, tok, bal) + + chain_client = MagicMock() + chain_client.get_current_onchain_nonce.return_value = 0 + router = Router( + token_selector=TokenSelector(treasury=treasury, chain_id=CHAIN_1), + rail_selector=RailSelector(), + fleet_balancer=FleetBalancer( + wallets=["0xWalletA", "0xWalletB"], + nonce_manager=NonceManager(chain_client=chain_client), + chain_id=CHAIN_1, + ), + events=events, + ) + escrow = MagicMock(spec=EscrowClient) + escrow.create_payment.return_value = "0xescrow_seam4" + escrow.release_payment.return_value = True + + mpc = MagicMock() + mpc.address.return_value = "0xRoot" + mpc.sign_and_send.return_value = "0xTx4" + + wallet = AgentWallet( + mpc=mpc, treasury=treasury, escrow=escrow, + router=router, access_policy=access_policy, + ) + return wallet, treasury, escrow, mpc + + +class TestSeam4RouterInPayPath: + def test_pay_routes_and_emits_wallet_op_event(self): + from switchboard.agent_wallet import PaymentRequest + from switchboard.metrics import WalletOpEvent + + events: list = [] + wallet, _, escrow, _ = _wallet_with_router(events=events.append) + + # 100 USDC -> escrow rail (above x402 micro threshold), a fleet wallet. + req = PaymentRequest(chain_id=CHAIN_1, token=USDC, amount_wei=200_000, payee=PAYEE) + receipt = wallet.pay(req, agent_id="router-agent") + + # Router picked rail + a signing wallet, recorded on the receipt. + assert receipt.token == USDC + assert receipt.rail == "escrow" + assert receipt.wallet in ("0xWalletA", "0xWalletB") + assert receipt.escrow_id == "0xescrow_seam4" + + # Exactly one routing WalletOpEvent, canonical shape, correct agent. + routed = [e for e in events if isinstance(e, WalletOpEvent)] + assert len(routed) == 1 + assert routed[0].agent_id == "router-agent" + assert routed[0].rail == "escrow" + assert routed[0].denied is False + + def test_pay_consults_access_policy_before_signing_and_denies(self): + """A denied access-policy decision blocks the pay before MPC signs.""" + from dataclasses import dataclass + + from switchboard.agent_wallet import PaymentRequest, AccessDenied + + @dataclass + class _Decision: + denied: bool + reason: object + + class DenyEngine: + def __init__(self): + self.called_with = None + + def check(self, agent_id, action): + self.called_with = (agent_id, action) + return _Decision(denied=True, reason="tier_ceiling") + + engine = DenyEngine() + wallet, treasury, escrow, mpc = _wallet_with_router(access_policy=engine) + before = treasury.balance(CHAIN_1, USDC) + + req = PaymentRequest(chain_id=CHAIN_1, token=USDC, amount_wei=200_000, payee=PAYEE) + with pytest.raises(AccessDenied) as ei: + wallet.pay(req, agent_id="blocked-agent") + + assert ei.value.reason == "tier_ceiling" + # Access check ran with the agent + a pay action; nothing signed/debited. + assert engine.called_with[0] == "blocked-agent" + assert engine.called_with[1]["type"] == "pay" + mpc.sign_and_send.assert_not_called() + escrow.create_payment.assert_not_called() + assert treasury.balance(CHAIN_1, USDC) == before + + def test_pay_allowed_by_access_policy_then_routes(self): + """An allowed decision lets the routed pay proceed to a receipt.""" + from switchboard.agent_wallet import PaymentRequest + + class AllowEngine: + def check(self, agent_id, action): + from dataclasses import make_dataclass + D = make_dataclass("D", [("denied", bool), ("reason", object)]) + return D(False, None) + + wallet, _, escrow, mpc = _wallet_with_router(access_policy=AllowEngine()) + req = PaymentRequest(chain_id=CHAIN_1, token=USDC, amount_wei=200_000, payee=PAYEE) + receipt = wallet.pay(req, agent_id="ok-agent") + assert receipt.rail == "escrow" + mpc.sign_and_send.assert_called_once() + + def test_no_router_keeps_direct_path(self): + """Without a Router, pay() behaves exactly as before (no rail/wallet).""" + from switchboard.agent_wallet import AgentWallet, PaymentRequest, EscrowClient + from switchboard.mpc_wallet import MPCWallet + from switchboard.treasury import Treasury + + treasury = Treasury() + treasury.credit(CHAIN_1, USDC, 1_000_000) + escrow = MagicMock(spec=EscrowClient) + escrow.create_payment.return_value = "0xdirect" + escrow.release_payment.return_value = True + wallet = AgentWallet(mpc=MPCWallet(), treasury=treasury, escrow=escrow) + + req = PaymentRequest(chain_id=CHAIN_1, token=USDC, amount_wei=500, payee=PAYEE) + receipt = wallet.pay(req) + assert receipt.rail is None + assert receipt.wallet is None + assert receipt.token == USDC diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 0000000..02a3efb --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,640 @@ +"""Tests for Unit ⑮ — MCP server (switchboard/mcp_server.py). + +Each tool round-trips against a mocked/fresh wallet. +Policy-denied calls return structured errors. + +TDD: tests were defined before the implementation and drive the contract. + +Coverage: +- initialize handshake +- tools/list returns all 7 tools from registry +- tools/call: wallet_balance, pay, create_escrow, confirm_payment, + request_refund, policy_status, escrow_metrics +- Policy enforcement: revoked key, expired key, policy violation +- Unknown tool → error +- Missing session_key → error +- Access-policy denial → error +- serve() I/O loop processes multiple messages +""" + +from __future__ import annotations + +import io +import json +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch + +import pytest + +from switchboard.agent_wallet import AgentWallet, PaymentRequest, PaymentReceipt +from switchboard.delegation import Delegation, SpendPolicy +from switchboard.mcp_server import MCPServer, _POLICY_DENIED, _SESSION_INVALID, _INVALID_PARAMS, _METHOD_NOT_FOUND +from switchboard.metrics import AllMetrics, EscrowMetrics, WalletOpsMetrics, FleetHealth +from switchboard.tools import AllowAllPolicy, Decision +from switchboard.treasury import Treasury + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +ETH = "0x0000000000000000000000000000000000000000" +USDC = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" +CHAIN_ID = 84532 +PAYEE = "0xDeadBeef00000000000000000000000000000001" + + +def make_wallet(balance: int = 0, token: str = USDC) -> AgentWallet: + """Create an AgentWallet with optional pre-funded treasury.""" + treasury = Treasury() + if balance > 0: + treasury.credit(chain_id=CHAIN_ID, token=token, amount=balance) + mpc = MagicMock() + mpc.address.return_value = "0xWalletAddress" + mpc.sign_and_send.return_value = "0xTxHash" + escrow = MagicMock() + escrow.create_payment.return_value = "escrow-001" + escrow.release_payment.return_value = True + escrow.request_refund.return_value = True + return AgentWallet(mpc=mpc, treasury=treasury, escrow=escrow) + + +def make_server( + balance: int = 1_000_000_000, + token: str = USDC, + access_policy=None, + metrics_store=None, +) -> tuple[MCPServer, Delegation]: + wallet = make_wallet(balance=balance, token=token) + delegation = Delegation(wallet=wallet) + server = MCPServer( + wallet=wallet, + delegation=delegation, + access_policy=access_policy or AllowAllPolicy(), + metrics_store=metrics_store, + ) + return server, delegation + + +def active_policy( + token_allowlist=None, + per_tx_cap=None, + daily_cap=None, + allowed_counterparties=None, + hours_valid: float = 24.0, +) -> SpendPolicy: + return SpendPolicy( + expires_at=datetime.now(timezone.utc) + timedelta(hours=hours_valid), + token_allowlist=token_allowlist, + per_tx_cap=per_tx_cap, + daily_cap=daily_cap, + allowed_counterparties=allowed_counterparties, + ) + + +def call(server: MCPServer, method: str, params: dict, req_id=1) -> dict: + """Send a single message and return the parsed response.""" + msg = {"jsonrpc": "2.0", "id": req_id, "method": method, "params": params} + return server.handle_message(msg) + + +def tool_call(server: MCPServer, name: str, arguments: dict, req_id=2) -> dict: + return call(server, "tools/call", {"name": name, "arguments": arguments}, req_id) + + +# --------------------------------------------------------------------------- +# Initialization handshake +# --------------------------------------------------------------------------- + +class TestInitialize: + def test_initialize_returns_protocol_version(self): + server, _ = make_server() + resp = call(server, "initialize", {}) + assert resp["result"]["protocolVersion"] == "2024-11-05" + + def test_initialize_returns_server_info(self): + server, _ = make_server() + resp = call(server, "initialize", {}) + assert "serverInfo" in resp["result"] + assert resp["result"]["serverInfo"]["name"] == "switchboard-mcp" + + def test_initialize_returns_tools_capability(self): + server, _ = make_server() + resp = call(server, "initialize", {}) + assert "tools" in resp["result"]["capabilities"] + + def test_initialized_notification_returns_none(self): + server, _ = make_server() + # initialized is a notification (no id) + msg = {"jsonrpc": "2.0", "method": "initialized"} + resp = server.handle_message(msg) + assert resp is None + + def test_tools_call_before_initialize_returns_error(self): + wallet = make_wallet() + delegation = Delegation(wallet=wallet) + server = MCPServer(wallet=wallet, delegation=delegation) + # Do NOT call initialize + resp = tool_call(server, "wallet_balance", {"session_key": "x", "chain_id": 1}) + assert "error" in resp + + +# --------------------------------------------------------------------------- +# tools/list +# --------------------------------------------------------------------------- + +class TestToolsList: + def _list(self, server): + server.handle_message({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + return call(server, "tools/list", {}) + + def test_returns_all_seven_tools(self): + server, _ = make_server() + resp = self._list(server) + names = {t["name"] for t in resp["result"]["tools"]} + expected = { + "wallet_balance", "pay", "create_escrow", "confirm_payment", + "request_refund", "policy_status", "escrow_metrics", + } + assert expected.issubset(names) + + def test_each_tool_has_input_schema(self): + server, _ = make_server() + resp = self._list(server) + for t in resp["result"]["tools"]: + assert "inputSchema" in t, f"Tool {t['name']!r} missing inputSchema" + + def test_each_tool_has_description(self): + server, _ = make_server() + resp = self._list(server) + for t in resp["result"]["tools"]: + assert t.get("description"), f"Tool {t['name']!r} missing description" + + def test_tools_list_before_initialize(self): + """tools/list should work even before initialize (it's read-only).""" + server, _ = make_server() + resp = call(server, "tools/list", {}) + assert "result" in resp + + +# --------------------------------------------------------------------------- +# wallet_balance +# --------------------------------------------------------------------------- + +class TestWalletBalance: + def setup_method(self): + self.server, self.delegation = make_server(balance=5_000_000_000, token=USDC) + self.server.handle_message({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + policy = active_policy() + self.key = self.delegation.grant(agent_id="agent-1", policy=policy) + + def test_balance_returns_balance_and_spendable(self): + resp = tool_call(self.server, "wallet_balance", { + "session_key": self.key.key_id, + "chain_id": CHAIN_ID, + "token": USDC, + }) + content = json.loads(resp["result"]["content"][0]["text"]) + assert content["balance"] == 5_000_000_000 + assert content["spendable"] == 5_000_000_000 + + def test_balance_all_tokens_on_chain(self): + resp = tool_call(self.server, "wallet_balance", { + "session_key": self.key.key_id, + "chain_id": CHAIN_ID, + }) + content = json.loads(resp["result"]["content"][0]["text"]) + assert "balances" in content + tokens = {b["token"] for b in content["balances"]} + assert USDC in tokens + + def test_missing_chain_id_returns_error(self): + resp = tool_call(self.server, "wallet_balance", { + "session_key": self.key.key_id, + }) + assert "error" in resp + + def test_missing_session_key_returns_error(self): + resp = tool_call(self.server, "wallet_balance", { + "chain_id": CHAIN_ID, + }) + assert "error" in resp + + def test_unknown_session_key_returns_session_invalid_error(self): + resp = tool_call(self.server, "wallet_balance", { + "session_key": "nonexistent-key-id", + "chain_id": CHAIN_ID, + }) + assert "error" in resp + assert resp["error"]["code"] == _SESSION_INVALID + + +# --------------------------------------------------------------------------- +# pay +# --------------------------------------------------------------------------- + +class TestPay: + def setup_method(self): + self.server, self.delegation = make_server(balance=1_000_000_000, token=USDC) + self.server.handle_message({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + policy = active_policy(token_allowlist=[USDC]) + self.key = self.delegation.grant(agent_id="agent-1", policy=policy) + + def test_successful_pay_returns_receipt(self): + resp = tool_call(self.server, "pay", { + "session_key": self.key.key_id, + "chain_id": CHAIN_ID, + "token": USDC, + "amount": 100_000_000, + "payee": PAYEE, + }) + assert "result" in resp + content = json.loads(resp["result"]["content"][0]["text"]) + assert "tx_id" in content + assert content["amount"] == 100_000_000 + + def test_pay_with_wrong_token_raises_policy_violation(self): + resp = tool_call(self.server, "pay", { + "session_key": self.key.key_id, + "chain_id": CHAIN_ID, + "token": ETH, # not in allowlist + "amount": 1_000_000, + "payee": PAYEE, + }) + assert "error" in resp + assert resp["error"]["code"] == _POLICY_DENIED + + def test_pay_missing_payee_returns_error(self): + resp = tool_call(self.server, "pay", { + "session_key": self.key.key_id, + "chain_id": CHAIN_ID, + "token": USDC, + "amount": 100, + }) + assert "error" in resp + + def test_pay_missing_amount_returns_error(self): + resp = tool_call(self.server, "pay", { + "session_key": self.key.key_id, + "chain_id": CHAIN_ID, + "token": USDC, + "payee": PAYEE, + }) + assert "error" in resp + + def test_pay_over_per_tx_cap_returns_policy_denied(self): + server, delegation = make_server(balance=1_000_000_000, token=USDC) + server.handle_message({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + policy = active_policy(token_allowlist=[USDC], per_tx_cap=500_000) + key = delegation.grant(agent_id="agent-cap", policy=policy) + resp = tool_call(server, "pay", { + "session_key": key.key_id, + "chain_id": CHAIN_ID, + "token": USDC, + "amount": 999_999_999, + "payee": PAYEE, + }) + assert "error" in resp + assert resp["error"]["code"] == _POLICY_DENIED + + def test_revoked_key_pay_returns_policy_denied(self): + policy = active_policy() + key = self.delegation.grant(agent_id="agent-revoke", policy=policy) + self.delegation.revoke(key) + resp = tool_call(self.server, "pay", { + "session_key": key.key_id, + "chain_id": CHAIN_ID, + "token": USDC, + "amount": 100, + "payee": PAYEE, + }) + assert "error" in resp + # revoked key has been removed → SESSION_INVALID + assert resp["error"]["code"] in (_SESSION_INVALID, _POLICY_DENIED) + + def test_expired_key_pay_returns_policy_denied(self): + server, delegation = make_server(balance=1_000_000_000, token=USDC) + server.handle_message({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + expired_policy = SpendPolicy( + expires_at=datetime.now(timezone.utc) - timedelta(hours=1), + ) + key = delegation.grant(agent_id="agent-expired", policy=expired_policy) + resp = tool_call(server, "pay", { + "session_key": key.key_id, + "chain_id": CHAIN_ID, + "token": USDC, + "amount": 100, + "payee": PAYEE, + }) + assert "error" in resp + assert resp["error"]["code"] == _POLICY_DENIED + + +# --------------------------------------------------------------------------- +# create_escrow +# --------------------------------------------------------------------------- + +class TestCreateEscrow: + def setup_method(self): + self.server, self.delegation = make_server(balance=1_000_000_000, token=USDC) + self.server.handle_message({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + policy = active_policy() + self.key = self.delegation.grant(agent_id="agent-escrow", policy=policy) + + def test_create_escrow_returns_escrow_id(self): + resp = tool_call(self.server, "create_escrow", { + "session_key": self.key.key_id, + "chain_id": CHAIN_ID, + "token": USDC, + "amount": 100_000_000, + "payee": PAYEE, + }) + assert "result" in resp + content = json.loads(resp["result"]["content"][0]["text"]) + assert "escrow_id" in content + assert content["status"] == "Locked" + + def test_create_escrow_missing_payee_returns_error(self): + resp = tool_call(self.server, "create_escrow", { + "session_key": self.key.key_id, + "chain_id": CHAIN_ID, + "token": USDC, + "amount": 100_000_000, + }) + assert "error" in resp + + +# --------------------------------------------------------------------------- +# confirm_payment +# --------------------------------------------------------------------------- + +class TestConfirmPayment: + def setup_method(self): + self.server, self.delegation = make_server(balance=1_000_000_000, token=USDC) + self.server.handle_message({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + policy = active_policy() + self.key = self.delegation.grant(agent_id="agent-confirm", policy=policy) + + def test_confirm_payment_returns_released_true(self): + resp = tool_call(self.server, "confirm_payment", { + "session_key": self.key.key_id, + "escrow_id": "escrow-001", + }) + assert "result" in resp + content = json.loads(resp["result"]["content"][0]["text"]) + assert content["released"] is True + + def test_confirm_payment_missing_escrow_id_returns_error(self): + resp = tool_call(self.server, "confirm_payment", { + "session_key": self.key.key_id, + }) + assert "error" in resp + + +# --------------------------------------------------------------------------- +# request_refund +# --------------------------------------------------------------------------- + +class TestRequestRefund: + def setup_method(self): + self.server, self.delegation = make_server() + self.server.handle_message({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + policy = active_policy() + self.key = self.delegation.grant(agent_id="agent-refund", policy=policy) + + def test_request_refund_succeeds(self): + resp = tool_call(self.server, "request_refund", { + "session_key": self.key.key_id, + "escrow_id": "escrow-002", + }) + assert "result" in resp + content = json.loads(resp["result"]["content"][0]["text"]) + assert content["refund_requested"] is True + + def test_request_refund_missing_escrow_id_returns_error(self): + resp = tool_call(self.server, "request_refund", { + "session_key": self.key.key_id, + }) + assert "error" in resp + + +# --------------------------------------------------------------------------- +# policy_status +# --------------------------------------------------------------------------- + +class TestPolicyStatus: + def setup_method(self): + self.server, self.delegation = make_server() + self.server.handle_message({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + policy = active_policy(token_allowlist=[USDC], per_tx_cap=1_000_000) + self.key = self.delegation.grant(agent_id="agent-status", policy=policy) + + def test_policy_status_returns_key_id(self): + resp = tool_call(self.server, "policy_status", { + "session_key": self.key.key_id, + }) + assert "result" in resp + content = json.loads(resp["result"]["content"][0]["text"]) + assert content["key_id"] == self.key.key_id + + def test_policy_status_shows_active(self): + resp = tool_call(self.server, "policy_status", { + "session_key": self.key.key_id, + }) + content = json.loads(resp["result"]["content"][0]["text"]) + assert content["active"] is True + + def test_policy_status_shows_token_allowlist(self): + resp = tool_call(self.server, "policy_status", { + "session_key": self.key.key_id, + }) + content = json.loads(resp["result"]["content"][0]["text"]) + assert content["token_allowlist"] == [USDC] + + def test_policy_status_shows_per_tx_cap(self): + resp = tool_call(self.server, "policy_status", { + "session_key": self.key.key_id, + }) + content = json.loads(resp["result"]["content"][0]["text"]) + assert content["per_tx_cap"] == 1_000_000 + + def test_policy_status_shows_agent_id(self): + resp = tool_call(self.server, "policy_status", { + "session_key": self.key.key_id, + }) + content = json.loads(resp["result"]["content"][0]["text"]) + assert content["agent_id"] == "agent-status" + + def test_policy_status_not_expired(self): + resp = tool_call(self.server, "policy_status", { + "session_key": self.key.key_id, + }) + content = json.loads(resp["result"]["content"][0]["text"]) + assert content["expired"] is False + + +# --------------------------------------------------------------------------- +# escrow_metrics +# --------------------------------------------------------------------------- + +class TestEscrowMetrics: + def test_escrow_metrics_returns_fill_rate(self): + metrics = AllMetrics( + escrow=EscrowMetrics( + total_count=10, + released_count=8, + fill_rate=0.8, + timeout_rate=0.1, + refund_rate=0.1, + avg_time_to_release_s=120.0, + ), + wallet_ops=WalletOpsMetrics(), + fleet=FleetHealth(), + ) + server, delegation = make_server(metrics_store=metrics) + server.handle_message({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + policy = active_policy() + key = delegation.grant(agent_id="agent-metrics", policy=policy) + + resp = tool_call(server, "escrow_metrics", { + "session_key": key.key_id, + }) + assert "result" in resp + content = json.loads(resp["result"]["content"][0]["text"]) + assert content["fill_rate"] == pytest.approx(0.8) + assert content["total_count"] == 10 + + def test_escrow_metrics_no_store_returns_nulls(self): + server, delegation = make_server(metrics_store=None) + server.handle_message({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + policy = active_policy() + key = delegation.grant(agent_id="agent-m2", policy=policy) + resp = tool_call(server, "escrow_metrics", { + "session_key": key.key_id, + }) + content = json.loads(resp["result"]["content"][0]["text"]) + assert content["fill_rate"] is None + assert content["total_count"] == 0 + + +# --------------------------------------------------------------------------- +# Unknown tool +# --------------------------------------------------------------------------- + +class TestUnknownTool: + def test_unknown_tool_returns_method_not_found(self): + server, delegation = make_server() + server.handle_message({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + policy = active_policy() + key = delegation.grant(agent_id="agent-x", policy=policy) + resp = tool_call(server, "totally_unknown_tool", { + "session_key": key.key_id, + }) + assert "error" in resp + assert resp["error"]["code"] == _METHOD_NOT_FOUND + + +# --------------------------------------------------------------------------- +# Access-policy denial +# --------------------------------------------------------------------------- + +class TestAccessPolicyDenial: + def test_access_policy_denial_returns_policy_denied_error(self): + class DenyAllPolicy: + def check(self, agent_id: str, action: str) -> Decision: + return Decision(denied=True, reason="tier_insufficient") + + server, delegation = make_server(access_policy=DenyAllPolicy()) + server.handle_message({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + policy = active_policy() + key = delegation.grant(agent_id="agent-deny", policy=policy) + + resp = tool_call(server, "pay", { + "session_key": key.key_id, + "chain_id": CHAIN_ID, + "token": USDC, + "amount": 100, + "payee": PAYEE, + }) + assert "error" in resp + assert resp["error"]["code"] == _POLICY_DENIED + assert "tier_insufficient" in resp["error"]["message"] + + def test_wallet_balance_gated_by_policy(self): + class DenyAll: + def check(self, agent_id, action): + return Decision(denied=True, reason="no_access") + + server, delegation = make_server(access_policy=DenyAll()) + server.handle_message({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + policy = active_policy() + key = delegation.grant(agent_id="agent-deny2", policy=policy) + resp = tool_call(server, "wallet_balance", { + "session_key": key.key_id, + "chain_id": CHAIN_ID, + }) + assert "error" in resp + assert resp["error"]["code"] == _POLICY_DENIED + + +# --------------------------------------------------------------------------- +# serve() I/O loop +# --------------------------------------------------------------------------- + +class TestServeLoop: + def test_serve_processes_multiple_messages(self): + server, delegation = make_server() + policy = active_policy() + key = delegation.grant(agent_id="agent-loop", policy=policy) + + messages = [ + json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}), + json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}), + json.dumps({ + "jsonrpc": "2.0", "id": 3, "method": "tools/call", + "params": {"name": "policy_status", "arguments": {"session_key": key.key_id}}, + }), + ] + in_stream = io.StringIO("\n".join(messages) + "\n") + out_stream = io.StringIO() + server._in = in_stream + server._out = out_stream + + server.serve() + + output = out_stream.getvalue().strip().splitlines() + assert len(output) == 3 + responses = [json.loads(line) for line in output] + assert responses[0]["id"] == 1 + assert responses[1]["id"] == 2 + assert responses[2]["id"] == 3 + + def test_serve_handles_parse_error_gracefully(self): + server, _ = make_server() + in_stream = io.StringIO("not valid json\n") + out_stream = io.StringIO() + server._in = in_stream + server._out = out_stream + + server.serve() + + output = out_stream.getvalue().strip() + resp = json.loads(output) + assert "error" in resp + assert resp["error"]["code"] == -32700 # PARSE_ERROR + + def test_serve_handles_empty_lines(self): + server, _ = make_server() + in_stream = io.StringIO("\n\n\n") + out_stream = io.StringIO() + server._in = in_stream + server._out = out_stream + # Should not raise + server.serve() + assert out_stream.getvalue() == "" + + def test_ping_returns_empty_result(self): + server, _ = make_server() + resp = server.handle_message({"jsonrpc": "2.0", "id": 99, "method": "ping", "params": {}}) + assert resp["id"] == 99 + assert resp["result"] == {} diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..bcba1e7 --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,501 @@ +""" +Tests for switchboard.metrics — escrow-fulfilment metrics + wallet ops. + +Unit ⑳ of the agent-wallet-multitoken-settlement plan. + +Record shapes consumed by these metrics are defined here and serve +as the canonical spec for what the backend must emit. +""" + +from __future__ import annotations + +import time +from typing import Any + +import pytest + +from switchboard.metrics import ( + # Record types (input shape) + EscrowEvent, + WalletOpEvent, + EscrowState, + # Computed metric containers + EscrowMetrics, + WalletOpsMetrics, + FleetHealth, + # Compute functions + compute_escrow_metrics, + compute_wallet_ops_metrics, + compute_fleet_health, + # Aggregate + compute_all_metrics, +) + + +# --------------------------------------------------------------------------- +# Fixtures — canonical record shapes +# --------------------------------------------------------------------------- + +def _ts(offset: float = 0.0) -> float: + """Return a stable base timestamp plus offset (seconds).""" + return 1_750_000_000.0 + offset + + +def make_escrow_event( + request_id: str = "req-001", + event_type: str = "Released", + token: str = "ETH", + amount: float = 1.0, + created_at: float | None = None, + resolved_at: float | None = None, + payer: str = "0xPayer", + payee: str = "0xPayee", + chain_id: int = 1, +) -> EscrowEvent: + """Build an EscrowEvent with sane defaults.""" + return EscrowEvent( + request_id=request_id, + event_type=event_type, + token=token, + amount=amount, + created_at=created_at if created_at is not None else _ts(0), + resolved_at=resolved_at, + payer=payer, + payee=payee, + chain_id=chain_id, + ) + + +def make_wallet_op( + op_type: str = "pay", + token: str = "ETH", + rail: str = "escrow", + amount: float = 1.0, + agent_id: str = "agent-1", + wallet_id: str = "wallet-A", + denied: bool = False, + denial_reason: str | None = None, + ts: float | None = None, +) -> WalletOpEvent: + return WalletOpEvent( + op_type=op_type, + token=token, + rail=rail, + amount=amount, + agent_id=agent_id, + wallet_id=wallet_id, + denied=denied, + denial_reason=denial_reason, + timestamp=ts if ts is not None else _ts(0), + ) + + +def make_escrow_state( + request_id: str = "req-001", + state: str = "Locked", + token: str = "ETH", + amount: float = 1.0, + created_at: float | None = None, + wallet_id: str = "wallet-A", +) -> EscrowState: + return EscrowState( + request_id=request_id, + state=state, + token=token, + amount=amount, + created_at=created_at if created_at is not None else _ts(-600), + wallet_id=wallet_id, + ) + + +# --------------------------------------------------------------------------- +# EscrowEvent record shape tests +# --------------------------------------------------------------------------- + +class TestEscrowEventShape: + def test_has_required_fields(self): + ev = make_escrow_event() + assert ev.request_id == "req-001" + assert ev.event_type == "Released" + assert ev.token == "ETH" + assert ev.amount == 1.0 + assert ev.created_at == _ts(0) + assert ev.resolved_at is None + assert ev.payer == "0xPayer" + assert ev.payee == "0xPayee" + assert ev.chain_id == 1 + + def test_all_escrow_event_types_accepted(self): + for et in ("Released", "Refunded", "Cancelled", "Timeout", "Challenged"): + ev = make_escrow_event(event_type=et) + assert ev.event_type == et + + def test_token_field_is_string(self): + # ERC-20 address or "ETH" + ev = make_escrow_event(token="0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + assert ev.token.startswith("0x") + + +class TestWalletOpEventShape: + def test_has_required_fields(self): + op = make_wallet_op() + assert op.op_type == "pay" + assert op.token == "ETH" + assert op.rail == "escrow" + assert op.amount == 1.0 + assert op.agent_id == "agent-1" + assert op.wallet_id == "wallet-A" + assert op.denied is False + assert op.denial_reason is None + + def test_denied_op_carries_reason(self): + op = make_wallet_op(denied=True, denial_reason="daily_cap_exceeded") + assert op.denied is True + assert op.denial_reason == "daily_cap_exceeded" + + +class TestEscrowStateShape: + def test_has_required_fields(self): + st = make_escrow_state() + assert st.request_id == "req-001" + assert st.state in ("Locked", "Released", "Refunded", "Cancelled") + assert st.token == "ETH" + assert st.amount == 1.0 + assert st.wallet_id == "wallet-A" + + +# --------------------------------------------------------------------------- +# compute_escrow_metrics +# --------------------------------------------------------------------------- + +class TestFillRate: + def test_all_released_gives_100_pct(self): + events = [ + make_escrow_event("r1", "Released", resolved_at=_ts(60)), + make_escrow_event("r2", "Released", resolved_at=_ts(120)), + ] + m = compute_escrow_metrics(events) + assert m.fill_rate == pytest.approx(1.0) + + def test_mixed_events(self): + events = [ + make_escrow_event("r1", "Released", resolved_at=_ts(60)), + make_escrow_event("r2", "Refunded", resolved_at=_ts(300)), + make_escrow_event("r3", "Timeout", resolved_at=_ts(400)), + make_escrow_event("r4", "Released", resolved_at=_ts(100)), + ] + m = compute_escrow_metrics(events) + # 2 Released out of 4 total resolved = 0.5 + assert m.fill_rate == pytest.approx(0.5) + + def test_empty_events_gives_none(self): + m = compute_escrow_metrics([]) + assert m.fill_rate is None + + def test_no_resolved_events_gives_none(self): + # Only Locked/pending events (no resolved_at) + events = [make_escrow_event("r1", "Locked")] + m = compute_escrow_metrics(events) + assert m.fill_rate is None + + +class TestTimeToRelease: + def test_computes_mean_seconds_for_released_events(self): + events = [ + make_escrow_event("r1", "Released", + created_at=_ts(0), resolved_at=_ts(60)), + make_escrow_event("r2", "Released", + created_at=_ts(0), resolved_at=_ts(120)), + ] + m = compute_escrow_metrics(events) + assert m.avg_time_to_release_s == pytest.approx(90.0) + + def test_only_released_counted_for_time_to_release(self): + events = [ + make_escrow_event("r1", "Released", + created_at=_ts(0), resolved_at=_ts(60)), + make_escrow_event("r2", "Refunded", + created_at=_ts(0), resolved_at=_ts(600)), + ] + m = compute_escrow_metrics(events) + assert m.avg_time_to_release_s == pytest.approx(60.0) + + def test_no_released_events_gives_none(self): + events = [make_escrow_event("r1", "Timeout")] + m = compute_escrow_metrics(events) + assert m.avg_time_to_release_s is None + + +class TestTimeoutRate: + def test_timeout_rate_pure(self): + events = [ + make_escrow_event("r1", "Timeout"), + make_escrow_event("r2", "Timeout"), + make_escrow_event("r3", "Released", resolved_at=_ts(60)), + ] + m = compute_escrow_metrics(events) + assert m.timeout_rate == pytest.approx(2 / 3) + + def test_no_timeouts_gives_zero(self): + events = [make_escrow_event("r1", "Released", resolved_at=_ts(60))] + m = compute_escrow_metrics(events) + assert m.timeout_rate == pytest.approx(0.0) + + +class TestRefundRate: + def test_refund_rate(self): + events = [ + make_escrow_event("r1", "Refunded"), + make_escrow_event("r2", "Released", resolved_at=_ts(60)), + make_escrow_event("r3", "Released", resolved_at=_ts(60)), + ] + m = compute_escrow_metrics(events) + assert m.refund_rate == pytest.approx(1 / 3) + + +class TestChallengeRate: + def test_challenge_rate(self): + events = [ + make_escrow_event("r1", "Challenged"), + make_escrow_event("r2", "Challenged"), + make_escrow_event("r3", "Released", resolved_at=_ts(60)), + make_escrow_event("r4", "Released", resolved_at=_ts(60)), + make_escrow_event("r5", "Released", resolved_at=_ts(60)), + ] + m = compute_escrow_metrics(events) + assert m.challenge_rate == pytest.approx(2 / 5) + + def test_zero_challenge_rate(self): + events = [make_escrow_event("r1", "Released", resolved_at=_ts(60))] + m = compute_escrow_metrics(events) + assert m.challenge_rate == pytest.approx(0.0) + + +class TestEscrowMetricsTotalCounts: + def test_total_count(self): + events = [ + make_escrow_event("r1", "Released", resolved_at=_ts(60)), + make_escrow_event("r2", "Refunded"), + make_escrow_event("r3", "Timeout"), + ] + m = compute_escrow_metrics(events) + assert m.total_count == 3 + + def test_released_count(self): + events = [ + make_escrow_event("r1", "Released", resolved_at=_ts(60)), + make_escrow_event("r2", "Released", resolved_at=_ts(90)), + make_escrow_event("r3", "Refunded"), + ] + m = compute_escrow_metrics(events) + assert m.released_count == 2 + + def test_pending_count_from_states(self): + states = [ + make_escrow_state("r1", "Locked"), + make_escrow_state("r2", "Locked"), + make_escrow_state("r3", "Released"), + ] + m = compute_escrow_metrics([], states=states) + assert m.pending_count == 2 + + +# --------------------------------------------------------------------------- +# compute_wallet_ops_metrics +# --------------------------------------------------------------------------- + +class TestSpendByToken: + def test_spend_by_token_sums_correctly(self): + ops = [ + make_wallet_op(token="ETH", amount=1.0), + make_wallet_op(token="ETH", amount=2.0), + make_wallet_op(token="USDC", amount=100.0), + make_wallet_op(token="LUX", amount=500.0), + ] + m = compute_wallet_ops_metrics(ops) + assert m.spend_by_token["ETH"] == pytest.approx(3.0) + assert m.spend_by_token["USDC"] == pytest.approx(100.0) + assert m.spend_by_token["LUX"] == pytest.approx(500.0) + + def test_denied_ops_excluded_from_spend(self): + ops = [ + make_wallet_op(token="ETH", amount=5.0, denied=False), + make_wallet_op(token="ETH", amount=99.0, denied=True), + ] + m = compute_wallet_ops_metrics(ops) + assert m.spend_by_token["ETH"] == pytest.approx(5.0) + + +class TestSpendByRail: + def test_spend_by_rail(self): + ops = [ + make_wallet_op(rail="x402", amount=0.01), + make_wallet_op(rail="x402", amount=0.02), + make_wallet_op(rail="escrow", amount=1.0), + make_wallet_op(rail="mpp", amount=0.5), + ] + m = compute_wallet_ops_metrics(ops) + assert m.spend_by_rail["x402"] == pytest.approx(0.03) + assert m.spend_by_rail["escrow"] == pytest.approx(1.0) + assert m.spend_by_rail["mpp"] == pytest.approx(0.5) + + +class TestPolicyDenials: + def test_total_denial_count(self): + ops = [ + make_wallet_op(denied=True, denial_reason="daily_cap_exceeded"), + make_wallet_op(denied=True, denial_reason="token_not_allowed"), + make_wallet_op(denied=False), + make_wallet_op(denied=False), + ] + m = compute_wallet_ops_metrics(ops) + assert m.policy_denial_count == 2 + + def test_denials_by_reason(self): + ops = [ + make_wallet_op(denied=True, denial_reason="daily_cap_exceeded"), + make_wallet_op(denied=True, denial_reason="daily_cap_exceeded"), + make_wallet_op(denied=True, denial_reason="token_not_allowed"), + ] + m = compute_wallet_ops_metrics(ops) + assert m.denials_by_reason["daily_cap_exceeded"] == 2 + assert m.denials_by_reason["token_not_allowed"] == 1 + + def test_zero_denials(self): + ops = [make_wallet_op(denied=False) for _ in range(5)] + m = compute_wallet_ops_metrics(ops) + assert m.policy_denial_count == 0 + assert m.denials_by_reason == {} + + +class TestOpTotals: + def test_total_ops_count(self): + ops = [make_wallet_op() for _ in range(7)] + m = compute_wallet_ops_metrics(ops) + assert m.total_ops == 7 + + def test_empty_ops(self): + m = compute_wallet_ops_metrics([]) + assert m.total_ops == 0 + assert m.spend_by_token == {} + assert m.spend_by_rail == {} + assert m.policy_denial_count == 0 + + +# --------------------------------------------------------------------------- +# compute_fleet_health +# --------------------------------------------------------------------------- + +class TestFleetHealth: + def test_healthy_wallets_reported(self): + ops = [ + make_wallet_op(wallet_id="wallet-A"), + make_wallet_op(wallet_id="wallet-A"), + make_wallet_op(wallet_id="wallet-B"), + ] + h = compute_fleet_health(ops) + assert "wallet-A" in h.wallet_op_counts + assert h.wallet_op_counts["wallet-A"] == 2 + assert h.wallet_op_counts["wallet-B"] == 1 + + def test_active_wallet_count(self): + ops = [ + make_wallet_op(wallet_id="wallet-A"), + make_wallet_op(wallet_id="wallet-B"), + make_wallet_op(wallet_id="wallet-C"), + ] + h = compute_fleet_health(ops) + assert h.active_wallet_count == 3 + + def test_denial_rate_per_wallet(self): + ops = [ + make_wallet_op(wallet_id="wallet-A", denied=True), + make_wallet_op(wallet_id="wallet-A", denied=False), + make_wallet_op(wallet_id="wallet-B", denied=False), + ] + h = compute_fleet_health(ops) + assert h.denial_rate_by_wallet["wallet-A"] == pytest.approx(0.5) + assert h.denial_rate_by_wallet["wallet-B"] == pytest.approx(0.0) + + def test_empty_ops_zero_active(self): + h = compute_fleet_health([]) + assert h.active_wallet_count == 0 + assert h.wallet_op_counts == {} + + +# --------------------------------------------------------------------------- +# compute_all_metrics — aggregate +# --------------------------------------------------------------------------- + +class TestComputeAllMetrics: + def test_returns_all_three_components(self): + escrow_events = [ + make_escrow_event("r1", "Released", resolved_at=_ts(60)), + ] + wallet_ops = [ + make_wallet_op(), + ] + result = compute_all_metrics( + escrow_events=escrow_events, + wallet_ops=wallet_ops, + ) + assert hasattr(result, "escrow") + assert hasattr(result, "wallet_ops") + assert hasattr(result, "fleet") + assert isinstance(result.escrow, EscrowMetrics) + assert isinstance(result.wallet_ops, WalletOpsMetrics) + assert isinstance(result.fleet, FleetHealth) + + def test_full_fixture_round_trip(self): + """Realistic fixture: a mix of outcomes + multi-token ops.""" + escrow_events = [ + make_escrow_event("e1", "Released", token="ETH", + created_at=_ts(0), resolved_at=_ts(30)), + make_escrow_event("e2", "Released", token="USDC", + created_at=_ts(0), resolved_at=_ts(90)), + make_escrow_event("e3", "Refunded", token="LUX"), + make_escrow_event("e4", "Timeout", token="ZOO"), + make_escrow_event("e5", "Challenged", token="ETH"), + ] + wallet_ops = [ + make_wallet_op(token="ETH", rail="escrow", amount=1.0, wallet_id="W1"), + make_wallet_op(token="USDC", rail="x402", amount=50.0, wallet_id="W1"), + make_wallet_op(token="LUX", rail="escrow", amount=200.0, wallet_id="W2"), + make_wallet_op(token="ZOO", rail="mpp", amount=10.0, wallet_id="W2"), + make_wallet_op(token="ETH", rail="escrow", amount=0.5, + denied=True, denial_reason="per_tx_cap_exceeded", wallet_id="W1"), + ] + + result = compute_all_metrics( + escrow_events=escrow_events, + wallet_ops=wallet_ops, + ) + + # Escrow metrics + em = result.escrow + # 2 Released out of 5 total = 0.4 + assert em.fill_rate == pytest.approx(0.4) + # avg time-to-release = (30+90)/2 = 60 + assert em.avg_time_to_release_s == pytest.approx(60.0) + assert em.timeout_rate == pytest.approx(1 / 5) + assert em.refund_rate == pytest.approx(1 / 5) + assert em.challenge_rate == pytest.approx(1 / 5) + assert em.total_count == 5 + + # Wallet ops + wm = result.wallet_ops + assert wm.spend_by_token["ETH"] == pytest.approx(1.0) # 0.5 denied excluded + assert wm.spend_by_token["USDC"] == pytest.approx(50.0) + assert wm.spend_by_token["LUX"] == pytest.approx(200.0) + assert wm.spend_by_token["ZOO"] == pytest.approx(10.0) + assert wm.spend_by_rail["escrow"] == pytest.approx(201.0) + assert wm.spend_by_rail["x402"] == pytest.approx(50.0) + assert wm.spend_by_rail["mpp"] == pytest.approx(10.0) + assert wm.policy_denial_count == 1 + assert wm.denials_by_reason["per_tx_cap_exceeded"] == 1 + + # Fleet + fh = result.fleet + assert fh.active_wallet_count == 2 + assert fh.wallet_op_counts["W1"] == 3 + assert fh.wallet_op_counts["W2"] == 2 diff --git a/tests/test_payment_protocol_negotiation.py b/tests/test_payment_protocol_negotiation.py new file mode 100644 index 0000000..f54b1b4 --- /dev/null +++ b/tests/test_payment_protocol_negotiation.py @@ -0,0 +1,234 @@ +""" +Unit ⑥ — Token negotiation tests for payment_protocol.py (v1.2) + +Tests: +- deterministic pick: same inputs → same negotiated token +- highest combined-rank wins +- no-common-token → None +- v1.1 back-compat: PaymentRequest without settlement_token still parses fine +- SettlementToken dataclass fields +""" + +import pytest +from src.payment_protocol import ( + SettlementToken, + negotiate_settlement_token, + PaymentRequest, +) + + +# ─── SettlementToken structure ─────────────────────────────────────────────── + +class TestSettlementToken: + def test_fields(self): + tok = SettlementToken(chain_id=8453, token="0xUSDS", min_amount=0, rank=1) + assert tok.chain_id == 8453 + assert tok.token == "0xUSDS" + assert tok.rank == 1 + + def test_equality_by_chain_and_token(self): + a = SettlementToken(chain_id=1, token="0xA", min_amount=0, rank=2) + b = SettlementToken(chain_id=1, token="0xA", min_amount=5, rank=10) + # Two tokens on the same chain+address are the same settlement instrument + # regardless of min_amount or rank — equality on (chain_id, token). + assert a.chain_id == b.chain_id + assert a.token == b.token + + +# ─── negotiate_settlement_token ─────────────────────────────────────────────── + +class TestNegotiateSettlementToken: + # Canonical token fixtures + ETH_BASE = SettlementToken(chain_id=8453, token="0x0000000000000000000000000000000000000000", min_amount=0, rank=1) + USDC_BASE = SettlementToken(chain_id=8453, token="0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", min_amount=0, rank=2) + DAI_BASE = SettlementToken(chain_id=8453, token="0x6B175474E89094C44Da98b954EedeAC495271d0F", min_amount=0, rank=3) + LUX_BASE = SettlementToken(chain_id=8453, token="0xLUXaddress", min_amount=0, rank=4) + + def test_single_common_token(self): + payer_offer = [self.USDC_BASE] + payee_accepts = [self.USDC_BASE] + result = negotiate_settlement_token(payer_offer, payee_accepts) + assert result is not None + assert result.token == self.USDC_BASE.token + + def test_deterministic_pick(self): + """Same inputs always pick the same token.""" + payer_offer = [self.USDC_BASE, self.DAI_BASE] + payee_accepts = [self.DAI_BASE, self.USDC_BASE] + r1 = negotiate_settlement_token(payer_offer, payee_accepts) + r2 = negotiate_settlement_token(payer_offer, payee_accepts) + assert r1 is not None + assert r1.token == r2.token + + def test_highest_combined_rank_wins(self): + """ + Combined rank = payer_rank + payee_rank; higher rank = more preferred. + Payer ranks DAI=3 higher than USDC=2. + Payee ranks DAI=5 higher than USDC=1. + Combined: DAI=8, USDC=3 → DAI wins. + """ + payer_usdc = SettlementToken(chain_id=8453, token="0xUSDS", min_amount=0, rank=2) + payer_dai = SettlementToken(chain_id=8453, token="0xDAI", min_amount=0, rank=3) + payee_usdc = SettlementToken(chain_id=8453, token="0xUSDS", min_amount=0, rank=1) + payee_dai = SettlementToken(chain_id=8453, token="0xDAI", min_amount=0, rank=5) + + result = negotiate_settlement_token([payer_usdc, payer_dai], [payee_usdc, payee_dai]) + assert result is not None + assert result.token == "0xDAI" + + def test_no_common_token_returns_none(self): + payer_offer = [self.ETH_BASE] + payee_accepts = [self.USDC_BASE] + result = negotiate_settlement_token(payer_offer, payee_accepts) + assert result is None + + def test_empty_payer_returns_none(self): + result = negotiate_settlement_token([], [self.USDC_BASE]) + assert result is None + + def test_empty_payee_returns_none(self): + result = negotiate_settlement_token([self.USDC_BASE], []) + assert result is None + + def test_both_empty_returns_none(self): + result = negotiate_settlement_token([], []) + assert result is None + + def test_multiple_common_picks_highest_combined_rank(self): + """ + Three common tokens — pick the one with highest combined rank. + Payer: ETH=1, USDC=2, DAI=3 + Payee: DAI=1, USDC=3, ETH=2 + Combined: ETH=3, USDC=5, DAI=4 → USDC wins (5). + """ + payer = [ + SettlementToken(chain_id=8453, token="0xETH", min_amount=0, rank=1), + SettlementToken(chain_id=8453, token="0xUSDC", min_amount=0, rank=2), + SettlementToken(chain_id=8453, token="0xDAI", min_amount=0, rank=3), + ] + payee = [ + SettlementToken(chain_id=8453, token="0xDAI", min_amount=0, rank=1), + SettlementToken(chain_id=8453, token="0xUSDC", min_amount=0, rank=3), + SettlementToken(chain_id=8453, token="0xETH", min_amount=0, rank=2), + ] + result = negotiate_settlement_token(payer, payee) + assert result is not None + assert result.token == "0xUSDC" + + def test_cross_chain_tokens_dont_match(self): + """Tokens on different chain_ids should not intersect.""" + eth_mainnet = SettlementToken(chain_id=1, token="0xUSDC", min_amount=0, rank=5) + eth_base = SettlementToken(chain_id=8453, token="0xUSDC", min_amount=0, rank=5) + result = negotiate_settlement_token([eth_mainnet], [eth_base]) + assert result is None + + def test_tiebreaker_is_deterministic(self): + """ + When combined ranks are tied, result must still be deterministic (not random). + Pick the lexicographically smallest token address as a stable tiebreaker. + """ + tok_a = SettlementToken(chain_id=1, token="0xAAAA", min_amount=0, rank=2) + tok_b = SettlementToken(chain_id=1, token="0xBBBB", min_amount=0, rank=2) + payer = [tok_a, tok_b] + payee = [ + SettlementToken(chain_id=1, token="0xAAAA", min_amount=0, rank=2), + SettlementToken(chain_id=1, token="0xBBBB", min_amount=0, rank=2), + ] + r1 = negotiate_settlement_token(payer, payee) + r2 = negotiate_settlement_token(list(reversed(payer)), list(reversed(payee))) + assert r1 is not None + assert r1.token == r2.token # deterministic + + +# ─── PaymentRequest v1.2 — settlement_token field ───────────────────────────── + +class TestPaymentRequestV12: + def test_settlement_token_field_present(self): + """PaymentRequest now has a settlement_token field (defaults None).""" + req = PaymentRequest( + request_id="r1", + payer="0xPAYER", + payee="0xPAYEE", + amount_wei=10**18, + ) + assert hasattr(req, "settlement_token") + assert req.settlement_token is None + + def test_settlement_token_can_be_set(self): + tok = SettlementToken(chain_id=8453, token="0xUSDC", min_amount=0, rank=2) + req = PaymentRequest( + request_id="r2", + payer="0xPAYER", + payee="0xPAYEE", + amount_wei=10**18, + settlement_token=tok, + ) + assert req.settlement_token is not None + assert req.settlement_token.token == "0xUSDC" + + def test_v11_back_compat_from_dict_no_settlement_token(self): + """A v1.1 dict without settlement_token must still parse cleanly.""" + d = { + "version": "1.1", + "request_id": "v11-test", + "payer": "0xPAYER", + "payee": "0xPAYEE", + "amount_wei": 10**18, + "currency": "ETH", + "chain_id": 1, + "timeout_blocks": 100, + "challenge_period_blocks": 10, + "description": "", + "metadata": {}, + "created_at": 1234567890.0, + "status": "pending", + } + req = PaymentRequest.from_dict(d) + assert req.settlement_token is None + assert req.currency == "ETH" + + def test_currency_alias_still_works(self): + """currency field is retained as v1.1-compatible alias for ETH profile.""" + req = PaymentRequest( + request_id="alias-test", + payer="0xPAYER", + payee="0xPAYEE", + amount_wei=10**18, + currency="ETH", + ) + assert req.currency == "ETH" + + def test_settlement_token_serializes_to_dict(self): + """settlement_token should appear in to_dict() output.""" + tok = SettlementToken(chain_id=8453, token="0xUSDC", min_amount=0, rank=2) + req = PaymentRequest( + request_id="ser-test", + payer="0xPAYER", + payee="0xPAYEE", + amount_wei=10**18, + settlement_token=tok, + ) + d = req.to_dict() + assert "settlement_token" in d + + def test_content_hash_excludes_settlement_token(self): + """ + settlement_token is a negotiated result (like status); it MUST NOT + change the content_hash so both sides can agree on the hash before + negotiation is finalised. + """ + req_bare = PaymentRequest( + request_id="hash-test", + payer="0xPAYER", + payee="0xPAYEE", + amount_wei=10**18, + ) + tok = SettlementToken(chain_id=8453, token="0xUSDC", min_amount=0, rank=2) + req_with_tok = PaymentRequest( + request_id="hash-test", + payer="0xPAYER", + payee="0xPAYEE", + amount_wei=10**18, + settlement_token=tok, + ) + assert req_bare.content_hash() == req_with_tok.content_hash() diff --git a/tests/test_thinking_chain.py b/tests/test_thinking_chain.py new file mode 100644 index 0000000..ddaf8db --- /dev/null +++ b/tests/test_thinking_chain.py @@ -0,0 +1,238 @@ +import pytest +from switchboard.escrow_adapters import InMemoryEscrowClient, SwapSettlementAdapter +from switchboard.thinking_chain import ( + ThinkingChain, StepRecord, StepType, StepOutcome, + HanzoEscrowThinkingChain, ChainHaltedError, +) +from switchboard.agent_wallet import AgentWallet +from switchboard.treasury import Treasury +from switchboard.mpc_wallet import MPCWallet +from switchboard.access_policy import AccessPolicy, AgentTier, TierConfig, TokenBucketConfig +from src.payment_protocol import SettlementToken + +USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +DAI = "0x6B175474E89094C44Da98b954EedeAC495271d0F" +LUX = "0xLUX0000000000000000000000000000000000001" +ZOO = "0xZOO0000000000000000000000000000000000002" + +def test_inmemory_escrow_create_and_release(): + client = InMemoryEscrowClient() + eid = client.create_payment(chain_id=1, token=USDC, amount=1_000_000, payee="0xPayee") + assert eid.startswith("escrow-") + assert client.get_escrow(eid)["state"] == "open" + result = client.release_payment(eid) + assert result is True + assert client.get_escrow(eid)["state"] == "released" + +def test_inmemory_escrow_refund(): + client = InMemoryEscrowClient() + eid = client.create_payment(chain_id=1, token=USDC, amount=500_000, payee="0xPayee") + result = client.refund_payment(eid) + assert result is True + assert client.get_escrow(eid)["state"] == "refunded" + +def test_inmemory_escrow_double_release_raises(): + client = InMemoryEscrowClient() + eid = client.create_payment(chain_id=1, token=USDC, amount=100, payee="0xP") + client.release_payment(eid) + with pytest.raises(ValueError, match="not open"): + client.release_payment(eid) + +def test_swap_settlement_adapter_cross_token(): + client = InMemoryEscrowClient() + adapter = SwapSettlementAdapter(client) + eid = adapter.swap_and_create(chain_id=1, from_token=USDC, to_token=DAI, amount=1_000_000, payee="0xPayee") + escrow = client.get_escrow(eid) + assert escrow["token"] == DAI + assert escrow["amount"] == 1_000_000 + assert escrow["state"] == "open" + + +# --------------------------------------------------------------------------- +# Task 2: ThinkingChain tests +# --------------------------------------------------------------------------- + +def _make_payer_wallet(token=USDC, amount=5_000_000): + treasury = Treasury() + treasury.credit(chain_id=1, token=token, amount=amount) + return AgentWallet(treasury=treasury) + +def _payer_offers(): + return [ + SettlementToken(chain_id=1, token=USDC, min_amount=0, rank=10), + SettlementToken(chain_id=1, token=LUX, min_amount=0, rank=5), + SettlementToken(chain_id=1, token=ZOO, min_amount=0, rank=3), + ] + +def _payee_accepts_usdc(): + return [SettlementToken(chain_id=1, token=USDC, min_amount=0, rank=10)] + +def _payee_accepts_dai(): + return [SettlementToken(chain_id=1, token=DAI, min_amount=0, rank=10)] + +def _trusted_policy(): + """AccessPolicy whose TRUSTED tier admits high-value test amounts. + + Uses a custom TierConfig so we exercise the real per-tx-cap plumbing + without mutating the production _DEFAULT_TIER_CONFIG. + """ + cfg = TierConfig( + explorer=TokenBucketConfig(per_tx_cap=1_000, rate=1.0, capacity=10), + standard=TokenBucketConfig(per_tx_cap=10_000, rate=10.0, capacity=50), + trusted=TokenBucketConfig(per_tx_cap=100_000_000, rate=100.0, capacity=200), + ) + return AccessPolicy(tier_config=cfg) + +def test_step_types_are_ordered(): + """Six canonical step types exist and maintain declaration order.""" + types = list(StepType) + assert types[0] == StepType.ASSESS_TASK + assert types[-1] == StepType.RELEASE_OR_REFUND + assert len(types) == 6 + +def test_step_record_is_frozen(): + from switchboard.metrics import WalletOpEvent + import time + ev = WalletOpEvent(op_type="test", token=USDC, rail="", amount=0.0, + agent_id="", wallet_id="", denied=False, + denial_reason=None, timestamp=time.time()) + rec = StepRecord( + step_type=StepType.ASSESS_TASK, + reasoning="test", + outcome=StepOutcome.PASS, + data={"k": "v"}, + events=[ev], + ) + import dataclasses + assert dataclasses.is_dataclass(rec) + with pytest.raises((AttributeError, dataclasses.FrozenInstanceError)): + rec.outcome = StepOutcome.FAIL # type: ignore + +def test_hanzo_happy_path_settles(): + """Happy path: same token (USDC), policy allows, chain releases escrow.""" + policy = _trusted_policy() + policy.register("hanzo-agent", AgentTier.TRUSTED) + wallet = _make_payer_wallet(USDC, 5_000_000) + chain = HanzoEscrowThinkingChain( + payer_wallet=wallet, + payee_address="0xPayee", + payer_offers=_payer_offers(), + payee_accepts=_payee_accepts_usdc(), + amount=1_000_000, + access_policy=policy, + agent_id="hanzo-agent", + ) + records = chain.run() + assert len(records) == 6 + assert all(r.outcome == StepOutcome.PASS for r in records) + assert records[2].step_type == StepType.POLICY_CHECK + assert records[3].step_type == StepType.CREATE_ESCROW + assert "escrow_id" in records[3].data + assert records[5].step_type == StepType.RELEASE_OR_REFUND + assert records[5].data.get("action") == "release" + +def test_hanzo_chain_is_inspectable(): + """Records are stored on chain.records after run().""" + policy = _trusted_policy() + policy.register("hanzo-agent", AgentTier.TRUSTED) + wallet = _make_payer_wallet(USDC, 5_000_000) + chain = HanzoEscrowThinkingChain( + payer_wallet=wallet, + payee_address="0xPayee", + payer_offers=_payer_offers(), + payee_accepts=_payee_accepts_usdc(), + amount=1_000_000, + access_policy=policy, + agent_id="hanzo-agent", + ) + chain.run() + assert len(chain.records) == 6 + # each record carries the step type in declaration order + for i, st in enumerate(StepType): + assert chain.records[i].step_type == st + +def test_hanzo_denied_by_policy_halts(): + """A policy denial on POLICY_CHECK step raises ChainHaltedError.""" + # Explorer tier with per_tx_cap=1_000 will deny amount=1_000_000 + policy = AccessPolicy() + policy.register("low-tier-agent", AgentTier.EXPLORER) + wallet = _make_payer_wallet(USDC, 5_000_000) + chain = HanzoEscrowThinkingChain( + payer_wallet=wallet, + payee_address="0xPayee", + payer_offers=_payer_offers(), + payee_accepts=_payee_accepts_usdc(), + amount=1_000_000, # exceeds Explorer per_tx_cap=1_000 + access_policy=policy, + agent_id="low-tier-agent", + ) + with pytest.raises(ChainHaltedError) as exc_info: + chain.run() + err = exc_info.value + assert err.step_record.step_type == StepType.POLICY_CHECK + assert err.step_record.outcome == StepOutcome.HALT + # chain.records contains steps up to and including the halted one + assert chain.records[-1].step_type == StepType.POLICY_CHECK + assert chain.records[-1].outcome == StepOutcome.HALT + +def test_hanzo_negotiate_no_common_token_halts(): + """If payer and payee share no token, NEGOTIATE_TOKEN step halts.""" + policy = _trusted_policy() + policy.register("agent-x", AgentTier.TRUSTED) + wallet = _make_payer_wallet(USDC, 5_000_000) + # payee only accepts ZOO; payer only offers USDC + chain = HanzoEscrowThinkingChain( + payer_wallet=wallet, + payee_address="0xPayee", + payer_offers=[SettlementToken(chain_id=1, token=USDC, min_amount=0, rank=10)], + payee_accepts=[SettlementToken(chain_id=1, token=ZOO, min_amount=0, rank=10)], + amount=1_000_000, + access_policy=policy, + agent_id="agent-x", + ) + with pytest.raises(ChainHaltedError) as exc_info: + chain.run() + assert exc_info.value.step_record.step_type == StepType.NEGOTIATE_TOKEN + +def test_thinking_chain_events_collected(): + """Each step that calls real modules emits WalletOpEvent(s) in its record.""" + policy = _trusted_policy() + policy.register("event-agent", AgentTier.TRUSTED) + wallet = _make_payer_wallet(USDC, 5_000_000) + chain = HanzoEscrowThinkingChain( + payer_wallet=wallet, + payee_address="0xPayee", + payer_offers=_payer_offers(), + payee_accepts=_payee_accepts_usdc(), + amount=1_000_000, + access_policy=policy, + agent_id="event-agent", + ) + chain.run() + # The POLICY_CHECK step must have at least one WalletOpEvent + policy_step = next(r for r in chain.records if r.step_type == StepType.POLICY_CHECK) + assert len(policy_step.events) >= 1 + # CREATE_ESCROW and RELEASE_OR_REFUND must each emit at least one event + create_step = next(r for r in chain.records if r.step_type == StepType.CREATE_ESCROW) + assert len(create_step.events) >= 1 + release_step = next(r for r in chain.records if r.step_type == StepType.RELEASE_OR_REFUND) + assert len(release_step.events) >= 1 + + +def test_hanzo_create_escrow_debits_wallet(): + """CREATE_ESCROW step debits the wallet treasury — wallet is no longer hollow.""" + policy = _trusted_policy() + policy.register("debit-agent", AgentTier.TRUSTED) + wallet = _make_payer_wallet(USDC, 1_500_000) + chain = HanzoEscrowThinkingChain( + payer_wallet=wallet, + payee_address="0xPayee", + payer_offers=_payer_offers(), + payee_accepts=_payee_accepts_usdc(), + amount=1_000_000, + access_policy=policy, + agent_id="debit-agent", + ) + chain.run() + # Treasury must have been debited by the escrow amount + assert wallet.balance(1, USDC) == 500_000 diff --git a/tests/test_tools_registry.py b/tests/test_tools_registry.py new file mode 100644 index 0000000..90d858e --- /dev/null +++ b/tests/test_tools_registry.py @@ -0,0 +1,323 @@ +"""Tests for the ⑰ tool registry (switchboard/tools.py). + +TDD: these tests were written before the implementation and describe the +contract that MCP and CLI depend on. + +Coverage: +- TOOL_DEFINITIONS is non-empty +- Each entry has the required fields with correct types +- get_tool() finds by name / returns None for unknown +- get_registry() returns a stable, complete list +- registry_as_json() round-trips back to the right names +- sync_registry_json() writes the "tools" key into registry.json +- AccessPolicy seam: AllowAllPolicy.check() always allows +- Decision dataclass is frozen/hashable +- Required tools are present: wallet_balance, pay, create_escrow, + confirm_payment, request_refund, policy_status, escrow_metrics +""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +import pytest + +from switchboard.tools import ( + TOOL_DEFINITIONS, + AllowAllPolicy, + Decision, + ToolDef, + get_registry, + get_tool, + registry_as_json, + sync_registry_json, +) + + +# --------------------------------------------------------------------------- +# Fixture helpers +# --------------------------------------------------------------------------- + +REQUIRED_TOOL_NAMES = { + "wallet_balance", + "pay", + "create_escrow", + "confirm_payment", + "request_refund", + "policy_status", + "escrow_metrics", +} + + +# --------------------------------------------------------------------------- +# Basic structure tests +# --------------------------------------------------------------------------- + +class TestToolDefinitions: + def test_registry_non_empty(self): + assert len(TOOL_DEFINITIONS) >= 7, "Expected at least 7 tools" + + def test_all_required_tools_present(self): + names = {t.name for t in TOOL_DEFINITIONS} + missing = REQUIRED_TOOL_NAMES - names + assert not missing, f"Missing required tools: {missing}" + + def test_each_tool_has_name(self): + for t in TOOL_DEFINITIONS: + assert isinstance(t.name, str) and t.name, f"Tool has blank name: {t}" + + def test_each_tool_has_description(self): + for t in TOOL_DEFINITIONS: + assert isinstance(t.description, str) and t.description, \ + f"Tool {t.name!r} has blank description" + + def test_each_tool_has_op(self): + for t in TOOL_DEFINITIONS: + assert isinstance(t.op, str) and t.op, \ + f"Tool {t.name!r} has blank op" + + def test_each_tool_schema_is_object_type(self): + for t in TOOL_DEFINITIONS: + assert isinstance(t.schema, dict), f"Tool {t.name!r} schema is not a dict" + assert t.schema.get("type") == "object", \ + f"Tool {t.name!r} schema type is not 'object'" + + def test_each_tool_schema_has_properties(self): + for t in TOOL_DEFINITIONS: + assert "properties" in t.schema, \ + f"Tool {t.name!r} schema missing 'properties'" + + def test_each_tool_schema_has_required(self): + for t in TOOL_DEFINITIONS: + assert "required" in t.schema, \ + f"Tool {t.name!r} schema missing 'required' list" + + def test_each_tool_schema_required_is_list(self): + for t in TOOL_DEFINITIONS: + assert isinstance(t.schema["required"], list), \ + f"Tool {t.name!r} 'required' is not a list" + + def test_session_key_required_in_all_tools(self): + """Every tool must require a session_key (gate by default).""" + for t in TOOL_DEFINITIONS: + assert "session_key" in t.schema["required"], \ + f"Tool {t.name!r} does not require 'session_key'" + + def test_policy_metadata_present(self): + for t in TOOL_DEFINITIONS: + assert isinstance(t.policy, dict), \ + f"Tool {t.name!r} policy is not a dict" + assert "required_tier" in t.policy, \ + f"Tool {t.name!r} policy missing 'required_tier'" + assert "rate_class" in t.policy, \ + f"Tool {t.name!r} policy missing 'rate_class'" + + def test_tool_def_is_frozen(self): + t = TOOL_DEFINITIONS[0] + with pytest.raises((AttributeError, TypeError)): + t.name = "hacked" # type: ignore[misc] + + def test_names_are_unique(self): + names = [t.name for t in TOOL_DEFINITIONS] + assert len(names) == len(set(names)), "Duplicate tool names found" + + +# --------------------------------------------------------------------------- +# get_registry() and get_tool() +# --------------------------------------------------------------------------- + +class TestGetRegistry: + def test_returns_list(self): + reg = get_registry() + assert isinstance(reg, list) + + def test_returns_all_tool_defs(self): + reg = get_registry() + assert len(reg) == len(TOOL_DEFINITIONS) + + def test_returns_a_copy(self): + """Mutating the returned list must not affect TOOL_DEFINITIONS.""" + reg = get_registry() + reg.clear() + assert len(TOOL_DEFINITIONS) > 0 + + +class TestGetTool: + def test_finds_existing_tool(self): + for name in REQUIRED_TOOL_NAMES: + t = get_tool(name) + assert t is not None, f"get_tool({name!r}) returned None" + assert t.name == name + + def test_returns_none_for_unknown(self): + assert get_tool("nonexistent_tool_xyz") is None + + def test_returns_tool_def_instance(self): + t = get_tool("pay") + assert isinstance(t, ToolDef) + + +# --------------------------------------------------------------------------- +# Specific tool schema correctness +# --------------------------------------------------------------------------- + +class TestPayToolSchema: + def test_pay_requires_chain_id(self): + t = get_tool("pay") + assert "chain_id" in t.schema["required"] + + def test_pay_requires_token(self): + t = get_tool("pay") + assert "token" in t.schema["required"] + + def test_pay_requires_amount(self): + t = get_tool("pay") + assert "amount" in t.schema["required"] + + def test_pay_requires_payee(self): + t = get_tool("pay") + assert "payee" in t.schema["required"] + + def test_pay_amount_is_integer_type(self): + t = get_tool("pay") + assert t.schema["properties"]["amount"]["type"] == "integer" + + +class TestWalletBalanceSchema: + def test_wallet_balance_requires_chain_id(self): + t = get_tool("wallet_balance") + assert "chain_id" in t.schema["required"] + + def test_wallet_balance_token_optional(self): + t = get_tool("wallet_balance") + assert "token" not in t.schema["required"] + assert "token" in t.schema["properties"] + + def test_chain_id_is_integer(self): + t = get_tool("wallet_balance") + assert t.schema["properties"]["chain_id"]["type"] == "integer" + + +class TestEscrowMetricsSchema: + def test_escrow_metrics_has_optional_chain_id(self): + t = get_tool("escrow_metrics") + assert "chain_id" in t.schema["properties"] + assert "chain_id" not in t.schema["required"] + + +# --------------------------------------------------------------------------- +# registry_as_json() +# --------------------------------------------------------------------------- + +class TestRegistryAsJson: + def test_returns_string(self): + s = registry_as_json() + assert isinstance(s, str) + + def test_parses_as_json(self): + s = registry_as_json() + data = json.loads(s) + assert isinstance(data, list) + + def test_contains_all_tools(self): + data = json.loads(registry_as_json()) + names = {entry["name"] for entry in data} + assert REQUIRED_TOOL_NAMES.issubset(names) + + def test_each_entry_has_schema(self): + data = json.loads(registry_as_json()) + for entry in data: + assert "schema" in entry, f"Entry {entry['name']!r} missing 'schema'" + + def test_each_entry_has_op(self): + data = json.loads(registry_as_json()) + for entry in data: + assert "op" in entry, f"Entry {entry['name']!r} missing 'op'" + + def test_each_entry_has_policy(self): + data = json.loads(registry_as_json()) + for entry in data: + assert "policy" in entry, f"Entry {entry['name']!r} missing 'policy'" + + +# --------------------------------------------------------------------------- +# sync_registry_json() +# --------------------------------------------------------------------------- + +class TestSyncRegistryJson: + def test_writes_tools_key(self, tmp_path): + registry_file = tmp_path / "registry.json" + registry_file.write_text(json.dumps({ + "84532": {"name": "base-sepolia", "escrow": None, "usdc": "0xABC"}, + })) + sync_registry_json(registry_path=registry_file) + data = json.loads(registry_file.read_text()) + assert "tools" in data + + def test_preserves_existing_chains(self, tmp_path): + registry_file = tmp_path / "registry.json" + registry_file.write_text(json.dumps({"84532": {"name": "base-sepolia"}})) + sync_registry_json(registry_path=registry_file) + data = json.loads(registry_file.read_text()) + assert "84532" in data + + def test_tools_list_matches_registry(self, tmp_path): + registry_file = tmp_path / "registry.json" + registry_file.write_text(json.dumps({"84532": {}})) + sync_registry_json(registry_path=registry_file) + data = json.loads(registry_file.read_text()) + names = {t["name"] for t in data["tools"]} + assert REQUIRED_TOOL_NAMES.issubset(names) + + def test_tools_entries_have_schema(self, tmp_path): + registry_file = tmp_path / "registry.json" + registry_file.write_text(json.dumps({"x": {}})) + sync_registry_json(registry_path=registry_file) + data = json.loads(registry_file.read_text()) + for t in data["tools"]: + assert "schema" in t + + +# --------------------------------------------------------------------------- +# Access-policy seam tests +# --------------------------------------------------------------------------- + +class TestAllowAllPolicy: + def test_always_allows_any_action(self): + p = AllowAllPolicy() + d = p.check(agent_id="agent-1", action="pay") + assert d.denied is False + + def test_always_allows_unknown_action(self): + p = AllowAllPolicy() + d = p.check(agent_id="agent-1", action="totally_unknown") + assert d.denied is False + + def test_check_returns_decision(self): + p = AllowAllPolicy() + d = p.check(agent_id="agent-1", action="pay") + assert isinstance(d, Decision) + + def test_decision_reason_is_none_when_allowed(self): + p = AllowAllPolicy() + d = p.check(agent_id="agent-1", action="pay") + assert d.reason is None + + +class TestDecision: + def test_denied_true(self): + d = Decision(denied=True, reason="rate_limit_exceeded") + assert d.denied is True + assert d.reason == "rate_limit_exceeded" + + def test_denied_false(self): + d = Decision(denied=False) + assert d.denied is False + assert d.reason is None + + def test_decision_is_frozen(self): + d = Decision(denied=False) + with pytest.raises((AttributeError, TypeError)): + d.denied = True # type: ignore[misc] diff --git a/tests/test_treasury.py b/tests/test_treasury.py new file mode 100644 index 0000000..0990978 --- /dev/null +++ b/tests/test_treasury.py @@ -0,0 +1,168 @@ +"""Tests for switchboard.treasury — Unit ⑧ (Treasury portion). + +TDD: these tests are written first and must be run to confirm they fail before +implementation exists, then pass after implementation is complete. +""" + +from __future__ import annotations + +import pytest + +from switchboard.treasury import Treasury, InsufficientBalance + + +# Token addresses used in tests +ETH = "0x0000000000000000000000000000000000000000" # native ETH sentinel +USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +DAI = "0x6B175474E89094C44Da98b954EedeAC495271d0F" +LUX = "0xLUX0000000000000000000000000000000000001" # kcolbchain partner token +ZOO = "0xZOO0000000000000000000000000000000000002" # kcolbchain partner token + +CHAIN_1 = 1 +CHAIN_137 = 137 # Polygon + + +# --------------------------------------------------------------------------- +# Construction / empty state +# --------------------------------------------------------------------------- + + +def test_empty_treasury_balance_is_zero(): + t = Treasury() + assert t.balance(CHAIN_1, ETH) == 0 + + +def test_balance_isolated_across_chains(): + t = Treasury() + t.credit(CHAIN_1, USDC, 1_000) + assert t.balance(CHAIN_137, USDC) == 0 + + +def test_balance_isolated_across_tokens(): + t = Treasury() + t.credit(CHAIN_1, USDC, 500) + assert t.balance(CHAIN_1, DAI) == 0 + + +# --------------------------------------------------------------------------- +# credit / debit +# --------------------------------------------------------------------------- + + +def test_credit_increases_balance(): + t = Treasury() + t.credit(CHAIN_1, ETH, 10 ** 18) + assert t.balance(CHAIN_1, ETH) == 10 ** 18 + + +def test_credit_is_additive(): + t = Treasury() + t.credit(CHAIN_1, USDC, 100) + t.credit(CHAIN_1, USDC, 200) + assert t.balance(CHAIN_1, USDC) == 300 + + +def test_debit_reduces_balance(): + t = Treasury() + t.credit(CHAIN_1, USDC, 500) + t.debit(CHAIN_1, USDC, 200) + assert t.balance(CHAIN_1, USDC) == 300 + + +def test_debit_to_zero_is_allowed(): + t = Treasury() + t.credit(CHAIN_1, ETH, 100) + t.debit(CHAIN_1, ETH, 100) + assert t.balance(CHAIN_1, ETH) == 0 + + +def test_debit_below_zero_raises(): + t = Treasury() + t.credit(CHAIN_1, ETH, 50) + with pytest.raises(InsufficientBalance): + t.debit(CHAIN_1, ETH, 51) + + +def test_debit_on_empty_raises(): + t = Treasury() + with pytest.raises(InsufficientBalance): + t.debit(CHAIN_1, USDC, 1) + + +def test_credit_negative_raises(): + t = Treasury() + with pytest.raises(ValueError): + t.credit(CHAIN_1, USDC, -1) + + +def test_debit_negative_raises(): + t = Treasury() + with pytest.raises(ValueError): + t.debit(CHAIN_1, USDC, -1) + + +# --------------------------------------------------------------------------- +# spendable (respects reserves) +# --------------------------------------------------------------------------- + + +def test_spendable_equals_balance_with_no_reserve(): + t = Treasury() + t.credit(CHAIN_1, USDC, 1_000) + assert t.spendable(CHAIN_1, USDC) == 1_000 + + +def test_spendable_respects_reserve(): + t = Treasury() + t.credit(CHAIN_1, USDC, 1_000) + t.set_reserve(CHAIN_1, USDC, 200) + assert t.spendable(CHAIN_1, USDC) == 800 + + +def test_spendable_never_negative(): + """Reserve > balance → spendable == 0, not negative.""" + t = Treasury() + t.credit(CHAIN_1, ETH, 100) + t.set_reserve(CHAIN_1, ETH, 500) + assert t.spendable(CHAIN_1, ETH) == 0 + + +def test_reserve_default_is_zero(): + t = Treasury() + t.credit(CHAIN_1, ETH, 99) + assert t.spendable(CHAIN_1, ETH) == 99 + + +# --------------------------------------------------------------------------- +# Multi-token / multi-chain snapshot +# --------------------------------------------------------------------------- + + +def test_balances_snapshot_returns_all_tokens(): + t = Treasury() + t.credit(CHAIN_1, ETH, 10 ** 18) + t.credit(CHAIN_1, USDC, 500_000_000) + t.credit(CHAIN_1, LUX, 1_000) + snap = t.balances(CHAIN_1) + assert snap[ETH] == 10 ** 18 + assert snap[USDC] == 500_000_000 + assert snap[LUX] == 1_000 + + +def test_balances_snapshot_excludes_other_chains(): + t = Treasury() + t.credit(CHAIN_1, USDC, 100) + t.credit(CHAIN_137, ZOO, 200) + snap = t.balances(CHAIN_1) + assert ZOO not in snap + snap_poly = t.balances(CHAIN_137) + assert USDC not in snap_poly + + +def test_partner_tokens_lux_zoo_tracked(): + """Partner tokens LUX and ZOO are first-class; no special-casing needed.""" + t = Treasury() + t.credit(CHAIN_1, LUX, 9_000) + t.credit(CHAIN_1, ZOO, 4_200) + assert t.balance(CHAIN_1, LUX) == 9_000 + assert t.balance(CHAIN_1, ZOO) == 4_200 diff --git a/tests/test_x402_multitoken.py b/tests/test_x402_multitoken.py new file mode 100644 index 0000000..cea5dc7 --- /dev/null +++ b/tests/test_x402_multitoken.py @@ -0,0 +1,294 @@ +""" +Unit ⑦ — x402 multi-token accepts[] envelope tests + +Tests: +- X402Server can be constructed with a multi-token accepts list +- 402 response advertises accepted tokens in X-Payment-Accepts header +- PaymentRequirements extended with accepts[] entries {chain_id, token, min_amount, rank} +- Middleware validates that an incoming settlement_token is in the accepted set +- Payment in a non-accepted token is rejected +- Payment in an accepted token is allowed +- Back-compat: server without accepts[] still works as before +""" + +import json +import pytest +from unittest.mock import MagicMock + +from switchboard.x402.server import ( + X402Server, + PaymentRequirements, + AcceptedToken, + PaymentVerifier, +) +from switchboard.x402_middleware import ( + X402Middleware, + PaymentOffer, + PaymentScheme, +) + + +# ─── AcceptedToken structure ───────────────────────────────────────────────── + +class TestAcceptedToken: + def test_fields(self): + tok = AcceptedToken(chain_id=8453, token="0xUSDC", min_amount=0, rank=1) + assert tok.chain_id == 8453 + assert tok.token == "0xUSDC" + assert tok.min_amount == 0 + assert tok.rank == 1 + + def test_to_dict(self): + tok = AcceptedToken(chain_id=8453, token="0xUSDC", min_amount=100, rank=2) + d = tok.to_dict() + assert d == {"chain_id": 8453, "token": "0xUSDC", "min_amount": 100, "rank": 2} + + def test_from_dict(self): + d = {"chain_id": 1, "token": "0xDAI", "min_amount": 0, "rank": 3} + tok = AcceptedToken.from_dict(d) + assert tok.chain_id == 1 + assert tok.token == "0xDAI" + assert tok.rank == 3 + + +# ─── PaymentRequirements multi-token extension ─────────────────────────────── + +class TestPaymentRequirementsMultiToken: + def test_accepts_list_in_to_header(self): + """When accepts is set, to_header() includes it in the JSON output.""" + toks = [ + AcceptedToken(chain_id=8453, token="0xUSDC", min_amount=0, rank=2), + AcceptedToken(chain_id=8453, token="0xDAI", min_amount=0, rank=1), + ] + reqs = PaymentRequirements( + scheme="exact", + network="base", + asset="USDC", + amount="1.00", + pay_to="0xPAYEE", + accepts=toks, + ) + header = reqs.to_header() + data = json.loads(header) + assert "accepts" in data + assert len(data["accepts"]) == 2 + tokens = {entry["token"] for entry in data["accepts"]} + assert "0xUSDC" in tokens + assert "0xDAI" in tokens + + def test_accepts_roundtrips_via_from_dict(self): + toks = [AcceptedToken(chain_id=1, token="0xUSDC", min_amount=0, rank=5)] + reqs = PaymentRequirements( + scheme="exact", network="mainnet", asset="USDC", + amount="2.00", pay_to="0xPAYEE", accepts=toks, + ) + header = reqs.to_header() + reqs2 = PaymentRequirements.from_header(header) + assert len(reqs2.accepts) == 1 + assert reqs2.accepts[0].token == "0xUSDC" + assert reqs2.accepts[0].rank == 5 + + def test_no_accepts_still_works(self): + """Back-compat: PaymentRequirements without accepts behaves as before.""" + reqs = PaymentRequirements( + scheme="exact", network="base", asset="USDC", + amount="1.00", pay_to="0xPAYEE", + ) + header = reqs.to_header() + data = json.loads(header) + assert "accepts" not in data # not included when empty + + def test_from_header_without_accepts(self): + raw = json.dumps({ + "scheme": "exact", "network": "base", "asset": "USDC", + "amount": "1.00", "payTo": "0xPAYEE", + }) + reqs = PaymentRequirements.from_header(raw) + assert reqs.accepts == [] + + +# ─── X402Server multi-token 402 response ───────────────────────────────────── + +class TestX402ServerMultiToken: + def _make_server(self, accepts=None): + return X402Server( + pay_to_address="0xPAYEE", + amount_usdc="1.00", + accepts=accepts, + ) + + def test_build_402_without_accepts(self): + server = self._make_server() + status, headers, body = server.build_402_response() + assert status == 402 + data = json.loads(body) + assert "payment_requirements" in data + + def test_build_402_with_accepts_lists_tokens(self): + toks = [ + AcceptedToken(chain_id=8453, token="0xUSDC", min_amount=0, rank=2), + AcceptedToken(chain_id=8453, token="0xDAI", min_amount=0, rank=1), + ] + server = self._make_server(accepts=toks) + status, headers, body = server.build_402_response() + assert status == 402 + data = json.loads(body) + reqs = data.get("payment_requirements", {}) + assert "accepts" in reqs + assert len(reqs["accepts"]) == 2 + + def test_build_402_header_carries_accepts(self): + toks = [AcceptedToken(chain_id=8453, token="0xUSDC", min_amount=0, rank=2)] + server = self._make_server(accepts=toks) + _, headers, _ = server.build_402_response() + raw = headers.get("X-Payment-Required", "") + data = json.loads(raw) + assert "accepts" in data + assert data["accepts"][0]["token"] == "0xUSDC" + + +# ─── Middleware — settlement_token validation ────────────────────────────────── + +class TestX402MiddlewareMultiToken: + USDC_BASE = AcceptedToken(chain_id=8453, token="0xUSDC", min_amount=0, rank=2) + DAI_BASE = AcceptedToken(chain_id=8453, token="0xDAI", min_amount=0, rank=1) + + def _make_middleware(self, accepted_tokens=None, **kwargs): + client = MagicMock() + client.wallet_address = "0xPAYER" + return X402Middleware( + payment_client=client, + accepted_tokens=accepted_tokens, + **kwargs, + ) + + def test_validate_settlement_token_accepted(self): + """A settlement_token in the accepted list passes validation.""" + mw = self._make_middleware(accepted_tokens=[self.USDC_BASE, self.DAI_BASE]) + # Should not raise + mw._validate_settlement_token(chain_id=8453, token="0xUSDC") + + def test_validate_settlement_token_rejected(self): + """A settlement_token NOT in the accepted list raises ValueError.""" + mw = self._make_middleware(accepted_tokens=[self.USDC_BASE]) + with pytest.raises(ValueError, match="not an accepted settlement token"): + mw._validate_settlement_token(chain_id=8453, token="0xDAI") + + def test_validate_settlement_token_wrong_chain_rejected(self): + """Token on wrong chain_id is rejected even if address matches.""" + mw = self._make_middleware(accepted_tokens=[self.USDC_BASE]) # chain_id=8453 + with pytest.raises(ValueError, match="not an accepted settlement token"): + mw._validate_settlement_token(chain_id=1, token="0xUSDC") # mainnet + + def test_no_accepted_tokens_bypasses_check(self): + """When accepted_tokens is not set (None), the check is a no-op (back-compat).""" + mw = self._make_middleware(accepted_tokens=None) + # Must not raise regardless of what token is proposed + mw._validate_settlement_token(chain_id=8453, token="0xANYTHING") + + def test_empty_accepted_tokens_rejects_all(self): + """An explicit empty list means no token is acceptable.""" + mw = self._make_middleware(accepted_tokens=[]) + with pytest.raises(ValueError, match="not an accepted settlement token"): + mw._validate_settlement_token(chain_id=8453, token="0xUSDC") + + def test_validate_offer_with_accepted_token_passes(self): + """_validate_offer passes when offer token is in accepted_tokens list.""" + mw = self._make_middleware( + accepted_tokens=[self.USDC_BASE], + max_payment_wei=10**18, + ) + offer = PaymentOffer( + amount_wei=1000, + currency="USDC", + recipient="0xRECIPIENT", + chain_id=8453, + token="0xUSDC", + ) + mw._validate_offer(offer) # must not raise + + def test_validate_offer_with_rejected_token_raises(self): + """_validate_offer raises when offer token is not in accepted_tokens.""" + mw = self._make_middleware( + accepted_tokens=[self.USDC_BASE], + max_payment_wei=10**18, + ) + offer = PaymentOffer( + amount_wei=1000, + currency="ZOO", + recipient="0xRECIPIENT", + chain_id=8453, + token="0xZOO", + ) + with pytest.raises(ValueError, match="not an accepted settlement token"): + mw._validate_offer(offer) + + def test_validate_offer_no_token_field_bypasses_token_check(self): + """Offers without a token field (v1.1-style) bypass the token check (back-compat).""" + mw = self._make_middleware( + accepted_tokens=[self.USDC_BASE], + max_payment_wei=10**18, + ) + offer = PaymentOffer( + amount_wei=1000, + currency="ETH", + recipient="0xRECIPIENT", + chain_id=8453, + # token field absent / None + ) + mw._validate_offer(offer) # must not raise + + +# ─── PaymentOffer multi-token extension ────────────────────────────────────── + +class TestPaymentOfferMultiToken: + def test_from_header_with_token_field(self): + header = json.dumps({ + "amount": "1000000", + "recipient": "0xRECIPIENT", + "chainId": 8453, + "token": "0xUSDC", + }) + offer = PaymentOffer.from_header(header) + assert offer.token == "0xUSDC" + + def test_from_header_without_token_field(self): + """Back-compat: no token field → offer.token is None.""" + header = json.dumps({ + "amount": "1000000", + "recipient": "0xRECIPIENT", + "chainId": 8453, + }) + offer = PaymentOffer.from_header(header) + assert offer.token is None + + +# ─── X402Server.validate_settlement_token (server-side) ────────────────────── + +class TestX402ServerSettlementTokenValidation: + USDC_BASE = AcceptedToken(chain_id=8453, token="0xUSDC", min_amount=0, rank=2) + DAI_BASE = AcceptedToken(chain_id=8453, token="0xDAI", min_amount=0, rank=1) + + def _make_server(self, accepts): + return X402Server( + pay_to_address="0xPAYEE", + amount_usdc="1.00", + accepts=accepts, + ) + + def test_accepted_token_passes(self): + server = self._make_server([self.USDC_BASE, self.DAI_BASE]) + ok, msg = server.validate_settlement_token(chain_id=8453, token="0xDAI") + assert ok is True + assert msg == "" + + def test_non_accepted_token_fails(self): + server = self._make_server([self.USDC_BASE]) + ok, msg = server.validate_settlement_token(chain_id=8453, token="0xLUX") + assert ok is False + assert "not accepted" in msg + + def test_no_accepts_configured_always_passes(self): + server = self._make_server([]) + ok, msg = server.validate_settlement_token(chain_id=8453, token="0xANY") + assert ok is True diff --git a/web/index.html b/web/index.html index bb5f348..605261d 100644 --- a/web/index.html +++ b/web/index.html @@ -287,6 +287,7 @@ docs lab ↗ simulator ↗ + onboarding ↗ source ↗ diff --git a/web/lab/_build.js b/web/lab/_build.js index b06f8aa..78ee1fd 100644 --- a/web/lab/_build.js +++ b/web/lab/_build.js @@ -63,12 +63,17 @@ const SIDEBAR = ` Dev Tools + `; diff --git a/web/metrics.html b/web/metrics.html new file mode 100644 index 0000000..43f36b5 --- /dev/null +++ b/web/metrics.html @@ -0,0 +1,775 @@ + + + + + + switchboard — escrow metrics + + + + + + + + + +
+ + kcolbchain/switchboard + + +
+ +
+ + +
+
+

escrow metrics

+

+ Fill rate, time-to-release, timeouts, refunds, challenge rate, + spend by token/rail, policy denials, and fleet health — refreshed + every 30 s from contract events. +

+
+
+
+ polling · 30 s +
+
+ + +
+
+ Last updated: + · + Interval: 30 s +
+
+ + + + +
+
+ + +
+
+

escrow fulfilment

+ — events +
+ +
+
+
fill rate
+
+
released / total
+
+
+
timeout rate
+
+
timed-out / total
+
+
+
refund rate
+
+
refunded / total
+
+
+
challenge rate
+
+
challenged / total
+
+
+ + +
+ +
+
+ + + + +
+
+
fill rate
+
— / — events
+
+ +
+
avg time-to-release
+
+
seconds (released events)
+
+ +
+
pending escrows
+
+
currently locked
+
+ +
+
total events
+
+
all terminal events
+
+
+
+ + +
+
+

fill rate over time

+ rolling 24 h window +
+
+
hourly fill rate %
+ +
+
+ + +
+
+

wallet ops

+ — ops total +
+
+ +
+
spend by token
+
+
+ +
+
spend by rail
+
+
+ +
+
policy denials 0
+
+
+
+
+ + +
+
+

fleet health

+ — wallets active +
+
+ + + + + + + + + + +
walletopsdenial ratehealth
+
+
+ +
+ + + + + + + diff --git a/web/mock-api.js b/web/mock-api.js new file mode 100644 index 0000000..30e70a8 --- /dev/null +++ b/web/mock-api.js @@ -0,0 +1,219 @@ +/** + * switchboard — Agent Onboarding Mock API + * ========================================= + * Defines the contract shape and provides in-browser stub implementations. + * Wire this against a real backend by replacing each handler with a fetch() + * to the documented endpoint. All responses are JSON. + * + * Contract version: v0.1 (matches unit ⑱ / spec §10) + * + * ───────────────────────────────────────────────────────────────────────── + * ENDPOINT CONTRACT + * ───────────────────────────────────────────────────────────────────────── + * + * POST /api/auth/session + * Body: { email: string, password?: string } // API-key auth: no password + * 200: { session_token: string, user_id: string, display_name: string } + * 401: { error: "invalid_credentials" } + * + * DELETE /api/auth/session + * Headers: Authorization: Bearer + * 204: (no body) + * + * POST /api/keys + * Headers: Authorization: Bearer + * Body: { provider: string, key: string, label?: string } + * provider ∈ { "openai", "anthropic", "google", "cohere", "custom" } + * key — raw provider API key; NEVER stored in plaintext; backend + * immediately encrypts and stores cipher only; never returned. + * 200: { key_id: string, provider: string, label: string, + * masked: string, // e.g. "sk-…3a9c" + * created_at: string } // ISO 8601 + * 422: { error: "invalid_key", detail: string } + * + * GET /api/keys + * Headers: Authorization: Bearer + * 200: [ { key_id, provider, label, masked, created_at } ] + * + * DELETE /api/keys/:key_id + * Headers: Authorization: Bearer + * 204: (no body) + * + * GET /api/agent/mcp-endpoint + * Headers: Authorization: Bearer + * 200: { endpoint: string, // wss://switchboard.kcolbchain.io/mcp + * session_key: string, // scoped, revocable session key + * expires_at: string, // ISO 8601 + * policy: { + * token_allowlist: string[], // e.g. ["LUX","USDC","ZOO","ETH"] + * per_tx_cap_usd: number, + * daily_cap_usd: number, + * allowed_counterparties: string[] | null + * } } + * + * GET /api/wallet/balances + * Headers: Authorization: Bearer + * 200: [ { chain_id: number, chain_name: string, + * token: string, token_address: string, + * balance: string, // decimal string, full precision + * balance_usd: number } ] + * + * POST /api/escrow/create + * Headers: Authorization: Bearer + * Body: { payee: string, token: string, amount: string, + * chain_id: number, challenge_period_s: number } + * 200: { request_id: string, tx_hash: string, status: "pending_confirmation" } + * 422: { error: "no_common_settlement_token" | "policy_violation" | "insufficient_balance", + * detail: string } + * + * GET /api/policy/status + * Headers: Authorization: Bearer + * 200: { session_key: string, expires_at: string, + * daily_cap_usd: number, daily_spent_usd: number, + * per_tx_cap_usd: number, token_allowlist: string[], + * allowed_counterparties: string[] | null, + * policy_denials_24h: number } + * + * GET /api/metrics + * Headers: Authorization: Bearer + * 200: { escrow: { fill_rate: number, // 0-1 + * avg_release_ms: number, + * timeout_rate: number, + * refund_rate: number, + * challenge_rate: number, + * open_count: number }, + * wallet: { spend_by_token: { [token]: number }, // USD + * spend_by_rail: { x402: number, escrow: number, mpp: number }, + * policy_denials_24h: number, + * fleet_health: number } } // 0-1 + * ───────────────────────────────────────────────────────────────────────── + */ + +const MockAPI = (() => { + const DELAY = () => new Promise(r => setTimeout(r, 320 + Math.random() * 180)); + + let _session = null; + let _keys = []; + + async function auth({ email }) { + await DELAY(); + if (!email || !email.includes('@')) return { ok: false, error: 'invalid_credentials' }; + _session = { + session_token: 'sb_sess_' + Math.random().toString(36).slice(2), + user_id: 'usr_' + Math.random().toString(36).slice(2, 10), + display_name: email.split('@')[0], + }; + return { ok: true, data: _session }; + } + + async function logout() { + await DELAY(); + _session = null; + return { ok: true }; + } + + async function addKey({ provider, key, label }) { + await DELAY(); + if (!key || key.length < 8) return { ok: false, error: 'invalid_key', detail: 'Key too short' }; + const entry = { + key_id: 'key_' + Math.random().toString(36).slice(2, 10), + provider, + label: label || provider, + masked: key.slice(0, 4) + '…' + key.slice(-4), + created_at: new Date().toISOString(), + }; + _keys.push(entry); + return { ok: true, data: entry }; + } + + async function listKeys() { + await DELAY(); + return { ok: true, data: [..._keys] }; + } + + async function deleteKey(key_id) { + await DELAY(); + _keys = _keys.filter(k => k.key_id !== key_id); + return { ok: true }; + } + + async function getMcpEndpoint() { + await DELAY(); + return { + ok: true, data: { + endpoint: 'wss://switchboard.kcolbchain.io/mcp', + session_key: 'sb_sk_' + Math.random().toString(36).slice(2, 18), + expires_at: new Date(Date.now() + 86400000).toISOString(), + policy: { + token_allowlist: ['LUX', 'USDC', 'ZOO', 'ETH', 'DAI'], + per_tx_cap_usd: 50, + daily_cap_usd: 500, + allowed_counterparties: null, + }, + } + }; + } + + async function getBalances() { + await DELAY(); + return { + ok: true, data: [ + { chain_id: 7777777, chain_name: 'LUX C-chain', token: 'LUX', token_address: '0x0000…0000', balance: '14820.50', balance_usd: 2442.38 }, + { chain_id: 8453, chain_name: 'Base', token: 'USDC', token_address: '0x036C…f7e', balance: '1024.00', balance_usd: 1024.00 }, + { chain_id: 8453, chain_name: 'Base', token: 'ZOO', token_address: '0xd34d…cafe', balance: '50000.00', balance_usd: 410.00 }, + { chain_id: 1, chain_name: 'Ethereum', token: 'ETH', token_address: '0x0000…0000', balance: '0.31', balance_usd: 992.00 }, + ] + }; + } + + async function createEscrow({ payee, token, amount, chain_id }) { + await DELAY(); + return { + ok: true, data: { + request_id: 'req_' + Math.random().toString(36).slice(2, 12), + tx_hash: '0x' + [...Array(64)].map(() => Math.floor(Math.random()*16).toString(16)).join(''), + status: 'pending_confirmation', + } + }; + } + + async function getPolicyStatus() { + await DELAY(); + return { + ok: true, data: { + session_key: 'sb_sk_demo…', + expires_at: new Date(Date.now() + 82800000).toISOString(), + daily_cap_usd: 500, + daily_spent_usd: 127.40, + per_tx_cap_usd: 50, + token_allowlist: ['LUX', 'USDC', 'ZOO', 'ETH', 'DAI'], + allowed_counterparties: null, + policy_denials_24h: 2, + } + }; + } + + async function getMetrics() { + await DELAY(); + return { + ok: true, data: { + escrow: { + fill_rate: 0.94, + avg_release_ms: 1840, + timeout_rate: 0.03, + refund_rate: 0.03, + challenge_rate: 0.01, + open_count: 7, + }, + wallet: { + spend_by_token: { USDC: 88.20, LUX: 24.10, ETH: 15.10 }, + spend_by_rail: { x402: 62.40, escrow: 52.00, mpp: 13.00 }, + policy_denials_24h: 2, + fleet_health: 1.0, + } + } + }; + } + + return { auth, logout, addKey, listKeys, deleteKey, getMcpEndpoint, getBalances, createEscrow, getPolicyStatus, getMetrics }; +})(); diff --git a/web/onboarding.html b/web/onboarding.html new file mode 100644 index 0000000..a6e64a8 --- /dev/null +++ b/web/onboarding.html @@ -0,0 +1,1774 @@ + + + + + +switchboard — agent onboarding + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + +