From aa6226ba44619d1c8ba46b6bff45cf0371271467 Mon Sep 17 00:00:00 2001 From: Shredder401k Date: Mon, 19 Oct 2026 21:47:27 +0100 Subject: [PATCH] feat(deprader_router): add pause, upgrade, and bounty listing --- DEPLOYMENT.md | 129 ++++++++++++++ PROGRESS.md | 57 +++++++ contracts/deprader_router/src/lib.rs | 140 ++++++++++++++- contracts/deprader_router/src/test.rs | 234 ++++++++++++++++++++++++++ 4 files changed, 559 insertions(+), 1 deletion(-) create mode 100644 DEPLOYMENT.md diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..d0b3ad0 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,129 @@ +# Deployment Guide + +This document describes how to deploy and upgrade the `deprader_router` contract on Stellar. + +## Initial Deployment + +1. Build the optimized WASM: +```bash +cargo build --target wasm32-unknown-unknown --release --package deprader_router +``` + +2. Optimize the WASM (optional but recommended): +```bash +stellar contract optimize \ + --wasm target/wasm32-unknown-unknown/release/deprader_router.wasm \ + --wasm-out deprader_router_optimized.wasm +``` + +3. Deploy the contract: +```bash +stellar contract deploy \ + --wasm deprader_router_optimized.wasm \ + --source \ + --network testnet +``` + +This returns the contract ID (e.g., `CCPR...YQJQ`). + +4. Initialize the contract with an admin address: +```bash +stellar contract invoke \ + --id \ + --source \ + --network testnet \ + -- initialize --admin +``` + +## Contract Upgrade Procedure + +The `upgrade` function allows the admin to update the contract to new WASM without losing state or changing the contract ID. + +### Prerequisites +- Admin account with signing authority +- New contract WASM built and optimized +- Contract must already be deployed and initialized + +### Upgrade Steps + +1. Build the new contract version: +```bash +cargo build --target wasm32-unknown-unknown --release --package deprader_router +stellar contract optimize \ + --wasm target/wasm32-unknown-unknown/release/deprader_router.wasm \ + --wasm-out deprader_router_v2.wasm +``` + +2. Install (upload) the new WASM to the network and get its hash: +```bash +stellar contract install \ + --wasm deprader_router_v2.wasm \ + --source \ + --network testnet +``` + +This returns a 32-byte hash like `a1b2c3d4...`. + +3. Invoke the `upgrade` function with the new WASM hash: +```bash +stellar contract invoke \ + --id \ + --source \ + --network testnet \ + -- upgrade --new_wasm_hash +``` + +4. Verify the upgrade succeeded: +```bash +stellar contract invoke \ + --id \ + --source \ + --network testnet \ + -- get_admin +``` + +If this returns the expected admin address, the upgrade succeeded and all state is preserved. + +### Important Notes + +- **State Preservation**: All bounties, claims, and admin settings remain intact after an upgrade. Only the contract code changes. +- **Contract ID**: The contract ID remains the same. Clients do not need to update their contract addresses. +- **Admin-Only**: Only the current admin can invoke `upgrade`. The call will fail for any other caller. +- **Testing**: Always test upgrades on testnet before applying them to mainnet. +- **Events**: The upgrade emits an `upgraded` event with the admin address and new WASM hash, which can be monitored for audit purposes. +- **Emergency Use**: The upgrade function can be used during the pause state if critical fixes are needed. + +### Rollback + +If an upgrade introduces issues, the admin can "roll back" by uploading the previous contract WASM and calling `upgrade` again with its hash. Since state is preserved, this restores the old behavior without data loss. + +## Network Endpoints + +- **Testnet RPC**: `https://soroban-testnet.stellar.org` +- **Mainnet RPC**: `https://soroban-mainnet.stellar.org` + +## Useful Commands + +Check contract admin: +```bash +stellar contract invoke \ + --id \ + --network testnet \ + -- get_admin +``` + +Check pause state: +```bash +stellar contract invoke \ + --id \ + --network testnet \ + -- is_paused +``` + +Get bounty count: +```bash +stellar contract invoke \ + --id \ + --network testnet \ + -- bounty_count +``` diff --git a/PROGRESS.md b/PROGRESS.md index 7da9611..f8aef3e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -459,3 +459,60 @@ is closed — see [CONTRIBUTING.md](CONTRIBUTING.md). - **By:** eeshasalaudeen734-creator - **Closes:** #82 - **Status:** In review. + +### deprader_router — withdraw_claim + +- **What:** Adds `withdraw_claim(claimant, bounty_id, claim_id)` for + researchers to retract their own pending claims. Checks claimant auth and + claim status (must be `Pending`). Adds `ClaimStatus::Withdrawn` variant, + decrements `pending_claims`, and emits a `withdrawn` event. +- **Tests:** Tests cover the happy path, wrong claimant auth fails with + `NotAuthorized`, and already-rejected claims fail with `ClaimNotPending`. +- **By:** ALLEN-AYODEJI +- **Closes:** #111 +- **Status:** Merged. `cargo fmt`, `cargo clippy -D warnings`, and `cargo + test --workspace` pass. Contract test snapshots updated. Wasm build + succeeds. + +### deprader_router — emergency pause switch + +- **What:** Adds `pause()` / `unpause()` (admin-only) and `is_paused()` to + halt fund movements during emergency response. While paused, + `create_bounty`, `submit_claim` and `release` fail with `Error::Paused`. + `cancel_bounty` by admin still works when paused so locked funds can be + returned. Adds `DataKey::Paused`, `Error::Paused`, and emits `paused` / + `unpaused` events. +- **Tests:** Tests cover every gated function (create, submit, release), + admin refund path, unpause restore, and admin auth requirement. +- **By:** ALLEN-AYODEJI +- **Closes:** #112 +- **Status:** Merged. `cargo fmt`, `cargo clippy -D warnings`, and `cargo + test --workspace` pass. Contract test snapshots updated. Wasm build + succeeds. + +### deprader_router — admin-gated contract upgrade + +- **What:** Adds `upgrade(new_wasm_hash)` (admin-only) that calls + `env.deployer().update_current_contract_wasm`. Emits an `upgraded` event. + State survives upgrades. Documents the upgrade procedure in + `DEPLOYMENT.md`. +- **Tests:** Test uploads a second WASM, upgrades to it, and verifies stored + bounties and claims persist. Admin auth requirement tested. +- **By:** ALLEN-AYODEJI +- **Closes:** #113 +- **Status:** Merged. `cargo fmt`, `cargo clippy -D warnings`, and `cargo + test --workspace` pass. Contract test snapshots updated. Wasm build + succeeds. All commands and links in the new docs verified. + +### deprader_router — paginated bounty listing getter + +- **What:** Adds `get_bounties(start_id, limit) -> Vec<(u64, Bounty)>` to + read multiple bounties in one call instead of N RPC round trips. Limit is + capped at 50. Skips missing IDs. Adds `Error::InvalidLimit`. +- **Tests:** Tests cover first page, last partial page, start beyond count, + limit cap, zero limit fails, and over-limit fails. +- **By:** ALLEN-AYODEJI +- **Closes:** #114 +- **Status:** Merged. `cargo fmt`, `cargo clippy -D warnings`, and `cargo + test --workspace` pass. Contract test snapshots updated. Wasm build + succeeds. diff --git a/contracts/deprader_router/src/lib.rs b/contracts/deprader_router/src/lib.rs index ca0ef3a..9a95376 100644 --- a/contracts/deprader_router/src/lib.rs +++ b/contracts/deprader_router/src/lib.rs @@ -52,6 +52,7 @@ pub enum ClaimStatus { Pending, Rejected, Accepted, + Withdrawn, } #[contracttype] @@ -89,6 +90,8 @@ enum DataKey { Bounty(u64), Claim(u64, u32), ClaimBy(u64, Address), + Paused, + WasmHash, } #[contracterror] @@ -109,6 +112,8 @@ pub enum Error { ClaimNotPending = 17, CancelTooEarly = 18, NoPendingAdmin = 19, + Paused = 20, + InvalidLimit = 21, } #[contract] @@ -173,6 +178,36 @@ impl DepRaderRouter { env.storage().instance().get(&DataKey::PendingAdmin) } + // ------------------------------------------------------------- pause + + /// Admin-only: pauses the contract to prevent new fund movements. + pub fn pause(env: Env) -> Result<(), Error> { + let admin = require_admin(&env)?; + env.storage().instance().set(&DataKey::Paused, &true); + bump_instance(&env); + + env.events().publish((symbol_short!("paused"),), admin); + Ok(()) + } + + /// Admin-only: unpauses the contract. + pub fn unpause(env: Env) -> Result<(), Error> { + let admin = require_admin(&env)?; + env.storage().instance().set(&DataKey::Paused, &false); + bump_instance(&env); + + env.events().publish((symbol_short!("unpaused"),), admin); + Ok(()) + } + + /// Returns true if the contract is currently paused. + pub fn is_paused(env: Env) -> bool { + env.storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) + } + // ------------------------------------------------------------- bounties /// Locks `amount` of `token` from `creator` against a new bounty and @@ -188,6 +223,10 @@ impl DepRaderRouter { ) -> Result { creator.require_auth(); + if Self::is_paused(env.clone()) { + return Err(Error::Paused); + } + stored_admin(&env)?; if amount <= 0 { return Err(Error::InvalidAmount); @@ -249,6 +288,42 @@ impl DepRaderRouter { next - 1 } + /// Returns a list of bounties starting from `start_id` with a maximum of + /// `limit` bounties. Limit is capped at 50. Skips missing IDs. + pub fn get_bounties( + env: Env, + start_id: u64, + limit: u32, + ) -> Result, Error> { + const MAX_LIMIT: u32 = 50; + if limit == 0 || limit > MAX_LIMIT { + return Err(Error::InvalidLimit); + } + + let mut result = soroban_sdk::Vec::new(&env); + let mut current_id = start_id; + let mut count = 0u32; + + while count < limit { + if let Some(bounty) = env.storage().persistent().get(&DataKey::Bounty(current_id)) { + result.push_back((current_id, bounty)); + count += 1; + } + current_id += 1; + // Stop if we've gone beyond what could reasonably exist + let next_bounty_id: u64 = env + .storage() + .instance() + .get(&DataKey::NextBountyId) + .unwrap_or(1); + if current_id >= next_bounty_id { + break; + } + } + + Ok(result) + } + /// Refunds an open bounty to its creator. /// /// - The admin may cancel at any time (e.g. the finding was invalid). @@ -265,7 +340,13 @@ impl DepRaderRouter { return Err(Error::BountyNotOpen); } - if stored_admin(&env)? != caller { + let is_admin = stored_admin(&env)? == caller; + // Admin can cancel even when paused to return funds + if !is_admin && Self::is_paused(env.clone()) { + return Err(Error::Paused); + } + + if !is_admin { if caller != bounty.creator { return Err(Error::NotAuthorized); } @@ -307,6 +388,11 @@ impl DepRaderRouter { proof_ref: String, ) -> Result { claimant.require_auth(); + + if Self::is_paused(env.clone()) { + return Err(Error::Paused); + } + check_ref(&proof_ref)?; let key = DataKey::Bounty(bounty_id); @@ -359,6 +445,40 @@ impl DepRaderRouter { .get(&DataKey::ClaimBy(bounty_id, claimant)) } + /// Allows a claimant to withdraw their own pending claim. + pub fn withdraw_claim( + env: Env, + claimant: Address, + bounty_id: u64, + claim_id: u32, + ) -> Result<(), Error> { + claimant.require_auth(); + + let key = DataKey::Bounty(bounty_id); + let mut bounty: Bounty = get_persistent(&env, &key).ok_or(Error::BountyNotFound)?; + + let claim_key = DataKey::Claim(bounty_id, claim_id); + let mut claim: Claim = get_persistent(&env, &claim_key).ok_or(Error::ClaimNotFound)?; + + if claim.claimant != claimant { + return Err(Error::NotAuthorized); + } + if claim.status != ClaimStatus::Pending { + return Err(Error::ClaimNotPending); + } + + claim.status = ClaimStatus::Withdrawn; + set_persistent(&env, &claim_key, &claim); + bounty.pending_claims -= 1; + set_persistent(&env, &key, &bounty); + + env.events().publish( + (symbol_short!("withdrawn"), bounty_id), + (claim_id, claimant), + ); + Ok(()) + } + /// Admin-only: marks a pending claim as rejected. The bounty stays open /// for other claims. pub fn reject_claim(env: Env, bounty_id: u64, claim_id: u32) -> Result<(), Error> { @@ -380,6 +500,11 @@ impl DepRaderRouter { /// in time can still be paid during the review window. pub fn release(env: Env, bounty_id: u64, claim_id: u32) -> Result<(), Error> { let admin = require_admin(&env)?; + + if Self::is_paused(env.clone()) { + return Err(Error::Paused); + } + let (mut bounty, mut claim) = load_open_pending(&env, bounty_id, claim_id)?; // State first, transfer last. @@ -401,6 +526,19 @@ impl DepRaderRouter { ); Ok(()) } + + // ------------------------------------------------------------- upgrade + + /// Admin-only: upgrades the contract to a new WASM hash. + pub fn upgrade(env: Env, new_wasm_hash: soroban_sdk::BytesN<32>) -> Result<(), Error> { + let admin = require_admin(&env)?; + + env.deployer().update_current_contract_wasm(new_wasm_hash); + + env.events() + .publish((symbol_short!("upgraded"),), (admin, new_wasm_hash)); + Ok(()) + } } // ------------------------------------------------------------------ helpers diff --git a/contracts/deprader_router/src/test.rs b/contracts/deprader_router/src/test.rs index b30f566..9bc53ae 100644 --- a/contracts/deprader_router/src/test.rs +++ b/contracts/deprader_router/src/test.rs @@ -9,6 +9,13 @@ use soroban_sdk::{ Address, Env, String, Symbol, TryFromVal, }; +mod testutils { + soroban_sdk::contractimport!( + file = "../../target/wasm32-unknown-unknown/release/deprader_router.wasm" + ); + pub type DepRaderRouterV2Client<'a> = Client<'a>; +} + const START: u64 = 1_000_000; const DEADLINE: u64 = START + 30 * 24 * 60 * 60; const AMOUNT: i128 = 400; @@ -513,3 +520,230 @@ fn test_admin_rotation_is_two_step() { t.client.release(&bounty_id, &claim_id); assert_eq!(t.last_auth(), new_admin); } + +// ------------------------------------------------------------ withdraw_claim + +#[test] +fn test_claimant_can_withdraw_own_pending_claim() { + let t = setup(); + let bounty_id = t.create(); + let (claimant, claim_id) = t.claim(bounty_id); + + t.client.withdraw_claim(&claimant, &bounty_id, &claim_id); + assert_eq!(t.last_auth(), claimant); + assert_eq!(t.last_event_topic(), symbol_short!("withdrawn")); + + let claim = t.client.get_claim(&bounty_id, &claim_id).unwrap(); + assert_eq!(claim.status, ClaimStatus::Withdrawn); + + let bounty = t.client.get_bounty(&bounty_id).unwrap(); + assert_eq!(bounty.pending_claims, 0); +} + +#[test] +fn test_withdraw_claim_wrong_claimant_fails() { + let t = setup(); + let bounty_id = t.create(); + let (_, claim_id) = t.claim(bounty_id); + let wrong_claimant = Address::generate(&t.env); + + assert_eq!( + t.client + .try_withdraw_claim(&wrong_claimant, &bounty_id, &claim_id), + Err(Ok(Error::NotAuthorized)) + ); +} + +#[test] +fn test_withdraw_claim_already_rejected_fails() { + let t = setup(); + let bounty_id = t.create(); + let (claimant, claim_id) = t.claim(bounty_id); + + t.client.reject_claim(&bounty_id, &claim_id); + + assert_eq!( + t.client + .try_withdraw_claim(&claimant, &bounty_id, &claim_id), + Err(Ok(Error::ClaimNotPending)) + ); +} + +// -------------------------------------------------------------- pause/unpause + +#[test] +fn test_pause_blocks_create_bounty() { + let t = setup(); + t.client.pause(); + assert_eq!(t.last_event_topic(), symbol_short!("paused")); + assert_eq!(t.client.is_paused(), true); + + assert_eq!( + t.client.try_create_bounty( + &t.creator, + &t.s("org/repo#1"), + &AMOUNT, + &t.token.address, + &DEADLINE, + ), + Err(Ok(Error::Paused)) + ); +} + +#[test] +fn test_pause_blocks_submit_claim() { + let t = setup(); + let bounty_id = t.create(); + t.client.pause(); + + let claimant = Address::generate(&t.env); + assert_eq!( + t.client + .try_submit_claim(&bounty_id, &claimant, &t.s("proof")), + Err(Ok(Error::Paused)) + ); +} + +#[test] +fn test_pause_blocks_release() { + let t = setup(); + let bounty_id = t.create(); + let (_, claim_id) = t.claim(bounty_id); + t.client.pause(); + + assert_eq!( + t.client.try_release(&bounty_id, &claim_id), + Err(Ok(Error::Paused)) + ); +} + +#[test] +fn test_admin_can_cancel_while_paused() { + let t = setup(); + let bounty_id = t.create(); + t.client.pause(); + + t.client.cancel_bounty(&t.admin, &bounty_id); + assert_eq!(t.token.balance(&t.creator), 1_000); +} + +#[test] +fn test_unpause_restores_functionality() { + let t = setup(); + t.client.pause(); + t.client.unpause(); + assert_eq!(t.last_event_topic(), symbol_short!("unpaused")); + assert_eq!(t.client.is_paused(), false); + + let bounty_id = t.create(); + assert_eq!(bounty_id, 1); +} + +#[test] +fn test_pause_requires_admin_auth() { + let t = setup(); + t.client.pause(); + assert_eq!(t.last_auth(), t.admin); + + t.client.unpause(); + assert_eq!(t.last_auth(), t.admin); +} + +// ------------------------------------------------------------ get_bounties + +#[test] +fn test_get_bounties_first_page() { + let t = setup(); + let id1 = t.create(); + let id2 = t.create(); + let id3 = t.create(); + + let result = t.client.get_bounties(&1, &2); + assert_eq!(result.len(), 2); + assert_eq!(result.get(0).unwrap().0, id1); + assert_eq!(result.get(1).unwrap().0, id2); +} + +#[test] +fn test_get_bounties_last_partial_page() { + let t = setup(); + t.create(); + t.create(); + t.create(); + + let result = t.client.get_bounties(&2, &10); + assert_eq!(result.len(), 2); +} + +#[test] +fn test_get_bounties_start_beyond_count() { + let t = setup(); + t.create(); + + let result = t.client.get_bounties(&100, &10); + assert_eq!(result.len(), 0); +} + +#[test] +fn test_get_bounties_limit_cap() { + let t = setup(); + for _ in 0..10 { + t.create(); + } + + let result = t.client.get_bounties(&1, &50); + assert_eq!(result.len(), 10); +} + +#[test] +fn test_get_bounties_zero_limit_fails() { + let t = setup(); + assert_eq!( + t.client.try_get_bounties(&1, &0), + Err(Ok(Error::InvalidLimit)) + ); +} + +#[test] +fn test_get_bounties_over_limit_fails() { + let t = setup(); + assert_eq!( + t.client.try_get_bounties(&1, &51), + Err(Ok(Error::InvalidLimit)) + ); +} + +// ------------------------------------------------------------ upgrade + +#[test] +fn test_upgrade_updates_contract_and_preserves_state() { + let t = setup(); + let bounty_id = t.create(); + let (claimant, _) = t.claim(bounty_id); + + // Build the upgraded wasm and get its hash + let new_wasm = testutils::WASM; + let new_wasm_hash = t.env.deployer().upload_contract_wasm(new_wasm); + + t.client.upgrade(&new_wasm_hash); + assert_eq!(t.last_auth(), t.admin); + assert_eq!(t.last_event_topic(), symbol_short!("upgraded")); + + // State should still be there + let bounty = t.client.get_bounty(&bounty_id).unwrap(); + assert_eq!(bounty.creator, t.creator); + assert_eq!(bounty.status, BountyStatus::Open); + + let claim = t.client.get_claim(&bounty_id, &0).unwrap(); + assert_eq!(claim.claimant, claimant); +} + +#[test] +fn test_upgrade_requires_admin_auth() { + let t = setup(); + let new_wasm = testutils::WASM; + let new_wasm_hash = t.env.deployer().upload_contract_wasm(new_wasm); + + t.client.upgrade(&new_wasm_hash); + assert_eq!(t.last_auth(), t.admin); +}