diff --git a/backend/tests/integration/stream-lifecycle.test.ts b/backend/tests/integration/stream-lifecycle.test.ts index 9bde6ec9..104a4a63 100644 --- a/backend/tests/integration/stream-lifecycle.test.ts +++ b/backend/tests/integration/stream-lifecycle.test.ts @@ -623,6 +623,112 @@ describe("Stream Lifecycle Integration Tests", () => { }); }); + describe("Full lifecycle: create → top up → partial withdraw → cancel", () => { + it("walks a single stream through every phase and verifies indexer state", async () => { + const streamId = 100; + + // ── Step 1: Create ────────────────────────────────────────────────── + const createEvent = createStreamCreatedEvent(streamId, { + deposited_amount: BigInt(100_000), + rate_per_second: BigInt(100), + }); + await worker.processEvent(createEvent); + + let dbStream = await testPrisma.stream.findUnique({ + where: { streamId }, + }); + expect(dbStream).toBeTruthy(); + expect(dbStream?.depositedAmount).toBe("100000"); + expect(dbStream?.withdrawnAmount).toBe("0"); + expect(dbStream?.isActive).toBe(true); + + // ── Step 2: Top up ────────────────────────────────────────────────── + // Add 50 000 more → deposited becomes 150 000 + const topUpEvent = createStreamToppedUpEvent(streamId, 50_000, 150_000); + topUpEvent.txHash = "topup-tx-hash"; + await worker.processEvent(topUpEvent); + + dbStream = await testPrisma.stream.findUnique({ + where: { streamId }, + }); + expect(dbStream?.depositedAmount).toBe("150000"); + expect(dbStream?.withdrawnAmount).toBe("0"); + expect(dbStream?.isActive).toBe(true); + + // ── Step 3: Partial withdraw ───────────────────────────────────────── + // Recipient withdraws 30 000 → withdrawn becomes 30 000 + const currentTimestamp = Math.floor(Date.now() / 1000); + const withdrawEvent = { + id: `evt-stream-withdrawn-${streamId}`, + type: "contract" as const, + ledger: 12350, + ledgerClosedAt: "2023-01-01T00:00:00Z", + transactionIndex: 0, + operationIndex: 0, + txHash: "withdraw-tx-hash", + topic: [scvSymbol("tokens_withdrawn"), scvU64(BigInt(streamId))], + value: scvMap([ + ["recipient", scvAccountAddress(RECIPIENT)], + ["amount", scvI128(BigInt(30_000))], + ["timestamp", scvU64(BigInt(currentTimestamp))], + ]), + inSuccessfulContractCall: true, + }; + await worker.processEvent(withdrawEvent); + + dbStream = await testPrisma.stream.findUnique({ + where: { streamId }, + }); + expect(dbStream?.withdrawnAmount).toBe("30000"); + expect(dbStream?.depositedAmount).toBe("150000"); + // Stream should still be active after partial withdrawal + expect(dbStream?.isActive).toBe(true); + + // Verify the withdraw event was recorded + const withdrawEventRecord = await testPrisma.streamEvent.findFirst({ + where: { streamId, eventType: "WITHDRAWN" }, + }); + expect(withdrawEventRecord).toBeTruthy(); + expect(withdrawEventRecord?.amount).toBe("30000"); + + // ── Step 4: Cancel ─────────────────────────────────────────────────── + // Cancel settles the remaining claimable. Withdrawn in the cancel event + // includes the 30 000 already withdrawn + newly accrued. + // For this test we use a cancel that settles 40 000 to recipient and + // refunds the rest (150 000 - 40 000 = 110 000) to sender. + const cancelEvent = createStreamCancelledEvent( + streamId, + 40_000, + 110_000, + ); + cancelEvent.txHash = "cancel-tx-hash"; + await worker.processEvent(cancelEvent); + + dbStream = await testPrisma.stream.findUnique({ + where: { streamId }, + }); + expect(dbStream?.withdrawnAmount).toBe("40000"); + expect(dbStream?.isActive).toBe(false); + + // Verify the cancel event was recorded + const cancelEventRecord = await testPrisma.streamEvent.findFirst({ + where: { streamId, eventType: "CANCELLED" }, + }); + expect(cancelEventRecord).toBeTruthy(); + expect(cancelEventRecord?.amount).toBe("110000"); + + // ── Verify that the final API response shows the completed stream ──── + const response = await request(app) + .get(`/v1/streams/${streamId}`) + .expect(200); + + expect(response.body.streamId).toBe(streamId); + expect(response.body.isActive).toBe(false); + expect(response.body.depositedAmount).toBe("150000"); + expect(response.body.withdrawnAmount).toBe("40000"); + }); + }); + describe("SSE client receives broadcast for each stream event", () => { let eventSource: EventSource; diff --git a/contracts/README.md b/contracts/README.md index 6883bc73..2a544974 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -2,6 +2,48 @@ This directory contains the Soroban smart contracts for FlowFi. +## Workspace Layout + +This directory is a [Cargo workspace](https://doc.rust-lang.org/book/ch14-03-cargo-workspaces.html) +root. The workspace manifest is `contracts/Cargo.toml`, and each contract lives in its +own member crate under this directory. + +``` +contracts/ +├── Cargo.toml # Workspace manifest — run `cargo build` / `cargo test` here +├── Cargo.lock +└── stream_contract/ # Member crate (the core streaming contract) + ├── Cargo.toml + └── src/ +``` + +**Always run `cargo build` / `cargo test` from the workspace root** (`contracts/`), never +from inside a member crate. The workspace `[profile.release]` section in `Cargo.toml` +configures WASM-specific optimisations for all members. + +## Adding a New Contract Crate + +1. Create a new crate directory under `contracts/`, e.g. `contracts/my_contract/`. +2. Add a `[package]` section in its `Cargo.toml` and reference `soroban-sdk` via + `workspace = true`: + ```toml + [dependencies] + soroban-sdk = { workspace = true } + ``` +3. Register the crate in `contracts/Cargo.toml` under `[workspace] members`: + ```toml + members = ["stream_contract", "my_contract"] + ``` +4. Run `cargo build` from `contracts/` to verify the workspace compiles. + +For contract-specific documentation see the crate's own `README.md`. + +## Crate docs + +- **[`stream_contract/`](./stream_contract/)** — Core streaming contract (create, top-up, + withdraw, cancel, pause/resume). See [`stream_contract/README.md`](./stream_contract/README.md) + for its full API reference. + ## Layout - `stream_contract/`: Contains the core streaming logic, including stream creation, funding, claiming, and cancellation. diff --git a/contracts/stream_contract/src/lib.rs b/contracts/stream_contract/src/lib.rs index 0395396f..44db038a 100644 --- a/contracts/stream_contract/src/lib.rs +++ b/contracts/stream_contract/src/lib.rs @@ -1,3 +1,16 @@ +//! # `stream_contract` — Soroban Payment-Streaming Contract +//! +//! ## Module responsibilities +//! +//! | Module | Responsibility | +//! |--------|---------------| +//! | [`lib.rs`](./lib.rs) | Public contract interface (`StreamContract`) — entrypoints exposed via `#[contractimpl]` | +//! | [`storage.rs`](./storage.rs) | Persistent state — read/write `ProtocolConfig` and `Stream` records to Soroban storage | +//! | [`types.rs`](./types.rs) | Data types — `Stream`, `ProtocolConfig`, `StreamStatus`, `DataKey` | +//! | [`errors.rs`](./errors.rs) | Error types — `StreamError` enum with all contract error variants | +//! | [`events.rs`](./events.rs) | Event payloads — typed structs emitted by each entrypoint | +//! | [`test.rs`](./test.rs) | Unit & integration tests — module gated behind `#[cfg(test)]` | + #![no_std] #![doc = include_str!("../README.md")] diff --git a/contracts/stream_contract/src/test.rs b/contracts/stream_contract/src/test.rs index 18245951..8956a93c 100644 --- a/contracts/stream_contract/src/test.rs +++ b/contracts/stream_contract/src/test.rs @@ -386,6 +386,74 @@ fn test_create_stream_emits_event() { assert_eq!(payload.rate_per_second, 5); } +// ─── #796 start_time / backdated timestamp guard ────────────────────────────── +// +// `create_stream` always derives `start_time` from `env.ledger().timestamp()` +// (see lib.rs:201). The contract does NOT accept a caller-supplied start_time, +// so backdated start times are structurally impossible via the public API. +// +// The tests below verify this invariant and demonstrate the risk that would +// exist if a backdated start_time were accepted. + +#[test] +fn test_create_stream_uses_ledger_timestamp_as_start_time() { + let env = Env::default(); + env.mock_all_auths(); + let (token, _) = create_token(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + mint(&env, &token, &sender, 1_000); + + // Set ledger to a known timestamp. + env.ledger().with_mut(|l| l.timestamp = 500_000); + + let client = create_contract(&env); + let stream_id = client.create_stream(&sender, &recipient, &token, &1_000, &1_000); + + let s = client.get_stream(&stream_id).unwrap(); + // start_time must be the ledger timestamp at creation, never caller-supplied. + assert_eq!(s.start_time, 500_000); + assert_eq!(s.last_update_time, 500_000); +} + +#[test] +fn test_backdated_start_time_would_immediately_vest_full_amount() { + let env = Env::default(); + env.mock_all_auths(); + let (token, _) = create_token(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + mint(&env, &token, &sender, 1_000); + + let client = create_contract(&env); + let stream_id = client.create_stream(&sender, &recipient, &token, &1_000, &1_000); + + // Simulate a backdated start_time by directly manipulating storage. + // This is NOT possible through the public API — the contract always uses + // env.ledger().timestamp() — but it demonstrates the risk that would exist + // if a caller-supplied start_time were ever added. + let mut stream = client.get_stream(&stream_id).unwrap(); + stream.start_time = 0; // backdated far into the past + stream.last_update_time = 0; // sync anchor to match + env.as_contract(&client.address, || { + env.storage() + .persistent() + .set(&types::DataKey::Stream(stream_id), &stream); + }); + + // Advance ledger well past the stream's natural end. + env.ledger().with_mut(|l| l.timestamp += 10_000); + + // The full deposited_amout would be immediately claimable because the + // elapsed time (start_time=0 → now=10_000) far exceeds the duration. + let claimable = client.get_claimable_amount(&stream_id).unwrap(); + assert_eq!(claimable, 1_000); + + // Backdated start times are intentionally prevented by the contract design: + // `create_stream` always uses `env.ledger().timestamp()`, so this scenario + // cannot occur via the public API. +} + // ─── top_up_stream ──────────────────────────────────────────────────────────── #[test]