diff --git a/README.md b/README.md index 38222fd..3b77297 100644 --- a/README.md +++ b/README.md @@ -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 | ✅ | ✅ | ❌ | ❌ | diff --git a/docs/competitive-survey.md b/docs/competitive-survey.md new file mode 100644 index 0000000..3890a64 --- /dev/null +++ b/docs/competitive-survey.md @@ -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. diff --git a/docs/gas-manager-unification.md b/docs/gas-manager-unification.md new file mode 100644 index 0000000..763b96c --- /dev/null +++ b/docs/gas-manager-unification.md @@ -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. diff --git a/switchboard/gas_budget.py b/switchboard/gas_budget.py index 64e741d..8b1474e 100644 --- a/switchboard/gas_budget.py +++ b/switchboard/gas_budget.py @@ -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 diff --git a/switchboard/gas_manager.py b/switchboard/gas_manager.py new file mode 100644 index 0000000..77dd22c --- /dev/null +++ b/switchboard/gas_manager.py @@ -0,0 +1,319 @@ +"""Unified gas management for Switchboard. + +This module consolidates the two legacy policies that existed in +``gas_budget.py`` and ``gas_tracker.py``: + +- rolling-window, per-wallet budgets +- calendar-reset, global budgets + +The public API is intentionally small and wrapper-friendly so the old imports +continue to work while the implementation lives in one place. +""" + +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 spend request would exceed the configured budget.""" + + +@dataclass(frozen=True) +class GasLimits: + """Gas ceilings for a wallet or global budget. + + ``None`` disables the corresponding limit. + """ + + per_hour: Optional[int] = None + per_day: Optional[int] = None + + +@dataclass +class BudgetStatus: + """Snapshot of current spend and pause state.""" + + 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 _RollingLedger: + events: Deque = field(default_factory=deque) + sum_hour: int = 0 + sum_day: int = 0 + paused: bool = False + + +@dataclass +class _CalendarLedger: + spent_hour: int = 0 + spent_day: int = 0 + last_reset_hour: float = 0.0 + last_reset_day: float = 0.0 + paused: bool = False + + +class GasManager: + """Unified gas manager. + + Parameters + ---------- + default_limits: + Default limits applied when a wallet has no override. + clock: + Injected clock used for deterministic tests. + mode: + ``"rolling"`` or ``"calendar"``. + scope: + ``"per-wallet"`` or ``"global"``. + """ + + def __init__( + self, + default_limits: GasLimits = GasLimits(), + clock: Callable[[], float] = time.time, + mode: str = "rolling", + scope: str = "per-wallet", + ): + if mode not in {"rolling", "calendar"}: + raise ValueError("mode must be 'rolling' or 'calendar'") + if scope not in {"per-wallet", "global"}: + raise ValueError("scope must be 'per-wallet' or 'global'") + + self._default_limits = default_limits + self._clock = clock + self._mode = mode + self._scope = scope + self._lock = threading.Lock() + self._limits: Dict[str, GasLimits] = {} + self._ledgers: Dict[str, object] = defaultdict(self._new_ledger) + self._global_key = "__global__" + + # ------------------------------------------------------------------ config + + def _new_ledger(self): + if self._mode == "rolling": + return _RollingLedger() + now = self._clock() + return _CalendarLedger( + spent_hour=0, + spent_day=0, + last_reset_hour=now, + last_reset_day=self._align_day(now), + paused=False, + ) + + def _align_day(self, now: float) -> float: + return (now // SECONDS_PER_DAY) * SECONDS_PER_DAY + + def _key(self, wallet: Optional[str]) -> str: + if self._scope == "global": + return self._global_key + if wallet is None: + raise ValueError("wallet is required in per-wallet mode") + return wallet + + def set_limits(self, wallet: str, limits: GasLimits) -> None: + with self._lock: + self._limits[wallet] = limits + + def limits_for(self, wallet: Optional[str] = None) -> GasLimits: + if self._scope == "global": + return self._limits.get(self._global_key, self._default_limits) + if wallet is None: + raise ValueError("wallet is required in per-wallet mode") + return self._limits.get(wallet, self._default_limits) + + # ----------------------------------------------------------------- internals + + def _evict_rolling(self, ledger: _RollingLedger) -> None: + now = self._clock() + day_cutoff = now - SECONDS_PER_DAY + hour_cutoff = now - SECONDS_PER_HOUR + + while ledger.events and ledger.events[0][0] <= day_cutoff: + ts, gas = ledger.events.popleft() + ledger.sum_day -= gas + if ts > hour_cutoff: + ledger.sum_hour -= gas + + ledger.sum_hour = sum(gas for ts, gas in ledger.events if ts > hour_cutoff) + + def _evict_calendar(self, ledger: _CalendarLedger) -> None: + now = self._clock() + if now - ledger.last_reset_hour >= SECONDS_PER_HOUR: + ledger.spent_hour = 0 + ledger.last_reset_hour = now - (now % SECONDS_PER_HOUR) + + current_day = self._align_day(now) + if current_day > ledger.last_reset_day: + ledger.spent_day = 0 + ledger.last_reset_day = current_day + + # In calendar mode the pause state is always derived from current spend. + limits = self._active_limits_for_current_scope() + ledger.paused = self._paused_from_totals( + spent_hour=ledger.spent_hour, + spent_day=ledger.spent_day, + limits=limits, + ) + + def _active_limits_for_current_scope(self, wallet: Optional[str] = None) -> GasLimits: + return self.limits_for(wallet) + + @staticmethod + def _paused_from_totals( + spent_hour: int, spent_day: int, limits: GasLimits + ) -> bool: + hour_ok = limits.per_hour is None or spent_hour < limits.per_hour + day_ok = limits.per_day is None or spent_day < limits.per_day + return not (hour_ok and day_ok) + + def _status_locked( + self, wallet: str, ledger: object, limits: GasLimits + ) -> BudgetStatus: + if self._mode == "rolling": + assert isinstance(ledger, _RollingLedger) + return BudgetStatus( + wallet=wallet, + limits=limits, + spent_last_hour=ledger.sum_hour, + spent_last_day=ledger.sum_day, + paused=ledger.paused, + ) + assert isinstance(ledger, _CalendarLedger) + return BudgetStatus( + wallet=wallet, + limits=limits, + spent_last_hour=ledger.spent_hour, + spent_last_day=ledger.spent_day, + paused=ledger.paused, + ) + + # ---------------------------------------------------------------- enforcement + + def can_spend(self, wallet: Optional[str], estimated_gas: int) -> bool: + if estimated_gas < 0: + raise ValueError("estimated_gas must be non-negative") + + with self._lock: + key = self._key(wallet) + ledger = self._ledgers[key] + limits = self.limits_for(wallet if self._scope == "per-wallet" else None) + + if self._mode == "rolling": + assert isinstance(ledger, _RollingLedger) + self._evict_rolling(ledger) + 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 + + assert isinstance(ledger, _CalendarLedger) + self._evict_calendar(ledger) + if ledger.paused: + return False + if limits.per_hour is not None and ledger.spent_hour + estimated_gas > limits.per_hour: + return False + if limits.per_day is not None and ledger.spent_day + estimated_gas > limits.per_day: + return False + return True + + def check(self, wallet: Optional[str], estimated_gas: int) -> None: + if not self.can_spend(wallet, estimated_gas): + raise BudgetExhausted(self.status(wallet)) + + def record(self, wallet: Optional[str], gas_used: int) -> BudgetStatus: + if gas_used < 0: + raise ValueError("gas_used must be non-negative") + + with self._lock: + key = self._key(wallet) + ledger = self._ledgers[key] + limits = self.limits_for(wallet if self._scope == "per-wallet" else None) + + if self._mode == "rolling": + assert isinstance(ledger, _RollingLedger) + self._evict_rolling(ledger) + now = self._clock() + ledger.events.append((now, gas_used)) + ledger.sum_hour += gas_used + ledger.sum_day += gas_used + ledger.paused = self._paused_from_totals( + spent_hour=ledger.sum_hour, + spent_day=ledger.sum_day, + limits=limits, + ) + return self._status_locked(key, ledger, limits) + + assert isinstance(ledger, _CalendarLedger) + self._evict_calendar(ledger) + ledger.spent_hour += gas_used + ledger.spent_day += gas_used + ledger.paused = self._paused_from_totals( + spent_hour=ledger.spent_hour, + spent_day=ledger.spent_day, + limits=limits, + ) + return self._status_locked(key, ledger, limits) + + # ---------------------------------------------------------------- introspect + + def status(self, wallet: Optional[str]) -> BudgetStatus: + with self._lock: + key = self._key(wallet) + ledger = self._ledgers[key] + limits = self.limits_for(wallet if self._scope == "per-wallet" else None) + if self._mode == "rolling": + self._evict_rolling(ledger) # type: ignore[arg-type] + else: + self._evict_calendar(ledger) # type: ignore[arg-type] + return self._status_locked(key, ledger, limits) + + def resume(self, wallet: Optional[str]) -> None: + with self._lock: + key = self._key(wallet) + ledger = self._ledgers[key] + if self._mode == "rolling": + assert isinstance(ledger, _RollingLedger) + ledger.paused = False + else: + assert isinstance(ledger, _CalendarLedger) + ledger.paused = False + + def reset(self, wallet: Optional[str]) -> None: + with self._lock: + key = self._key(wallet) + self._ledgers[key] = self._new_ledger() + + # Convenience for wrappers / tests. + def spent(self, wallet: Optional[str]) -> tuple[int, int]: + s = self.status(wallet) + return s.spent_last_hour, s.spent_last_day diff --git a/switchboard/gas_tracker.py b/switchboard/gas_tracker.py index 72976c1..1300044 100644 --- a/switchboard/gas_tracker.py +++ b/switchboard/gas_tracker.py @@ -1,197 +1,122 @@ -import time +"""Legacy singleton gas tracker. + +Compatibility wrapper around :mod:`switchboard.gas_manager` that preserves the +old calendar-reset, global-budget behavior and the legacy method names used by +existing callers and tests. +""" + +from __future__ import annotations + import threading -from typing import Optional, Callable -import datetime +import time +from typing import Callable, Optional + +from .gas_manager import GasLimits, GasManager, SECONDS_PER_DAY, SECONDS_PER_HOUR + class GasBudgetExhaustedError(Exception): - """ - Custom exception raised for informational purposes when gas budget is exhausted. - The `GasTracker` itself will pause subsequent `can_send_transaction` calls, - but it's up to the caller to handle this immediate exhaustion event. - """ - pass + """Raised when a transaction would exceed the current gas budget.""" + class GasTracker: + """Singleton global gas tracker with calendar resets. + + ``0`` limits mean "no limit" to preserve the legacy semantics. """ - Tracks cumulative gas spent and enforces configurable hourly and daily limits. - If a limit is exceeded, the tracker's `is_paused()` method will return True, - and `can_send_transaction()` will return False until the budget resets. - This class is implemented as a singleton to ensure a single, consistent - gas budget is managed across the application. - """ - _instance: Optional['GasTracker'] = None - _lock = threading.Lock() # For singleton instantiation + + _instance: Optional["GasTracker"] = None + _lock = time.thread_time if hasattr(time, "thread_time") else None def __new__(cls, *args, **kwargs): - """ - Ensures that only one instance of GasTracker is created (singleton pattern). - """ if cls._instance is None: - with cls._lock: - if cls._instance is None: - cls._instance = super().__new__(cls) + cls._instance = super().__new__(cls) return cls._instance def __init__(self, hourly_limit: int = 0, daily_limit: int = 0, time_source: Callable[[], float] = time.time): - """ - Initializes the GasTracker. - - Args: - hourly_limit (int): Maximum gas allowed per hour. 0 means no hourly limit. - daily_limit (int): Maximum gas allowed per day. 0 means no daily limit. - time_source (Callable[[], float]): A function that returns the current time - as a float timestamp. Defaults to `time.time`. - """ - if not hasattr(self, '_initialized'): - self._hourly_limit = hourly_limit - self._daily_limit = daily_limit - self._spent_gas_hourly = 0 - self._spent_gas_daily = 0 - self._time_source = time_source # Callable to get current timestamp - self._last_reset_hour = self._time_source() - self._last_reset_day = self._time_source() - self._is_paused = False # True if any limit is currently exceeded - self._tracker_lock = threading.Lock() # For internal state changes - self._initialized = True - - self._align_last_reset_day() # Ensure daily timestamp is at start of day - self._update_pause_state() # Check initial limits based on current spent (if any) + if getattr(self, "_initialized", False): + return + + self._time_source = time_source + self._manager = GasManager( + default_limits=GasLimits( + per_hour=hourly_limit or None, + per_day=daily_limit or None, + ), + clock=self._time_source, + mode="calendar", + scope="global", + ) + self._hourly_limit = hourly_limit + self._daily_limit = daily_limit + now = self._time_source() + self._last_reset_hour = now + self._last_reset_day = now + self._spent_gas_hourly = 0 + self._spent_gas_daily = 0 + self._is_paused = False + self._tracker_lock = threading.Lock() + self._initialized = True + self._sync_from_manager() + + # ----------------------------------------------------------------- helpers + + def _limits_to_manager(self) -> GasLimits: + return GasLimits( + per_hour=self._hourly_limit or None, + per_day=self._daily_limit or None, + ) + + def _sync_from_manager(self) -> None: + status = self._manager.status(None) + self._spent_gas_hourly = status.spent_last_hour + self._spent_gas_daily = status.spent_last_day + self._is_paused = status.paused + + # ---------------------------------------------------------------- methods def _align_last_reset_day(self): - """ - Aligns `_last_reset_day` to the start of the current UTC day. - This ensures daily limits reset consistently at midnight UTC. - """ - current_datetime_utc = datetime.datetime.fromtimestamp(self._time_source(), tz=datetime.timezone.utc) - start_of_day_utc = current_datetime_utc.replace(hour=0, minute=0, second=0, microsecond=0) - self._last_reset_day = start_of_day_utc.timestamp() - - def _update_pause_state(self): - """ - Internal method to update the `_is_paused` flag based on current spending and limits. - Sets `_is_paused` to True if any limit is currently exceeded. - """ - can_proceed_hourly = (self._hourly_limit == 0) or (self._spent_gas_hourly < self._hourly_limit) - can_proceed_daily = (self._daily_limit == 0) or (self._spent_gas_daily < self._daily_limit) - self._is_paused = not (can_proceed_hourly and can_proceed_daily) - - def _reset_if_needed(self): - """ - Checks if an hour or day has passed since the last reset and resets counters. - Also updates the pause state. This method should be called before any - interaction with the tracker's state (e.g., recording gas, checking limits). - """ now = self._time_source() - - # Hourly reset - # Aligns _last_reset_hour to the start of the current full hour. - if now - self._last_reset_hour >= 3600: # 1 hour - self._spent_gas_hourly = 0 - self._last_reset_hour = now - (now % 3600) # Align to start of current hour - - # Daily reset - current_day_dt = datetime.datetime.fromtimestamp(now, tz=datetime.timezone.utc).date() - last_reset_day_dt = datetime.datetime.fromtimestamp(self._last_reset_day, tz=datetime.timezone.utc).date() - - if current_day_dt > last_reset_day_dt: - self._spent_gas_daily = 0 - self._align_last_reset_day() + self._last_reset_day = now - (now % SECONDS_PER_DAY) - self._update_pause_state() + def _reset_if_needed(self): + # The unified GasManager handles resets internally. Keep this method for + # legacy callers and synchronize the mirrored attributes. + self._sync_from_manager() def record_gas_usage(self, gas_used: int): - """ - Records actual gas used for a confirmed transaction. - Updates internal spending totals and the pause state. - This method should be called after a transaction has successfully - completed and its actual gas usage is known. - - Args: - gas_used (int): The amount of gas used by the transaction. - """ with self._tracker_lock: - self._reset_if_needed() # Always check for resets before recording - - self._spent_gas_hourly += gas_used - self._spent_gas_daily += gas_used - - self._update_pause_state() # Recalculate pause state after adding gas + self._manager.record(None, gas_used) + self._sync_from_manager() def can_send_transaction(self, estimated_gas_cost: int) -> bool: - """ - Checks if a transaction with the given estimated gas cost can be sent - without exceeding current limits. This method should be called - before attempting to send a transaction. - - Args: - estimated_gas_cost (int): The estimated gas cost for the transaction. - - Returns: - bool: True if the transaction can be sent, False otherwise. - """ with self._tracker_lock: - self._reset_if_needed() # Always check for resets before deciding - - if self._is_paused: - return False - - if self._hourly_limit > 0 and (self._spent_gas_hourly + estimated_gas_cost) > self._hourly_limit: - return False - - if self._daily_limit > 0 and (self._spent_gas_daily + estimated_gas_cost) > self._daily_limit: - return False - - return True + allowed = self._manager.can_spend(None, estimated_gas_cost) + self._sync_from_manager() + return allowed def is_paused(self) -> bool: - """ - Returns True if the tracker is currently paused due to budget exhaustion. - This flag is updated automatically on resets and when gas is recorded. - - Returns: - bool: True if paused, False otherwise. - """ with self._tracker_lock: - self._reset_if_needed() # Ensure current state is up-to-date + self._sync_from_manager() return self._is_paused - + def set_limits(self, hourly_limit: int = 0, daily_limit: int = 0): - """ - Sets new hourly and daily gas limits. - Updates the pause state based on new limits and current spending. - - Args: - hourly_limit (int): The new hourly gas limit. - daily_limit (int): The new daily gas limit. - """ with self._tracker_lock: self._hourly_limit = hourly_limit self._daily_limit = daily_limit - self._reset_if_needed() # Apply potential resets based on time - self._update_pause_state() # Update pause state considering new limits + self._manager._default_limits = self._limits_to_manager() + self._sync_from_manager() def get_current_spent(self) -> tuple[int, int]: - """ - Returns current (hourly_spent, daily_spent) gas totals. - - Returns: - tuple[int, int]: A tuple containing current hourly spent gas and daily spent gas. - """ with self._tracker_lock: - self._reset_if_needed() + self._sync_from_manager() return self._spent_gas_hourly, self._spent_gas_daily def reset_all(self): - """ - Resets all internal counters to zero, unpauses the tracker, - and sets reset timestamps to the current time. - Useful for testing or complete reconfiguration. - """ with self._tracker_lock: - self._spent_gas_hourly = 0 - self._spent_gas_daily = 0 - self._last_reset_hour = self._time_source() + self._manager.reset(None) + now = self._time_source() + self._last_reset_hour = now self._align_last_reset_day() - self._is_paused = False # Explicitly unpause - self._update_pause_state() # Re-evaluate pause state (should be unpaused) + self._is_paused = False + self._sync_from_manager() diff --git a/tests/test_gas_manager.py b/tests/test_gas_manager.py new file mode 100644 index 0000000..e4cb358 --- /dev/null +++ b/tests/test_gas_manager.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from switchboard.gas_manager import GasLimits, GasManager, SECONDS_PER_DAY, SECONDS_PER_HOUR + + +class FakeClock: + def __init__(self, start: float = 1_700_000_000.0): + self._t = start + + def __call__(self) -> float: + return self._t + + def advance(self, seconds: float) -> None: + self._t += seconds + + +def test_rolling_per_wallet_isolated_budget(): + clock = FakeClock() + gm = GasManager(default_limits=GasLimits(per_hour=100, per_day=300), clock=clock, mode="rolling", scope="per-wallet") + + assert gm.can_spend("a", 50) + gm.record("a", 50) + assert gm.can_spend("a", 50) + assert not gm.can_spend("a", 51) + assert gm.can_spend("b", 100) + + +def test_calendar_global_resets_hour_and_day(): + clock = FakeClock(start=0.0) + gm = GasManager(default_limits=GasLimits(per_hour=100, per_day=200), clock=clock, mode="calendar", scope="global") + + gm.record(None, 100) + assert not gm.can_spend(None, 1) + + clock.advance(SECONDS_PER_HOUR + 1) + assert gm.can_spend(None, 100) + gm.record(None, 50) + assert gm.spent(None) == (50, 150) + + clock.advance(SECONDS_PER_DAY + 1) + assert gm.can_spend(None, 100) + assert gm.spent(None) == (0, 0) + + +def test_check_raises_budget_exhausted(): + gm = GasManager(default_limits=GasLimits(per_hour=10), mode="rolling", scope="per-wallet") + gm.record("wallet", 10) + try: + gm.check("wallet", 1) + raised = False + except Exception as exc: + raised = True + assert exc.__class__.__name__ == "BudgetExhausted" + assert raised