Skip to content

Commit 96d49ec

Browse files
authored
Merge branch 'main' into feature/escrow-release-conditions
2 parents a228925 + 1a57df0 commit 96d49ec

20 files changed

Lines changed: 2241 additions & 505 deletions

contracts/CONTRACT_ABI.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Stellar Stream contract storage layout
2+
3+
This document describes the storage keys used by `StellarStreamContract`. The
4+
key enum in `contracts/src/lib.rs` is the source of truth; any layout change
5+
must update this document and the migration notes below in the same release.
6+
7+
## Key inventory
8+
9+
| Key | Value | Persistence | Lifecycle / TTL |
10+
| --- | --- | --- | --- |
11+
| `Admin` | `Address` | Instance | Written by `initialize`; retained for the contract lifetime. |
12+
| `NativeToken` | `Address` | Instance | Written by `initialize`; retained for the contract lifetime. |
13+
| `AllowedTokens` | `Vec<Address>` | Instance | Written by `initialize`, `add_allowed_token`, and `remove_allowed_token`; retained for the contract lifetime. |
14+
| `NextStreamId` | `u64` | Instance | Monotonically increases after stream creation; retained for the contract lifetime. |
15+
| `Stream(id)` | `Stream` | Persistent | Created by `create_stream`/`create_split_stream`; updated by claim, pause, resume, cancel, clawback, and transfer. Persistent storage is required because streams outlive individual ledgers. |
16+
| `SplitChildren(parent_id)` | `Vec<u64>` | Instance | Written when a split stream is created; retained as an index for the parent stream. |
17+
| `ChildToParent(child_id)` | `u64` | Instance | Written when a split stream is created; retained as a reverse lookup index. |
18+
19+
The legacy `EscrowVestingContract` at the top of `lib.rs` uses the string
20+
instance keys `total_vested` (`i128`) and `claimed_amount` (`i128`). They are
21+
independent of the `DataKey` layout and are retained for compatibility with
22+
that legacy entry point.
23+
24+
## Budget estimate for 1,000 streams
25+
26+
The contract stores one `Stream(0..999)` record per stream. A stream contains
27+
two addresses, one token address, five `u64`/boolean lifecycle fields, three
28+
`i128` amounts, and optional metadata. A conservative planning estimate is
29+
approximately 0.5–1.5 KiB per stream before Soroban serialization overhead,
30+
or roughly 0.5–1.5 MiB for 1,000 streams. Split streams additionally require
31+
one child index and one reverse index entry per child, plus the vector entry on
32+
each parent. Real budgets must be measured with the target SDK and metadata
33+
size; the estimate is not a protocol limit.
34+
35+
## Upgrade and migration impact
36+
37+
`DataKey` variants and the encoded fields of `Stream` are persistent ABI. New
38+
variants should be appended, not reordered. Adding fields to `Stream` requires
39+
a versioned decoder or an explicit migration because old serialized values
40+
cannot be assumed to contain the new field. Existing `Stream(id)` records must
41+
remain readable throughout the migration.
42+
43+
Before deploying a layout-changing WASM:
44+
45+
1. Freeze new stream creation or gate it behind a migration version.
46+
2. Snapshot and validate `NextStreamId`, all stream records, and both split
47+
indexes.
48+
3. Run a bounded, resumable migration that rewrites each old `Stream(id)` into
49+
the new representation without changing balances or claimed amounts.
50+
4. Verify conservation (`claimed_amount <= total_amount`) and that every child
51+
has a matching `ChildToParent` entry.
52+
5. Keep a compatibility read path until the migration is complete, then bump
53+
the documented contract version and re-run the ABI/storage audit.
54+
55+
Storage TTLs are deliberately not used for stream state: expiry would make a
56+
valid long-running stream unreadable. If temporary operational keys are added
57+
in a future version, their TTL and cleanup behavior must be documented here.

contracts/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ soroban-sdk = "21.0.0"
1313
soroban-sdk = { version = "21.0.0", features = ["testutils"] }
1414
insta = { version = "1.34.0", features = ["yaml"] }
1515

16+
# `testutils` is a soroban-sdk feature, not a feature of this crate; declare it
17+
# so `cargo clippy -- -D warnings` does not treat the cfg as unexpected.
18+
[lints.rust]
19+
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("testutils"))'] }
20+
1621
[profile.release]
1722
opt-level = "z"
1823
overflow-checks = true

contracts/build.rs

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,7 @@ fn main() {
1313
}
1414

1515
// Check if wasm-opt is available
16-
let wasm_opt_available = Command::new("wasm-opt")
17-
.arg("--version")
18-
.output()
19-
.is_ok();
16+
let wasm_opt_available = Command::new("wasm-opt").arg("--version").output().is_ok();
2017

2118
if !wasm_opt_available {
2219
println!("cargo:warning=wasm-opt not found in PATH. Install via: npm install -g wasm-opt or brew install binaryen");
@@ -32,9 +29,7 @@ fn main() {
3229
return;
3330
}
3431

35-
let original_size = std::fs::metadata(&wasm_file)
36-
.map(|m| m.len())
37-
.unwrap_or(0);
32+
let original_size = std::fs::metadata(&wasm_file).map(|m| m.len()).unwrap_or(0);
3833

3934
// Run wasm-opt with -O4 optimization level
4035
println!("cargo:warning=Running wasm-opt -O4 on WASM binary...");
@@ -53,9 +48,7 @@ fn main() {
5348
return;
5449
}
5550

56-
let optimized_size = std::fs::metadata(&wasm_file)
57-
.map(|m| m.len())
58-
.unwrap_or(0);
51+
let optimized_size = std::fs::metadata(&wasm_file).map(|m| m.len()).unwrap_or(0);
5952

6053
let reduction_percent = if original_size > 0 {
6154
((original_size - optimized_size) as f64 / original_size as f64) * 100.0

contracts/fuzz/Cargo.toml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[package]
2+
name = "stellar-stream-fuzz"
3+
version = "0.0.0"
4+
edition = "2021"
5+
publish = false
6+
7+
[package.metadata]
8+
cargo-fuzz = true
9+
10+
# Isolate this crate from the parent workspace so it does not participate in
11+
# normal `cargo test` / `cargo build` runs.
12+
[workspace]
13+
14+
[dependencies]
15+
libfuzzer-sys = "0.4"
16+
stellar-stream = { path = ".." }
17+
soroban-sdk = { version = "21.0.0", features = ["testutils"] }
18+
19+
[[bin]]
20+
name = "fuzz_stream_lifecycle"
21+
path = "fuzz_targets/fuzz_stream_lifecycle.rs"
22+
test = false
23+
doc = false
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
//! Fuzz target: randomised stream lifecycle sequences (#697).
2+
//!
3+
//! # Running
4+
//! ```bash
5+
//! cargo install cargo-fuzz
6+
//! cd contracts/fuzz
7+
//! cargo fuzz run fuzz_stream_lifecycle
8+
//! ```
9+
//!
10+
//! # Invariants checked
11+
//! 1. `claimed_amount` never exceeds `total_amount` for any stream.
12+
//! 2. `claimable(stream_id, now)` is monotonically non-decreasing in `now`
13+
//! while the stream is not paused (vesting never runs backwards).
14+
//! 3. `claimable(stream_id, now)` never exceeds `total_amount - claimed_amount`.
15+
//! 4. A `claim` for an amount within the reported `claimable` never panics;
16+
//! a `claim` for an amount strictly greater than `claimable` always fails
17+
//! (via `try_claim`) rather than transferring more than vested.
18+
//! 5. Once `canceled`, `claimable` never increases further.
19+
//! 6. The contract never panics on well-formed inputs within the harness's
20+
//! generated bounds (arithmetic overflow, storage-key confusion, etc.).
21+
22+
#![no_main]
23+
24+
extern crate std;
25+
26+
use libfuzzer_sys::fuzz_target;
27+
use soroban_sdk::testutils::{Address as _, Ledger as _};
28+
use soroban_sdk::{token, Address, Env};
29+
use stellar_stream::{StellarStreamContract, StellarStreamContractClient};
30+
31+
/// Number of distinct streams created per fuzz run. Kept small so the
32+
/// fuzzer can exercise repeated interaction with the same stream quickly.
33+
const NUM_STREAMS: usize = 3;
34+
35+
fn run(data: &[u8]) {
36+
if data.len() < 24 {
37+
return;
38+
}
39+
40+
let env = Env::default();
41+
env.mock_all_auths();
42+
43+
let contract_id = env.register(StellarStreamContract, ());
44+
let client = StellarStreamContractClient::new(&env, &contract_id);
45+
46+
let token_admin = Address::generate(&env);
47+
let token_id = env
48+
.register_stellar_asset_contract_v2(token_admin.clone())
49+
.address();
50+
let token_client = token::Client::new(&env, &token_id);
51+
let token_admin_client = token::StellarAssetClient::new(&env, &token_id);
52+
53+
let sender = Address::generate(&env);
54+
let recipient = Address::generate(&env);
55+
// Mint generously so "insufficient sender balance" never masks the
56+
// invariants under test — the fuzzer targets stream-accounting bugs,
57+
// not balance-check bugs (those are covered by the unit test suite).
58+
token_admin_client.mint(&sender, &i128::MAX);
59+
60+
let mut stream_ids: std::vec::Vec<u64> = std::vec::Vec::new();
61+
// Shadow bookkeeping: last observed claimable() per stream, to check
62+
// monotonicity across time advances.
63+
let mut last_claimable: std::vec::Vec<i128> = std::vec::Vec::new();
64+
let mut canceled: std::vec::Vec<bool> = std::vec::Vec::new();
65+
66+
let mut i = 0;
67+
while i + 12 <= data.len() && stream_ids.len() < NUM_STREAMS {
68+
let total_amount = 1 + (u32::from_le_bytes(data[i..i + 4].try_into().unwrap()) as i128 % 1_000_000);
69+
let duration = 1 + (u32::from_le_bytes(data[i + 4..i + 8].try_into().unwrap()) as u64 % 100_000);
70+
let interval = u32::from_le_bytes(data[i + 8..i + 12].try_into().unwrap()) as u64 % 1000;
71+
i += 12;
72+
73+
let start_time = env.ledger().timestamp();
74+
let end_time = start_time + duration;
75+
76+
let id = client.create_stream(
77+
&sender,
78+
&recipient,
79+
&token_id,
80+
&total_amount,
81+
&start_time,
82+
&end_time,
83+
&interval,
84+
&None,
85+
);
86+
stream_ids.push(id);
87+
last_claimable.push(0);
88+
canceled.push(false);
89+
}
90+
91+
if stream_ids.is_empty() {
92+
return;
93+
}
94+
95+
while i + 6 <= data.len() {
96+
let op = data[i] % 4;
97+
let stream_idx = (data[i + 1] as usize) % stream_ids.len();
98+
let advance = u32::from_le_bytes([data[i + 2], data[i + 3], data[i + 4], data[i + 5]]) as u64 % 50_000;
99+
i += 6;
100+
101+
let stream_id = stream_ids[stream_idx];
102+
103+
match op {
104+
0 => {
105+
// ── advance ledger time ──────────────────────────────────
106+
env.ledger().with_mut(|li| {
107+
li.timestamp = li.timestamp.saturating_add(advance);
108+
});
109+
}
110+
1 => {
111+
// ── claimable() monotonicity + bound checks ──────────────
112+
let now = env.ledger().timestamp();
113+
let claimable_now = client.claimable(&stream_id, &now);
114+
let stream = client.get_stream(&stream_id);
115+
116+
assert!(
117+
stream.claimed_amount <= stream.total_amount,
118+
"claimed_amount ({}) exceeded total_amount ({}) for stream {}",
119+
stream.claimed_amount,
120+
stream.total_amount,
121+
stream_id,
122+
);
123+
124+
assert!(
125+
claimable_now <= stream.total_amount - stream.claimed_amount,
126+
"claimable ({claimable_now}) exceeds remaining unclaimed for stream {stream_id}",
127+
);
128+
129+
if !canceled[stream_idx] && !stream.paused {
130+
assert!(
131+
claimable_now >= last_claimable[stream_idx],
132+
"claimable decreased over time for stream {stream_id}: {} -> {claimable_now}",
133+
last_claimable[stream_idx],
134+
);
135+
}
136+
last_claimable[stream_idx] = claimable_now;
137+
}
138+
2 => {
139+
// ── claim exactly the reported claimable amount ──────────
140+
let now = env.ledger().timestamp();
141+
let claimable_now = client.claimable(&stream_id, &now);
142+
if claimable_now <= 0 {
143+
continue;
144+
}
145+
let prev_recipient_balance = token_client.balance(&recipient);
146+
let result = client.try_claim(&stream_id, &recipient, &claimable_now);
147+
if let Ok(Ok(claimed)) = result {
148+
assert_eq!(claimed, claimable_now, "claim returned a different amount than requested");
149+
let new_balance = token_client.balance(&recipient);
150+
assert_eq!(
151+
new_balance,
152+
prev_recipient_balance + claimable_now,
153+
"recipient balance did not increase by the claimed amount",
154+
);
155+
}
156+
// An Err result (e.g. ClaimTooFrequent) is a valid outcome —
157+
// only invariant is that it must not panic the host and must
158+
// not transfer tokens, which the balance check above already
159+
// would have caught via prev/new mismatch had it happened.
160+
}
161+
3 => {
162+
// ── cancel ────────────────────────────────────────────────
163+
if !canceled[stream_idx] {
164+
client.cancel(&stream_id, &sender);
165+
canceled[stream_idx] = true;
166+
}
167+
}
168+
_ => unreachable!(),
169+
}
170+
}
171+
172+
// ── final invariant sweep ─────────────────────────────────────────────
173+
for &stream_id in &stream_ids {
174+
let stream = client.get_stream(&stream_id);
175+
assert!(
176+
stream.claimed_amount <= stream.total_amount,
177+
"final check: claimed_amount exceeded total_amount for stream {stream_id}",
178+
);
179+
}
180+
}
181+
182+
fuzz_target!(|data: &[u8]| {
183+
run(data);
184+
});

0 commit comments

Comments
 (0)