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
67 changes: 67 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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"
66 changes: 66 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
20 changes: 20 additions & 0 deletions contracts/stream_contract/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)`
Expand All @@ -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,
}

Expand All @@ -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,
}

Expand Down
144 changes: 144 additions & 0 deletions contracts/stream_contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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<ProtocolConfig> {
try_load_config(&env)
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading