Soroban smart contracts for the AgentPay protocol: escrow, usage recording, and payment settlement on Stellar.
Every push and pull request runs the following gates automatically:
| Step | Command |
|---|---|
| Formatting | cargo fmt --all -- --check |
| Linting | cargo clippy --all-targets -- -D warnings |
| Build | cargo build |
| Tests | cargo test |
| Wasm build | cargo build --target wasm32-unknown-unknown --release |
The Rust toolchain is pinned via rust-toolchain.toml (stable channel with wasm32-unknown-unknown target). Cargo registry and build artefacts are cached between runs to keep CI fast.
- escrow — Records usage and supports settlement logic for machine-to-machine payments.
- CHANGELOG — versioned history of entrypoints, events, and error codes; contribution conventions.
- EscrowError code table — full reference for all 17 error codes: trigger conditions, overloaded codes, and the entrypoints that raise each code.
A service's ServiceMetadata carries a description and an owner. The
current owner (or the admin) can reassign the owner via
transfer_service_ownership(caller, service_id, new_owner) without touching the
description. The call honours the pause gate and emits owner_chg for
indexers.
A service's metadata (description + owner) and its registration flag live in
independent storage slots. clear_service_metadata (admin-gated, idempotent)
removes only the metadata; the registration flag and per-(agent, service) usage
history are untouched.
propose_admin_transfer rejects proposing the current admin as the new admin
(panics with InvalidAdminProposal). This surfaces no-op handovers as caller
mistakes rather than silently storing a pending entry equal to the active admin.
When a metering client over-reports (e.g. double-counts a batch), the
admin can call decrement_usage(env, agent, service_id, amount) to
subtract the erroneous delta from the per-pair counter without discarding
the legitimate remainder. The decrement uses saturating arithmetic (clamps
at zero, never underflows) and emits a distinct usage_dec event so
corrections are auditable and distinguishable from record_usage and
settle.
TotalUsageByAgent and TotalRequestsAllTime are not adjusted by
decrement_usage. These counters track the raw reported figure for
analytics; correcting the per-pair balance should not retroactively distort
the lifetime signal. Off-chain billing pipelines that need the corrected
view should subtract the decrement event amount from the lifetime counter
when processing the usage_dec event.
record_usage supports an optional per-agent rate limit anchored to
env.ledger().timestamp(). It is configured by two admin settings and is
disabled by default (both default to 0):
set_max_requests_per_window(max)— maxrequestsan agent may accumulate per window (get_max_requests_per_window).set_rate_window_seconds(seconds)— the fixed window length (get_rate_window_seconds).
The limiter is active only when both are non-zero. Semantics are a
fixed window (not sliding): the window opens at an agent's first in-window
call and rolls forward as a whole once now >= window_start + window_seconds,
resetting the count. A call that would push the in-window count above the cap
is rejected with RateLimitExceeded (#15). State is per-agent
(DataKey::RateWindow(agent)), and an agent can never reset its own window
early — window_start only advances. Window arithmetic is saturating.
init stamps the current storage schema version (v2) directly, so a freshly
deployed contract reports get_schema_version() == 2 without ever running a
migration. A legacy contract deployed before this change carries the implicit v1
default and must call migrate_v1_to_v2() to reach v2; calling that migration on
a fresh v2 deploy panics with MigrationVersionMismatch.
get_contract_config() returns a ContractConfig struct containing all global
settings in a single read. It is a pure read — no require_auth, no pause gate
— and is available even before init (in which case admin is None and all
other fields carry their defaults).
The struct fields and their defaults when the storage slot is absent:
| Field | Type | Default | Individual getter |
|---|---|---|---|
paused |
bool |
false |
is_paused |
allowlist_enabled |
bool |
false |
is_allowlist_enabled |
require_service_registration |
bool |
false |
is_service_registration_required |
max_requests_per_call |
u32 |
u32::MAX (no cap) |
get_max_requests_per_call |
min_requests_per_call |
u32 |
0 (no floor) |
get_min_requests_per_call |
max_requests_per_window |
u32 |
0 (disabled) |
get_max_requests_per_window |
window_seconds |
u64 |
0 (disabled) |
get_rate_window_seconds |
schema_version |
u32 |
1 (pre-migration) |
get_schema_version |
admin |
Option<Address> |
None |
get_admin |
The per-field getters remain available and always return values identical to
the corresponding fields in this struct. ContractConfig is a convenience
snapshot only and does not replace any existing getter.
- Rust (stable, with
rustfmt) - Stellar Soroban CLI (optional, for deployment)
-
Clone the repo (or add remote and pull):
git clone <repo-url> && cd agentpay-contracts
-
Install Rust (if needed):
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh rustup component add rustfmt
-
Verify setup:
cargo fmt --all -- --check cargo build cargo test
agentpay-contracts/
├── Cargo.toml # Workspace root
├── contracts/
│ └── escrow/
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs # Contract logic
│ └── test.rs # Unit tests
└── .github/workflows/
└── ci.yml # CI: fmt, build, test
| Command | Description |
|---|---|
cargo fmt --all |
Format code |
cargo fmt --all -- --check |
Check formatting (CI) |
cargo build |
Build |
cargo test |
Run tests |
- Escrow: Build, Test, and Deploy Guide — build the release WASM, run the test suite, and deploy to testnet with the Stellar/Soroban CLI.
- Escrow: Schema Versioning & Migration — the difference between
version()andSchemaVersion, the double-run guard, and the migration runbook. - Escrow: Storage DataKey Reference — complete map of every
DataKeyvariant: stored value type, default when absent, which entrypoints write it, and whether it is drained bysettle. Explains why everything usespersistent()and the per-pair vs per-agent vs singleton key cardinality.
On push/PR to main, GitHub Actions runs:
- Format check (
cargo fmt --all -- --check) - Build (
cargo build) - Tests (
cargo test)
See CONTRIBUTING.md for the full guide, including the append-only error-code table, event conventions, and the test/coverage gate.
- Fork the repo and create a branch.
- Make changes; ensure
cargo fmt,cargo build, andcargo testpass locally. - Open a pull request. CI must pass before merge.
MIT
record_usage now requires the recorded agent to authorize the call via
agent.require_auth(). This closes a usage-forgery vector where any party
could inflate a competitor agent's counters — and therefore its bill on the
next settle — with no signature from the agent.
Auth is checked at step 0, before the pause gate:
| Step | Check | Error |
|---|---|---|
| 0 | agent.require_auth() |
Soroban host auth error |
| 1 | Contract paused | #4 ContractPaused |
| 2 | requests == 0 |
#2 RequestsMustBePositive |
| 3 | requests > max |
#8 RequestsExceedsMaxPerCall |
| 4 | requests < min |
#9 RequestsBelowMinPerCall |
| 5 | Service not registered | #7 ServiceNotRegistered |
| 6 | Service disabled | #12 ServiceDisabled |
| 7 | Agent not allowed | #10 AgentNotAllowed |
Soroban's auth tree supports sub-invocation authorization — an agent can
pre-authorize a trusted metering operator to call record_usage on its
behalf by having the operator's call appear as a sub-invocation of an
agent-signed outer call. This means existing off-chain settlement loops
can continue to operate without requiring every agent to sign each
individual record_usage call directly, as long as the operator is
authorized via the auth tree.
Migration path for existing metering operators:
- The agent signs an outer transaction that authorizes the operator's
contract call via Soroban's
authorize_as_current_contractor sub-invocation auth. - The operator's metering loop submits
record_usageas a sub-invocation within that authorized context. - Alternatively, agents can sign each
record_usagecall directly (standard path) if the metering loop supports it.