Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ roundtrip = decode_offer(wire) # exact equality

## Comparison

Full competitive survey: [docs/competitive-survey.md](docs/competitive-survey.md)

| | switchboard | raw x402 server | Stripe meter | Custom RPC paywall |
|---|---|---|---|---|
| HTTP/402 native | ✅ | ✅ | ❌ | ❌ |
Expand Down
42 changes: 42 additions & 0 deletions docs/competitive-survey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Competitive Survey: Native-ETH Agent Escrow

_Date: 2026-06-05_

## Scope
This survey asked a narrow question: among public agent-payment / agent-escrow projects, who actually ships a **native-ETH** escrow primitive — a payable `createPayment{value: ...}`-style entry point that settles in plain ETH on an EVM chain.

Method:
- checked the live repo/docs pages or official docs for each project
- looked for a payable escrow contract, request-id keyed flow, timeout/refund, and any public mainnet or testnet deployment notes
- if I could not find a public escrow contract, I labeled it **no public evidence** instead of guessing

## Table

| Project | Native ETH? | Agent-targeted? | Mainnet / live? | Repo / docs | Notes |
|---|---|---:|---|---|---|
| switchboard | yes | yes | testnet + public demo | https://github.com/kcolbchain/switchboard | `AgentEscrow.sol` uses a payable create path with request-id, timeout, challenge period, refund/cancel flow. |
| Coinbase x402 | no, USDC rail | yes | yes (Base) | https://github.com/coinbase/x402 | HTTP 402 payment rail; the live docs / issue trail point at USDC settlement rather than native ETH escrow. |
| Google A2A / AP2 x402 | no public escrow contract found | yes | docs/spec only | https://github.com/google-a2a/a2a-x402 | Public material describes an agent-payment envelope / protocol, not a native-ETH escrow contract. |
| Circle Nanopayments | no, USDC batched settlement | yes | yes | https://developers.circle.com/gateway/nanopayments | Gas-free USDC nanopayments with off-chain authorizations and batched onchain settlement. |
| MPP / Tempo | no, stablecoin micropayments | yes | yes | https://mpp.dev/use-cases/micropayments | One-time charges use on-chain stablecoin transfers on Tempo; not a native-ETH escrow primitive. |
| Kleros Escrow | yes, ETH escrow contract | no | yes | https://docs.kleros.io/products/escrow | ETH escrow exists, but the flow is human-dispute / arbitration-centric, not agent-payment specific. |
| Reality.eth | no | no | yes | https://realitio.github.io/docs/html/ | General-purpose on-chain oracle / dispute primitive, not an agent escrow rail. |
| UMA Optimistic Oracle / Polymarket wrappers | no | no | yes | https://docs.uma.xyz/resources/glossary | Oracle/dispute rail, with escrow-like settlement patterns in some app-specific wrappers, but not a native agent escrow product. |
| Polymarket UMA sports oracle wrapper | no | no | yes | https://github.com/Polymarket/uma-sports-oracle | App-specific oracle wrapper; useful sanity check, but not an agent escrow product. |

## Positioning summary

Switchboard appears to be the **only public project I found that combines all three** of the following in one minimal primitive:

1. native ETH escrow on an EVM chain,
2. an agent-targeted request-id / timeout / challenge / refund flow,
3. a public codebase and live demo path.

That makes the “native-ETH escrow” claim believable **within the agent-payment niche**, not in the broader escrow market. The broader escrow market absolutely has ETH escrow already, especially Kleros and older oracle/dispute systems. But those are not agent-payment rails.

So the honest positioning is:
- **first / rare in agent payments:** native-ETH escrow with an agent flow
- **not first in general escrow:** ETH escrow has existed for years
- **clearly differentiated vs USDC rails:** x402, Circle, AP2, and MPP all lean on token rails or off-chain authorization rather than plain ETH escrow

If we keep this page current, it becomes the grounding doc for the README positioning table and for future roadmap calls.
41 changes: 41 additions & 0 deletions docs/gas-manager-unification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# GasManager Unification

## Goal
Unify the two legacy gas-budget implementations into a single core so we can support:

- rolling-window budgets for per-wallet agent spend
- calendar-reset budgets for the legacy global tracker
- backward-compatible imports for existing callers

## Proposed API

### Core
`GasManager(default_limits, clock, mode, scope)`

- `mode="rolling"` for sliding windows
- `mode="calendar"` for UTC hour/day resets
- `scope="per-wallet"` for isolated wallets
- `scope="global"` for a singleton/global budget

### Shared methods
- `set_limits(...)`
- `limits_for(...)`
- `can_spend(...)`
- `check(...)`
- `record(...)`
- `status(...)`
- `resume(...)`
- `reset(...)`
- `spent(...)`

## Compatibility plan

- `switchboard.gas_budget.GasBudgetTracker` becomes a thin rolling/per-wallet wrapper.
- `switchboard.gas_tracker.GasTracker` becomes a singleton calendar/global wrapper.
- Existing tests keep using the legacy names.

## Why this is better

- One source of truth for budget behavior.
- Old imports keep working.
- New code can choose the policy explicitly instead of inheriting hidden semantics from the module name.
232 changes: 17 additions & 215 deletions switchboard/gas_budget.py
Original file line number Diff line number Diff line change
@@ -1,233 +1,35 @@
"""
Gas budget tracker for agent wallets.

Tracks cumulative gas spent per wallet over rolling hour and day windows,
enforces configurable limits, and pauses execution when a budget is exhausted.

Implements issue #5:
https://github.com/kcolbchain/switchboard/issues/5

Design goals
------------
- Monotonic, thread-safe accounting — safe from multiple agent worker threads.
- Rolling-window enforcement (not calendar buckets), so a burst at 23:59 does
not reset to zero one minute later.
- Pluggable clock for deterministic tests.
- Pure Python, zero new runtime deps.

Typical usage::

tracker = GasBudgetTracker(
default_limits=GasLimits(per_hour=2_000_000, per_day=20_000_000),
)
"""Gas budget tracker for agent wallets.

if not tracker.can_spend(wallet, estimated_gas):
raise BudgetExhausted(tracker.status(wallet))

# ... send tx ...
tracker.record(wallet, gas_used=receipt.gasUsed)
Compatibility wrapper around :mod:`switchboard.gas_manager`.
"""

from __future__ import annotations

import threading
import time
from collections import defaultdict, deque
from dataclasses import dataclass, field
from typing import Callable, Deque, Dict, Optional


SECONDS_PER_HOUR = 3_600
SECONDS_PER_DAY = 86_400


class BudgetExhausted(RuntimeError):
"""Raised when a wallet would exceed its configured gas budget."""

from .gas_manager import (
BudgetExhausted,
BudgetStatus,
GasLimits,
GasManager,
SECONDS_PER_DAY,
SECONDS_PER_HOUR,
)

@dataclass(frozen=True)
class GasLimits:
"""Per-wallet gas ceilings. ``None`` disables the corresponding window."""

per_hour: Optional[int] = None
per_day: Optional[int] = None
class GasBudgetTracker(GasManager):
"""Per-wallet rolling-window gas budgets.


@dataclass
class BudgetStatus:
"""Snapshot of a wallet's current spend vs. its limits."""

wallet: str
limits: GasLimits
spent_last_hour: int
spent_last_day: int
paused: bool

@property
def remaining_hour(self) -> Optional[int]:
if self.limits.per_hour is None:
return None
return max(0, self.limits.per_hour - self.spent_last_hour)

@property
def remaining_day(self) -> Optional[int]:
if self.limits.per_day is None:
return None
return max(0, self.limits.per_day - self.spent_last_day)


@dataclass
class _WalletLedger:
"""Internal per-wallet state. Protected by the tracker lock."""

# (timestamp_seconds, gas_used) entries, oldest first.
events: Deque = field(default_factory=deque)
sum_hour: int = 0
sum_day: int = 0
paused: bool = False


class GasBudgetTracker:
"""Tracks cumulative gas per wallet and enforces rolling-window limits.

Parameters
----------
default_limits:
Applied to any wallet that does not have explicit limits set via
:meth:`set_limits`.
clock:
Injectable seconds-resolution clock. Defaults to :func:`time.time`.
Tests should pass a controllable clock to avoid real sleeps.
This preserves the legacy API used by the rest of the package and tests.
"""

def __init__(
self,
default_limits: GasLimits = GasLimits(),
clock: Callable[[], float] = time.time,
clock=time.time,
):
self._default_limits = default_limits
self._clock = clock
self._lock = threading.Lock()
self._ledgers: Dict[str, _WalletLedger] = defaultdict(_WalletLedger)
self._limits: Dict[str, GasLimits] = {}

# ---- configuration -------------------------------------------------

def set_limits(self, wallet: str, limits: GasLimits) -> None:
"""Override the default limits for ``wallet``."""
with self._lock:
self._limits[wallet] = limits

def limits_for(self, wallet: str) -> GasLimits:
return self._limits.get(wallet, self._default_limits)

# ---- enforcement ---------------------------------------------------

def can_spend(self, wallet: str, estimated_gas: int) -> bool:
"""Return ``True`` if ``estimated_gas`` fits within every active window."""
if estimated_gas < 0:
raise ValueError("estimated_gas must be non-negative")

with self._lock:
ledger = self._ledgers[wallet]
self._evict_locked(ledger)
limits = self.limits_for(wallet)

if ledger.paused:
return False
if limits.per_hour is not None and ledger.sum_hour + estimated_gas > limits.per_hour:
return False
if limits.per_day is not None and ledger.sum_day + estimated_gas > limits.per_day:
return False
return True

def check(self, wallet: str, estimated_gas: int) -> None:
"""Raise :class:`BudgetExhausted` if ``estimated_gas`` cannot be spent."""
if not self.can_spend(wallet, estimated_gas):
raise BudgetExhausted(self.status(wallet))

def record(self, wallet: str, gas_used: int) -> BudgetStatus:
"""Record a post-confirmation gas spend and return the new status.

Auto-pauses the wallet if a limit is crossed after this record.
"""
if gas_used < 0:
raise ValueError("gas_used must be non-negative")

with self._lock:
ledger = self._ledgers[wallet]
self._evict_locked(ledger)

now = self._clock()
ledger.events.append((now, gas_used))
ledger.sum_hour += gas_used
ledger.sum_day += gas_used

limits = self.limits_for(wallet)
if (
limits.per_hour is not None and ledger.sum_hour >= limits.per_hour
) or (
limits.per_day is not None and ledger.sum_day >= limits.per_day
):
ledger.paused = True

return self._status_locked(wallet, ledger, limits)

# ---- introspection -------------------------------------------------

def status(self, wallet: str) -> BudgetStatus:
with self._lock:
ledger = self._ledgers[wallet]
self._evict_locked(ledger)
return self._status_locked(wallet, ledger, self.limits_for(wallet))

def resume(self, wallet: str) -> None:
"""Manually unpause a wallet. The operator is responsible for ensuring
the underlying budget has freed up — this does not reset counters."""
with self._lock:
self._ledgers[wallet].paused = False

def reset(self, wallet: str) -> None:
"""Clear all recorded spend for ``wallet`` (e.g. after a new funding round)."""
with self._lock:
self._ledgers[wallet] = _WalletLedger()

# ---- internals -----------------------------------------------------

def _evict_locked(self, ledger: _WalletLedger) -> None:
"""Drop events that have aged out of both windows and refresh sums."""
now = self._clock()
day_cutoff = now - SECONDS_PER_DAY
hour_cutoff = now - SECONDS_PER_HOUR

# Evict from the daily window (which also removes from hourly).
while ledger.events and ledger.events[0][0] <= day_cutoff:
ts, gas = ledger.events.popleft()
ledger.sum_day -= gas
if ts > hour_cutoff:
# Shouldn't happen — hour window is a subset of day — but keep
# sums consistent defensively.
ledger.sum_hour -= gas

# Rebuild sum_hour from events (cheap: bounded by day window size).
ledger.sum_hour = sum(gas for ts, gas in ledger.events if ts > hour_cutoff)
super().__init__(default_limits=default_limits, clock=clock, mode="rolling", scope="per-wallet")

# Auto-unpause if limits have freed up again.
if ledger.paused:
limits_ok_hour = True
limits_ok_day = True
# We don't know wallet limits here; caller re-checks before spending.
# We keep paused sticky until explicit resume() or a fresh record()
# re-evaluates. See docstring on resume().
del limits_ok_hour, limits_ok_day

def _status_locked(
self, wallet: str, ledger: _WalletLedger, limits: GasLimits
) -> BudgetStatus:
return BudgetStatus(
wallet=wallet,
limits=limits,
spent_last_hour=ledger.sum_hour,
spent_last_day=ledger.sum_day,
paused=ledger.paused,
)
# Backward-friendly alias used in a few docs.
GasBudget = GasBudgetTracker
Loading
Loading