Soroban smart contracts for Astraguard, the trust and safety layer for Stellar: conditional payment escrow with dispute resolution, an insurance pool that backs verified projects with real coverage, and a registry anchor that timestamps confirmed fraud flags on-chain for public auditability.
This repository is the on-chain layer only: escrow, insurance pool, and registry anchor contracts. Off-chain verification, scoring, and any dashboard/frontend are out of scope here.
Astraguard's contracts give the Stellar ecosystem safety primitives that rarely exist on-chain: payments that only release when a condition is actually met (or an arbiter says otherwise), capital-backed insurance for projects that pass off-chain verification, and a public, immutable record of confirmed fraud that any wallet or protocol can check before trusting a counterparty.
Every privileged write — coverage decisions, fraud flags — is gated to a single oracle address; nothing in these contracts trusts off-chain data except through that one authorized account.
- Conditional Escrow: Funds lock on
createand release to the seller once the buyer confirms, the timeout passes, or a dispute is resolved - Dispute Resolution: Either the buyer or the seller can raise a dispute; the escrow's designated arbiter issues a binding decision — including a partial split
- Insurance Pool: Premiums fund a shared pool; the oracle grants coverage to verified projects within a solvency-ratio cap, and a claims committee approves or rejects payouts
- Registry Anchor: Confirmed fraud flags are timestamped from the ledger clock (not caller-supplied) and anchored permanently, with lookups by subject
- Timelocked Admin Handover: Every contract's admin can only be changed via a 48-hour propose → accept flow, so a compromised or malicious admin key can't take effect silently
- TTL-Managed Storage: Every write to persistent storage bumps that entry's TTL, and every state-changing call bumps the contract instance's TTL, so escrows/claims/flags don't silently expire off the ledger
astraguard-contracts/
├── contracts/
│ ├── shared/ # astraguard-shared: access control, timelock, TTL helpers
│ │ └── src/{access,timelock,ttl}.rs
│ ├── escrow/ # Conditional payment escrow
│ │ └── src/{lib,test}.rs
│ ├── insurance-pool/ # Premiums, coverage, claims, payouts
│ │ └── src/{lib,test}.rs
│ └── registry-anchor/ # On-chain anchor of confirmed fraud flags
│ └── src/{lib,test}.rs
├── scripts/
│ ├── build.sh # cargo build + stellar contract optimize, all contracts
│ └── deploy.sh # deploy all three to a given network, record IDs
├── deployments/
│ ├── testnet.json # contract IDs per network, filled in by deploy.sh
│ └── mainnet.json
├── tests/README.md # scope for future cross-contract integration tests
└── Cargo.toml # Rust workspace
graph TB
subgraph Users
BY[Buyer]
SE[Seller]
AR[Arbiter]
OR[Oracle — backend-controlled]
CM[Claims Committee]
VI[Victim / Claimant]
end
subgraph Contract["Smart Contracts (Soroban / Rust)"]
ESC[escrow — create / release / dispute / resolve]
POOL[insurance-pool — coverage, claims, payouts]
REG[registry-anchor — confirmed fraud flags]
SHR[shared — access control, timelock, TTL]
end
subgraph Stellar["Stellar Network"]
LEDGER[Ledger]
ASSET[Token Contract — e.g. USDC]
end
BY -->|create, funds locked| ESC
SE -->|release| ESC
BY -->|dispute| ESC
SE -->|dispute| ESC
AR -->|resolve| ESC
ESC -->|transfer| ASSET
SE -->|deposit_premium| POOL
OR -->|set_coverage| POOL
VI -->|file_claim| POOL
CM -->|approve_claim / reject_claim| POOL
POOL -->|payout| ASSET
OR -->|anchor_flag| REG
REG -->|timestamp from ledger| LEDGER
ESC -.-> SHR
POOL -.-> SHR
REG -.-> SHR
contracts/escrow: Locks buyer funds, releases to the seller on confirmation/timeout, and settles arbiter decisions on disputescontracts/insurance-pool: Collects premiums, tracks per-project coverage under a solvency-ratio cap, and runs claims through committee approval before payoutcontracts/registry-anchor: Oracle-only append of confirmed fraud flags, hashed and timestamped, queryable by subjectcontracts/shared(astraguard-shared): Common admin/oracle access control (access.rs), the timelocked admin-handover flow (timelock.rs), and TTL-bump helpers (ttl.rs) used by all three contracts
Outcomes enforced on-chain once an arbiter resolves a dispute (Decision):
| Decision | Seller | Buyer |
|---|---|---|
No dispute (release) |
100% | 0% |
ReleaseToSeller |
100% | 0% |
RefundToBuyer |
0% | 100% |
Split(seller_bps) |
seller_bps / 10000 |
remainder |
| Component | Technology | Purpose |
|---|---|---|
| Smart Contracts | Rust + Soroban SDK 26 | Escrow, insurance pool, registry anchor |
| Asset Settlement | Any Soroban token contract (e.g. Stellar USDC) | Escrow funding, premiums, insurance payouts |
| Wallet / Auth | Freighter Wallet (via the frontend repo) | Signs buyer/seller/arbiter/committee transactions |
| Off-chain Indexing | Contract events | Consumed by the backend indexer, not by this repo |
initialize(admin)— one-time setupcreate(buyer, seller, arbiter, asset, amount, timeout, conditions)— locksamountofassetfrombuyer;arbiteris aMultisigConfig(M-of-N signer set);conditionsis the hash of an off-chain terms documentrelease(escrow_id)— buyer-authorized any time, or permissionless oncetimeouthas passeddispute(escrow_id, caller, reason)—callermust be the buyer or sellerresolve(escrow_id, decision)— arbiter multisig M-of-N binding resolutionget_escrow(escrow_id)— full record and statuspropose_admin(candidate)/accept_admin()— timelocked admin handover
initialize(admin, oracle, asset, coverage_ratio_bps, committee, approval_threshold)deposit_premium(from, project, amount)— funds flowing into the poolset_coverage(project, status, amount)— oracle-only; rejected if it would push total active coverage pastcoverage_ratio_bpsof the pool's balancefile_claim(project, victim, amount, evidence_hash)— requires the project to haveActivecoverageapprove_claim(claim_id, member)— committee member vote; claim becomes payable onceapproval_thresholddistinct members have approvedreject_claim(claim_id, member)— any single committee member can reject a still-Filedclaimpayout(claim_id)— disburses anApprovedclaim from pooled capitalget_coverage(project),get_claim(claim_id),pool_balance()— queriespropose_admin(candidate)/accept_admin()
initialize(admin, oracle)anchor_flag(subject, record_hash, category)— oracle-only; timestamp is taken from the ledger clock, not a caller-supplied argument, so flags can't be backdated; returnsflag_idsupersede_flag(flag_id, reason_hash)— oracle-only; marks a previously anchored flag as retracted/corrected without deleting the original record (tamper-evidence is preserved).reason_hashis the hash of the off-chain correction document. Returns the writtenSupersession. Errors withAlreadySupersededif called twice on the same flag.get_flag(flag_id)— returns the rawFlag; does not indicate supersession on its ownget_supersession(flag_id)— returnsSome(Supersession)if the flag has been retracted,Noneif it is still liveget_flag_with_supersession(flag_id)— preferred query; returnsFlagWithSupersession { flag, supersession }so consumers can distinguish live from retracted in a single callget_flags_for_subject(subject)— returns all flag ids ever anchored againstsubject, including superseded ones; callget_flag_with_supersessionon each id and filter out those with a non-Nonesupersession to see only live flagspropose_admin(candidate)/accept_admin()
sequenceDiagram
actor Buyer
actor Seller
actor Arbiter
participant Contract as escrow
participant Asset as Token Contract
rect rgb(235, 245, 255)
Note over Buyer,Contract: Create — one signed transaction
Buyer->>Contract: create(seller, arbiter, asset, amount, timeout, conditions)
Contract->>Asset: transfer(buyer → contract, amount)
Contract-->>Buyer: escrow_id, status = Active
end
rect rgb(240, 255, 240)
Note over Seller,Contract: Happy path
Buyer->>Contract: release(escrow_id)
Contract->>Asset: transfer(contract → seller, amount)
Contract-->>Seller: status = Released
end
rect rgb(255, 235, 235)
Note over Buyer,Arbiter: Dispute path
Buyer->>Contract: dispute(escrow_id, buyer, reason)
Contract-->>Contract: status = Disputed
Arbiter->>Contract: resolve(escrow_id, decision)
Contract->>Asset: transfer(s) per decision
Contract-->>Buyer: status = Resolved
Contract-->>Seller: status = Resolved
end
EscrowStatus, enforced by contracts/escrow/src/lib.rs:
┌────────┐
│ Active │ ← create() locked funds; buyer or seller may act
└───┬────┘
│
├─────────────────────┐
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ Released │ │ Disputed │ ← buyer or seller called dispute()
└──────────┘ └────┬─────┘
(terminal) │
▼
┌──────────┐
│ Resolved │ ← arbiter called resolve() (terminal)
└──────────┘
| From | To | Trigger |
|---|---|---|
| Active | Released | Buyer calls release, or anyone after timeout |
| Active | Disputed | Buyer or seller calls dispute |
| Disputed | Resolved | Arbiter calls resolve — funds move per Decision |
ClaimStatus, enforced by contracts/insurance-pool/src/lib.rs:
Filed ──approve_claim (≥ threshold)──▶ Approved ──payout──▶ PaidOut
│
└──reject_claim (any 1 committee member)──▶ Rejected (terminal)
- Escrow Fund Isolation: Each escrow's funds and status are tracked independently — no commingling across buyers or sellers
- Arbiter/Committee-Gated Actions:
resolverequires M-of-N authorisation from the escrow's arbiter multisig;set_coverage/anchor_flagrequire M-of-N authorisation from the oracle multisig;approve_claim/reject_claimrequire committee membership — all enforced viarequire_authon each signer, not just a convention. A single compromised key cannot unilaterally resolve disputes or anchor false flags. - Atomic Settlement: Release, dispute-split, and claim payouts move funds in the same transaction as the state transition — no partial payouts
- Solvency Guard:
insurance-poolrejects new active coverage that would push total exposure pastcoverage_ratio_bpsof the pool's actual token balance - Immutable Registry: Anchored fraud flags cannot be edited or deleted once confirmed; timestamps come from the ledger clock, not the caller. If the oracle anchors a flag in error,
supersede_flagattaches aSupersessionrecord alongside the original without touching it — the original entry remains on-chain for tamper-evidence, while the supersession signals to consumers that the flag has been retracted - Timelocked Admin Handover: Admin changes require a
propose_admin→ 48h wait →accept_adminflow on every contract (astraguard-shared::timelock) - TTL Extension: Every persistent write and every state-changing call bumps storage TTL (
astraguard-shared::ttl) so live data doesn't expire off the ledger between accesses - Checks-Effects-Interactions:
escrow::release/resolveandinsurance-pool::payoutwrite settled state (status, TTL, coverage totals) before invoking the token contract'stransfer, not after — a panicking or maliciousassetcontract can't reenter to see (or exploit) staleActive/Approvedstate, and a failed transfer still rolls back the whole call in Soroban, so this costs nothing on the success path - Freeze Semantics on Payout:
insurance-pool::payoutre-checks the project's coverage status at disbursement time, not just atfile_claim. If the oracle suspends or removes coverage after a claim is filed or approved (e.g. because a fraud flag is anchored via the registry),payoutreturnsCoverageSuspendedand no funds move — the claim stays inApprovedstate for the committee to re-evaluate
Known gaps, called out rather than hidden: There is no pause/circuit-breaker function; none is implemented, so none is claimed here. TTL threshold/bump constants in ttl.rs are reasonable starting values, not tuned against a specific network's rent-fee economics yet. env.events().publish(...) is deprecated in favor of the #[contractevent] macro (soroban-sdk 26); migrating changes the on-chain event encoding, so it's left as a deliberate follow-up rather than a drive-by rename — see Roadmap.
Requires a Rust toolchain with the wasm32v1-none target (see rust-toolchain.toml) and the stellar CLI.
cargo test --workspace
./scripts/build.shscripts/build.sh builds all three contracts for wasm32v1-none and runs stellar contract optimize on each.
./scripts/deploy.sh testnet <source-account>This deploys escrow, insurance-pool, and registry-anchor and writes their contract IDs to deployments/testnet.json. It does not call initialize — each contract's constructor arguments (admin, oracle, asset, committee, ...) depend on addresses specific to your deployment.
stellar contract invoke --id <ESCROW_ID> --source <source-account> --network testnet \
-- initialize --admin <ADMIN_ADDRESS>
stellar contract invoke --id <INSURANCE_POOL_ID> --source <source-account> --network testnet \
-- initialize --admin <ADMIN_ADDRESS> \
--oracle '{"signers":["<ORACLE_SIGNER_1>","<ORACLE_SIGNER_2>"],"threshold":2}' \
--asset <TOKEN_ADDRESS> \
--coverage_ratio_bps 5000 --committee '[<MEMBER_1>,<MEMBER_2>]' --approval_threshold 2
stellar contract invoke --id <REGISTRY_ANCHOR_ID> --source <source-account> --network testnet \
-- initialize --admin <ADMIN_ADDRESS> \
--oracle '{"signers":["<ORACLE_SIGNER_1>","<ORACLE_SIGNER_2>"],"threshold":2}'- Escrow — Buyer calls
create; funds lock in one signed transaction. Seller (or anyone, post-timeout) callsreleasefor the happy path. Either party candispute; the arbiter'sresolvecall is final and moves funds per itsDecision. - Insurance — Premiums accumulate via
deposit_premium. The oracle (driven by the backend's trust-score service) grantsActivecoverage viaset_coverage, capped by the solvency guard. A victim files a claim; the claims committee reachesapproval_thresholdviaapprove_claim(or any member canreject_claim); an approved claim is disbursed viapayout. - Registry — Once the backend's two-person review confirms a fraud report, the oracle calls
anchor_flag. The hash and ledger timestamp are permanent; anyone can callget_flags_for_subjectbefore trusting an address. If a flag was anchored in error, the oracle callssupersede_flag— the original record stays on-chain for tamper-evidence and aSupersessionis attached alongside it. Consumers should useget_flag_with_supersessionto distinguish live flags from retracted ones.
cargo test --workspaceCurrent unit test coverage, per contract (contracts/<name>/src/test.rs):
- escrow: create + happy-path release; dispute → arbiter split resolution; non-party cannot dispute
- insurance-pool: premium deposit → coverage → claim → committee approval → payout; coverage rejected beyond the solvency ratio; committee member rejection of a filed claim; non-committee members can't approve or reject
- registry-anchor: anchor + query a flag by id and by subject; multiple flags accumulate per subject;
supersede_flaghappy path, idempotency guard (AlreadySuperseded), and unknown-flag guard (FlagNotFound);get_flag_with_supersessionreturns live vs retracted state correctly
Not yet covered: cross-contract integration scenarios (see tests/README.md), and this test suite has not been run against a live Rust/Soroban toolchain in this environment — verify with cargo test --workspace before relying on it.
The initial testnet MVP focuses on a single end-to-end flow:
- Buyer creates and funds an escrow for a seller under a defined condition
- Seller receives funds on release, or either party raises a dispute for the arbiter to resolve
Insurance pool and registry anchor are scaffolded and unit-tested but not yet wired into a real backend oracle or a real multisig committee.
- Conditional escrow (
create/release/dispute/resolve) with a full state machine and unit tests - Insurance pool with solvency-capped coverage, claim approval/rejection, and payout
- Registry anchor for confirmed fraud flags
- Oracle-only
supersede_flagfor retraction-without-deletion (tamper-evidence preserved) - Timelocked admin handover and persistent-storage TTL management on all three contracts
- CI: build + test on every push/PR
- Checks-effects-interactions ordering on all fund-transferring calls (
escrow::release/resolve,insurance-pool::payout) - Freeze semantics for
insurance-pool::payout— coverage status is re-checked at payout time; a project suspended or removed after filing/approval returnsCoverageSuspendedand blocks disbursement - Migrate event emission from
env.events().publish(...)to the#[contractevent]macro - Replace single-address
arbiter/oracle/committee members with real multisig accounts - Cross-contract integration tests (see
tests/README.md) - Validate TTL threshold/bump constants against target network rent economics
- External audit
- Mainnet launch — see
docs/mainnet-readiness.mdfor the full gate checklist (closes #11)
soroban-sdk = "26.1.0"— Soroban smart contract SDK (the only external dependency; seeCargo.toml)
| Code | Error | Cause |
|---|---|---|
| 1 | AlreadyInitialized |
initialize called twice |
| 2 | EscrowNotFound |
Invalid or unknown escrow_id |
| 3 | InvalidAmount |
amount <= 0 in create |
| 4 | InvalidTimeout |
timeout is not in the future |
| 5 | NotParty |
dispute caller is neither buyer nor seller |
| 6 | AlreadySettled |
release/dispute called on a non-Active escrow |
| 7 | NotDisputed |
resolve called on an escrow that isn't Disputed |
| 8 | InvalidSplit |
Decision::Split basis points > 10000 |
| 9 | InvalidArbiterConfig |
arbiter.threshold is zero or exceeds the signer count |
| Code | Error | Cause |
|---|---|---|
| 1 | AlreadyInitialized |
initialize called twice |
| 2 | ProjectNotCovered |
file_claim against a project without Active coverage |
| 3 | ClaimNotFound |
Invalid or unknown claim_id |
| 4 | InvalidAmount |
Non-positive amount in deposit_premium/file_claim, or negative set_coverage amount |
| 5 | CoverageExceedsSolvency |
New active coverage would exceed coverage_ratio_bps of the pool balance |
| 6 | NotCommitteeMember |
approve_claim/reject_claim caller isn't in the committee |
| 7 | ClaimNotApproved |
payout called on a claim that isn't Approved |
| 8 | ClaimAlreadySettled |
approve_claim/reject_claim called on a claim that isn't Filed |
| 9 | InsufficientPoolBalance |
payout amount exceeds the pool's current token balance |
| 10 | AlreadyCommitteeMember |
add_committee_member called for an address already on the committee |
| 11 | MemberNotFound |
remove_committee_member called for an address not on the committee |
| 12 | CoverageSuspended |
payout called for a claim whose project's coverage is no longer Active; suspension after filing or approval blocks payout (Freeze semantics) |
| Code | Error | Cause |
|---|---|---|
| 1 | AlreadyInitialized |
initialize called twice |
| 2 | FlagNotFound |
Invalid or unknown flag_id in any flag query or supersede_flag |
| 3 | AlreadySuperseded |
supersede_flag called on a flag that has already been superseded |
| 4 | InvalidOracleConfig |
oracle.threshold is zero or exceeds the signer count at initialize |
| Contract | Event | Emitted When |
|---|---|---|
| escrow | created |
create locks funds |
| escrow | released |
release settles to the seller |
| escrow | disputed |
dispute freezes an active escrow |
| escrow | resolved |
resolve executes the arbiter's decision |
| insurance-pool | premium |
deposit_premium receives funds |
| insurance-pool | coverage |
set_coverage changes a project's status/amount |
| insurance-pool | claim_filed |
file_claim registers a new claim |
| insurance-pool | claim_rejected |
reject_claim rejects a filed claim |
| insurance-pool | claim_paid |
payout disburses an approved claim |
| insurance-pool | committee_added |
add_committee_member adds a new committee member |
| insurance-pool | committee_removed |
remove_committee_member removes a committee member |
| registry-anchor | flagged |
anchor_flag anchors a confirmed fraud flag |
| registry-anchor | flag_superseded |
supersede_flag retracts a previously anchored flag |
| all three | admin_proposed |
propose_admin starts the 48h timelock |
| all three | admin_changed |
accept_admin completes the handover |
| insurance-pool, registry-anchor | oracle_proposed |
propose_oracle starts the 48h oracle rotation timelock |
| insurance-pool, registry-anchor | oracle_changed |
accept_oracle completes the oracle rotation |
MIT
- GitHub Issues: Create an issue
- Stellar Discord: https://discord.gg/stellar
- Stellar Developers: https://developers.stellar.org
Contributions are welcome. Before opening a PR:
cargo test --workspacepassescargo fmt --all -- --checkandcargo clippy --workspace --all-targetsare clean- New functions include unit tests in the relevant
src/test.rs - Changes to escrow split logic, coverage solvency math, or the registry's oracle-gating get explicit review — these are the paths that move funds or make public trust claims
If you run cargo update: soroban-env-host (a transitive dev/test dependency of soroban-sdk) declares ed25519-dalek = ">=2.0.0" with no upper bound. soroban-sdk itself pins ^2. If a new ed25519-dalek major version is published, an unconstrained cargo update can resolve two incompatible major versions into the tree and fail to build with a CryptoRng/rand_core trait-bound error. If that happens, re-pin with cargo update -p ed25519-dalek --precise <latest 2.x> rather than chasing the newer major version.