diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..f702a5f9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,67 @@ +version: 2 + +updates: + # ── npm: root workspace ──────────────────────────────────────────────────── + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 10 + groups: + minor-and-patch: + update-types: + - "minor" + - "patch" + + # ── npm: frontend ────────────────────────────────────────────────────────── + - package-ecosystem: "npm" + directory: "/frontend" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 10 + groups: + minor-and-patch: + update-types: + - "minor" + - "patch" + + # ── npm: backend ─────────────────────────────────────────────────────────── + - package-ecosystem: "npm" + directory: "/backend" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 10 + groups: + minor-and-patch: + update-types: + - "minor" + - "patch" + + # ── Cargo: contracts ─────────────────────────────────────────────────────── + - package-ecosystem: "cargo" + directory: "/contracts" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + groups: + minor-and-patch: + update-types: + - "minor" + - "patch" + + # ── GitHub Actions ───────────────────────────────────────────────────────── + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + groups: + actions-minor-and-patch: + update-types: + - "minor" + - "patch" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9079dd3..c6cad249 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,69 @@ +name: Contracts CI + +on: + push: + branches: [main, develop] + paths: + - "contracts/**" + - ".github/workflows/ci.yml" + pull_request: + branches: [main] + paths: + - "contracts/**" + - ".github/workflows/ci.yml" + +jobs: + contracts: + name: Build, Fmt, Clippy & Test + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + components: rustfmt, clippy + + - name: Cache Cargo registry and build artifacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + contracts/target + key: ${{ runner.os }}-cargo-${{ hashFiles('contracts/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + # ── Formatting gate ────────────────────────────────────────────────────── + - name: Check formatting (cargo fmt) + working-directory: contracts + run: cargo fmt --all -- --check + + # ── Lint gate ──────────────────────────────────────────────────────────── + - name: Lint with Clippy + working-directory: contracts + run: | + cargo clippy \ + --all-targets \ + --target wasm32-unknown-unknown \ + -- -D warnings + + # ── WASM build ─────────────────────────────────────────────────────────── + - name: Build contracts (WASM release) + working-directory: contracts + run: | + cargo build \ + --release \ + --target wasm32-unknown-unknown + + # ── Unit tests ─────────────────────────────────────────────────────────── + - name: Run contract unit tests + working-directory: contracts + run: cargo test --all # Continuous Integration workflow for FlowFi # Covers frontend linting/build, backend build/test, and Soroban contract build/test. name: CI diff --git a/contracts/stream_contract/src/events.rs b/contracts/stream_contract/src/events.rs index dc8f9dc8..767633a1 100644 --- a/contracts/stream_contract/src/events.rs +++ b/contracts/stream_contract/src/events.rs @@ -70,6 +70,19 @@ pub struct FeeCollectedEvent { pub token: Address, } +/// Emitted when the protocol admin is transferred to a new address. +/// +/// Topic: `("admin_transferred",)` +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdminTransferredEvent { + /// The previous admin address that initiated the transfer. + pub previous_admin: Address, + /// The new admin address that now controls the protocol. + pub new_admin: Address, +} + +/// Emitted when a stream is paused. /// Emitted when a sender pauses an active stream. /// /// Topic: `("stream_paused", stream_id)` @@ -78,6 +91,11 @@ pub struct FeeCollectedEvent { pub struct StreamPausedEvent { pub stream_id: u64, pub sender: Address, + /// Ledger timestamp at which accrual was frozen. + pub paused_at: u64, +} + +/// Emitted when a stream is resumed after being paused. pub paused_at: u64, } @@ -89,6 +107,8 @@ pub struct StreamPausedEvent { pub struct StreamResumedEvent { pub stream_id: u64, pub sender: Address, + /// Ledger timestamp at which streaming resumed. + pub resumed_at: u64, pub new_end_time: u64, } diff --git a/contracts/stream_contract/src/lib.rs b/contracts/stream_contract/src/lib.rs index 6880440c..838c6320 100644 --- a/contracts/stream_contract/src/lib.rs +++ b/contracts/stream_contract/src/lib.rs @@ -12,6 +12,7 @@ use soroban_sdk::{contract, contractimpl, token, vec, Address, Env, InvokeError, use errors::StreamError; use events::{ + AdminTransferredEvent, FeeCollectedEvent, StreamCancelledEvent, StreamCreatedEvent, FeeCollectedEvent, StreamCancelledEvent, StreamCompletedEvent, StreamCreatedEvent, StreamPausedEvent, StreamResumedEvent, StreamToppedUpEvent, TokensWithdrawnEvent, }; @@ -95,6 +96,46 @@ impl StreamContract { Ok(()) } + /// Transfer the protocol admin role to a new address. + /// + /// The current admin must authenticate. After this call the new address + /// becomes the sole admin and the previous admin loses all admin privileges. + /// + /// # Errors + /// - `NotInitialized` — `initialize` has not been called. + /// - `NotAdmin` — caller is not the current admin. + pub fn transfer_admin( + env: Env, + current_admin: Address, + new_admin: Address, + ) -> Result<(), StreamError> { + current_admin.require_auth(); + + let config = load_config(&env)?; + if config.admin != current_admin { + return Err(StreamError::NotAdmin); + } + + save_config( + &env, + &ProtocolConfig { + admin: new_admin.clone(), + treasury: config.treasury, + fee_rate_bps: config.fee_rate_bps, + }, + ); + + env.events().publish( + (Symbol::new(&env, "admin_transferred"),), + AdminTransferredEvent { + previous_admin: current_admin, + new_admin, + }, + ); + + Ok(()) + } + /// Returns the current protocol fee configuration, or `None` if not yet initialized. pub fn get_fee_config(env: Env) -> Option { try_load_config(&env) @@ -168,6 +209,7 @@ impl StreamContract { last_update_time: start_time, is_active: true, paused: false, + paused_at: 0, paused_at: None, status: StreamStatus::Active, }, @@ -245,6 +287,97 @@ impl StreamContract { Ok(()) } + // ─── Stream Pause / Resume ──────────────────────────────────────────────── + + /// Pause an active stream, freezing accrual at the current ledger time. + /// + /// Only the stream's sender may pause their own stream. + /// + /// # Errors + /// - `StreamNotFound` — no stream exists with `stream_id`. + /// - `Unauthorized` — caller is not the stream's sender. + /// - `StreamInactive` — stream is already inactive. + pub fn pause_stream( + env: Env, + sender: Address, + stream_id: u64, + ) -> Result<(), StreamError> { + sender.require_auth(); + + let mut stream = load_stream(&env, stream_id)?; + + if stream.sender != sender { + return Err(StreamError::Unauthorized); + } + if !stream.is_active { + return Err(StreamError::StreamInactive); + } + + let now = env.ledger().timestamp(); + stream.paused = true; + stream.paused_at = now; + + save_stream(&env, stream_id, &stream); + + env.events().publish( + (Symbol::new(&env, "stream_paused"), stream_id), + StreamPausedEvent { + stream_id, + sender, + paused_at: now, + }, + ); + + Ok(()) + } + + /// Resume a paused stream, adjusting `last_update_time` so that the + /// pause interval is not counted as streamed time. + /// + /// Only the stream's sender may resume their own stream. + /// + /// # Errors + /// - `StreamNotFound` — no stream exists with `stream_id`. + /// - `Unauthorized` — caller is not the stream's sender. + /// - `StreamInactive` — stream is already inactive. + pub fn resume_stream( + env: Env, + sender: Address, + stream_id: u64, + ) -> Result<(), StreamError> { + sender.require_auth(); + + let mut stream = load_stream(&env, stream_id)?; + + if stream.sender != sender { + return Err(StreamError::Unauthorized); + } + if !stream.is_active { + return Err(StreamError::StreamInactive); + } + + let now = env.ledger().timestamp(); + // Shift last_update_time forward by the duration of the pause so that + // the pause window is excluded from accrual calculations. + let pause_duration = now.saturating_sub(stream.paused_at); + stream.last_update_time = stream.last_update_time.saturating_add(pause_duration); + stream.paused = false; + stream.paused_at = 0; + + save_stream(&env, stream_id, &stream); + + env.events().publish( + (Symbol::new(&env, "stream_resumed"), stream_id), + StreamResumedEvent { + stream_id, + sender, + resumed_at: now, + }, + ); + + Ok(()) + } + // ─── Internal Helpers ───────────────────────────────────────────────────── /// Ensures the supplied token address implements the Soroban token interface. @@ -270,6 +403,14 @@ impl StreamContract { /// - Uses `checked_sub` for deposited - already_withdrawn calculation /// - Overflow boundary: i128::MAX (~1.7e19) for both rate and duration fn calculate_claimable(stream: &Stream, now: u64) -> i128 { + // When the stream is paused, accrue only up to the moment it was paused. + let effective_now = if stream.paused && stream.paused_at < now { + stream.paused_at + } else { + now + }; + + let elapsed = effective_now.saturating_sub(stream.last_update_time); let effective_now = if stream.paused { stream.paused_at.unwrap_or(stream.last_update_time) } else { @@ -373,6 +514,9 @@ impl StreamContract { if stream.paused { return Err(StreamError::StreamInactive); } + if stream.paused { + return Err(StreamError::StreamInactive); + } let now = env.ledger().timestamp(); let claimable = Self::calculate_claimable(&stream, now); diff --git a/contracts/stream_contract/src/test.rs b/contracts/stream_contract/src/test.rs index 79666baf..073d2d8c 100644 --- a/contracts/stream_contract/src/test.rs +++ b/contracts/stream_contract/src/test.rs @@ -10,6 +10,7 @@ use soroban_sdk::{ use errors::StreamError; use events::{ + AdminTransferredEvent, FeeCollectedEvent, StreamCancelledEvent, StreamCreatedEvent, FeeCollectedEvent, StreamCancelledEvent, StreamCompletedEvent, StreamCreatedEvent, StreamPausedEvent, StreamResumedEvent, StreamToppedUpEvent, TokensWithdrawnEvent, }; @@ -69,6 +70,7 @@ fn test_datakey_stream_serializes_deterministically() { last_update_time: 1, is_active: true, paused: false, + paused_at: 0, paused_at: None, status: StreamStatus::Active, }; @@ -1822,3 +1824,361 @@ fn test_fuzz_large_amount_no_overflow() { assert!(claimable <= *amount); } } + +// ─── transfer_admin (#459) ───────────────────────────────────────────────────── + +#[test] +fn test_transfer_admin_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + let client = create_contract(&env); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let new_admin = Address::generate(&env); + + client.initialize(&admin, &treasury, &100); + client.transfer_admin(&admin, &new_admin); + + let cfg = client.get_fee_config().unwrap(); + assert_eq!(cfg.admin, new_admin); + // Treasury and fee must remain unchanged. + assert_eq!(cfg.treasury, treasury); + assert_eq!(cfg.fee_rate_bps, 100); +} + +#[test] +fn test_transfer_admin_rejects_non_admin() { + let env = Env::default(); + env.mock_all_auths(); + let client = create_contract(&env); + + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + let treasury = Address::generate(&env); + + client.initialize(&admin, &treasury, &100); + let result = client.try_transfer_admin(&attacker, &Address::generate(&env)); + assert_eq!(result, Err(Ok(StreamError::NotAdmin))); +} + +#[test] +fn test_transfer_admin_rejects_not_initialized() { + let env = Env::default(); + env.mock_all_auths(); + let client = create_contract(&env); + + let result = client.try_transfer_admin( + &Address::generate(&env), + &Address::generate(&env), + ); + assert_eq!(result, Err(Ok(StreamError::NotInitialized))); +} + +#[test] +fn test_transfer_admin_new_admin_can_update_fee_config() { + let env = Env::default(); + env.mock_all_auths(); + let client = create_contract(&env); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let new_admin = Address::generate(&env); + let new_treasury = Address::generate(&env); + + client.initialize(&admin, &treasury, &100); + client.transfer_admin(&admin, &new_admin); + + // New admin must be able to update fee config. + client.update_fee_config(&new_admin, &new_treasury, &200); + let cfg = client.get_fee_config().unwrap(); + assert_eq!(cfg.admin, new_admin); + assert_eq!(cfg.treasury, new_treasury); + assert_eq!(cfg.fee_rate_bps, 200); + + // Old admin must no longer be able to update fee config. + let result = client.try_update_fee_config(&admin, &treasury, &50); + assert_eq!(result, Err(Ok(StreamError::NotAdmin))); +} + +#[test] +fn test_transfer_admin_emits_event() { + let env = Env::default(); + env.mock_all_auths(); + let client = create_contract(&env); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let new_admin = Address::generate(&env); + + client.initialize(&admin, &treasury, &100); + client.transfer_admin(&admin, &new_admin); + + let events = env.events().all(); + let ev = events + .iter() + .find(|e| { + Symbol::try_from_val(&env, &e.1.get(0).unwrap()).unwrap() + == Symbol::new(&env, "admin_transferred") + }) + .expect("admin_transferred event not found"); + + let payload: AdminTransferredEvent = + AdminTransferredEvent::try_from_val(&env, &ev.2).unwrap(); + assert_eq!(payload.previous_admin, admin); + assert_eq!(payload.new_admin, new_admin); +} + +// ─── pause_stream / resume_stream (#462) ───────────────────────────────────── + +#[test] +fn test_pause_stops_accrual() { + 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); + + // Stream: 1 000 tokens over 1 000 s → 1 token/s + let id = client.create_stream(&sender, &recipient, &token, &1_000, &1_000); + + // Advance 200 s before pause — 200 tokens accrued. + env.ledger().with_mut(|l| l.timestamp += 200); + client.pause_stream(&sender, &id); + + // Advance another 300 s while paused — accrual must NOT increase. + env.ledger().with_mut(|l| l.timestamp += 300); + + // Verify stream state: paused flag is set. + let s = client.get_stream(&id).unwrap(); + assert!(s.paused); + + // Advance 100 more seconds; stream is still paused, accrual still frozen. + env.ledger().with_mut(|l| l.timestamp += 100); + + // Expect paused_at (200 s mark) → last_update_time (also 200 s mark) → elapsed = 0 + // So claimable should be the 0 s elapsed since paused_at. + // (Withdraw must be rejected on a paused stream — tested separately.) +} + +#[test] +fn test_withdraw_on_paused_stream_returns_stream_inactive() { + 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 id = client.create_stream(&sender, &recipient, &token, &1_000, &1_000); + + env.ledger().with_mut(|l| l.timestamp += 100); + client.pause_stream(&sender, &id); + env.ledger().with_mut(|l| l.timestamp += 100); + + // Withdraw must be rejected while paused. + let result = client.try_withdraw(&recipient, &id); + assert_eq!(result, Err(Ok(StreamError::StreamInactive))); +} + +#[test] +fn test_resume_adjusts_last_update_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); + + let client = create_contract(&env); + let id = client.create_stream(&sender, &recipient, &token, &1_000, &1_000); + + // Advance 200 s, pause, then advance 300 s while paused, then resume. + env.ledger().with_mut(|l| l.timestamp += 200); + client.pause_stream(&sender, &id); + env.ledger().with_mut(|l| l.timestamp += 300); + client.resume_stream(&sender, &id); + + let s = client.get_stream(&id).unwrap(); + assert!(!s.paused); + // last_update_time = original (0) + pause_duration (300) = 300 + // because resume_stream shifts it by pause_duration (300). + assert_eq!(s.last_update_time, 300); + + // Advance 100 s after resume and withdraw; expect 300 tokens + // (200 pre-pause + 100 post-resume, since nothing was withdrawn yet). + env.ledger().with_mut(|l| l.timestamp += 100); + let token_client = token::Client::new(&env, &token); + let before = token_client.balance(&recipient); + let claimed = client.withdraw(&recipient, &id); + let after = token_client.balance(&recipient); + assert_eq!(claimed, 300); + assert_eq!(after - before, 300); +} + +#[test] +fn test_cancel_paused_stream_settles_at_paused_at() { + 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, 2_000); + + let client = create_contract(&env); + let token_client = token::Client::new(&env, &token); + + // Stream: 1 000 tokens over 1 000 s → 1 token/s + let id = client.create_stream(&sender, &recipient, &token, &1_000, &1_000); + + // Advance 300 s — 300 tokens accrued. + env.ledger().with_mut(|l| l.timestamp += 300); + client.pause_stream(&sender, &id); + + // Advance 200 more s while paused — accrual must NOT count this time. + env.ledger().with_mut(|l| l.timestamp += 200); + + let sender_before = token_client.balance(&sender); + + // Cancel the paused stream. + client.cancel_stream(&sender, &id); + + let sender_after = token_client.balance(&sender); + // Sender must be refunded the non-accrued portion: 1 000 − 300 = 700. + assert_eq!(sender_after - sender_before, 700); + + let s = client.get_stream(&id).unwrap(); + assert!(!s.is_active); +} + +#[test] +fn test_cancel_paused_stream_emits_correct_event() { + 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 id = client.create_stream(&sender, &recipient, &token, &1_000, &1_000); + env.ledger().with_mut(|l| l.timestamp += 400); + client.pause_stream(&sender, &id); + env.ledger().with_mut(|l| l.timestamp += 100); + + client.cancel_stream(&sender, &id); + + let events = env.events().all(); + let ev = events + .iter() + .find(|e| { + Symbol::try_from_val(&env, &e.1.get(0).unwrap()).unwrap() + == Symbol::new(&env, "stream_cancelled") + }) + .expect("stream_cancelled event not found"); + + let payload: StreamCancelledEvent = + StreamCancelledEvent::try_from_val(&env, &ev.2).unwrap(); + // 400 tokens accrued before pause; 0 previously withdrawn. + assert_eq!(payload.refunded_amount, 600); + assert_eq!(payload.amount_withdrawn, 0); +} + +#[test] +fn test_resume_then_cancel_settles_across_pause_boundary() { + 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, 2_000); + + let client = create_contract(&env); + let token_client = token::Client::new(&env, &token); + + // Stream: 1 000 tokens / 1 000 s → 1 token/s + let id = client.create_stream(&sender, &recipient, &token, &1_000, &1_000); + + // Phase 1: 200 s of streaming → 200 tokens accrued. + env.ledger().with_mut(|l| l.timestamp += 200); + + // Phase 2: pause for 150 s (no extra accrual). + client.pause_stream(&sender, &id); + env.ledger().with_mut(|l| l.timestamp += 150); + + // Phase 3: resume and stream for another 100 s → 100 additional tokens. + client.resume_stream(&sender, &id); + env.ledger().with_mut(|l| l.timestamp += 100); + + let sender_before = token_client.balance(&sender); + client.cancel_stream(&sender, &id); + let sender_after = token_client.balance(&sender); + + // Total accrued = 200 + 100 = 300. Refund = 1 000 − 300 = 700. + assert_eq!(sender_after - sender_before, 700); +} + +#[test] +fn test_pause_stream_emits_event() { + let env = Env::default(); + env.mock_all_auths(); + let (token, _) = create_token(&env); + let sender = Address::generate(&env); + mint(&env, &token, &sender, 1_000); + + let client = create_contract(&env); + let id = client.create_stream(&sender, &Address::generate(&env), &token, &1_000, &1_000); + + env.ledger().with_mut(|l| l.timestamp += 50); + client.pause_stream(&sender, &id); + + let events = env.events().all(); + let ev = events + .iter() + .find(|e| { + Symbol::try_from_val(&env, &e.1.get(0).unwrap()).unwrap() + == Symbol::new(&env, "stream_paused") + }) + .expect("stream_paused event not found"); + + let payload: StreamPausedEvent = StreamPausedEvent::try_from_val(&env, &ev.2).unwrap(); + assert_eq!(payload.stream_id, id); + assert_eq!(payload.sender, sender); + assert_eq!(payload.paused_at, 50); +} + +#[test] +fn test_resume_stream_emits_event() { + let env = Env::default(); + env.mock_all_auths(); + let (token, _) = create_token(&env); + let sender = Address::generate(&env); + mint(&env, &token, &sender, 1_000); + + let client = create_contract(&env); + let id = client.create_stream(&sender, &Address::generate(&env), &token, &1_000, &1_000); + + env.ledger().with_mut(|l| l.timestamp += 100); + client.pause_stream(&sender, &id); + env.ledger().with_mut(|l| l.timestamp += 50); + client.resume_stream(&sender, &id); + + let events = env.events().all(); + let ev = events + .iter() + .find(|e| { + Symbol::try_from_val(&env, &e.1.get(0).unwrap()).unwrap() + == Symbol::new(&env, "stream_resumed") + }) + .expect("stream_resumed event not found"); + + let payload: StreamResumedEvent = StreamResumedEvent::try_from_val(&env, &ev.2).unwrap(); + assert_eq!(payload.stream_id, id); + assert_eq!(payload.sender, sender); + assert_eq!(payload.resumed_at, 150); +} diff --git a/contracts/stream_contract/src/types.rs b/contracts/stream_contract/src/types.rs index 5a25cd86..227cea50 100644 --- a/contracts/stream_contract/src/types.rs +++ b/contracts/stream_contract/src/types.rs @@ -1,4 +1,4 @@ -#![allow(unused)] + use soroban_sdk::{contracttype, Address}; @@ -54,6 +54,10 @@ pub struct Stream { pub last_update_time: u64, /// `false` once fully withdrawn or cancelled. pub is_active: bool, + /// `true` while the stream is paused; accrual is frozen at `paused_at`. + pub paused: bool, + /// Ledger timestamp at which the stream was paused (0 when not paused). + pub paused_at: u64, /// `true` when the stream is paused by the sender. pub paused: bool, /// Ledger timestamp when the stream was paused, `None` if not paused.