diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b081cde --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Copy to .env and fill in. Never commit .env. +# +# RPCs +RPC_BASE_SEPOLIA=https://sepolia.base.org +RPC_OP_SEPOLIA=https://sepolia.optimism.io +RPC_LUX_TESTNET=https://api.lux-test.network/ext/bc/C/rpc + +# Deployer key — testnet only, NEVER mainnet keys here +DEPLOYER_PRIVATE_KEY=0x0000000000000000000000000000000000000000000000000000000000000000 + +# Etherscan / Basescan multichain key (used for verification) +ETHERSCAN_API_KEY=replace-me diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8ddadc1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + +jobs: + forge: + name: forge test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: foundry-rs/foundry-toolchain@v1 + with: + version: stable + + - name: Install OZ + forge-std + run: | + forge install foundry-rs/forge-std --shallow + forge install OpenZeppelin/openzeppelin-contracts@v5.0.2 --shallow + + - run: forge build --sizes + - run: forge test -vv + + pytest: + name: pytest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install package + test deps + run: | + python -m pip install --upgrade pip + python -m pip install -e '.[dev]' + + - name: Collect tests (must succeed) + run: pytest tests/ --collect-only -q + + - name: Run tests + run: pytest tests/ -v diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..a1c832a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,56 @@ +name: Publish to PyPI + +on: + push: + tags: + - "v*" + workflow_dispatch: + +# OIDC trusted-publishing requires id-token: write at the job level. +permissions: + contents: read + +jobs: + build: + name: Build sdist + wheel + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install build deps + run: | + python -m pip install --upgrade pip + python -m pip install build + + - name: Build + run: python -m build + + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + # Restrict to tag pushes — manual dispatch builds but does not publish + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + environment: + name: pypi + url: https://pypi.org/p/switchboard-agent + permissions: + id-token: write # OIDC trusted publishing + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Publish to PyPI (OIDC) + uses: pypa/gh-action-pypi-publish@release/v1 + # No password / token — uses OIDC trusted-publishing config on PyPI side. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd2fcbe --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Foundry +out/ +cache/ +broadcast/ +lib/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +*.egg-info/ +*.egg +.pytest_cache/ +.tox/ +.coverage +htmlcov/ +.venv/ +venv/ +env/ + +# Env / secrets +.env +.env.* +!.env.example + +# Editors / OS +.vscode/ +.idea/ +.DS_Store +*.swp diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4c6b0d0 --- /dev/null +++ b/Makefile @@ -0,0 +1,58 @@ +.PHONY: install build test clean fmt deploy-base-sepolia deploy-op-sepolia deploy-lux-testnet verify-base-sepolia + +# ── Setup ────────────────────────────────────────────────────────────────── + +install: + forge install + +build: + forge build + +clean: + forge clean + +fmt: + forge fmt + +test: + forge test -vv + +# ── Deploy ───────────────────────────────────────────────────────────────── +# Each target loads .env, then runs the Deploy script with --broadcast --verify. +# Override RPC with RPC__OVERRIDE=... if you need a private endpoint. + +deploy-base-sepolia: + @test -f .env || (echo ".env not found — copy .env.example" && exit 1) + . ./.env && forge script script/Deploy.s.sol:Deploy \ + --rpc-url $$RPC_BASE_SEPOLIA \ + --broadcast \ + --verify \ + --etherscan-api-key $$ETHERSCAN_API_KEY \ + -vvv + +deploy-op-sepolia: + @test -f .env || (echo ".env not found — copy .env.example" && exit 1) + . ./.env && forge script script/Deploy.s.sol:Deploy \ + --rpc-url $$RPC_OP_SEPOLIA \ + --broadcast \ + --verify \ + --etherscan-api-key $$ETHERSCAN_API_KEY \ + -vvv + +deploy-lux-testnet: + @test -f .env || (echo ".env not found — copy .env.example" && exit 1) + . ./.env && forge script script/Deploy.s.sol:Deploy \ + --rpc-url $$RPC_LUX_TESTNET \ + --broadcast \ + --legacy \ + -vvv + +# ── Verify (re-run if --verify failed during deploy) ─────────────────────── + +verify-base-sepolia: + @test -n "$(ADDRESS)" || (echo "usage: make verify-base-sepolia ADDRESS=0x..." && exit 1) + . ./.env && forge verify-contract \ + --chain-id 84532 \ + --etherscan-api-key $$ETHERSCAN_API_KEY \ + --constructor-args $$(cast abi-encode "constructor(uint256)" 84532) \ + $(ADDRESS) contracts/AgentEscrow.sol:AgentEscrow diff --git a/README.md b/README.md index 753fbd9..38222fd 100644 --- a/README.md +++ b/README.md @@ -194,12 +194,35 @@ Open issues with [`good first issue`](https://github.com/kcolbchain/switchboard/ ## Install ```bash -pip install -e . -# optional, for ZAP binary wire: -pip install 'luxfi-zap @ git+https://github.com/luxfi/zap@main#subdirectory=python' +pip install switchboard-agent # PyPI distribution name +# import name stays `switchboard`: +# from switchboard.x402_middleware import X402Middleware + +# Optional extras: +pip install 'switchboard-agent[fastapi]' # FastAPI middleware deps +pip install 'switchboard-agent[flask]' # Flask middleware deps +pip install 'switchboard-agent[zap]' # ZAP binary wire (luxfi/zap) +pip install 'switchboard-agent[all]' # everything ``` -Python 3.11+. Tests: `pytest tests/`. +Python 3.11+. Tests: `pytest tests/`. Solidity tests: `forge test` (see [Foundry setup](#foundry--on-chain-deployment) below). + +### Foundry / on-chain deployment + +`AgentEscrow.sol` ships with a Foundry scaffold for testnet deploys: + +```bash +forge install # pulls OpenZeppelin + forge-std +forge build +forge test -vv + +# Copy .env.example → .env, then: +make deploy-base-sepolia # 84532 +make deploy-op-sepolia # 11155420 +make deploy-lux-testnet # 96368 +``` + +Deployed addresses go into `switchboard/registry.json` (chainId-keyed). --- diff --git a/contracts/AgentEscrow.sol b/contracts/AgentEscrow.sol index 6358b31..21fe235 100644 --- a/contracts/AgentEscrow.sol +++ b/contracts/AgentEscrow.sol @@ -1,6 +1,9 @@ // 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"; + /** * @title AgentEscrow * @notice Escrow contract for agent-to-agent payments with timeout and refund. @@ -9,18 +12,32 @@ pragma solidity ^0.8.20; * 2. Agent performs work off-chain * 3. Payer confirms → funds released to payee * 4. Timeout expires → payer can reclaim (after challenge period) + * + * Hardening (kcolb/testnet-hardening): + * - registerAgent is now onlyOwner (was permissionless — security bug). + * - confirmPayment / requestRefund / cancelPayment are nonReentrant — they perform + * external ETH transfers via low-level call. + * - State transitions follow checks-effects-interactions; the nonReentrant guard is + * defense in depth. */ -contract AgentEscrow { - enum State { Created, Locked, Confirmed, Released, Refunded, Cancelled } +contract AgentEscrow is Ownable, ReentrancyGuard { + enum State { + Created, + Locked, + Confirmed, + Released, + Refunded, + Cancelled + } struct Payment { address payer; address payee; uint256 amount; - uint256 timeoutBlocks; // blocks until auto-expire - uint256 challengePeriod; // blocks payer must wait to reclaim after timeout + 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 + string requestId; // off-chain payment request ID uint256 createdAt; } @@ -29,7 +46,10 @@ contract AgentEscrow { // requestId → Payment mapping(string => Payment) public payments; - // Access control for agents + // Owner-curated allowlist of trusted agent addresses. Off-chain consumers (e.g. + // discovery / reputation services) can read this; the contract itself does not + // gate state-changing functions on registry membership today, but the allowlist + // is preserved as a public source of truth for tooling. mapping(address => bool) public registeredAgents; // Events @@ -38,26 +58,32 @@ contract AgentEscrow { event PaymentConfirmed(string indexed requestId, address indexed payer); event PaymentReleased(string indexed requestId, address indexed payee, uint256 amount); event PaymentRefunded(string indexed requestId, address indexed payer, uint256 amount); + event PaymentCancelled(string indexed requestId, address indexed payer, uint256 amount); event AgentRegistered(address indexed agent); event AgentDeregistered(address indexed agent); - constructor(uint256 _chainId) { + constructor(uint256 _chainId) Ownable(msg.sender) { chainId = _chainId; } - modifier onlyRegisteredAgent() { - require(registeredAgents[msg.sender], "Caller is not a registered agent"); - _; - } - /** - * @notice Register an agent address (permissioned) + * @notice Register an agent address. Permissioned: only the contract owner. + * @dev Previously permissionless — a known bug fixed in kcolb/testnet-hardening. */ - function registerAgent(address agent) external { + function registerAgent(address agent) external onlyOwner { + require(agent != address(0), "agent cannot be zero address"); registeredAgents[agent] = true; emit AgentRegistered(agent); } + /** + * @notice Deregister a previously registered agent. + */ + function deregisterAgent(address agent) external onlyOwner { + registeredAgents[agent] = false; + emit AgentDeregistered(agent); + } + /** * @notice Create a payment request and lock funds in escrow * @param requestId Unique off-chain request ID @@ -65,12 +91,11 @@ contract AgentEscrow { * @param timeoutBlocks Blocks until the payment can be auto-expired * @param challengePeriod Blocks payer must wait after timeout to reclaim */ - function createPayment( - string calldata requestId, - address payee, - uint256 timeoutBlocks, - uint256 challengePeriod - ) external payable returns (bool) { + function createPayment(string calldata requestId, address payee, uint256 timeoutBlocks, uint256 challengePeriod) + external + payable + returns (bool) + { require(msg.value > 0, "Must send ETH"); require(bytes(requestId).length > 0, "requestId cannot be empty"); require(payee != address(0), "payee cannot be zero address"); @@ -97,19 +122,22 @@ contract AgentEscrow { * @notice Payer confirms work is done → release funds to payee * @dev Can only be called by the original payer. Only in Locked state. */ - function confirmPayment(string calldata requestId) external returns (bool) { + function confirmPayment(string calldata requestId) external 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; p.state = State.Released; - - (bool success, ) = p.payee.call{value: p.amount}(""); - require(success, "Transfer to payee failed"); + p.amount = 0; emit PaymentConfirmed(requestId, msg.sender); - emit PaymentReleased(requestId, p.payee, p.amount); + emit PaymentReleased(requestId, payee, amount); + + (bool success,) = payee.call{value: amount}(""); + require(success, "Transfer to payee failed"); return true; } @@ -117,39 +145,41 @@ contract AgentEscrow { * @notice Payer requests refund after timeout + challenge period * @dev After timeout expires AND challenge period passes, payer can reclaim. */ - function requestRefund(string calldata requestId) external returns (bool) { + function requestRefund(string calldata requestId) external 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" - ); + require(block.number >= p.createdAt + p.timeoutBlocks + p.challengePeriod, "Challenge period not over"); + uint256 amount = p.amount; + address payer = p.payer; p.state = State.Refunded; + p.amount = 0; - (bool success, ) = p.payer.call{value: p.amount}(""); - require(success, "Refund transfer failed"); + emit PaymentRefunded(requestId, payer, amount); - emit PaymentRefunded(requestId, p.payer, p.amount); + (bool success,) = payer.call{value: amount}(""); + require(success, "Refund transfer failed"); return true; } /** * @notice Cancel a payment before timeout (mutual agreement) */ - function cancelPayment(string calldata requestId) external returns (bool) { + function cancelPayment(string calldata requestId) external 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; p.state = State.Cancelled; p.amount = 0; - (bool success, ) = p.payer.call{value: amount}(""); - require(success, "Cancel refund failed"); + emit PaymentCancelled(requestId, payer, amount); + (bool success,) = payer.call{value: amount}(""); + require(success, "Cancel refund failed"); return true; } diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..c61eadc --- /dev/null +++ b/foundry.toml @@ -0,0 +1,25 @@ +[profile.default] +src = "contracts" +out = "out" +libs = ["lib"] +test = "test" +script = "script" +solc_version = "0.8.20" +optimizer = true +optimizer_runs = 200 +remappings = [ + "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/", + "forge-std/=lib/forge-std/src/", +] +fs_permissions = [{ access = "read", path = "./switchboard/registry.json" }] + +[rpc_endpoints] +base_sepolia = "${RPC_BASE_SEPOLIA}" +op_sepolia = "${RPC_OP_SEPOLIA}" +lux_testnet = "${RPC_LUX_TESTNET}" + +[etherscan] +base_sepolia = { key = "${ETHERSCAN_API_KEY}", chain = 84532, url = "https://api-sepolia.basescan.org/api" } +op_sepolia = { key = "${ETHERSCAN_API_KEY}", chain = 11155420, url = "https://api-sepolia-optimistic.etherscan.io/api" } + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6263d1a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,80 @@ +[build-system] +requires = ["hatchling>=1.21"] +build-backend = "hatchling.build" + +[project] +name = "switchboard-agent" +version = "0.1.0" +description = "Programmable payments for AI agents — HTTP/402, on-chain escrow, gas budgets, nonce manager, ZAP binary wire." +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.11" +authors = [ + { name = "kcolbchain", email = "services@kcolbchain.com" }, +] +keywords = ["agent", "payments", "x402", "escrow", "ethereum", "ai", "web3"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries", +] +dependencies = [ + "pydantic>=2", + "web3>=6", + "httpx", + "eth-account", + "sortedcontainers", +] + +[project.optional-dependencies] +fastapi = ["fastapi>=0.110", "starlette>=0.36"] +flask = ["flask>=3"] +zap = ["luxfi-zap"] +dev = [ + "pytest>=8", + "pytest-asyncio>=0.23", + "ruff>=0.4", +] +all = [ + "fastapi>=0.110", + "starlette>=0.36", + "flask>=3", + "luxfi-zap", +] + +[project.urls] +Homepage = "https://github.com/kcolbchain/switchboard" +Repository = "https://github.com/kcolbchain/switchboard" +Issues = "https://github.com/kcolbchain/switchboard/issues" +Organization = "https://kcolbchain.com" + +[project.scripts] +switchboard = "src.payment_protocol:main" + +[tool.hatch.build.targets.wheel] +packages = ["switchboard", "src"] + +[tool.hatch.build.targets.wheel.force-include] +"switchboard/registry.json" = "switchboard/registry.json" + +[tool.hatch.build.targets.sdist] +include = [ + "switchboard", + "src", + "contracts", + "README.md", + "LICENSE", + "pyproject.toml", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] + +[tool.ruff] +line-length = 100 +target-version = "py311" diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol new file mode 100644 index 0000000..a7f15a7 --- /dev/null +++ b/script/Deploy.s.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2} from "forge-std/Script.sol"; +import {AgentEscrow} from "../contracts/AgentEscrow.sol"; + +/// @title Deploy +/// @notice Deploys AgentEscrow with the active chain's ID baked in via constructor. +/// @dev Run: forge script script/Deploy.s.sol --rpc-url --broadcast --verify +contract Deploy is Script { + function run() external returns (AgentEscrow escrow) { + uint256 deployerKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + uint256 chainId = block.chainid; + + vm.startBroadcast(deployerKey); + escrow = new AgentEscrow(chainId); + vm.stopBroadcast(); + + console2.log("AgentEscrow deployed:"); + console2.log(" chainId:", chainId); + console2.log(" address:", address(escrow)); + console2.log(" owner: ", escrow.owner()); + } +} diff --git a/src/payment_protocol.py b/src/payment_protocol.py index 7063c4e..87bd052 100644 --- a/src/payment_protocol.py +++ b/src/payment_protocol.py @@ -75,9 +75,17 @@ def from_dict(cls, d: dict) -> 'PaymentRequest': return cls(**d) def content_hash(self) -> str: - """Calculate content-based hash for integrity check""" + """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.""" + 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) + canonical = json.dumps(d, sort_keys=True, separators=(',', ':')) h = hashlib.sha256() - h.update(self.to_json().encode('utf-8')) + h.update(canonical.encode('utf-8')) return "0x" + h.hexdigest() @@ -507,7 +515,7 @@ def parse_wei(amount: str) -> int: multiplier = { "ETH": 10**18, - "wei": 1, + "WEI": 1, "KETH": 10**21, }.get(currency.upper(), 10**18) diff --git a/switchboard/__init__.py b/switchboard/__init__.py new file mode 100644 index 0000000..714d057 --- /dev/null +++ b/switchboard/__init__.py @@ -0,0 +1,19 @@ +"""switchboard — programmable payments for AI agents. + +Distributed on PyPI as ``switchboard-agent``; the import name is ``switchboard``. +""" +from __future__ import annotations + +import json +from importlib import resources +from typing import Any + +__version__ = "0.1.0" + +__all__ = ["__version__", "load_registry"] + + +def load_registry() -> dict[str, Any]: + """Return the bundled chain registry (chainId -> {name, escrow, usdc}).""" + with resources.files(__package__).joinpath("registry.json").open("r") as f: + return json.load(f) diff --git a/switchboard/nonce_manager.py b/switchboard/nonce_manager.py index 036d5d9..31c2735 100644 --- a/switchboard/nonce_manager.py +++ b/switchboard/nonce_manager.py @@ -22,15 +22,19 @@ class WalletState: def __init__(self, confirmed_nonce: int): # The highest sequentially confirmed nonce known to the manager. self.confirmed_nonce: int = confirmed_nonce - + # Stores nonces that have been acquired by the manager but not yet confirmed on-chain. # SortedSet ensures nonces are kept in order for easy processing and unique storage. self.pending_nonces: SortedSet[int] = SortedSet() - + # Maps a pending nonce to its associated transaction object. # This allows re-queuing of transactions if a reorg invalidates their nonces. self.pending_transactions: Dict[int, Any] = {} + # Nonces confirmed out-of-order (ahead of confirmed_nonce). They roll into + # confirmed_nonce when the gap fills. + self.out_of_order_confirmations: set = set() + class NonceManager: """ Manages nonces for multiple wallet addresses, tracking pending and confirmed @@ -77,17 +81,20 @@ def _sync_with_onchain_nonce(self, state: WalletState, address: str): if onchain_nonce > state.confirmed_nonce: # The on-chain nonce is higher than our locally confirmed nonce. - # This implies transactions have been confirmed that we might not have tracked locally, - # or previous pending nonces have been included in a block. - - # Identify and remove any local pending nonces that are now below the current - # on-chain nonce, as they are effectively confirmed. - nonces_to_remove = SortedSet(n for n in state.pending_nonces if n < onchain_nonce) + # Pending nonces strictly less than onchain_nonce are confirmed; pending nonces + # equal to onchain_nonce are stale (the chain advanced past them without including + # our tx) and should be re-issued on the next acquire. + nonces_to_remove = SortedSet(n for n in state.pending_nonces if n <= onchain_nonce) for n in nonces_to_remove: state.pending_nonces.remove(n) if n in state.pending_transactions: del state.pending_transactions[n] - + + # Out-of-order confirmations the chain has now subsumed are no longer interesting. + state.out_of_order_confirmations = { + n for n in state.out_of_order_confirmations if n > onchain_nonce + } + # Update our locally tracked confirmed_nonce to reflect the latest on-chain state. state.confirmed_nonce = onchain_nonce @@ -149,6 +156,9 @@ def confirm_nonce(self, address: str, nonce: int): Marks a nonce as successfully confirmed on the blockchain (i.e., the transaction using it has been mined into a block). + Confirmations may arrive out of order. Out-of-order confirmations are stashed + and rolled into `confirmed_nonce` once the preceding nonces confirm. + Args: address: The wallet address. nonce: The nonce to confirm. @@ -156,30 +166,26 @@ def confirm_nonce(self, address: str, nonce: int): with self._lock: state = self._get_wallet_state(address) - # If the nonce is currently pending, remove it. + # If the nonce is currently pending, drop it from pending tracking. if nonce in state.pending_nonces: state.pending_nonces.remove(nonce) if nonce in state.pending_transactions: del state.pending_transactions[nonce] - elif nonce < state.confirmed_nonce: - # If the nonce is already less than the current confirmed_nonce, - # it means it was previously processed (e.g., via _sync_with_onchain_nonce). + + if nonce < state.confirmed_nonce: + # Already counted (e.g., via prior sync). return - # If the confirmed nonce is sequential to our current `confirmed_nonce`, - # we can advance our `confirmed_nonce`. We also check for and confirm - # any subsequent nonces that are now also sequential. if nonce == state.confirmed_nonce: + # Advance by one, then roll forward through any stashed out-of-order + # confirmations that are now contiguous. state.confirmed_nonce += 1 - while state.confirmed_nonce in state.pending_nonces: - state.pending_nonces.remove(state.confirmed_nonce) - if state.confirmed_nonce in state.pending_transactions: - del state.pending_transactions[state.confirmed_nonce] + while state.confirmed_nonce in state.out_of_order_confirmations: + state.out_of_order_confirmations.discard(state.confirmed_nonce) state.confirmed_nonce += 1 - # If `nonce > state.confirmed_nonce` and it was not previously pending, - # it implies a gap in confirmations. We do not directly advance `state.confirmed_nonce` - # past such a gap. The `_sync_with_onchain_nonce` method will eventually correct - # `state.confirmed_nonce` if the missing nonces are confirmed on-chain. + else: + # nonce > confirmed_nonce: out-of-order. Stash for later roll-forward. + state.out_of_order_confirmations.add(nonce) def on_reorg(self, address: str, reverted_to_nonce: int): """ @@ -206,6 +212,11 @@ def on_reorg(self, address: str, reverted_to_nonce: int): if state.confirmed_nonce > reverted_to_nonce: state.confirmed_nonce = reverted_to_nonce + # Drop any stashed out-of-order confirmations the reorg has invalidated. + state.out_of_order_confirmations = { + n for n in state.out_of_order_confirmations if n < reverted_to_nonce + } + reverted_txns = [] nonces_to_remove = SortedSet() diff --git a/switchboard/registry.json b/switchboard/registry.json new file mode 100644 index 0000000..9c8fd6e --- /dev/null +++ b/switchboard/registry.json @@ -0,0 +1,17 @@ +{ + "84532": { + "name": "base-sepolia", + "escrow": null, + "usdc": "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + }, + "11155420": { + "name": "op-sepolia", + "escrow": null, + "usdc": "0x5fd84259d66Cd46123540766Be93DFE6D43130D7" + }, + "96368": { + "name": "lux-testnet", + "escrow": null, + "usdc": null + } +} diff --git a/test/AgentEscrow.t.sol b/test/AgentEscrow.t.sol new file mode 100644 index 0000000..27b0c76 --- /dev/null +++ b/test/AgentEscrow.t.sol @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test, console2} from "forge-std/Test.sol"; +import {AgentEscrow} from "../contracts/AgentEscrow.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +/// @notice Reentrancy attacker — re-enters confirmPayment from receive(). +contract Reenterer { + AgentEscrow public escrow; + string public targetReq; + bool public didReenter; + + constructor(AgentEscrow _escrow) { + escrow = _escrow; + } + + function setTarget(string calldata reqId) external { + targetReq = reqId; + } + + receive() external payable { + if (!didReenter) { + didReenter = true; + // attempt re-entry; expected to revert under nonReentrant + try escrow.confirmPayment(targetReq) { + // shouldn't reach + } + catch { + // swallow — outer call must still succeed for this test we WANT to revert + revert("reentrancy blocked"); + } + } + } +} + +contract AgentEscrowTest is Test { + AgentEscrow internal escrow; + + address internal owner = address(0xA11CE); + address internal payer = address(0xB0B); + address internal payee = address(0xC0DE); + address internal stranger = address(0xDEAD); + + uint256 internal constant TIMEOUT = 100; + uint256 internal constant CHALLENGE = 10; + uint256 internal constant AMOUNT = 1 ether; + + function setUp() public { + vm.prank(owner); + escrow = new AgentEscrow(31337); + + vm.deal(payer, 10 ether); + vm.deal(stranger, 1 ether); + } + + // ── Happy path ───────────────────────────────────────────────────────── + + function test_happyPath_createConfirmReleased() public { + uint256 payeeBalBefore = payee.balance; + + vm.prank(payer); + escrow.createPayment{value: AMOUNT}("req-1", payee, TIMEOUT, CHALLENGE); + + assertTrue(escrow.isState("req-1", AgentEscrow.State.Locked)); + + vm.prank(payer); + escrow.confirmPayment("req-1"); + + assertTrue(escrow.isState("req-1", AgentEscrow.State.Released)); + assertEq(payee.balance, payeeBalBefore + AMOUNT); + } + + // ── Timeout / refund path ────────────────────────────────────────────── + + function test_timeoutRefund_path() public { + uint256 payerBalBefore = payer.balance; + + vm.prank(payer); + escrow.createPayment{value: AMOUNT}("req-2", payee, TIMEOUT, CHALLENGE); + + // Try refund too early — must revert + vm.prank(payer); + vm.expectRevert(bytes("Challenge period not over")); + escrow.requestRefund("req-2"); + + // Roll past timeout + challenge + vm.roll(block.number + TIMEOUT + CHALLENGE + 1); + + assertTrue(escrow.isExpired("req-2")); + + vm.prank(payer); + escrow.requestRefund("req-2"); + + assertTrue(escrow.isState("req-2", AgentEscrow.State.Refunded)); + // Net: payer paid AMOUNT, refund returned AMOUNT → balance unchanged + assertEq(payer.balance, payerBalBefore); + } + + // ── Double-confirm reverts ───────────────────────────────────────────── + + function test_doubleConfirm_reverts() public { + vm.prank(payer); + escrow.createPayment{value: AMOUNT}("req-3", payee, TIMEOUT, CHALLENGE); + + vm.prank(payer); + escrow.confirmPayment("req-3"); + + vm.prank(payer); + vm.expectRevert(bytes("Payment not in Locked state")); + escrow.confirmPayment("req-3"); + } + + // ── Regression: only owner can register agents ───────────────────────── + // Previously registerAgent was permissionless — anyone could squat the + // registry. This test pins the fix. + + function test_registerAgent_onlyOwner_strangerReverts() public { + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, stranger)); + escrow.registerAgent(stranger); + assertFalse(escrow.registeredAgents(stranger)); + } + + function test_registerAgent_onlyOwner_payerReverts() public { + vm.prank(payer); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, payer)); + escrow.registerAgent(payer); + } + + function test_registerAgent_ownerSucceeds() public { + vm.prank(owner); + escrow.registerAgent(payee); + assertTrue(escrow.registeredAgents(payee)); + } + + function test_registerAgent_zeroAddressReverts() public { + vm.prank(owner); + vm.expectRevert(bytes("agent cannot be zero address")); + escrow.registerAgent(address(0)); + } + + // ── Reentrancy attempt reverts ───────────────────────────────────────── + + function test_reentrancy_confirmPayment_reverts() public { + Reenterer attacker = new Reenterer(escrow); + attacker.setTarget("req-r"); + + // payer creates a payment whose payee is the attacker + vm.prank(payer); + escrow.createPayment{value: AMOUNT}("req-r", address(attacker), TIMEOUT, CHALLENGE); + + // Confirm — this triggers attacker.receive() which re-enters confirmPayment. + // The Reenterer wraps the inner call in try/catch and reverts when re-entry + // is blocked, which propagates out: the whole tx must revert with + // "Transfer to payee failed" (since the payee call's success bool is false). + vm.prank(payer); + vm.expectRevert(bytes("Transfer to payee failed")); + escrow.confirmPayment("req-r"); + + // State must NOT be Released — refund still possible after timeout + assertFalse(escrow.isState("req-r", AgentEscrow.State.Released)); + } + + // ── Cancel path ──────────────────────────────────────────────────────── + + function test_cancel_returnsFunds() public { + uint256 balBefore = payer.balance; + + vm.prank(payer); + escrow.createPayment{value: AMOUNT}("req-c", payee, TIMEOUT, CHALLENGE); + + vm.prank(payer); + escrow.cancelPayment("req-c"); + + assertTrue(escrow.isState("req-c", AgentEscrow.State.Cancelled)); + assertEq(payer.balance, balBefore); + } + + // ── Only payer can confirm ───────────────────────────────────────────── + + function test_onlyPayerCanConfirm() public { + vm.prank(payer); + escrow.createPayment{value: AMOUNT}("req-x", payee, TIMEOUT, CHALLENGE); + + vm.prank(stranger); + vm.expectRevert(bytes("Only payer can confirm")); + escrow.confirmPayment("req-x"); + } +} diff --git a/tests/test_payment_protocol.py b/tests/test_payment_protocol.py index c904e01..950e31b 100644 --- a/tests/test_payment_protocol.py +++ b/tests/test_payment_protocol.py @@ -53,7 +53,7 @@ def __init__(self): def functions(self): return MockContractFunctions(self) - def/events(self): + def events(self): return MockEvents()