The on-chain read half of SpendGuard. Zero custody. Zero writes. Zero signing.
policy-view-helper is the smart-contract half of the SpendGuard ecosystem: a minimal, read-only Soroban contract that gives the SpendGuard indexer a single clean, versioned interface for querying an OpenZeppelin stellar-accounts smart account's declared spending_limit policy state โ no hand-decoding of nested ledger entries, no stale caches, no wall-clock guesswork.
It never holds funds, never writes state, and never sits in the payment path โ it only answers questions about public on-chain policy state.
policy-view-helper is |
policy-view-helper never |
|---|---|
| โ A read-only on-chain query wrapper | โ Holds, moves, or signs for funds |
โ
A versioned public interface (WASM_VERSION = 1) |
โ Writes storage or emits events |
| โ Two cross-contract reads per query | โ Blocks, freezes, or enforces anything |
| โ Raw ledger-accurate policy values | โ Converts ledgers to wall-clock on-chain |
If the helper is wrong or down, no funds are at risk โ the real backstop is the on-chain policy enforcement it merely reflects.
Smart accounts built on OpenZeppelin's framework declare spending-limit guardrails on-chain. But reading that state requires traversing complex nested structures (ContextRule โ installed Policy contracts โ SpendingLimitData) and the indexer would have to hand-decode multiple raw ledger entries per account per poll cycle.
policy-view-helper collapses that into one call:
- ๐ Two functions, one purpose โ a version probe and a policy-state read, nothing else.
- ๐ Cross-contract reads, zero state โ every query resolves addresses on-chain at call time; nothing is stored, cached, or remembered.
- ๐งฎ Raw on-chain values โ returns precise stroop amounts and ledger sequences; wall-clock conversion is left to the indexer, which has access to Soroban RPC close-time data.
- ๐ Versioned for indexers โ
get_versionlets the SpendGuard indexer fail loudly instead of silently mis-reading a changed API.
sequenceDiagram
autonumber
participant IX as SpendGuard Indexer / SDK
participant VH as policy-view-helper (this contract)
participant SA as OZ Smart Account
participant PL as spending_limit Policy Contract
IX->>VH: simulateContract: get_spending_limit_state(account, rule_id)
VH->>SA: get_context_rule(rule_id)
SA-->>VH: ContextRule { context_type, policies: [...] }
VH->>VH: Reject CreateContract (non-transfer) context rules
loop For each policy address in rule
VH->>PL: get_spending_limit_data(rule_id, account)
alt Policy returns valid SpendingLimitData
PL-->>VH: SpendingLimitData { limit, period, history, spent }
VH-->>IX: SpendingLimitView (raw ledger-accurate values)
end
end
Note over VH: No policy matched โ PolicyViewError
| Function | Signature | Returns |
|---|---|---|
get_version |
() -> u32 |
1 โ WASM version marker for indexer compatibility checks |
get_spending_limit_state |
(account: Address, context_rule_id: u32) |
Result<SpendingLimitView, PolicyViewError> |
#[contracttype]
pub struct SpendingLimitView {
/// Maximum amount (in stroops, 1 XLM = 10^7 stroops) in the rolling window.
pub cap: i128,
/// Rolling window length in ledgers (~5 s per ledger on Stellar).
pub window_ledgers: u32,
/// Total amount spent so far in the current rolling window.
pub spent_in_window: i128,
/// Ledger sequence of the earliest retained spending entry (0 = no history).
pub window_started_ledger: u32,
}| Code | Error | Meaning |
|---|---|---|
1 |
NoSpendingLimitPolicyInstalled |
No policy on the rule answered get_spending_limit_data |
2 |
AccountNotFound |
The smart account rejected the query (rule missing, wrong address) |
3 |
UnsupportedContextType |
The context rule targets a non-transfer context, where spending_limit never applies |
SpendGuard's whole design is built on a single principle: the least powerful tool that solves the problem. This contract is the extreme version of that.
- ๐ Read-only by construction โ zero storage writes, zero state mutations, zero events emitted (verifiable by reading the source)
- ๐ Zero custody โ never holds, moves, or signs for funds
- ๐ Public data only โ every input and output is public on-chain state; no secrets anywhere in the execution path
- ๐งฎ No floats, no
unwrapin production โi128integer math throughout, all errors propagated explicitly - ๐ See SECURITY.md for the full scope, audit status, and disclosure process
git clone https://github.com/Spendguard/spendguard-contract.git
cd spendguard-contract
stellar contract build # โ target/wasm32v1-none/release/policy_view_helper.wasm
cargo test --workspace # 9 tests: 6 behavior + 3 OZ serialization-compatRequirements: Rust stable (pinned in rust-toolchain.toml with the wasm32v1-none target and rustfmt/clippy/rust-src components) and the Soroban CLI.
| Network | Contract ID | Status |
|---|---|---|
| Stellar Testnet | CCAM4NRAUB6SO3XLL2SRZQEHSOUYQGDKGNPCUIQ5KKI2S6QKWC2VN6NX |
Active |
This is the ID the SpendGuard app reads via POLICY_VIEW_HELPER_CONTRACT_ID.
spendguard-contract/
โโโ contracts/policy-view-helper/ # The single Soroban contract
โ โโโ src/
โ โ โโโ lib.rs # #[contract] entry point + public interface
โ โ โโโ read.rs # Cross-contract query implementation
โ โ โโโ types.rs # SpendingLimitView, errors, OZ mirror types
โ โ โโโ test.rs # 9 tests incl. real-OZ serialization round-trips
โ โโโ test_snapshots/ # Soroban snapshot fixtures
โโโ .github/workflows/ci.yml # Test & Clippy on every push/PR to master
โโโ rust-toolchain.toml # Pinned Rust stable + wasm32v1-none target
โโโ CONTRIBUTING.md ยท SECURITY.md ยท drips.json ยท LICENSE
โโโ Cargo.toml # Workspace root manifest
| Component | Version / Target |
|---|---|
| Rust Edition | 2021 |
| Soroban SDK | 26.1.0 (experimental_spec_shaking_v2) |
OpenZeppelin stellar-accounts |
=0.7.2 (pinned dev-dependency) |
| Soroban Test Helpers | 0.2.4 |
| Compilation Target | wasm32v1-none |
| Release profile | opt-level = "z", lto = true, overflow-checks = true, panic = "abort" |
The stellar-accounts dependency is pinned and only used in tests โ the production contract carries its own mirror types and verifies they still decode real OZ data via oz_serialization_compat round-trip tests.
| Command | Description |
|---|---|
stellar contract build |
Builds the optimized WASM to target/wasm32v1-none/release/policy_view_helper.wasm |
cargo test --workspace |
Runs the 9-test suite (positive, negative, and OZ compatibility cases) |
cargo clippy --workspace --all-targets -- -D warnings |
Lints with warnings denied (CI gate) |
CI (GitHub Actions) runs Test & Clippy on every push and pull request to master.
spending_limitpolicy type only โ the helper reads thespending_limitpolicy installed on a transfer-context rule.simple_thresholdandweighted_thresholdpolicies are not queried (yet), and OZ scopesspending_limitto transfer contexts by design โ an upstream constraint, not a SpendGuard one.- Ledger windows, not wall-clock โ all window fields are ledger sequences; the indexer is responsible for converting them to timestamps using Soroban RPC close-time data.
- Mirror types must track OpenZeppelin โ the production mirror types are verified against
stellar-accounts0.7.2; an OZ upgrade requires re-running the compatibility tests before the pin moves. - No events emitted โ indexing still relies on the OZ contracts' own
transfer/spending_limit_enforcedevents; this contract is query-only.
- ๐งฉ Support additional OZ policy types (
simple_threshold,weighted_threshold) - ๐ Mainnet deployment alongside the app's live-testnet flow
- โก Batch / multi-rule convenience queries for the indexer
- ๐ Independent audit of the narrow cross-contract read path
Contributors are welcome โ and rewarded. This contract is a great Drips-wave contribution target: ~300 lines of Rust, a pinned dependency, and a test suite that encodes the entire contract surface.
- ๐ฏ Start with the issues labelled good first issue
- ๐ Read CONTRIBUTING.md โ Conventional Commits, no
unwrapoutside tests, positive + negative test required per function - ๐ Security-sensitive bugs go through SECURITY.md
- ๐งช
cargo test --workspacebefore opening a PR
MIT ยฉ SpendGuard Contributors