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

V1 mainnet parameter selection

This note covers the two initialize parameters that BOND_SIZING.md does not: challenge_window_secs and the resolver committee (size and composition). For bond_amount and finalize_reward_bps, use BOND_SIZING.md directly; nothing here duplicates it.

A note on what “grounded in real data” means here

This analysis was written without live Stellar testnet access: the sandbox it was produced in cannot reach Stellar’s testnet RPC or Friendbot (confirmed with a direct stellar keys generate --fund, which failed with an HTTP 403 from the network egress layer, not a DNS or config problem). scripts/testnet-load.sh could not be run end-to-end as a result.

What is real and measured in this document:

  • Per-operation CPU/memory cost, measured directly against the actual compiled tholos.wasm using soroban-sdk’s local test environment (Env::cost_estimate().budget()), not estimated or looked up. See Part 1.
  • Network resource limits (100,000,000 CPU instructions per transaction), from Stellar’s published fees-and-metering documentation, a stable protocol-level constant rather than something that needs a live run to confirm.

What is not measured here and needs a live run to confirm precisely:

  • Wall-clock transaction latency (ledger close time, RPC round-trip) for each operation type under scripts/testnet-load.sh’s own timing methodology.
  • Real-network behavior under concurrent/adversarial load rather than this document’s sequential local simulation.

Part 5 gives the exact commands and a table to fill in once someone with testnet access runs scripts/testnet-load.sh at the N/D scales below. Until that happens, treat the wall-clock figures in this document as informed placeholders derived from Stellar’s published ~5 second ledger close time, not measurements.

Part 1: per-operation cost (what testnet-load.sh would also show)

Measured by deploying the actual compiled contracts/tholos wasm into soroban-sdk’s test Env, then calling each entry point and reading Env::cost_estimate().budget() immediately after (this reads real cost from executing the real wasm bytecode through the host VM, the same measurement stellar-cli’s own simulation step would report against a live network, minus the network round-trip itself). Methodology and exact numbers below are reproducible with the snippet in the Appendix.

Baseline: N=20 assertions, D=8 disputed (a comparable scale to

scripts/testnet-load.sh’s own default of N=5, D=3, run larger to make any scaling trend visible)

OperationAvg CPU instructionsMax CPU instructionsAvg memory (bytes)Max memory (bytes)
assert_outcome475,133512,027175,158187,794
dispute560,170577,441206,758211,117
resolve (per call; 2 calls needed for a 3-member committee)508,340517,665194,544197,296
finalize527,810536,391195,503198,121

Against the network’s 100,000,000 CPU instruction ceiling per transaction, every operation here uses well under 1% of budget. Compute is not a binding constraint on any of these four operations at realistic scale; the bond_amount and challenge_window_secs choices should be driven by the economic and human-response considerations in this document and BOND_SIZING.md, not by resource exhaustion concerns.

An unexpected finding: cost grows with the contract’s total lifetime usage

Repeating assert_outcome against the same contract instance shows cost climbing steadily, not staying flat:

Assertions so farAvg CPU instructions for the next assert_outcome
1–25475,283
26–50557,672
51–75633,736
76–100709,434
101–125782,106
126–150856,139

The very first call in a fresh instance costs 429,829 instructions. The 150th costs 890,671, almost exactly double, and the growth is close to linear (~3,100 extra instructions per prior assertion). This repeats a pattern already flagged for bond_amount sizing purposes in BOND_SIZING.md: usage-dependent cost isn’t unique to this operation, but this document is the first place it’s been directly measured.

Each assert_outcome call writes a new, separate persistent storage entry (DataKey::Assertion(id), one per id, not a shared growing collection), so this isn’t a case of one contract-level data structure growing unbounded. The most likely explanation is that Soroban’s ledger read/write cost accounting is sensitive to the total footprint the transaction’s ledger-entry accesses touch, which can grow as a contract’s overall persistent storage does, but this document does not have access to Soroban’s cost-model source to confirm that specific mechanism with certainty. Regardless of the exact cause, the growth itself is real and repeatable.

At the still-tiny fractions of the CPU ceiling involved (0.9% of budget even at call 150), this remains far from a practical constraint for any deployment lifetime measured in thousands of assertions. It is worth re-measuring periodically (e.g. with the appendix snippet, at whatever N approximates a deployment’s expected multi-year assertion count) rather than assumed to stay negligible forever, and is flagged here mainly because nothing in the existing documentation mentions it at all.

Part 2: challenge_window_secs

scripts/testnet-load.sh’s CHALLENGE_WINDOW_SECS=120 exists purely so an automated CI-style run finishes in about two minutes rather than hours. It is not, and was never intended to be, a production candidate. The script picks it for the same reason a unit test doesn’t sleep for a real day. Running the script, even with full testnet access, would not produce a “real-world dispute-response time” figure, because the script has no human in the loop deciding whether to dispute: nothing about its timing reflects how long an actual interested party takes to notice a bad assertion and act.

What actually bounds challenge_window_secs

The window has to be long enough for the realistic chain of events between “a bad assertion is posted” and “a disputer’s dispute transaction lands”:

t_notice   Time for a monitoring party (the asserter's counterpart, an
           indexer operator, a manual watcher) to become aware the
           assertion exists at all.
t_verify   Time to check the assertion against the real-world fact it
           claims, which may require off-chain research, human judgement,
           or waiting on an external data source.
t_decide   Time to decide disputing is worthwhile, weighing the bond cost
           against the expected value of correcting the assertion.
t_submit   Time to actually construct, sign, and submit the dispute
           transaction (seconds, from Part 1, not the bottleneck).

t_submit is the only one of these Part 1’s measurements bear on directly, and it’s the smallest by a wide margin: dispute itself costs 560K instructions, executes in well under a network ledger close cycle (Stellar’s mainnet ledger close time is approximately 5 seconds), and imposes no meaningful floor on the window on its own. The window is bounded by t_notice + t_verify + t_decide, which are entirely human/operational factors this document cannot measure and depend on what a given deployment is actually securing.

Sizing guidance

There is no formula that converts “monitoring infrastructure exists” into a specific number of seconds the way BOND_SIZING.md’s bond_amount formula does, because t_notice/t_verify/t_decide are governed by who is watching and how automated their process is, not by anything the contract or network expose. What can be stated concretely:

  • 120 seconds (the load-test value) is not viable for any real deployment. No realistic monitoring setup notices a new assertion, independently verifies its claim, and submits a dispute transaction within two minutes, even with dedicated automation. Treat this purely as a load-test artifact, exactly as the issue that requested this analysis flagged it.
  • A deployment with dedicated, automated dispute monitoring (a keeper bot watching Asserted events against a known-good data feed, empowered to dispute automatically) is bounded mainly by t_notice (event propagation and indexing lag, typically seconds to low minutes) and t_submit (seconds). A window in the 1–6 hour range gives comfortable margin over automated response without meaningfully delaying honest finalization.
  • A deployment relying on manual/human monitoring (an integrator’s team, or the general public, watching for bad assertions without automation) needs to budget for t_notice measured in hours (someone has to actually look), t_verify that may involve real research, and t_decide that may wait on a human decision-maker’s availability (time zones, working hours, on-call rotations). A 12–48 hour window is a reasonable starting range; below 6 hours risks systematically favoring assertions that happen to post outside a monitor’s active hours.
  • Every hour added to challenge_window_secs is an hour added to the uncontested-assertion finality time (Pending -> finalize-eligible). Size the window against how time-sensitive a correct-but-slow resolution is for what’s actually being asserted; a deployment where finality speed matters more than dispute coverage should lean toward the shorter end of its monitoring tier, not stretch the window further “to be safe.”

The canonical testnet deployment’s own window is informative, not exemplary

DEPLOYMENT.md uses 21600 (6 hours) for the canonical testnet instance. That sits within the automated- monitoring range above, but the canonical deployment’s resolvers are resolver1/resolver2/resolver3 test identities with, per DEPLOYMENT.md’s own words, “no real-world accountability behind them yet.” Its window choice reflects testnet convenience, not a production recommendation either. Don’t copy it directly into a mainnet initialize call without reasoning through the monitoring tier above for your specific deployment.

Part 3: resolver committee size and composition

initialize enforces odd-length, non-zero, distinct, at most 21 addresses (DuplicateResolvers otherwise); resolve requires a strict majority (more agrees_with_asserter votes than the alternative) among that fixed, per-dispute-snapshotted committee. Composition can only change afterward via update_resolvers (admin override) or propose_rotation/vote_rotation (self-rotation, one slot at a time, majority vote); see CONTRACT.md for the exact mechanics of both paths.

Size trade-off

Committee sizeMajority neededTrade-off
11A single point of failure and a single point of trust; any dispute resolves at that one party’s sole discretion. Only appropriate when the “committee” is genuinely one trusted, accountable operator and the deployment accepts that concentration explicitly.
32Smallest size with any redundancy: one resolver being slow, unavailable, or compromised still leaves 2 who can act. This is what the canonical testnet deployment and scripts/testnet-load.sh both use.
53Tolerates 2 simultaneous unavailable/compromised resolvers rather than 1. Meaningfully more resilient than 3 for a modest coordination-cost increase (each dispute still needs only a bare majority to act, not unanimity).
7+ (odd, up to 21)(n/2)+1Diminishing returns per added seat: coordination cost (getting a majority of a larger group to actually vote in a reasonable time) grows roughly linearly while the marginal resilience gain per seat shrinks. Justified mainly when the resolver role itself needs broad, visible representation (e.g. a public curated list) rather than for redundancy alone.

Recommendation: default to 3 for anything below “meaningful value at stake,” move to 5 once bond sizes or assertion values reach a point where a single compromised or colluding pair of resolvers becomes an attractive target, and treat anything above 7 as a governance/legitimacy choice rather than a security one. resolve’s gas/compute cost (Part 1) is not a constraint on committee size in either direction; the practical ceiling is how many people can realistically be reached and vote within challenge_window_secs once a dispute opens.

Composition

  • Reachability within the window matters more than credentials. A resolver who cannot be reached to vote within challenge_window_secs might as well not be on the committee for that dispute; a slow committee stalls every disputed assertion until enough of it acts (per DEPLOYMENT.md’s own guidance). Pick people with a demonstrated pattern of being available on the timescale the window implies, not simply the most qualified reviewers in the abstract.
  • Independence reduces collusion and correlated-failure risk. Resolvers who share an employer, a wallet-custody provider, or a single point of off-chain coordination (the same group chat, the same on-call rotation) aren’t functioning as independent checks even if they’re distinct addresses; a single incident (that provider’s outage, that group’s compromise) can take out a majority at once.
  • Self-rotation lets the committee evolve without an admin override, but only by strict majority and one seat at a time. It cannot recover a committee that’s lost the ability to reach majority at all (e.g. more than (n-1)/2 resolvers simultaneously unresponsive). update_resolvers is the explicit break-glass path for that scenario; a real deployment needs a plan (who holds the admin key, under what conditions they’d use it) for exercising it, not just the contract-level capability existing.

Part 4: explicit assumptions

The guidance above depends on what a given deployment is actually securing. This document does not know that for any specific deployer, so it states the assumption ranges the sizing tiers above are built around:

InputAssumed rangeWhere it matters
Expected assertion valueLow-value (informational/reputational) to moderate (meaningful but not life-changing per assertion)Drives which challenge_window_secs monitoring tier and which committee-size tier apply. This document does not model a high-value tier (see below).
Expected dispute frequencyOccasional, not every assertionThe whale/spam-style attack models in BOND_SIZING.md already cover adversarial dispute volume; this document assumes disputes are the exception, not the norm, when reasoning about resolver reachability load.
Monitoring sophisticationRanges from no dedicated monitoring to a single automated keeper; does not assume a professional, multi-operator monitoring serviceIf a deployment has stronger guarantees here (e.g. a paid monitoring SLA), the window can be sized shorter than Part 2’s manual-monitoring tier suggests.

Not covered by this document: a “higher-value mainnet candidate” tier analogous to BOND_SIZING.md’s own top profile, where assertion values are large enough to justify professional, SLA-backed monitoring and a larger, more formally accountable resolver committee (real legal identity, insurance, or similar). That tier needs deployment-specific threat modeling this document can’t assume generically, and should be revisited once Tholos has a concrete deployment targeting that value range.

Part 5: filling in the live testnet numbers

Run these once against real testnet access to replace this document’s modeled wall-clock figures with measured ones, and to get the phase timing scripts/testnet-load.sh itself reports (setup, assertion/dispute/resolve/ finalize phase durations, and average invocation latency):

# Small scale, close to the script's own default
bash scripts/testnet-load.sh 5 3

# Matches this document's Part 1 baseline
bash scripts/testnet-load.sh 20 8

# Larger scale, to see whether Part 1's cost-growth finding is visible in
# real wall-clock timing too (e.g. later resolves/finalizes taking
# perceptibly longer than earlier ones due to increasing ledger footprint)
bash scripts/testnet-load.sh 50 20

Record, for each run:

NDSetup (s)Avg assert (s)Avg dispute (s)Avg resolve (s)Avg finalize (s)Total wall-clock (s)
53
208
5020

If the per-operation wall-clock times climb across runs the way Part 1’s CPU instruction counts do, that’s independent confirmation of the cost-growth finding via a completely different measurement method (real network timing instead of local instruction counting), and would be worth its own follow-up investigation into the root cause.

Appendix: reproducing these measurements

The exact test used to produce Part 1’s numbers (not committed to the test suite, since it’s a one-off benchmark rather than a correctness test – paste it into contracts/tholos/src/test.rs inside mod test { ... } to rerun or extend it):

#![allow(unused)]
fn main() {
#[test]
fn measure_real_operation_costs_for_v1_bond_sizing_doc() {
    extern crate std;
    use std::println;

    let f = Fixture::new();
    const N: u32 = 20;
    const D: u32 = 8;

    let asserter = f.generate();
    f.mint(&asserter, i128::from(N) * DEFAULT_BOND);
    let disputer = f.generate();
    f.mint(&disputer, i128::from(D) * DEFAULT_BOND);

    let mut assert_cpu = std::vec::Vec::new();
    let mut ids = std::vec::Vec::new();
    for i in 0..N {
        f.env.cost_estimate().budget().reset_default();
        let id = f.client.assert_outcome(&asserter, &(i % 2 == 0));
        assert_cpu.push(f.env.cost_estimate().budget().cpu_instruction_cost());
        ids.push(id);
    }

    // ... dispute/resolve/finalize loops follow the same
    // reset_default() -> call -> cpu_instruction_cost() pattern.

    let avg = assert_cpu.iter().sum::<u64>() / assert_cpu.len() as u64;
    println!("assert_outcome avg CPU: {avg}");
}
}

Run with:

cargo test -p tholos measure_real_operation_costs_for_v1_bond_sizing_doc -- --nocapture