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 23 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.
set_service_price(service_id, 0) and remove_service_price(service_id) both
make get_service_price(service_id) and compute_billing(agent, service_id)
read back 0, but they are not identical:
set_service_price(_, 0)stores an explicit zero price inDataKey::ServicePriceremove_service_price(_)removes the price slot entirely, reclaiming storageremove_service_price(_)also emitsprice_rmv(service_id)so indexers can distinguish a removal from a write-to-zero
Use removal when a service is being retired or reset to a truly unpriced state;
use 0 only when you intentionally want to keep an explicit free-service price
record on chain.
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.
Agents can now be funded with prepaid credit balances via credit_agent(agent, amount).
The balance is stored in stroops and is drawn down by settle as usage is
settled. record_usage rejects a write when the prepaid balance cannot cover
that service's projected bill for the updated usage total, so prepaid accounts
can enforce solvency before work is accepted.
get_agent_credit(agent) reads the current balance. A successful settlement
emits credit_debited(agent, debit, new_balance) so off-chain systems can
track balance consumption in real time.
In addition to lifetime request counters, the escrow contract tracks lifetime settled value in stroops:
get_total_settled_by_agent(agent)returns the cross-service amount ever settled for one agent.get_total_settled_all_time()returns the protocol-wide amount ever settled.
These counters are updated by settlement drains, use saturating arithmetic, and
are never reset or decremented by later settle calls. They default to 0
before the first billable settlement, so dashboards can read them without
special-casing new agents or fresh deployments.
record_usage enforces an inclusive per-call floor and ceiling on the requests
argument via two admin settings:
set_min_requests_per_call(min)— floor;record_usagerejects values below this withRequestsBelowMinPerCall(#9). Defaults to0(no floor).set_max_requests_per_call(max)— ceiling;record_usagerejects values above this withRequestsExceedsMaxPerCall(#8). Defaults tou32::MAX(no cap).
Both setters enforce the invariant min ≤ max at write time:
set_min_requests_per_call(min)rejects aminthat exceeds the currently-storedMaxRequestsPerCall(defaulting tou32::MAX).set_max_requests_per_call(max)rejects amaxthat falls below the currently-storedMinRequestsPerCall(defaulting to0).
A contradictory range (min > max) would make every record_usage call
unsatisfiable — any supplied value would trip either #8 or #9 — silently
bricking metering until an operator noticed and corrected the configuration.
The cross-bound check prevents this state from ever being stored.
min == max is explicitly allowed and enforces an exact per-call request
count: every record_usage call must supply precisely that many requests.
This is useful for forcing callers to bundle a fixed number of requests per
write to amortise per-transaction ledger costs.
When both bounds need to change, set the ceiling first and then the floor:
set_max_requests_per_call(new_max); // step 1: raise or set ceiling
set_min_requests_per_call(new_min); // step 2: set floor (checked against new_max)
Setting the floor first risks a transient InvalidRequestBounds rejection if
the new floor temporarily exceeds the old (not-yet-updated) ceiling.
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.
reset_rate_window(env, agent) is an admin-gated, pause-respecting entrypoint
that clears the per-agent RateWindow storage slot, so the next record_usage
call opens a fresh window with a zero count. This lets an operator lift a
throttle immediately — for example, when a misconfigured cap has been raised,
or a legitimate burst the operator wants to forgive.
Idempotent: resetting an agent that has no stored rate window is a no-op. The configured cap and window length are not changed — only the agent's accumulated count for the current window is cleared.
Emits a rate_rst(agent) event so the override is auditable.
To inspect an agent's current rate-limit state without triggering a new request:
get_rate_window(env, agent)— returns(window_start, count)(the raw stored state).get_remaining_in_window(env, agent)— returns the remaining capacity as au32, accounting for window expiration. Returns the full cap if the window has expired or the rate limiter is disabled.
Both are pure reads and do not mutate state or roll the window forward.
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.
get_billing_summary(agent, service_id) returns a BillingSummary struct
containing usage, price, and the computed bill for an (agent, service_id)
pair in a single round-trip. This is a pure read — no require_auth, no pause
gate — that provides a coherent snapshot from the same ledger state, preventing
race conditions where separate reads could return inconsistent values (e.g., a
usage value from one ledger and a price from another).
The struct fields and their defaults when the storage slot is absent:
| Field | Type | Default | Description |
|---|---|---|---|
requests |
u32 |
0 |
Accumulated request count for the pair |
price_stroops |
i128 |
0 |
Per-request price in stroops |
billed |
i128 |
0 |
Computed bill: requests * price_stroops with saturating arithmetic |
last_settlement |
Option<u64> |
None |
Ledger timestamp of the last settle call, or None if never settled |
The billed field uses the same saturating arithmetic as compute_billing:
- When a tier schedule is configured, the bill uses the tier-aware computation
- Otherwise, the bill is
requests * price_stroopswith saturation ati128::MAX - For unknown pairs (no usage, no price), all numeric fields default to zero
This combined read is particularly useful for off-chain dashboards that need to
render a single agent-service row, as it replaces three separate host invocations
(get_usage, get_service_price, and compute_billing) with one atomic read.
Every rate-limit and per-call bound setter publishes a cfg_set event
after the storage write succeeds, so indexers and security monitors can
observe policy changes on-chain instead of only inferring them from
storage diffs. All six setters share one decodable schema: topic
(symbol_short!("cfg_set"),), data (name: Symbol, value).
| Setter | name |
value type |
|---|---|---|
set_max_requests_per_call |
max_call |
u32 |
set_min_requests_per_call |
min_call |
u32 |
set_max_requests_per_window |
max_win |
u32 |
set_rate_window_seconds |
win_sec |
u64 |
set_allowlist_enabled |
allowlist |
bool |
set_require_service_registration |
req_reg |
bool |
A single subscriber can decode every config event with one schema:
match on the first tuple element (Symbol) to route to the right
handler, then decode the second element as u32, u64, or bool
per the table above.
Notes:
- Events fire even when the new value equals the current stored value — setters are not short-circuited by an equality check, so every call is observable.
- This is purely additive:
price_set,paused, and all other existing event payloads are unchanged. - Events expose no more information than was already readable via the
corresponding getter (
get_max_requests_per_call,is_allowlist_enabled, etc.) —cfg_setonly makes an existing, publicly-readable state change observable in real time.
Admins can configure a global price band [min_stroops, max_stroops]
that every subsequent set_service_price call must respect. By default the
band is unbounded (0 to i128::MAX), so existing behaviour is unchanged.
| Entrypoint | Signature | Description |
|---|---|---|
set_price_bounds |
(min_stroops: i128, max_stroops: i128) |
Admin-gated. Persist the floor and ceiling. Emits bnd_set(min, max). |
get_min_service_price |
() → i128 |
Read the floor; returns 0 if never set. |
get_max_service_price |
() → i128 |
Read the ceiling; returns i128::MAX if never set. |
After passing the existing negative-price gate and the registration/disabled
gates, set_service_price reads MinServicePrice (default 0) and
MaxServicePrice (default i128::MAX) and rejects any price outside
[floor, ceiling] with PriceOutOfBounds (#23).
A price of 0 means free service — usage is still recorded but settlement
bills nothing. The price bounds interact with this as follows:
- When
min_stroops == 0(the default), a zero price is permitted as usual. - When
min_stroops > 0, free services are explicitly forbidden:set_service_price(svc, 0)is rejected withPriceOutOfBoundsuntil the floor is lowered back to0.
This is intentional policy: a positive floor expresses that all services in
the band must have a non-zero cost. Admins who want to allow free services
alongside bounded paid services should keep min_stroops = 0.
| Code | Variant | Trigger |
|---|---|---|
#23 |
PriceOutOfBounds |
set_service_price price falls outside [floor, ceiling]. |
#24 |
InvertedPriceBand |
set_price_bounds called with min_stroops > max_stroops. |
set_price_boundsis admin-gated: a non-admin call panics with Soroban's host auth error before any storage is touched.get_min_service_price/get_max_service_priceare pure reads (no auth required), so dashboards can query the current band without signing.- An inverted band (
min > max) is rejected immediately, so the stored bounds are always a valid intervalmin ≤ max.
Soroban persistent entries have a TTL (measured in ledgers). Without periodic extension, entries that are not frequently rewritten will eventually expire and be archived off-chain, breaking reads until restored. The escrow contract manages TTL automatically for price-tier and service-metadata entries:
- Shared constants:
LEDGERS_TTL_THRESHOLD(100 800 ledgers, ~7 days) andLEDGERS_TTL_EXTEND_TO(201 600 ledgers, ~14 days) define the bump policy. - Shared helper:
bump_persistent(env, key)extends the TTL of any persistent entry whose remaining TTL is at or below the threshold. - Reads:
get_price_tiersandget_service_metadatacallbump_persistentafter reading, keeping actively-queried entries alive. - Writes:
set_price_tiers,set_service_metadata,register_service_with_metadata, andtransfer_service_ownershipcallbump_persistentafter writing. - Deletes:
remove_price_tiersandclear_service_metadatadelete the entry outright — no TTL extension is performed.
When the current TTL is above the threshold the extend_ttl call is a
host-level no-op, so there is negligible cost for frequently-accessed entries.
- 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 on blocklist | #17 AgentBlocked |
| 8 | 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.