Skip to content

testnet hardening: Ownable AgentEscrow + Foundry deploys + pip install#23

Open
abhicris wants to merge 2 commits intomainfrom
kcolb/testnet-hardening
Open

testnet hardening: Ownable AgentEscrow + Foundry deploys + pip install#23
abhicris wants to merge 2 commits intomainfrom
kcolb/testnet-hardening

Conversation

@abhicris
Copy link
Copy Markdown
Contributor

@abhicris abhicris commented May 4, 2026

Summary

Makes switchboard deployable on testnet end-to-end:

  • Contract hardening (contracts/AgentEscrow.sol)

    • registerAgent is now onlyOwner (was permissionless — anyone could register any address as a "trusted agent"). deregisterAgent added, also onlyOwner.
    • confirmPayment / requestRefund / cancelPayment are nonReentrant (OpenZeppelin ReentrancyGuard) and now follow checks-effects-interactions: state is updated and p.amount is zeroed BEFORE the external call. Defense in depth.
    • Added agent != address(0) guard on registerAgent, plus a missing PaymentCancelled event.
    • Owner is set via Ownable(msg.sender) in the constructor.
  • Foundry scaffold

    • foundry.toml, script/Deploy.s.sol, test/AgentEscrow.t.sol (10 tests, all pass).
    • OpenZeppelin v5.0.2 + forge-std vendored via forge install (CI installs at job time; lib/ is gitignored).
    • Makefile targets: deploy-base-sepolia (84532), deploy-op-sepolia (11155420), deploy-lux-testnet (96368), verify-base-sepolia. All call forge script ... --broadcast --verify.
    • .env.example with RPC_BASE_SEPOLIA, RPC_OP_SEPOLIA, RPC_LUX_TESTNET, DEPLOYER_PRIVATE_KEY (placeholder), ETHERSCAN_API_KEY.
  • Python distribution

    • pyproject.toml (hatchling). PyPI name = switchboard-agent (the bare name switchboard is taken on PyPI by an unrelated project). Import name stays switchboard.
    • Version 0.1.0. Python >=3.11. Deps: pydantic>=2, web3>=6, httpx, eth-account, sortedcontainers. Extras: [fastapi], [flask], [zap], [dev], [all].
    • switchboard/__init__.py exposes __version__ and load_registry().
    • switchboard/registry.json stub (escrow addresses null until deploy).
  • CI / publishing

    • .github/workflows/publish.yml — PyPI on v* tag via OIDC trusted publishing (no PYPI_TOKEN needed once the project is configured on PyPI). Not invoked here.
    • .github/workflows/ci.ymlforge test + pytest on PR. Both run strict (no || true masking).
  • Latent bugs fixed (commit 31371a9)

    The def/events syntax error in tests/test_payment_protocol.py was masking three real bugs the test suite then surfaced. All fixed in this PR:

    1. nonce_manager.confirm_nonce ran a while-loop that auto-confirmed every contiguous pending nonce — confirming nonce N effectively confirmed N+1, N+2, ... up to the next gap. Replaced with explicit out-of-order tracking: out-of-order confirmations stash in WalletState.out_of_order_confirmations and roll into confirmed_nonce only when the gap fills. on_reorg now also clears stashed entries the reorg invalidates, and _sync_with_onchain_nonce uses <= so a pending nonce equal to the new on-chain nonce is treated as stale.
    2. parse_wei("X wei") returned X * 10**18. Dict key "wei" was lowercase; lookup used currency.upper(), so "WEI" missed and fell to the ETH default. Uppercase the key.
    3. PaymentRequest.content_hash() included created_at (set via field(default_factory=time.time)), so two requests with identical content produced different hashes. Excludes created_at and status from the hashed payload — they're runtime/volatile, not part of the request's identity.

Regression tests

Foundry:

[PASS] test_happyPath_createConfirmReleased        — create → confirm → released
[PASS] test_timeoutRefund_path                     — timeout + challenge → refund
[PASS] test_doubleConfirm_reverts                  — second confirm reverts
[PASS] test_registerAgent_onlyOwner_strangerReverts — bug regression
[PASS] test_registerAgent_onlyOwner_payerReverts   — bug regression
[PASS] test_registerAgent_ownerSucceeds            — owner happy path
[PASS] test_registerAgent_zeroAddressReverts       — zero-address guard
[PASS] test_reentrancy_confirmPayment_reverts      — re-entry blocked
[PASS] test_cancel_returnsFunds                    — cancel path
[PASS] test_onlyPayerCanConfirm                    — caller authz

forge test: 10 passed, 0 failed.

pytest: 59 passed, 0 failed.

Deploy plan

cp .env.example .env
# fill DEPLOYER_PRIVATE_KEY (testnet only) + ETHERSCAN_API_KEY

forge install
forge build && forge test

make deploy-base-sepolia       # 84532
make deploy-op-sepolia         # 11155420
make deploy-lux-testnet        # 96368  (no Etherscan verify — explorer TBD)

After each deploy: copy the address into switchboard/registry.json under the right chainId (currently null). That registry ships in the wheel.

TODO (registry.json)

chainId network escrow usdc
84532 base-sepolia TBD (deploy) filled
11155420 op-sepolia TBD (deploy) filled
96368 lux-testnet TBD (deploy) TBD (no canonical USDC on Lux testnet yet)

Test plan

  • forge build — Compiler run successful
  • forge test -vv — 10 passed
  • pytest --collect-only — 59 tests collected (was 1 collection error on master)
  • pytest — 59 passed, 0 failed
  • User deploys to base-sepolia / op-sepolia / lux-testnet
  • User updates registry.json with deployed addresses
  • User configures PyPI OIDC trusted publishing for switchboard-agent and tags v0.1.0

🤖 Generated with Claude Code

abhicris added 2 commits May 4, 2026 21:41
…paths

Closes the permissionless-registry bug in AgentEscrow.sol — anyone could
register any address. registerAgent / deregisterAgent are now onlyOwner
(OpenZeppelin Ownable). confirm/refund/cancel are nonReentrant and follow
checks-effects-interactions; the storage update now zeroes p.amount before
the external call as defense in depth.

Adds an "agent cannot be zero address" guard and a PaymentCancelled event
that was previously missing.
The syntax error in tests/test_payment_protocol.py (fixed in the previous commit)
had been hiding three bugs that the test suite then surfaced:

  1. nonce_manager.confirm_nonce ran a while-loop that auto-confirmed every
     contiguous pending nonce — confirming nonce N effectively confirmed N+1,
     N+2, ... up to the next gap. Replace with explicit out-of-order tracking:
     out-of-order confirmations are stashed in WalletState.out_of_order_confirmations
     and rolled into confirmed_nonce only when the gap fills. on_reorg now also
     clears stashed entries the reorg invalidates, and _sync_with_onchain_nonce
     uses <= so a pending nonce equal to the new on-chain nonce is treated as
     stale and re-issued on the next acquire.

  2. parse_wei("X wei") returned X * 10**18. Dict key "wei" was lowercase,
     lookup used currency.upper(), so "WEI" missed and fell to the ETH default.
     Uppercase the key.

  3. PaymentRequest.content_hash() included created_at (set via
     field(default_factory=time.time)), so two requests with identical content
     produced different hashes. Exclude created_at and status from the hashed
     payload — they're runtime/volatile, not part of the request's identity.

Drops the `|| true` on the pytest CI step now that all 59 tests pass.
@abhicris abhicris force-pushed the kcolb/testnet-hardening branch from 31371a9 to c9a37e1 Compare May 4, 2026 16:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant