Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions backend/tests/integration/stream-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
42 changes: 42 additions & 0 deletions contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions contracts/stream_contract/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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")]

Expand Down
68 changes: 68 additions & 0 deletions contracts/stream_contract/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading