Skip to content

About

Foundry-style invariant fuzzing for Soroban, with attack packs for smart-account policies and TTL state archival.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Breakwater logo: a wave breaking on a tetrapod

Breakwater

Foundry-style invariant testing for Soroban.

CI License Last commit Conventional Commits 1.0.0

soroban-breakwater on crates.io breakwater-cli on crates.io soroban-breakwater on docs.rs

Minimum supported Rust version Required soroban-sdk version Built for Stellar Protocol 28, tested on Protocol 29 Stellar CLI 28.1.0 Required OpenZeppelin stellar-accounts version Required proptest version

Stellar Testnet HackMeridian 2026 · Genesis · Stellar Unlocked Pitch deck

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 three attack packs for risks that are specific to Stellar:

Attack pack Risk it hunts Why it matters
policy-bypass Multi-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-archival Contract state that expires under Soroban's TTL-based state archival while the business logic still depends on it A one-year payment stream whose storage entry is archived at month six is a correctness bug no EVM tool knows to look for.
budget-snapshot Resource regressions: a change that makes a handler read, write, emit or rent more than before, without breaking any functional test An extra storage write passes every test and still makes each call dearer. A committed snapshot of per-handler resources turns that into a failing CI check.

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/1239456909 (2:55; full version https://vimeo.com/1239334137, 3:43).


Table of contents


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
Loading
  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.

Installation

Breakwater is published on crates.io: the core library soroban-breakwater, the command-line tool breakwater-cli and the three attack packs.

The command-line tool:

cargo install breakwater-cli --locked

This installs a binary named breakwater. To write a suite, add the core library and, optionally, the attack packs to the [dev-dependencies] of your crate:

[dev-dependencies]
soroban-breakwater = "0.1"
breakwater-pack-policy-bypass = "0.1"
breakwater-pack-ttl-archival = "0.1"
breakwater-pack-budget-snapshot = "0.1"

The core package is named soroban-breakwater because the name breakwater is already taken on crates.io. Its library is still called breakwater, so in code you write use breakwater::....

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.

Writing a test

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.

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.

budget-snapshot

A resource snapshot and CI gate in the spirit of forge snapshot. It does not look for a violation: it measures what every handler of a suite costs, stores max and p50 per resource in a file you commit, and fails when a later run is over it. The twelve resources are the ones the host meters per invocation: instructions, memory, disk and in-memory read entries, write entries, read and written bytes, contract event size, and persistent and temporary rent (ledger-bytes and bumped entries).

# Record the baseline (writes .breakwater-snapshot.json in the current directory)
cargo run -p breakwater-cli -- snapshot examples/ttl-archival --seed 7

# Check a later run against it: exit 0 within the tolerance, 1 on a regression
cargo run -p breakwater-cli -- snapshot examples/ttl-archival --check
cargo run -p breakwater-cli -- snapshot examples/ttl-archival --check --tolerance 2.5%

# The demo regression: `withdraw` also writes a receipt entry (feature `regress-demo`)
cargo run -p breakwater-cli -- snapshot examples/ttl-archival --check --features regress-demo

Real output of the last command (abridged), exit code 1:

suite: ttl-archival-budget   seed: 7   cases: 32   steps: 1..=20   auth: mock
mode: native   tolerance: 5%
────────────────────────────────────────────────────────────────────────
  withdraw         instructions                 max       270023 -> 309412         +14.5%  REGRESSION
  withdraw         write_entries                max            4 -> 5              +25.0%  REGRESSION
  withdraw         write_bytes                  max          904 -> 1016           +12.3%  REGRESSION
  withdraw         persistent_entry_rent_bumps  max            2 -> 3              +50.0%  REGRESSION
14 value(s) over the tolerance and 0 sample count change(s) in 1 handler(s)

Within the default 5% tolerance only withdraw is reported. With --tolerance 0%, as in this repository's CI, create_stream and cancel are listed as well, with small increases in instructions and mem_bytes (at most 2.5%) and none in the storage figures. Their code is unchanged: the increase is an effect of the test host, explained under "What depends on what" below. --check replays the seed, step range and authorization mode stored in the snapshot, so it takes no --seed, --steps or --auth. The default tolerance is 5%, which suits local use; this repository's CI uses --tolerance 0% as a strict gate.

Exit codes: 0 recorded, or every value within the tolerance (improvements are only reported); 1 a value is over the tolerance, or a handler measured a different number of successful calls under the same workload; 2 usage or configuration error, including a snapshot file that is missing or malformed, and a stale snapshot: other soroban-sdk or soroban-env-host version, other seed, cases, step range, authorization or metering mode, a changed generated workload (a digest of the executed steps is stored), or a different set of handlers. A stale snapshot is never compared; record it again when the change is intended.

To measure your own suite, add a breakwater_snapshot test next to breakwater_suite in tests/suite.rs: wrap each handler with Recorder::handler(handler, Target::new(contract, "function")), add recorder.invariant() (a suite needs at least one), run, then recorder.finish(&config, &report) and write_if_requested(). The measured call must be the last top-level call the handler makes, because the host reports only the last one; a handler that calls the contract again afterwards makes the measurement fail instead of misreading it. A handler that reports success without calling the contract at all makes it fail too, and is not credited with the previous step's call. Failed calls are not measured.

What the numbers mean. The snapshot's mode is native: the contract runs as Rust code in the test host. In this mode the storage-related figures (read and write entries and bytes, event size, rent) follow the contract's behaviour directly, so a new storage write, a larger entry or an extra event shows up as it would on the network. instructions and mem_bytes leave out Wasm instantiation and VM execution, and no figure includes the transaction's own size: use them only to compare one revision of the same code base with another, never as an on-chain fee. The numbers come from the host's cost model, not from a clock, and the generated workload is fixed by the seed, so under the same locked dependencies the figures are identical value for value on any machine. This has been verified on two platforms: the demo baseline was recorded on x86_64 Linux, this repository's CI (ubuntu-latest, x86_64) passes the check against it with a 0% tolerance, and a re-recording on aarch64 macOS (Apple Silicon) is byte-identical to the committed file.

What depends on what. The storage-related figures (write_entries, write_bytes, disk_read_entries, disk_read_bytes, memory_read_entries, event size, rent) are computed from the call's own footprint. They depend on the handler and the entries it touches, and on nothing else in the test ledger. instructions and mem_bytes also depend on history. The test host keeps every ledger entry the Env has touched in an ordered map that is copied on every write, so a call costs more the more entries were touched before it: about 280 instructions per touched entry for a call that writes one entry, roughly 2,100 to 2,600 for one that writes five. It makes no difference whether the entries were written by setup, by earlier steps or by another contract, whether they are persistent or temporary, or whether they are the nonce entries that mocked authorization creates for every authorized call. Entries that exist only in the ledger snapshot count for nothing until something touches them.

Measured on the ttl-archival demo by padding the ledger with 0, 10, 100, 300 and 1000 unrelated entries before the same three calls: create_stream took 232,966, 258,631, 461,918, 887,738 and 2,368,498 instructions, with write entries, written bytes and read entries identical in every row. Padding that exists only in the snapshot changed nothing: with 0 or with 1000 such entries the three calls took 230,852, 227,924 and 389,526 instructions. On the network a transaction loads only the entries of its own footprint into this map, so the history part of instructions and mem_bytes belongs to the test environment, not to the contract.

Reading a 0% result. A change that adds entries early in a sequence, such as the receipt withdraw writes in the demo, also raises instructions and mem_bytes of later handlers you did not touch, by a few percent at most in the demo, while no storage figure moves for them. Look first at the handler whose storage figures moved; that is the change. An increase in instructions or mem_bytes alone, in a handler whose storage figures are unchanged, is only worth chasing if it is large or that handler's code changed. Even an unchanged contract gets dearer along a sequence under mocked authorization: in the demo the same cancel takes 392,838 instructions with no withdrawal before it and 428,045 after sixteen. The snapshot therefore describes each handler in the states the seed produces, and changing the seed, the step range or the handlers changes the figures (the workload digest turns that into a stale snapshot instead of a false regression). Separate tolerances for the storage figures and for instructions and mem_bytes are planned in #32.

How Stellar is used

Breakwater is infrastructure, so "use of Stellar" means use of the protocol itself rather than a consumer flow:

Stellar feature Where Breakwater uses it
Soroban authorization framework (require_auth, __check_auth, CustomAccountInterface) policy-bypass executes through real custom-account auth
Soroban state archival (TTL, extend_ttl, restore) ttl-archival advances the ledger via testutils and reads each entry's live_until straight from the ledger entries (Env::host().get_stored_entries()), not through a contract read, so automatic restoration cannot hide an archived entry
Per-invocation resource metering (get_last_invocation_resources) RestoreCostWithinBudget bounds the disk reads and writes, instructions and memory of calls made while entries are archived
OpenZeppelin smart-account contracts (ecosystem) Demo target for the policy pack
Stellar CLI + Testnet Demo contracts are deployed to Testnet and the shrunk policy-bypass reproduction is replayed on-chain: three agent-signed payments through the real smart account (see Testnet evidence)

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:

Item Value
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 account CA7UFPQ3…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.

Protocol compatibility

Breakwater 0.1.x is built with soroban-sdk 28 and Stellar CLI 28.1.0, the versions the Stellar Software Versions page lists for Protocol 29. Protocol 29 reached Testnet on 29 September 2026 and Mainnet on 1 October 2026; it is a security release with no new CAPs.

On 27 October 2026 the shrunk policy-bypass sequence was replayed again on Testnet, now running Protocol 29, with fresh identities. Every deployment and the first two 49 USDC payments succeeded; the third payment needs a 1 728-ledger wait and is not recorded here.

Item Value
Demo smart-account contract (DemoAccount) CDZGJI7QRQ6CNCYGWSI7QTSJXZFMOTCMZDSVK4FMYASYJXS2L27ZX26N, deployed in cf86ed87…2c1a
Owner key account (OwnerAccount) CBZ7C2I277XT4E5PVBTWR4P57LVNGOE3DAVYYWK3WLDTHXTJQIZVOA6X, deployed in 9d2380a0…64bb
Payment 1 (49 USDC, ledger 5412911) 4c2731a32960ea10c1474f9857c98e9a90865ff4992e713ada74f93a3328437f
Payment 2 (49 USDC, ledger 5412922) a2a68662fb4add717fe4639048d41fd1c54ed22ec2c36e20c078ae97cde3ff20

soroban-sdk stays at major version 28 for now: OpenZeppelin stellar-accounts 0.8.0, which the policy-bypass pack depends on, requires soroban-sdk ^28. Breakwater will follow once OpenZeppelin publishes a release for a newer SDK. Because the public API of the Breakwater crates exposes soroban_sdk types, that upgrade is a breaking change and will ship as 0.2.0.

Project layout

Breakwater/
├── crates/
│   ├── breakwater/                  # core library: Suite, Handler, Invariant, replay, JSON export
│   ├── breakwater-cli/              # `breakwater run | export | report | snapshot`
│   └── packs/
│       ├── policy-bypass/           # invariants, generators and real-auth entries for OZ smart accounts
│       ├── ttl-archival/            # TTL probes, stream model and archival invariants
│       └── budget-snapshot/         # per-handler resource sampling, snapshot file and regression check
├── examples/
│   ├── policy-bypass/               # DemoAccount: an OZ smart account with a mis-sized spending window
│   │   ├── contracts/
│   │   │   ├── owner-account/       # the owner's Ed25519 key account (Signer::Delegated)
│   │   │   ├── spending-limit-policy/
│   │   │   └── ed25519-verifier/
│   │   └── tests/                   # suite.rs, common/ (fixture, handlers), repro_policy_bypass_7.rs
│   └── ttl-archival/                # PaymentStream (never extends TTL) and PaymentStreamFixed
│       └── tests/                   # suite.rs, common/, repro_ttl_archival_11.rs
├── .breakwater-snapshot.json        # resource baseline of the ttl-archival demo (`breakwater snapshot --check`)
├── scripts/
│   └── replay-testnet.sh            # replays an exported sequence on Testnet
├── site/                            # breakwater.belvast.de: landing page, mdBook docs (book/), build.sh
├── docs/
│   ├── assets/                      # logo (source and README size) and social preview image
│   ├── ARCHITECTURE.md
│   ├── DEVELOPMENT.md
│   ├── SUBMISSION.md
│   ├── PITCH_DECK.md                # source of the pitch deck, with the origin of every number
│   ├── deck/                        # breakwater-pitch.pdf / .pptx and the screenshots in shots/
│   └── reports/                     # HTML reports rendered by `breakwater report --html`
├── CLAUDE.md                        # working agreement for AI-assisted development
└── README.md

Prior work and hackathon scope

HackMeridian requires that prior work is disclosed and that work done during the event is distinguishable. Breakwater is a new project; this section states precisely what existed before 09:00 WET on 25 October 2026.

Before the event (research and design only, no code):

  • Survey of existing Soroban security tooling (Scout, Almanax, Sunbeam, soroban-token-fuzzer, soroban-fork, erst, soroban-upgrades-core) and of adjacent projects (Policywright, Soneso stellar-agent-wallet, Agent Card, PennyAgent) to confirm the gap this project fills.
  • Reading of the soroban-sdk testutils API, OpenZeppelin stellar-contracts smart-account docs, and the Stellar state-archival docs.
  • A written plan (module split, demo targets, pitch outline).

During the event (everything in this repository):

  • All source code, tests, examples, documentation and CI configuration. The first commit on main is timestamped after the event opened; the commit history is the record.

No code from any earlier project, hackathon or regional activation was reused.

Third-party code is pulled in as Cargo dependencies and is not vendored: soroban-sdk, proptest / proptest-state-machine, OpenZeppelin stellar-contracts, and the crates listed in Cargo.lock. Each keeps its own license.

AI-assisted development

This project was built with Claude Code (Anthropic) as a pair programmer, as permitted and encouraged by the HackMeridian rules. The working agreement is in CLAUDE.md.

Where AI meaningfully generated code or content:

Area AI contribution Human contribution
Core library (crates/breakwater) Boilerplate for trait definitions, proptest-state-machine wiring, error types Design of the Handler / Invariant / Suite API, shrinking strategy, review of every file
Attack packs First drafts of invariant implementations from written specs Threat modelling, choice of invariants, verification against real contracts
budget-snapshot pack and breakwater snapshot Design proposal, first drafts of the sampling wrapper, statistics, snapshot file model, comparison logic, the snapshot command, their tests and this documentation Decisions on what is measured and how, the file format, exit codes and tolerance semantics; review of the plan and of the diff
Demo contracts Scaffolding from OpenZeppelin examples Deliberate misconfigurations, TTL bug
CLI, report output, HTML report and Testnet replay scripts Argument parsing, report formatting, first drafts of the run / export / report commands, the HTML renderer and scripts/replay-testnet.sh UX decisions, pipeline design (how the CLI drives a suite, how an agent-signed authorization entry is spliced into a transaction), on-chain verification
Logo Generated with Google Nano Banana (Gemini image model) from the author's prompt Concept (a wave breaking on a breakwater tetrapod), selection, cropping
Demo video narration Narration synthesised with ElevenLabs from a clone of the author's own voice (created with his consent); video assembled by scripts Script, voice sample, review of every segment
Website and docs site First draft of the landing page, mdBook configuration and Pages workflow Content selection, review against README and docs
Release automation (scripts/release-plan.py, .github/workflows/release.yml, .claude/skills/release/SKILL.md) First drafts of all three files Decisions, review of the release process design, and verification of the real 0.1.1 release
Documentation Drafts of README, docs/, CI config All claims checked against the code and Stellar docs

Stellar documentation was pulled into the AI context through the official Raven MCP server and the Stellar Dev Skill plugin. All AI-generated code was read, tested and, where needed, rewritten by the team before being committed.

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
  • Done: resource budgets as a CI gate, compared run over run, as the budget-snapshot pack. Still open: hard per-step ceilings from RestoreCostWithinBudget in the same file, and a Wasm metering mode
  • 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; apply to the Stellar Community Fund (Build / Public Goods)
  • Done: publish to crates.io, as soroban-breakwater and its companion crates (see Installation)

Team

Belvast Innovation — Hannes Gao (belvast.de) · Karlsruhe, Germany

License

Apache-2.0. See LICENSE. See NOTICE for attribution.

About

Foundry-style invariant fuzzing for Soroban, with attack packs for smart-account policies and TTL state archival.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Contributors

Languages