Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Breakwater is a Rust framework and CLI for stateful property testing of Stellar smart contracts. You declare actors (who can call), handlers (what they can call) and invariants (what must always hold). Breakwater generates randomized multi-step transaction sequences against a local Soroban Env, checks every invariant after every step, shrinks any failing sequence to its minimal form, and exports it as a reproducible Rust unit test.

It ships with two attack packs for risks that are specific to Stellar:

Attack packRisk it huntsWhy it matters
policy-bypassMulti-step sequences that slip past OpenZeppelin smart-account policies (spending limits, context rules, signer scopes)These policies are the safety layer for AI-agent wallets paying over x402 / MPP. A policy that holds for one transaction may fail for five.
ttl-archivalContract state that expires under Soroban’s TTL-based state archival while the business logic still depends on itA one-year payment stream whose storage entry is archived at month six is a correctness bug no EVM tool knows to look for.

Status: HackMeridian 2026 submission (Genesis path · Stellar Unlocked track). Built 25–26 October 2026 in Lisbon. See Prior work and hackathon scope.

Submission materials: website https://breakwater.belvast.de · pitch deck docs/deck/breakwater-pitch.pdf · demo video https://vimeo.com/1239334137 (2:55).

Problem

Soroban already has good tools for three layers of contract security:

  • Static analysis — Scout (CoinFabrik)
  • AI review — Almanax
  • Formal verification — Certora Sunbeam

What is missing is the layer in between: stateful fuzzing. The official soroban-examples repository shows how to wire up cargo-fuzz and proptest by hand, and soroban-token-fuzzer covers the standard token interface, but there is no reusable framework where a developer can declare invariants and let a tool search for the transaction sequence that breaks them. Every team that wants this rebuilds it from scratch.

Two Stellar-specific developments make the gap urgent:

  1. Agentic payments. x402 and MPP are live on Stellar mainnet. AI agents hold smart accounts whose on-chain policies are the only thing standing between a prompt injection and a drained wallet. Nobody has a tool that attacks those policies with sequences rather than single calls.
  2. State archival. Soroban storage entries have a time-to-live. Contracts that forget to extend it, or extend it on the wrong schedule, fail silently months after deployment. The standard advice is “test it by bumping the ledger sequence manually”.

Breakwater is the reusable layer for both.

How it works

%%{init: {"flowchart": {"wrappingWidth": 400}}}%%
flowchart TD
    declare["<b>Declare</b><br/>actors, handlers, invariants"]
    generate["<b>Generate</b><br/>random call sequences (proptest)"]
    check["<b>Check</b><br/>every invariant after every step"]
    shrink["<b>Shrink and export</b><br/>a minimal sequence as a Rust unit test"]
    declare --> generate --> check
    check -- next step --> generate
    check -- violation --> shrink
  1. Declare. Implement the Handler trait for each contract call the fuzzer may make, give each one an Actor, and implement Invariant for each property that must hold.
  2. Generate. Breakwater drives a Soroban Env (with testutils) using proptest-state-machine. Each step picks a handler, an actor and arguments; ledger sequence and timestamp advance between steps so TTL and time-based logic are exercised.
  3. Check. After every step every invariant is evaluated. Authorization runs through the real __check_auth path for the policy-bypass pack so that smart-account policies are actually enforced, not mocked.
  4. Shrink and export. A failing sequence is shrunk to the shortest reproduction and written out as a self-contained #[test] you can drop into your crate.

A full architecture description is in docs/ARCHITECTURE.md.

Getting started

Quick start

Prerequisites are listed in docs/DEVELOPMENT.md. In short: Rust stable with the wasm32v1-none target, the Stellar CLI, and a working cargo test on a Soroban contract.

git clone https://github.com/Belvast-Innovation/Breakwater
cd Breakwater
cargo build --workspace

# Run the bundled demos (each drives `cargo test --test suite` in the example crate)
cargo run -p breakwater-cli -- run examples/policy-bypass --seed 7
cargo run -p breakwater-cli -- run examples/ttl-archival --seed 11

# Export the shrunk failure as a regression test and render a report
cargo run -p breakwater-cli -- export examples/policy-bypass --seed 7 --out examples/policy-bypass/tests/repro_policy_bypass_7.rs
cargo run -p breakwater-cli -- run examples/policy-bypass --seed 7 --json run.json
cargo run -p breakwater-cli -- report run.json --html report.html

Real output of the policy demo:

suite: policy-bypass
seed: 7   auth: suite default   steps: suite default
────────────────────────────────────────────────────────────────────────
violation found after 58 cases and 185 steps, shrunk to 3 step(s):
  #0 transfer       agent      +1 ledgers               amount_usdc=49 -> Success
  #1 transfer       agent      +10 ledgers              amount_usdc=49 -> Success
  #2 transfer       agent      +1718 ledgers            amount_usdc=49 -> Success
violation: agent spent 147 within 17280 ledgers, limit is 100 (rolling_cap)
reproduce with --seed 7; report: <temp dir>/breakwater-policy-bypass-7.json

Three payments of 49 USDC, the third landing exactly one policy window (1 728 ledgers) after the first: every single call respected the 50 USDC per-ledger cap and the “daily” cap, yet the agent spent 147 USDC in a day. Every step ran through the host’s real authorization path, so the OpenZeppelin smart account’s __check_auth and its SpendingLimit policies genuinely let it through.

Real output of the archival demo:

suite: ttl-archival
seed: 11   auth: suite default   steps: suite default
────────────────────────────────────────────────────────────────────────
violation found after 63 cases and 621 steps, shrunk to 2 step(s):
  #0 create_stream  sender     +17280 ledgers (1.0 d)   amount_usdc=1000 duration_ledgers=6307200 -> Success
  #1 create_stream  sender     +1555200 ledgers (90.0 d) amount_usdc=1000 duration_ledgers=6307200 -> Success
violation: stream 0 is archived with 275 days left to run (TTL ran out 60.0 days ago) (no_mid_lifecycle_expiry)
reproduce with --seed 11; report: <temp dir>/breakwater-ttl-archival-11.json

The report is written to std::env::temp_dir() ($TMPDIR or /tmp on Unix) as breakwater-<suite>-<seed>.json unless --json <file> names a path.

Exit codes: 0 all invariants held, 1 a violation was found, 2 usage or configuration error. Colours are off when stdout is not a terminal, when NO_COLOR is set, or with --no-color.

Everything needed to build, test and demo Breakwater on a fresh machine. Target platforms: macOS (Apple Silicon or Intel) and Linux. Windows via WSL2.

1. Rust

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup update stable
rustup target add wasm32v1-none          # Soroban contract target
rustup component add rustfmt clippy

rust-toolchain.toml at the repo root pins the channel; rustup picks it up automatically.

Verify:

rustc --version          # 1.91.0 or newer (soroban-sdk 28.0.0 requires it)
rustup target list --installed | grep wasm32v1-none

2. Stellar CLI

# macOS
brew install stellar-cli
# any platform
cargo install --locked stellar-cli

Configure Testnet and a funded identity. Keys stay in the CLI keystore; never write a secret key to a file in this repo.

stellar network add testnet \
  --rpc-url https://soroban-testnet.stellar.org:443 \
  --network-passphrase "Test SDF Network ; September 2015"

stellar keys generate breakwater-dev --network testnet --fund
stellar keys address breakwater-dev

3. Clone and build

git clone https://github.com/Belvast-Innovation/Breakwater
cd Breakwater
cargo build --workspace
cargo test --workspace

The workspace:

CratePathPurpose
breakwatercrates/breakwaterCore library: Suite, Handler, Invariant, sequence generation, shrinking, export
breakwater-clicrates/breakwater-clibreakwater run | export | report
breakwater-pack-policy-bypasscrates/packs/policy-bypassInvariants and generators for OpenZeppelin smart accounts
breakwater-pack-ttl-archivalcrates/packs/ttl-archivalInvariants and ledger-advance strategies for TTL
example-policy-bypassexamples/policy-bypassDemoAccount, an OpenZeppelin smart account with a mis-sized spending window, plus the suite
example-owner-accountexamples/policy-bypass/contracts/owner-accountThe owner’s Ed25519 key account (Signer::Delegated of the demo account)
example-spending-limit-policyexamples/policy-bypass/contracts/spending-limit-policyOpenZeppelin SpendingLimit as a deployable contract
example-ed25519-verifierexamples/policy-bypass/contracts/ed25519-verifierOpenZeppelin Ed25519 verifier as a deployable contract
example-ttl-archivalexamples/ttl-archivalPaymentStream, which never extends TTL, and PaymentStreamFixed behind the fixed feature

Not crates: scripts/replay-testnet.sh (Testnet replay, §8) and docs/reports/ (HTML reports rendered by breakwater report --html).

Build the contracts to WASM (needed for Testnet deploys; tests link them natively). This is what CI runs, with warnings denied; a bare cargo build --target wasm32v1-none is rejected by soroban-sdk 28:

export RUSTFLAGS="-D warnings"
stellar contract build --package example-policy-bypass
stellar contract build --package example-owner-account
stellar contract build --package example-spending-limit-policy
stellar contract build --package example-ed25519-verifier
stellar contract build --package example-ttl-archival

4. Dependencies and where they come from

DependencyWhyDocs
soroban-sdk (+ testutils)Soroban Env, ledger manipulation, auth, TTL gettershttps://docs.rs/soroban-sdk
proptest, proptest-state-machineRandom sequence generation and automatic shrinkinghttps://docs.rs/proptest-state-machine
OpenZeppelin stellar-contractsSmart-account, policy and signer contracts used as the demo targethttps://docs.openzeppelin.com/stellar-contracts
clap, serde, serde_json, anyhow, thiserrorCLI, report serialisation, error handlingdocs.rs
ed25519-dalekSigns the AuthDigestPreimage digest for External (Ed25519) smart-account signers in AuthMode::Realhttps://docs.rs/ed25519-dalek
soroban-fork (not yet a dependency)Lazy-loading mainnet/testnet fork for fuzzing live state. Currently supports soroban-sdk 25.x only; it will be added once it supports SDK 28https://docs.rs/soroban-fork

Versions are pinned in Cargo.toml; Cargo.lock is committed.

5. AI tooling: Claude Code, Raven MCP, Stellar Dev Skill

The project is developed with Claude Code. The working agreement is in CLAUDE.md; read it before your first session.

Raven MCP

Raven is Stellar’s official remote MCP server: one connection that gives the agent the Stellar docs, SDK references and ecosystem context. Add it once per machine:

claude mcp add --transport http raven https://raven.stellar.org/mcp

The first call opens a browser for OAuth sign-in; tokens are managed by the client. The repo also ships .mcp.json so the server is picked up automatically in this project:

{
  "mcpServers": {
    "raven": {
      "type": "http",
      "url": "https://raven.stellar.org/mcp"
    }
  }
}

Support channel: #raven in the Stellar Developers Discord.

Stellar Dev Skill

The Stellar Dev Skill plugin gives the agent up-to-date knowledge of the full Stellar stack (SDK idioms, CLI, x402/MPP). Install from the Claude Code plugin marketplace or follow https://developers.stellar.org/tools/ai. Also useful: https://developers.stellar.org/llms.txt for feeding docs context directly.

Other reference material

  • Soroban fuzzing example: https://developers.stellar.org/docs/build/smart-contracts/example-contracts/fuzzing
  • Testing TTL extension: https://developers.stellar.org/docs/build/guides/archival/test-ttl-extension
  • Soroban authorization: https://developers.stellar.org/docs/learn/fundamentals/contract-development/authorization
  • Protocol 28 (Adapter) upgrade notes: https://stellar.org/blog/developers/introducing-adapter-protocol-28-on-stellar

6. Day-to-day commands

# format, lint, test, wasm (what CI runs; the wasm step denies warnings too)
cargo fmt --all
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
RUSTFLAGS="-D warnings" stellar contract build --package example-ttl-archival   # and the other four packages, see §3

# run a suite with a fixed seed (drives `cargo test --test suite breakwater_suite` in that crate)
cargo run -p breakwater-cli -- run examples/policy-bypass --seed 7 --steps 1..=10 --auth real

# export the shrunk failure as a regression test, or render a saved report
cargo run -p breakwater-cli -- export examples/policy-bypass --seed 7 --out examples/policy-bypass/tests/repro_policy_bypass_7.rs
cargo run -p breakwater-cli -- run examples/ttl-archival --seed 11 --json run.json
cargo run -p breakwater-cli -- report run.json --html docs/reports/ttl-archival-seed-11.html

# deploy a demo contract to Testnet (constructor arguments follow `--`)
stellar contract deploy \
  --wasm target/wasm32v1-none/release/example_ttl_archival.wasm \
  --source breakwater-dev --network testnet \
  -- --token <usdc-sac-contract-id>

7. Git workflow (short version; full rules in CLAUDE.md §3)

git switch main && git pull
git switch -c feat/<slug>
# ... work, small atomic commits in Conventional Commits format ...
git push -u origin feat/<slug>
gh pr create --fill          # uses .github/pull_request_template.md
# CI green → squash-merge in the GitHub UI → branch auto-deleted

Before every push:

git log -1 --format='%an <%ae>%n%B'   # author is you; no AI attribution trailers

8. Replaying on Testnet

scripts/replay-testnet.sh replays a shrunk policy-bypass sequence as real transactions through the smart account’s __check_auth. Local fuzzing stays local; only the exported reproduction goes on-chain.

Prerequisites: funded identities in the Stellar CLI keystore (stellar keys generate <name> --network testnet --fund), jq, and the three helper contracts (owner key account, spending-limit policy, Ed25519 verifier) built as separate wasm files, passed as OWNER_WASM, POLICY_WASM, VERIFIER_WASM. Identities used by default:

IdentityRole
breakwater-devpays fees, issues the test USDC asset, deploys the contracts
breakwater-recipientmerchant receiving the payments (holds a USDC trustline)
breakwater-agentEd25519 key behind the agent’s Signer::External
breakwater-ownerEd25519 key behind the owner’s Signer::Delegated account
# setup (deploy, configure, mint) and the first two payments
scripts/replay-testnet.sh breakwater-out/policy-bypass.json --until-step 1

# one policy window later: the third payment
scripts/replay-testnet.sh breakwater-out/policy-bypass.json --resume-from 2

# print every command instead of running it
scripts/replay-testnet.sh breakwater-out/policy-bypass.json --dry-run

State (contract ids, completed steps, ledger of the last step) lives in breakwater-out/replay-<network>.json. A step with a ledger gap prints the current ledger, the target ledger and the estimated wait.

How an agent payment is submitted: stellar contract invoke --build-only simulates usdc.transfer(account, merchant, amount) and records an authorization entry for the smart account; the script reads that entry’s nonce, expiration ledger and root invocation, has oz-auth-entry (a binary in the policy-bypass pack) sign the OpenZeppelin AuthPayload for it with the agent key, splices the signed entry back into the envelope (stellar tx decode / stellar xdr encode / stellar tx encode), signs with the source key and sends. Signer seeds reach the helper only through the environment, read by stellar keys secret; nothing secret is printed or written.

Two things the on-chain path needs that the local demo does not: DemoAccount has a constructor that installs the initial context rules (an account with zero rules can never authorize add_context_rule on itself; the test suite passes an empty list), and the helper contracts are separate crates under examples/policy-bypass/contracts/, because one cdylib cannot export two __check_auth functions. stellar contract build --package <crate> builds each one.

Deployed on Testnet on 25 October 2026 (protocol 29 accepted the SDK 28 wasm; the smart account’s 47 KB upload cost about 7 300 XLM in Testnet resource fees, so fund the source account generously or run stellar keys fund):

ContractId
DemoAccountCBKDNENFLCLUE3U4NH55T5N5JIX6S6WS2FFDXCPWMXJM7ZQGPXVH3HJI
SpendingLimitPolicy (per call)CCIEVOGO6RZSCHN6COE3NLMDT3WOXSPTDPYDJMPVSULFREACAJ6CS247
SpendingLimitPolicy (“daily”)CC6Z5UZYWG5CR43YYGUZCMOGQHO2NEQZIU7M4MBR2NG3ELZQN7G2RRD7
Ed25519VerifierCA7UFPQ3MHRQGIOHG6B5TPBJZLZL6HCNCYNWPILVDDC6AHOMM5BJ5OJN
OwnerAccountCC2N5OKRJ5MA2SEVIUSRSMPRODN73Z2OH6CYZ7YHIZVAVD72N6K4PSCB
USDC Stellar Asset ContractCDDSS44LN2N4TNVTDH42PLEVHKDB5OZQRBHAD2KQRH6LIADRIBUMPDMM
PaymentStreamCBS6AO2L5RKKLHIC2FJOH66UIL2OHCPSSO6GAUCYNXMMI3RRQLG3D5KP

Replayed payments (49 USDC each, agent-signed, SUCCESS): ledger 5383703 b5ea44983473462379a6909abc335105a040f6d4fee83908cf97dee49cec6772, ledger 5383715 82be1eb8d373bf91aecc9ca4743041ea1f50d1961f7b5fb7ba7a3f9add1e5608, ledger 5387240 3f30f306985585ff62b31271f7aa050a26ec82f6458925d17aa5c32ce157aa8e. The merchant’s balance went from 0 to 147 USDC.

The state file and the report are local (breakwater-out/ is gitignored). If they are lost, regenerate the report with breakwater run examples/policy-bypass --seed 7 --json "$PWD/breakwater-out/policy-bypass.json" and rebuild the state file from the ids above with jq -n; the keys account, token, verifier, policy_per_call, policy_daily, owner_account, agent_rule and last_step_ledger are what --resume-from reads.

The payment-stream demo needs no custom authorization: stellar contract deploy ... -- --token <sac>, then invoke -- create_stream --sender <G> --recipient <G> --amount 10000000000 --duration_ledgers 6307200 as the sender and invoke --source <recipient> -- withdraw --id 0 as the recipient. Testnet closes a ledger about every 5 s (measured 31 ledgers in 155 s), so one policy window of 1 728 ledgers is about 2 h 25 min.

9. Troubleshooting

SymptomFix
error: target wasm32v1-none not foundrustup target add wasm32v1-none; the workspace needs Rust ≥ 1.91
error: soroban-sdk requires stellar-cli v25.2.0+ to build a contractUse stellar contract build --package <crate> instead of cargo build --target wasm32v1-none; SDK 28 needs the CLI’s spec shaking
testutils items missingenable the feature: soroban-sdk = { version = "...", features = ["testutils"] } in [dev-dependencies]
OpenZeppelin crate version mismatch with soroban-sdkboth must target the same SDK major; check Cargo.lock and pin, do not bump mid-event
Testnet account not foundstellar keys generate <name> --network testnet --fund again, or use Friendbot: https://friendbot.stellar.org/?addr=<G...>
Deploy fails with insufficient balanceUploading the 47 KB DemoAccount wasm cost about 7 300 XLM in Testnet resource fees; top up with stellar keys fund breakwater-dev --network testnet
Fuzz run is slowreuse an Env snapshot between iterations (Env::from_ledger_snapshot) instead of a fresh Env per step
Bare panic! reported as a bugthat is intended; contracts must use panic_with_error!

Writing a suite

The suite below is crates/breakwater/tests/counter.rs, compiled and run by cargo test: a counter contract, one handler, one invariant. The framework finds the eleventh increment and shrinks the sequence to eleven steps.

#![allow(unused)]
fn main() {
use breakwater::proptest::prelude::*;
use breakwater::{Actor, CallContext, CallOutcome, Handler, HandlerError, Invariant, Suite, TxLog, Violation};
use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, Symbol};

const COUNT: Symbol = symbol_short!("COUNT");
const LIMIT: u32 = 10;

#[contract]
pub struct Counter;

#[contractimpl]
impl Counter {
    pub fn increment(env: Env) -> u32 {
        let n: u32 = env.storage().instance().get(&COUNT).unwrap_or(0) + 1;
        env.storage().instance().set(&COUNT, &n);
        n
    }
    pub fn get(env: Env) -> u32 {
        env.storage().instance().get(&COUNT).unwrap_or(0)
    }
}

struct Increment { contract: String, actors: Vec<Actor> }   // addresses are kept as strkeys

impl Handler for Increment {
    type Args = ();
    fn name(&self) -> &'static str { "increment" }
    fn actors(&self) -> &[Actor] { &self.actors }
    fn strategy(&self) -> BoxedStrategy<()> { Just(()).boxed() }
    fn call(&self, env: &Env, _ctx: &CallContext, _actor: &Actor, _args: &()) -> Result<CallOutcome, HandlerError> {
        let id = Address::from_str(env, &self.contract);
        match CounterClient::new(env, &id).try_increment() {
            Ok(Ok(count)) => Ok(CallOutcome::returning(count)),
            Ok(Err(e)) => Err(HandlerError::Other(format!("conversion error: {e:?}"))),
            Err(Ok(e)) => Ok(CallOutcome::ContractError(e.get_code())),
            Err(Err(e)) => Err(HandlerError::Host(format!("{e:?}"))),
        }
    }
}

struct NeverAbove { contract: String }

impl Invariant for NeverAbove {
    fn name(&self) -> &'static str { "count_never_above_limit" }
    fn check(&self, env: &Env, _log: &TxLog) -> Result<(), Violation> {
        let id = Address::from_str(env, &self.contract);
        let count = CounterClient::new(env, &id).get();
        if count > LIMIT {
            return Err(Violation::new("count exceeded the limit").with_detail(count));
        }
        Ok(())
    }
}

#[test]
fn finds_the_eleventh_increment_and_shrinks_to_eleven_steps() {
    let env = Env::default();
    let contract = env.register(Counter, ()).to_string().to_string();
    let owner = Actor::generate(&env, "owner");
    let restore_id = contract.clone();

    let report = Suite::new("counter", env)
        .seed(42)
        .steps(1..=30)
        .cases(32)
        .handler(Increment { contract: contract.clone(), actors: vec![owner] })
        .invariant(NeverAbove { contract })
        // Native test contracts live in the host, not the ledger: re-register them
        // on every Env the suite restores from its snapshot.
        .on_restore(move |env| { env.register_at(&Address::from_str(env, &restore_id), Counter, ()); })
        .run()
        .expect("suite runs");

    let failure = report.failure().expect("the limit must be exceeded");
    assert_eq!(failure.violation.source, "count_never_above_limit");
    assert_eq!(failure.log.len(), LIMIT as usize + 1);   // shrunk to exactly eleven steps
}
}

A suite is an ordinary cargo test; the breakwater CLI drives the test named breakwater_suite in tests/suite.rs, passing seed, step range and auth mode through BREAKWATER_SEED, BREAKWATER_STEPS and BREAKWATER_AUTH, and reads the JSON report the suite writes to BREAKWATER_REPORT. The full API, including real-authorization mode and replay, is in docs/ARCHITECTURE.md; the two example suites under examples/*/tests/ are the reference implementations.

Attack packs

policy-bypass

Targets OpenZeppelin stellar-contracts 0.8.0 smart accounts. Built-in invariants:

  • RollingCap: cumulative spend of an actor within a rolling ledger window never exceeds the limit the operator meant (fires in the demo, seed 7)
  • AllowlistOnly: an actor only ever succeeds with allow-listed calls (holds in the demo)
  • RevokedNeverAuths: once revoked, a signer never authorizes a call again (holds in the demo)
  • PinnedWasm: the policy and verifier executables pinned at setup are the ones in place at every step (holds in the demo)

Generators bias toward bypass shapes (amounts just under a cap, ledger gaps that straddle a policy window). Every step runs through the host’s real authorization path (AuthMode::Real): the pack builds the SorobanAuthorizationEntry values an OpenZeppelin account expects, including the nested entry for a delegated signer, so a finding is a real bypass, not an artifact of mocked auth.

The bundled demo (examples/policy-bypass) is an OpenZeppelin smart account with no changes besides a constructor that installs the initial context rules; its agent rule carries two SpendingLimit policies: 50 USDC per ledger and 100 USDC per “day”, where the day was configured as 1 728 ledgers instead of 17 280. SpendingLimit counts its window in ledgers, and dividing 86 400 seconds by 50 instead of by 5 is a one-character mistake. The suite finds the three-payment bypass shown above and shrinks it to the window boundary.

ttl-archival

Advances the ledger according to a usage profile (LedgerAdvance::daily_then_silence(30, 90): one call a day for a month, then a quiet quarter) and asserts:

  • NoMidLifecycleExpiry: no persistent entry a running stream depends on runs out of TTL (fires in the demo, seed 11)
  • InstanceAlive: the contract instance stays alive while any stream is running (holds in the demo until the run stops at the first violation, seed 11)
  • RestoreRestoresFunction: after an archived entry is restored, withdraw and cancel still work (checked on a throwaway copy of the ledger, so the probe never disturbs the run; holds in the demo until the run stops at the first violation, seed 11)
  • NoUnboundedTtlExtension: no entry is kept alive beyond what its stream needs; catches a public, unauthenticated extend_ttl entry point used to park data at the contract’s rent (fires in the demo, seed 5)
  • RestoreCostWithinBudget: a call made while entries are archived stays within a resource budget (instructions, memory, disk read and write entries and bytes, from the host’s metering), so restoration cannot push a transaction over the network limits (fires in the demo, seed 3)

What expiry means on Soroban since Protocol 23: an archived persistent or instance entry is not lost, and a call that touches it does not simply fail. The next transaction that touches an archived entry must restore it (simulation adds the entry to the transaction’s restore list) and pay for the restoration, a transaction built without the restore fails, and a contract that never extends TTL sees the entry archived again after every quiet period of at least the minimum TTL. The pack therefore reads each entry’s live_until directly from the ledger entries, because any contract read, and the SDK’s own get_ttl test helper, would restore the entry and hide the finding.

The bundled demo (examples/ttl-archival) is a payment stream that keeps streams in persistent storage and never calls extend_ttl. Under the profile above, a one-year stream is archived after the quiet quarter with 275 days left to run; PaymentStreamFixed, which extends every touched entry to the stream’s remaining duration, survives the same profile. Two more suites in examples/ttl-archival/tests/ drive the other invariants: with seed 5, NoUnboundedTtlExtension shrinks to 3 steps in which anyone’s call to the unauthenticated bump_ttl keeps a stream alive 1.3 days past its end; with seed 3, RestoreCostWithinBudget shrinks to 4 steps in which one withdraw_many after a quiet quarter reads 5 entries from disk against a budget of 3. That 3 is a threshold the suite sets, not a network limit.

Architecture

This document describes Breakwater as implemented. When the implementation changes, update this file in the same PR.

1. Goals and non-goals

Goals

  • Let a Soroban developer declare actors, handlers and invariants in plain Rust and get stateful fuzzing with shrinking, without writing harness code.
  • Run entirely in-process against soroban_sdk::Env so a suite is just cargo test.
  • Make findings reproducible: every failure is a deterministic, exportable unit test.
  • Ship attack packs that encode Stellar-specific knowledge (smart-account policies, TTL archival).

Non-goals (for the hackathon)

  • Coverage-guided fuzzing (cargo-fuzz / libFuzzer). Random generation plus shrinking is enough to demo; coverage guidance is a roadmap item.
  • Formal verification. That is Certora Sunbeam’s job.
  • Mainnet execution. Testnet replay of a shrunk sequence is the on-chain evidence.

2. Core concepts

%%{init: {"flowchart": {"wrappingWidth": 420}}}%%
flowchart LR
    suite(["<b>Suite</b>"])
    suite --- env["<b>env:</b> Env<br/>post-setup environment; snapshotted for every run"]
    suite --- handlers["<b>handlers:</b> [Box#lt;dyn Handler#gt;]<br/>what may be called, by which actors, with an argument strategy"]
    suite --- invariants["<b>invariants:</b> [Box#lt;dyn Invariant#gt;]<br/>what must hold after every step"]
    suite --- advance["<b>advance:</b> LedgerAdvance<br/>how ledger sequence and timestamp move between steps"]
    suite --- on_restore["<b>on_restore:</b> [Fn(#amp;Env)]<br/>re-registers native test contracts on each restored Env"]
    suite --- config["<b>config:</b> SuiteConfig<br/>seed, step range, cases, auth mode, max shrink iterations"]

Actor

A named signer with a Soroban address. Because an Address is bound to the Env that created it and every run uses a freshly restored Env, an Actor stores the address as a strkey string and materialises it with actor.address(&env). Handlers and invariants hold contract ids the same way (Address::from_str(&env, &strkey)). For the policy-bypass pack, actors map onto smart-account signers (owner key, agent session key, revoked key) so that the authorization path is exercised with the right credential.

Handler

#![allow(unused)]
fn main() {
pub trait Handler: Send + Sync + 'static {
    type Args: Clone + Debug + Serialize + DeserializeOwned + Send + Sync + 'static;
    fn name(&self) -> &'static str;
    fn actors(&self) -> &[Actor];
    fn strategy(&self) -> BoxedStrategy<Self::Args>;        // proptest
    fn call(&self, env: &Env, ctx: &CallContext, actor: &Actor, args: &Self::Args)
        -> Result<CallOutcome, HandlerError>;
    fn args_from_json(&self, value: &serde_json::Value) -> Option<Self::Args> { /* serde default */ }
}

pub enum CallOutcome {
    Success,                        // expected
    SuccessWith(serde_json::Value), // expected, with the call's return value (`CallOutcome::returning(v)`)
    ContractError(u32),             // expected: a graceful `contracterror`
    Unauthorized,                   // expected: the host rejected the authorization
}
}

A handler wraps one contract function. call is expected to succeed or fail gracefully (a contracterror, reported as CallOutcome::ContractError, or an authorization rejection, reported as CallOutcome::Unauthorized). A HandlerError or a bare panic is treated as a bug and ends the sequence with a Violation of kind handler_error or panic, matching the convention in soroban-token-fuzzer. CallContext carries the step index, the auth mode, a per-step nonce and a signature expiration ledger for handlers that build real authorization entries. Args must be Serialize and DeserializeOwned so that steps can be logged, exported and replayed; the suite type-erases them internally. Each executed step becomes a StepRecord in the TxLog: handler, actor, arguments as JSON, ledger gap, ledger sequence and timestamp, outcome, and return_value (the JSON the handler attached with SuccessWith), all of which reach the exported report.

Invariant

#![allow(unused)]
fn main() {
pub trait Invariant {
    fn name(&self) -> &'static str;
    fn check(&self, env: &Env, log: &TxLog) -> Result<(), Violation>;
}
}

Invariants read contract state through the normal client and the TxLog (the sequence of steps executed so far, with outcomes). They are checked after every step, not only at the end, so the shrunk reproduction stops at the first violating step. A Violation carries kind (invariant, panic, handler_error), source (the invariant or handler name, filled in by the runner), message and an optional JSON detail.

LedgerAdvance

Controls how env.ledger() moves between steps. The gap is generated as part of each step, so it is deterministic for a seed and shrinks with the sequence:

  • Still
  • Fixed { ledgers, seconds } (default: one ledger, five seconds)
  • Random { ledgers: RangeInclusive<u32> }; the timestamp follows at SECONDS_PER_LEDGER (5 s) per ledger
  • Profile(Vec<(usize, Gap)>) — a usage profile: (steps, gap) segments applied in order, the last gap repeating once the profile is exhausted. LedgerAdvance::daily_then_silence(30, 90) builds “one call a day for 30 days, then nothing for 90 days”, the profile the ttl-archival pack runs under.

Because the gap is stored in each generated step, shrinking keeps the gaps of the steps it retains: a profile-driven failure shrinks to the few steps that carry the decisive gaps (for the stream demo: the creation and the step after the 90-day silence).

3. Execution pipeline

%%{init: {"flowchart": {"wrappingWidth": 420}}}%%
flowchart TD
    seed(["seed"]) --> machine["<b>proptest state machine</b>"]
    machine -- "Vec#lt;Step{handler, actor, args}#gt;" --> advance
    subgraph runner["Runner (Env + TxLog)"]
        direction TB
        advance["Step 1: env.ledger().set(advance)"] --> invoke["Step 2: handler.call(env, actor, args)<br/>real auth for the policy pack"]
        invoke --> verify["Step 3: inv.check(env, log) for every invariant"]
        verify -- "all hold: next step" --> advance
    end
    verify -- Violation --> shrinker["<b>Shrinker</b><br/>proptest shrinks the Vec#lt;Step#gt;;<br/>the Runner re-executes each candidate from a fresh snapshot Env until minimal"]
    shrinker --> exporter["<b>Exporter</b><br/>writes tests/repro_#lt;suite#gt;_#lt;seed#gt;.rs:<br/>a #35;[test] that rebuilds the Env, applies the steps<br/>and asserts the violation"]

Why proptest-state-machine. It already provides sequence generation, preconditions (so we do not generate obviously invalid steps) and shrinking of sequences. Writing a custom shrinker in two days is not realistic; reusing this is the single biggest time saver in the plan.

Env reuse. Setting up contracts from scratch per shrink candidate is slow and non-reusable. The suite keeps the post-setup Env and restores a fresh one for every generated sequence and every shrink candidate with Env::from_ledger_snapshot(base.to_ledger_snapshot()). Two facts about soroban-sdk 28 shape this:

  • Ledger entries (instance, persistent and temporary storage, contract instances, ledger info) survive the round trip. Native Rust test contracts registered with env.register(..) do not: they live in the host, not in the ledger. The suite therefore runs the on_restore hooks on every restored Env, and the suite author re-registers native contracts there with env.register_at(&address, Contract, ()), which keeps the restored instance storage intact.
  • mock_all_auths() and the budget are per-Env, so the runner re-applies them on every restore.

Determinism. Seed → sequence is reproducible. The CLI prints the seed on every failure and accepts --seed to replay.

4. Authorization model

Two modes, chosen per suite:

ModeHowUsed by
MockAllenv.mock_all_auths() on every restored Envgeneral handlers where auth is not what is under test
RealNo mocking. Before each call the handler installs real SorobanAuthorizationEntry values with env.set_auths(..); the host runs the custom account’s __check_auth and its policiespolicy-bypass pack

In Real mode the smart account’s policies are genuinely enforced by the Soroban host, so a sequence that gets through is a real bypass, not a mocking artifact. The pieces:

  • breakwater::auth (core, protocol-level): contract_fn builds the invocation tree, signature_payload hashes the HashIdPreimage::SorobanAuthorization the host expects to be signed, address_entry wraps it all in SorobanAddressCredentials. CallContext gives each step a unique nonce and a signature expiration ledger.
  • breakwater_pack_policy_bypass::auth::authorize (OpenZeppelin-specific): produces the account entry whose signature is an AuthPayload { signers, context_rule_ids }. An External signer signs the SHA-256 digest of AuthDigestPreimage { account, signature_payload, context_rule_ids }. A Delegated signer gets a second entry whose root invocation is account.__check_auth(preimage), because the account authenticates it with require_auth_for_args.
  • classify maps the result of a try_ call: a Contract error is CallOutcome::ContractError; an Auth error or Error(Context, InvalidAction) (how the host surfaces a __check_auth rejection to the caller) is CallOutcome::Unauthorized; anything else is a bug.

Measured on the demo suite (Apple Silicon, debug build): 86 cases and 280 steps in 1.3 s, about 5 ms per step including two policy calls, an Ed25519 signature and the nested owner entry. Generation therefore runs under Real directly; the fallback of generating under MockAll and re-verifying only the shrunk sequence under Real was not needed.

5. Attack packs

policy-bypass

Target: an OpenZeppelin stellar-contracts 0.8.0 smart account (examples/policy-bypass; its owner key account, spending-limit policy and Ed25519 verifier are separate crates under examples/policy-bypass/contracts/, because one cdylib cannot export two __check_auth, and DemoAccount has a constructor that installs the initial rules, because an account with zero rules can never authorize add_context_rule on itself) with two context rules: rule 0 (Default) for the owner, a Signer::Delegated hardware-key account, and rule 1 (CallContract(usdc)) for the agent, a Signer::External Ed25519 key, carrying two SpendingLimit policy instances: 50 USDC per ledger (the de-facto per-call cap) and 100 USDC per “day”.

The deliberate defect is in that last number. OpenZeppelin’s SpendingLimit measures its window in ledgers (period_ledgers), and at Stellar’s ~5 s close time a day is 17 280 ledgers. The demo installs 1 728, as an operator who divides 86 400 s by 50 instead of 5 would. Every single call still respects both caps; only a sequence that waits out the 2.4-hour window shows that “100 USDC per day” is really “100 USDC per 2.4 hours”. Mistaking a day for a ledger count is exactly the kind of real-world configuration error the pack exists to catch.

InvariantWhat it asserts
RollingCapΣ spend in window ≤ limit, computed from TxLog, compared to what the policy let through
AllowlistOnlyevery successful call hit a contract/function in the configured allow-list
RevokedNeverAuthsno call succeeded with a signer revoked earlier in the sequence
PinnedWasmpolicy/verifier WASM hashes at check time equal those at setup

Generators bias toward known bypass shapes: amount_near_cap draws mostly cap - 1, and window_straddling_advance draws ledger gaps uniformly over one and a half policy windows so about a third of them cross the boundary. Signer rotation is represented by the owner’s revoke_agent handler (remove_signer). Re-creating the agent’s rule (remove_context_rule + add_context_rule) is not generated yet: under Real auth the policy install callbacks require the sub-invocation tree, including the not-yet-assigned rule id, to be pre-built in the owner’s authorization entry. That is deferred.

Invariants evaluate the TxLog: RollingCap sums the agent’s successful transfer amounts inside the intended window (17 280 ledgers) and fires when the sum exceeds 100 USDC, however the policy was configured. PinnedWasm compares Address::executable() of the account, policies and verifier against the values captured at setup. Note that SpendingLimit keeps its state under (smart_account, context_rule_id): changing the limit or the period means a new rule, and a new rule starts with an empty history, which is the root of the rule-rotation bypass and the next generator shape to add.

ttl-archival

Target: examples/ttl-archival, a payment stream (create_stream, withdraw, cancel) that keeps streams in persistent storage and the token address plus id counter in instance storage, and never calls extend_ttl. PaymentStreamFixed (behind the fixed feature) is the same contract extending every touched entry to the stream’s remaining duration; it is the control.

Two facts about soroban-sdk 28 shape the pack:

  • Archived persistent and instance entries are restored on access. Since Protocol 23 a transaction whose restore list names an archived entry restores it before running (simulation fills that list); the test Env emulates this, so a contract read of an archived entry succeeds and resets the entry’s TTL to the network minimum. Only temporary entries vanish. The testutils get_ttl getters go through the same path and therefore cannot observe an archived entry.
  • live_until can be read without touching the entry. env.host().get_stored_entries() lists every ledger entry with its live_until; breakwater_pack_ttl_archival::ttl filters it by contract, key and durability. That is the only side-effect-free probe, and it is what the invariants use. Env::host() is a testutils-only accessor to the host implementation rather than a documented contract API; the dependency is isolated in ttl.rs and covered by a test so an SDK change surfaces immediately.

Invariants read the set of running streams from the TxLog (StreamModel: creations, cancellations, durations; ids follow creation order), never from the contract, so checking never restores anything:

InvariantWhat it asserts
NoMidLifecycleExpiryevery running stream’s persistent entry has TTL > 0 (0 is the last live ledger; negative means archived)
InstanceAlivewhile any stream is running, the contract instance has TTL > 0
RestoreRestoresFunctionfor every running stream whose entry is archived, a throwaway Env::from_ledger_snapshot copy is taken, natives are re-registered, and a suite-supplied probe calls withdraw and cancel there; both must succeed (or withdraw may report NothingToWithdraw). The fuzzed Env is untouched
NoUnboundedTtlExtensionno stream entry’s live_until exceeds the stream’s end ledger plus a slack (one day in the demo), and the instance’s does not exceed the latest running stream’s end plus the slack; the network minimum TTL is always allowed. Catches a public, unauthenticated extend_ttl entry point used to keep data alive at the contract’s rent
RestoreCostWithinBudgeta measured handler that runs while running-stream entries (or the instance) are archived stays within a ResourceBudget of the host’s metering for that call: instructions, memory, disk read entries and bytes, write entries and bytes. The demo budgets 3 disk-read entries and 1 000 bytes; in the seed-3 run the first call after a quiet quarter, withdraw_many([0]), restores the instance, the stream and the token balances at once: 5 disk-read entries, 1 384 bytes read, 6 entries and 1 588 bytes written, 235 660 instructions

The budget numbers are an approximation. The SDK meters a native test contract, so Wasm instantiation and the transaction’s own size are missing, and the test host counts the first read of a live entry as a disk read too; the invariant therefore measures only steps taken while an entry it watches was archived, remembering every watched entry’s live_until from its previous check. Disk reads and writes do include the restored entries, which is the signal that matters here.

The usage profile is what makes the pack bite. The demo Env sets the minimum persistent TTL to 30 days so that the story fits a quarter; the real minimum is a network configuration parameter (state_archival.min_persistent_ttl) that is changed by network upgrades, so read it from the network rather than from documentation: on 25 October 2026 stellar network settings --network testnet reported 120 960 ledgers (about seven days), and the demo stream deployed on Testnet at ledger 5383730 is accordingly live until ledger 5504689. The mainnet value was not queried for this project; Stellar Lab’s network limits page shows it. Under daily_then_silence(30, 90) the buggy contract fails in two steps: create_stream (365 days), then 90 days of silence, and the next call finds stream 0 archived with 275 days left to run, its TTL having run out 60 days earlier. The fixed contract holds under the same profile. Measured: 63 cases and 621 steps in 0.6 s under MockAll. Two further suites in examples/ttl-archival/tests/suite.rs drive the second defect and the restoration cost: keep_alive_suite finds create_stream, create_stream, bump_ttl(stream 0, 6 311 999 ledgers) by anyone, leaving the entry alive 1.3 days past the stream’s end plus slack; restore_cost_suite finds three create_stream calls, 90 days of silence and one withdraw_many([0]) that restores five entries in one call.

What archival costs on-chain. Auto-restore does not make the bug harmless. The next transaction that touches an archived entry must restore it and pay for the restoration: it implicitly adds that entry’s diskReadBytes and writeBytes (the restoration is a read from the archive and a write back into the live state), and a transaction that touches several archived entries at once can exceed the per-transaction limits and fail with INVOKE_HOST_FUNCTION_RESOURCE_LIMIT_EXCEEDED; a transaction built without the restore fails outright. Because the demo contract never extends TTL, a restored entry is archived again after every quiet period of at least the minimum TTL. In the other direction, extend_ttl can only lengthen a TTL, never shorten it, so a public, unauthenticated extension entry point lets anyone keep useless data alive indefinitely while the honest users carry the rent.

Real-world precedent. After the mainnet upgrade to Protocol 23 on 2025-09-03, it was found on 2025-10-09 that Protocol 23 had archived 478 contract entries with stale values; 84 of them had been restored into live state, and 77 entries were modified. Validators paused archival eviction, a stellar-core patch on 2025-10-10 quarantined the affected entries, and Protocol 24 (CAP-0076) on 2025-10-22 fixed the inconsistency (stellar.org, Addressing state archival inconsistencies). The archive-and-restore path is where mainnet’s most recent real incident happened, and it is exactly the path this pack probes.

6. CLI

breakwater run    <suite-dir> [--seed N] [--steps A..=B] [--auth mock|real] [--json <file>] [--no-color]
breakwater export <suite-dir> --out <file.rs> [--seed N] [--from <run.json>]
breakwater report <run.json> [--html <file>]

run executes cargo test --manifest-path <suite-dir>/Cargo.toml --test suite breakwater_suite -- --exact with BREAKWATER_SEED, BREAKWATER_STEPS, BREAKWATER_AUTH and BREAKWATER_REPORT in the environment. The suite applies the first three through SuiteConfig::from_env, writes its RunReport to the fourth through Exporter::write_if_requested, and returns without asserting when it is driven that way; the CLI judges the JSON. Cargo’s own output goes to stderr unchanged; the CLI prints the header, the shrunk sequence one step per line (index, handler, actor, ledger gap, arguments as k=v, outcome), the violation and the seed to reproduce with.

export turns the shrunk sequence into a regression test: a file that embeds the steps as JSON and calls common::replay_json, the entry point each example’s tests/common/mod.rs exposes. That function rebuilds the fixture and calls Suite::replay, which executes exactly the recorded steps (handler and actor by name, arguments rebuilt by Handler::args_from_json, gaps as recorded) and checks every invariant after each. The generated test asserts the same invariant fails at the same step with the same message. examples/*/tests/repro_*.rs are such files, committed as regression tests.

report renders a JSON report in the terminal, and with --html writes a single static page (inline CSS and SVG, no scripts, no external resources): header, step timeline over ledger sequence with the violating step highlighted, per-step table, and a cumulative-spend chart with the limit line for rolling_cap or a TTL-over-ledgers chart with the zero line for no_mid_lifecycle_expiry. docs/reports/ holds one page per demo.

Exit codes: 0 all invariants held, 1 violation found, 2 usage or configuration error (bad arguments, no tests/suite.rs, cargo failed without writing a report). The JSON report is the RunReport structure and is stable for CI consumption.

7. Stellar features exercised

FeatureWhere
require_auth / __check_auth / CustomAccountInterfaceReal auth mode
State archival: extend_ttl, entry live_until read straight from the ledger entries (not get_ttl, which restores), instance vs persistent durabilityttl-archival pack (§5)
Ledger info manipulation via testutils::LedgerLedgerAdvance
Per-invocation resource metering (get_last_invocation_resources)RestoreCostWithinBudget
Stellar CLI deploy + invoke on Testnetdemo replay: three agent-signed policy-bypass payments through the real smart account (README, Testnet evidence)

8. Decisions and open questions

Decided

  • How the CLI drives a suite: breakwater run <suite-dir> executes cargo test --test suite breakwater_suite in the target directory, passes seed, step range and auth mode through environment variables, and reads the JSON report the suite writes. No separate suite binary per example. See §6.
  • soroban-fork is not a dependency for the hackathon build: it currently supports soroban-sdk 25.x only and the workspace is pinned to 28.0.0. Mainnet-state fuzzing stays a roadmap item.
  • Real auth runs end to end at about 5 ms per step on the demo suite, so generation uses Real directly (see §4).
  • Sequence generation uses proptest-state-machine’s Sequential strategy directly; no custom Strategy was needed. Generators bias the arguments and ledger gaps instead of using preconditions.
  • Handler return values are recorded: CallOutcome::SuccessWith carries a value into StepRecord::return_value and the exported JSON. The ttl-archival StreamModel still infers stream ids from creation order, which matches the demo contract’s counter.
  • Export format: both. The core Exporter emits the JSON report, and breakwater export wraps the shrunk steps in a #[test] that replays them through Suite::replay (see §6).

Open

  • Generating smart-account rule rotation (remove_context_rule + add_context_rule) under Real auth: policy install callbacks need pre-built sub-invocations. Interesting because re-creating a rule reinstalls SpendingLimit with an empty history, which is a second way to reset the window. Tracked in the README Roadmap.

CLI

breakwater run    <suite-dir> [--seed N] [--steps A..=B] [--auth mock|real] [--json <file>] [--no-color]
breakwater export <suite-dir> --out <file.rs> [--seed N] [--from <run.json>]
breakwater report <run.json> [--html <file>]

run executes cargo test --manifest-path <suite-dir>/Cargo.toml --test suite breakwater_suite -- --exact with BREAKWATER_SEED, BREAKWATER_STEPS, BREAKWATER_AUTH and BREAKWATER_REPORT in the environment. The suite applies the first three through SuiteConfig::from_env, writes its RunReport to the fourth through Exporter::write_if_requested, and returns without asserting when it is driven that way; the CLI judges the JSON. Cargo’s own output goes to stderr unchanged; the CLI prints the header, the shrunk sequence one step per line (index, handler, actor, ledger gap, arguments as k=v, outcome), the violation and the seed to reproduce with.

export turns the shrunk sequence into a regression test: a file that embeds the steps as JSON and calls common::replay_json, the entry point each example’s tests/common/mod.rs exposes. That function rebuilds the fixture and calls Suite::replay, which executes exactly the recorded steps (handler and actor by name, arguments rebuilt by Handler::args_from_json, gaps as recorded) and checks every invariant after each. The generated test asserts the same invariant fails at the same step with the same message. examples/*/tests/repro_*.rs are such files, committed as regression tests.

report renders a JSON report in the terminal, and with --html writes a single static page (inline CSS and SVG, no scripts, no external resources): header, step timeline over ledger sequence with the violating step highlighted, per-step table, and a cumulative-spend chart with the limit line for rolling_cap or a TTL-over-ledgers chart with the zero line for no_mid_lifecycle_expiry. docs/reports/ holds one page per demo.

Exit codes: 0 all invariants held, 1 violation found, 2 usage or configuration error (bad arguments, no tests/suite.rs, cargo failed without writing a report). The JSON report is the RunReport structure and is stable for CI consumption.

Testnet evidence

Replaying a shrunk failing sequence on Testnet is part of the demo (scripts/replay-testnet.sh, see docs/DEVELOPMENT.md). Local fuzzing stays local; only the exported reproduction goes on-chain. Deployed and replayed on Testnet on 25 October 2026:

ItemValue
Demo smart-account contract (policy-bypass)CBKDNENFLCLUE3U4NH55T5N5JIX6S6WS2FFDXCPWMXJM7ZQGPXVH3HJI
Spending-limit policies (50 USDC per ledger, 100 USDC per 1 728 ledgers)CCIEVOGO…CS247, CC6Z5UZY…RRD7
Ed25519 verifier, owner key accountCA7UFPQ3…5OJN, CC2N5OKR…PSCB
Test asset USDC (Stellar Asset Contract)CDDSS44L…PDMM
Replayed bypass: payment 1 (49 USDC, ledger 5383703)b5ea4498…6772
Replayed bypass: payment 2 (49 USDC, ledger 5383715)82be1eb8…5608
Replayed bypass: payment 3 (49 USDC, ledger 5387240)3f30f306…aa8e
Demo payment-stream contract (ttl-archival)CBS6AO2L5RKKLHIC2FJOH66UIL2OHCPSSO6GAUCYNXMMI3RRQLG3D5KP
create_stream (1 000 USDC over 365 days, ledger 5383730)aa8cb83f…5f99
withdraw by the recipient six ledgers later (ledger 5383736)43ce190b…14d2

Each payment above went through the smart account’s real __check_auth: the agent’s Ed25519 key signed the OpenZeppelin AuthPayload, and the two SpendingLimit policies ran on-chain and let all three through: 147 USDC reached the merchant within 3 537 ledgers (about 4.9 hours, well inside one day of 17 280 ledgers), against a cap the operator meant as 100 USDC per day. The third payment was sent later than the earliest ledger it could have used (5385431, one 1 728-ledger window after the first); any ledger past that window and inside the day shows the same bypass.

The stream’s expiry itself cannot be waited for within the hackathon: Testnet’s minimum persistent TTL is currently 120 960 ledgers, about seven days (stellar network settings --network testnet, state_archival.min_persistent_ttl), and the stream entry, created at ledger 5383730 and never extended, is live until ledger 5504689 (stellar ledger entry fetch contract-data), roughly 1 November 2026. What the on-chain evidence shows is the stream being created and used; what the local suite shows is the entry being archived with most of the stream still to run. On a real network the consequence is not a failing call but a cost: the next transaction that touches an archived entry must restore it and pay for the restoration, a transaction built without the restore fails, and because the contract never extends TTL the entry is archived again after every quiet period of at least the minimum TTL.

Roadmap

Near term

  • Rule-rotation probe for policy-bypass: generate remove_context_rule + add_context_rule under real auth, since a re-created rule starts SpendingLimit with an empty history
  • Malicious keep-alive: bias generators toward unbounded extend_ttl calls on every public entry point, building on NoUnboundedTtlExtension
  • Restoration-cost budget as a CI gate: per-step resource ceilings from RestoreCostWithinBudget, compared run over run
  • Export of stellar-cli replay scripts and unsigned transaction XDR next to the Rust reproduction

Later

  • Coverage-guided mutation of sequences instead of random generation
  • Declarative construction of authorization trees for custom accounts
  • Shadow-state assertions: a reference model of the contract checked against the ledger after every step
  • Mainnet fork via soroban-fork once it supports soroban-sdk 28 (it currently targets 25.x)
  • AI-assisted generation of handlers and invariants from a contract’s interface
  • GitHub Action packaging; publish to crates.io; apply to the Stellar Community Fund (Build / Public Goods)