From 90cf37307c2b7f01940e766f9c22a65123e74839 Mon Sep 17 00:00:00 2001 From: emdevelopa Date: Thu, 28 May 2026 22:36:15 +0100 Subject: [PATCH 1/4] feat(contracts): add transfer_admin entry point and AdminTransferredEvent Add transfer_admin(env, current_admin, new_admin) to lib.rs that: - Requires the current admin's auth - Verifies the caller matches the stored admin - Updates ProtocolConfig.admin to the new address - Emits AdminTransferredEvent with previous_admin and new_admin Also adds paused/paused_at fields to Stream struct and pause_stream/resume_stream entry points with corresponding StreamPausedEvent/StreamResumedEvent events. Updates calculate_claimable to freeze accrual at paused_at when stream is paused, and withdraw to reject calls on paused streams. Refs: #459, #462 --- contracts/stream_contract/src/events.rs | 36 ++++++ contracts/stream_contract/src/lib.rs | 149 +++++++++++++++++++++++- contracts/stream_contract/src/types.rs | 6 +- 3 files changed, 187 insertions(+), 4 deletions(-) diff --git a/contracts/stream_contract/src/events.rs b/contracts/stream_contract/src/events.rs index f67ccb05..5af3619e 100644 --- a/contracts/stream_contract/src/events.rs +++ b/contracts/stream_contract/src/events.rs @@ -69,3 +69,39 @@ pub struct FeeCollectedEvent { pub fee_amount: i128, 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. +/// +/// Topic: `("stream_paused", stream_id)` +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +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. +/// +/// Topic: `("stream_resumed", stream_id)` +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StreamResumedEvent { + pub stream_id: u64, + pub sender: Address, + /// Ledger timestamp at which streaming resumed. + pub resumed_at: u64, +} diff --git a/contracts/stream_contract/src/lib.rs b/contracts/stream_contract/src/lib.rs index bb00ccd7..7dde635d 100644 --- a/contracts/stream_contract/src/lib.rs +++ b/contracts/stream_contract/src/lib.rs @@ -12,8 +12,8 @@ use soroban_sdk::{contract, contractimpl, token, Address, Env, Symbol}; use errors::StreamError; use events::{ - FeeCollectedEvent, StreamCancelledEvent, StreamCreatedEvent, StreamToppedUpEvent, - TokensWithdrawnEvent, + AdminTransferredEvent, FeeCollectedEvent, StreamCancelledEvent, StreamCreatedEvent, + StreamPausedEvent, StreamResumedEvent, StreamToppedUpEvent, TokensWithdrawnEvent, }; use storage::{ config_exists, load_config, load_stream, next_stream_id, save_config, save_stream, @@ -95,6 +95,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) @@ -155,6 +195,8 @@ impl StreamContract { start_time, last_update_time: start_time, is_active: true, + paused: false, + paused_at: 0, }, ); @@ -228,10 +270,108 @@ 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 ───────────────────────────────────────────────────── fn calculate_claimable(stream: &Stream, now: u64) -> i128 { - let elapsed = now.saturating_sub(stream.last_update_time); + // 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 streamed = (elapsed as i128) .checked_mul(stream.rate_per_second) @@ -269,6 +409,9 @@ impl StreamContract { if !stream.is_active { 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/types.rs b/contracts/stream_contract/src/types.rs index 678a77f3..996c6fa8 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}; @@ -44,6 +44,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, } /// Protocol-wide fee configuration. From b6010ce6fcee69221a21d3464a821a95c0aa8b94 Mon Sep 17 00:00:00 2001 From: emdevelopa Date: Thu, 28 May 2026 22:38:03 +0100 Subject: [PATCH 2/4] test(contracts): add tests for transfer_admin and paused stream scenarios - Successful admin transfer, non-admin rejected, not-initialized rejected - New admin can call update_fee_config, old admin cannot - AdminTransferredEvent emitted with correct payload - Pause stops accrual (frozen at paused_at) - Withdraw on paused stream returns StreamInactive - Resume adjusts last_update_time to skip pause window - Cancel paused stream settles at paused_at, not current time - Cancel emits correct refunded_amount when paused - Resume then cancel settles correctly across pause boundary - StreamPausedEvent and StreamResumedEvent emitted with correct payloads Refs: #459, #462 --- contracts/stream_contract/src/test.rs | 364 +++++++++++++++++++++++++- 1 file changed, 362 insertions(+), 2 deletions(-) diff --git a/contracts/stream_contract/src/test.rs b/contracts/stream_contract/src/test.rs index e6be38dd..c522666a 100644 --- a/contracts/stream_contract/src/test.rs +++ b/contracts/stream_contract/src/test.rs @@ -10,8 +10,8 @@ use soroban_sdk::{ use errors::StreamError; use events::{ - FeeCollectedEvent, StreamCancelledEvent, StreamCreatedEvent, StreamToppedUpEvent, - TokensWithdrawnEvent, + AdminTransferredEvent, FeeCollectedEvent, StreamCancelledEvent, StreamCreatedEvent, + StreamPausedEvent, StreamResumedEvent, StreamToppedUpEvent, TokensWithdrawnEvent, }; use types::{DataKey, Stream}; @@ -68,6 +68,8 @@ fn test_datakey_stream_serializes_deterministically() { start_time: 1, last_update_time: 1, is_active: true, + paused: false, + paused_at: 0, }; env.as_contract(&contract_id, || { env.storage().persistent().set(&key, &stream); @@ -920,3 +922,361 @@ fn test_cancel_stream_after_partial_withdrawal() { // Contract should have 100 tokens remaining (for recipient to withdraw) assert_eq!(contract_balance_after, 100); } + +// ─── 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); +} From 2249aa6c8c0f651a1d7347c4e883b972cbfcaa8b Mon Sep 17 00:00:00 2001 From: emdevelopa Date: Thu, 28 May 2026 22:39:11 +0100 Subject: [PATCH 3/4] ci: add cargo fmt --check and clippy gates to contracts CI Add .github/workflows/ci.yml with: - cargo fmt --all -- --check (formatting gate) - cargo clippy --all-targets --target wasm32-unknown-unknown -- -D warnings - WASM release build step - cargo test --all for unit tests - rustfmt and clippy components in toolchain setup - Cargo cache for faster builds Triggers on push/PR to main and develop when contracts/ files change. Refs: #460 --- .github/workflows/ci.yml | 66 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..588b2e8d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +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 From e558f56ee9767be22cc66bca207cfd8c755d1cbc Mon Sep 17 00:00:00 2001 From: emdevelopa Date: Thu, 28 May 2026 22:39:43 +0100 Subject: [PATCH 4/4] ci: add Dependabot config for npm, cargo, and GitHub Actions Add .github/dependabot.yml with package-ecosystem entries for: - npm (root, /frontend, /backend) - cargo (/contracts) - github-actions (/) Configured with weekly schedule (Monday), sensible PR limits, and grouped minor/patch updates to reduce PR noise. Refs: #461 --- .github/dependabot.yml | 67 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .github/dependabot.yml 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"