Skip to content
Open
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
6 changes: 6 additions & 0 deletions contracts/events/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ publish = false
crate-type = ["lib", "cdylib"]
doctest = false

[features]
# Testnet-only: zero the upgrade timelock so upgrades apply immediately for fast
# iteration. NEVER enable on mainnet — the default (feature off) keeps the full
# audit-mandated ~1-day timelock, so forgetting the flag fails safe (secure).
testnet = []

[dependencies]
soroban-sdk = { workspace = true }

Expand Down
6 changes: 6 additions & 0 deletions contracts/events/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@ pub(crate) const MAX_FEE_BPS: u32 = 1_000;
// to react before the new wasm lands.
// PENDING_UPGRADE_TTL_LEDGERS hard expiry on the proposal; ~30 days.
// Past this the admin must re-propose.
// Testnet builds (`--features testnet`) zero the upgrade timelock for fast
// iteration; the default build (mainnet + everything else) keeps the full
// ~1-day timelock. Fail-safe: omitting the flag yields the secure value, never 0.
#[cfg(not(feature = "testnet"))]
const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280;
#[cfg(feature = "testnet")]
const UPGRADE_TIMELOCK_LEDGERS: u32 = 0;
const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400;

// Initial contract version. Written by __constructor and bumped on
Expand Down
43 changes: 39 additions & 4 deletions contracts/events/src/event_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ const MIN_CONTRIBUTION_STROOPS: i128 = 100_000_000_i128; // 10 * 10^7
// ============================================================
// CREATE EVENT
// ============================================================
/// The address authorized to manage an event (select winners, cancel). A
/// per-event manager override takes precedence; otherwise management falls back
/// to the event owner (legacy events created before manager support). This
/// decouples the funding source (owner) from the operating identity (manager).
fn resolve_manager(env: &Env, event_id: u64, owner: &Address) -> Address {
storage::get_event_manager(env, event_id).unwrap_or_else(|| owner.clone())
}

pub fn create_event(
env: &Env,
params: CreateEventParams,
Expand Down Expand Up @@ -153,6 +161,12 @@ pub fn create_event(
};
storage::set_event(env, id, &record);

// Record the management authority override when the owner delegates it (so
// an org can fund from any wallet but keep management on its own wallet).
if let Some(manager) = &params.manager {
storage::set_event_manager(env, id, manager);
}

// Crowdfunding: pre-seat the builder as the sole winner at position 1.
if is_crowdfunding {
storage::append_winner(
Expand Down Expand Up @@ -183,6 +197,23 @@ pub fn create_event(
Ok(id)
}

/// Re-assign (or set) the management authority for an event. Gated by the
/// current manager (the override if present, else the owner), so an org can
/// rotate its operating wallet but an outsider cannot hijack management.
pub fn set_manager(env: &Env, event_id: u64, new_manager: Address) -> Result<(), Error> {
admin::require_not_paused(env)?;
let event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?;
resolve_manager(env, event_id, &event.owner).require_auth();
storage::set_event_manager(env, event_id, &new_manager);
Ok(())
}

/// The current management authority for an event (override if set, else owner).
pub fn get_manager(env: &Env, event_id: u64) -> Result<Address, Error> {
let event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?;
Ok(resolve_manager(env, event_id, &event.owner))
}

// ============================================================
// ADD FUNDS (partner / community contribution)
// ============================================================
Expand Down Expand Up @@ -293,7 +324,8 @@ pub fn start_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<(), E
return Err(Error::CancellationAlreadyStarted);
}

event.owner.require_auth();
// Management authority: the per-event manager override if set, else owner.
resolve_manager(env, event_id, &event.owner).require_auth();

let remaining = event.remaining_escrow;
let count = storage::contributor_count(env, event_id);
Expand Down Expand Up @@ -369,7 +401,8 @@ pub fn process_cancel_batch(
if !matches!(event.status, EventStatus::Cancelling) {
return Err(Error::CancellationNotStarted);
}
event.owner.require_auth();
// Management authority: the per-event manager override if set, else owner.
resolve_manager(env, event_id, &event.owner).require_auth();

let mut state =
storage::get_cancellation_state(env, event_id).ok_or(Error::CancellationNotStarted)?;
Expand Down Expand Up @@ -431,7 +464,8 @@ pub fn finalize_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<()
if !matches!(event.status, EventStatus::Cancelling) {
return Err(Error::CancellationNotStarted);
}
event.owner.require_auth();
// Management authority: the per-event manager override if set, else owner.
resolve_manager(env, event_id, &event.owner).require_auth();

let state =
storage::get_cancellation_state(env, event_id).ok_or(Error::CancellationNotStarted)?;
Expand Down Expand Up @@ -590,7 +624,8 @@ pub fn select_winners(
return Err(Error::InvalidPillar);
}

event.owner.require_auth();
// Management authority: the per-event manager override if set, else owner.
resolve_manager(env, event_id, &event.owner).require_auth();

// One-shot per event: detect a prior selection by an anchor row
// (milestone == None). Crowdfunding's create_event seeds an anchor too,
Expand Down
11 changes: 11 additions & 0 deletions contracts/events/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,17 @@ impl EventsContract {
event_ops::select_winners(&env, event_id, winners, op_id)
}

// ============================================================
// MANAGEMENT AUTHORITY (manager != funder/owner)
// ============================================================
pub fn set_manager(env: Env, event_id: u64, new_manager: Address) -> Result<(), Error> {
event_ops::set_manager(&env, event_id, new_manager)
}

pub fn get_manager(env: Env, event_id: u64) -> Result<Address, Error> {
event_ops::get_manager(&env, event_id)
}

pub fn claim_milestone(
env: Env,
event_id: u64,
Expand Down
16 changes: 16 additions & 0 deletions contracts/events/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,22 @@ pub fn set_event(env: &Env, id: u64, record: &EventRecord) {
touch_event_persistent(env, &key);
}

// Per-event management authority override (side map; absent => owner manages).
pub fn get_event_manager(env: &Env, id: u64) -> Option<Address> {
let key = DataKey::EventManager(id);
let m: Option<Address> = env.storage().persistent().get(&key);
if m.is_some() {
touch_event_persistent(env, &key);
}
m
}
Comment on lines +218 to +225

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify EventManager TTL refresh points and authority fallback path.
rg -n "EventManager|get_event_manager|set_event_manager|touch_event_persistent|resolve_manager|get_event\\(|set_event\\(" \
  contracts/events/src/storage.rs contracts/events/src/event_ops.rs

Repository: boundlessfi/boundless-contract

Length of output: 7208


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,120p' contracts/events/src/storage.rs
echo "----"
sed -n '180,260p' contracts/events/src/storage.rs
echo "----"
sed -n '40,110p' contracts/events/src/event_ops.rs
echo "----"
sed -n '140,220p' contracts/events/src/event_ops.rs
echo "----"
sed -n '300,360p' contracts/events/src/event_ops.rs
echo "----"
sed -n '780,860p' contracts/events/src/event_ops.rs

Repository: boundlessfi/boundless-contract

Length of output: 17659


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "resolve_manager\\(" contracts/events/src/event_ops.rs
echo "----"
rg -n "get_event_manager\\(|set_event_manager\\(" contracts/events/src
echo "----"
rg -n "storage::get_event\\(|storage::set_event\\(" contracts/events/src/event_ops.rs

Repository: boundlessfi/boundless-contract

Length of output: 2942


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '280,360p' contracts/events/src/event_ops.rs
echo "----"
sed -n '380,440p' contracts/events/src/event_ops.rs
echo "----"
sed -n '440,520p' contracts/events/src/event_ops.rs
echo "----"
sed -n '600,660p' contracts/events/src/event_ops.rs

Repository: boundlessfi/boundless-contract

Length of output: 10842


Manager override can expire independently and silently revert authority to owner

DataKey::EventManager(event_id) TTL is only refreshed in get_event_manager/set_event_manager, while normal event activity refreshes only DataKey::Event(event_id) via get_event/set_event. If the manager entry expires while the event stays alive, resolve_manager() falls back to event.owner, so subsequent require_auth in cancel/winner flows will accept the owner instead of the delegated manager.

Suggested fix
pub fn get_event(env: &Env, id: u64) -> Option<EventRecord> {
    let key = DataKey::Event(id);
    let rec: Option<EventRecord> = env.storage().persistent().get(&key);
    if rec.is_some() {
        touch_event_persistent(env, &key);
+       // Keep side-map authority key alive whenever the event itself is alive.
+       let _ = get_event_manager(env, id);
    }
    rec
}

pub fn set_event(env: &Env, id: u64, record: &EventRecord) {
    let key = DataKey::Event(id);
    env.storage().persistent().set(&key, record);
    touch_event_persistent(env, &key);
+   // Keep side-map authority key aligned with event TTL if present.
+   let _ = get_event_manager(env, id);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/events/src/storage.rs` around lines 218 - 225, The EventManager
override can silently expire because only DataKey::EventManager(id) is refreshed
in get_event_manager/set_event_manager while normal event activity only
refreshes DataKey::Event(id); update the code so touch_event_persistent (and/or
the event accessors get_event/set_event) also refreshes the corresponding
DataKey::EventManager(id) TTL when an event is accessed/updated: when
touch_event_persistent(env, &DataKey::Event(id)) is called, check
env.storage().persistent().get(&DataKey::EventManager(id)) and call
touch_event_persistent(env, &DataKey::EventManager(id)) (or reuse the existing
touch logic) so resolve_manager continues to see the delegated manager while the
event is active. Ensure you reference and update get_event, set_event,
touch_event_persistent, and DataKey::EventManager accordingly.


pub fn set_event_manager(env: &Env, id: u64, manager: &Address) {
let key = DataKey::EventManager(id);
env.storage().persistent().set(&key, manager);
touch_event_persistent(env, &key);
}

// ============================================================
// APPLICANTS (paged, persistent)
//
Expand Down
2 changes: 2 additions & 0 deletions contracts/events/src/tests/contributions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ fn create_hackathon(ctx: &Ctx) -> u64 {
winner_distribution: single_dist(&ctx.env),
application_credit_cost: 0,
fee_bps_override: None,
manager: None,
};
let op = BytesN::random(&ctx.env);
ctx.events.create_event(&params, &op)
Expand Down Expand Up @@ -383,6 +384,7 @@ fn cancel_at_boundary_pays_partners_full_no_owner_residual() {
winner_distribution: dist,
application_credit_cost: 0,
fee_bps_override: None,
manager: None,
};
let op_create = BytesN::random(&ctx.env);
let id = ctx.events.create_event(&params, &op_create);
Expand Down
87 changes: 87 additions & 0 deletions contracts/events/src/tests/cross_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ fn create_bounty(ctx: &Ctx, application_credit_cost: u32) -> u64 {
winner_distribution: one_winner_distribution(&ctx.env),
application_credit_cost,
fee_bps_override: None,
manager: None,
};
let op_id = BytesN::random(&ctx.env);
ctx.events.create_event(&params, &op_id)
Expand Down Expand Up @@ -321,6 +322,7 @@ fn select_winners_rejects_duplicate_position() {
winner_distribution: dist,
application_credit_cost: 1,
fee_bps_override: None,
manager: None,
};
let op_create = BytesN::random(&ctx.env);
let bounty_id = ctx.events.create_event(&params, &op_create);
Expand Down Expand Up @@ -366,6 +368,7 @@ fn select_winners_handles_multi_recipient_distribution() {
winner_distribution: dist,
application_credit_cost: 0, // free for this test
fee_bps_override: None,
manager: None,
};
let op_create = BytesN::random(&ctx.env);
let bounty_id = ctx.events.create_event(&params, &op_create);
Expand Down Expand Up @@ -487,6 +490,7 @@ fn cancel_after_select_winners_refunds_only_remaining() {
winner_distribution: dist,
application_credit_cost: 0,
fee_bps_override: None,
manager: None,
};
let op_create = BytesN::random(&ctx.env);
let bounty_id = ctx.events.create_event(&params, &op_create);
Expand Down Expand Up @@ -538,6 +542,7 @@ fn create_grant(ctx: &Ctx, n_milestones: u32) -> u64 {
winner_distribution: dist,
application_credit_cost: 0,
fee_bps_override: None,
manager: None,
};
let op_create = BytesN::random(&ctx.env);
ctx.events.create_event(&params, &op_create)
Expand Down Expand Up @@ -705,6 +710,7 @@ fn create_hackathon(ctx: &Ctx) -> u64 {
winner_distribution: dist,
application_credit_cost: 0,
fee_bps_override: None,
manager: None,
};
let op_create = BytesN::random(&ctx.env);
ctx.events.create_event(&params, &op_create)
Expand Down Expand Up @@ -855,6 +861,7 @@ fn create_event_charges_override_rate_when_provided() {
winner_distribution: one_winner_distribution(&ctx.env),
application_credit_cost: 0,
fee_bps_override: Some(override_bps),
manager: None,
};
let op = BytesN::random(&ctx.env);
let id = ctx.events.create_event(&params, &op);
Expand Down Expand Up @@ -892,6 +899,7 @@ fn add_funds_uses_event_override_not_global() {
winner_distribution: one_winner_distribution(&ctx.env),
application_credit_cost: 0,
fee_bps_override: Some(override_bps),
manager: None,
};
let op_create = BytesN::random(&ctx.env);
let id = ctx.events.create_event(&params, &op_create);
Expand Down Expand Up @@ -943,6 +951,7 @@ fn create_event_with_waiver_charges_no_fee() {
winner_distribution: one_winner_distribution(&ctx.env),
application_credit_cost: 0,
fee_bps_override: Some(0),
manager: None,
};
let op = BytesN::random(&ctx.env);
ctx.events.create_event(&params, &op);
Expand Down Expand Up @@ -970,6 +979,7 @@ fn create_event_rejects_override_above_max_fee_bps() {
application_credit_cost: 0,
// 60% is above the MAX_FEE_BPS = 1000 cap (post L4 audit fix).
fee_bps_override: Some(6000),
manager: None,
};
let op = BytesN::random(&ctx.env);
let res = ctx.events.try_create_event(&params, &op);
Expand Down Expand Up @@ -997,6 +1007,7 @@ fn create_event_omitted_override_falls_back_to_global_default() {
winner_distribution: one_winner_distribution(&ctx.env),
application_credit_cost: 0,
fee_bps_override: None,
manager: None,
};
let op = BytesN::random(&ctx.env);
let id = ctx.events.create_event(&params, &op);
Expand Down Expand Up @@ -1158,3 +1169,79 @@ fn select_winners_pays_against_remaining_escrow_including_top_ups() {
assert_eq!(event_post.remaining_escrow, 0);
assert_eq!(event_post.status, EventStatus::Completed);
}

// ============================================================
// MANAGEMENT AUTHORITY (manager decoupled from funder/owner)
// ============================================================
fn create_bounty_with_manager(ctx: &Ctx, manager: &Address) -> u64 {
let params = CreateEventParams {
pillar: Pillar::Bounty,
owner: ctx.owner.clone(),
token: ctx.token_addr.clone(),
total_budget: 10_000_0000000_i128,
release_kind: ReleaseKind::Single,
content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/events/draft/m"),
title: String::from_str(&ctx.env, "Managed Bounty"),
deadline: Some(ctx.env.ledger().timestamp() + 86_400),
winner_distribution: one_winner_distribution(&ctx.env),
application_credit_cost: 1,
fee_bps_override: None,
manager: Some(manager.clone()),
};
let op_id = BytesN::random(&ctx.env);
ctx.events.create_event(&params, &op_id)
}

#[test]
fn manager_defaults_to_owner_and_override_is_recorded() {
let ctx = setup();

// No override: management falls back to the funder/owner (legacy behavior).
let default_id = create_bounty(&ctx, 1);
assert_eq!(ctx.events.get_manager(&default_id), ctx.owner);

// Override: management is the org wallet, distinct from the funder.
let manager = Address::generate(&ctx.env);
let managed_id = create_bounty_with_manager(&ctx, &manager);
assert_eq!(ctx.events.get_manager(&managed_id), manager);
assert_ne!(ctx.events.get_manager(&managed_id), ctx.owner);
}

#[test]
fn manager_can_be_rotated() {
let ctx = setup();
let manager = Address::generate(&ctx.env);
let id = create_bounty_with_manager(&ctx, &manager);
assert_eq!(ctx.events.get_manager(&id), manager);

let manager2 = Address::generate(&ctx.env);
ctx.events.set_manager(&id, &manager2);
assert_eq!(ctx.events.get_manager(&id), manager2);
}

#[test]
fn manager_override_can_select_winners() {
let ctx = setup();
let manager = Address::generate(&ctx.env);
let bounty_id = create_bounty_with_manager(&ctx, &manager);

let op_apply = BytesN::random(&ctx.env);
ctx.events
.apply_to_bounty(&bounty_id, &ctx.applicant, &op_apply);

let winners = soroban_sdk::vec![
&ctx.env,
WinnerSpec {
recipient: ctx.applicant.clone(),
position: 1,
credit_earn: 0,
reputation_bump: 0,
},
];
let op_select = BytesN::random(&ctx.env);
ctx.events.select_winners(&bounty_id, &winners, &op_select);

// Managed event settled via the manager authority.
let event = ctx.events.get_event(&bounty_id);
assert_eq!(event.status, EventStatus::Completed);
}
4 changes: 4 additions & 0 deletions contracts/events/src/tests/crowdfunding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ fn create_campaign(ctx: &Ctx, milestones: u32) -> u64 {
winner_distribution: single_dist_100_at_1(&ctx.env),
application_credit_cost: 0,
fee_bps_override: None,
manager: None,
};
let op = BytesN::random(&ctx.env);
ctx.events.create_event(&params, &op)
Expand Down Expand Up @@ -169,6 +170,7 @@ fn create_rejects_single_release_kind() {
winner_distribution: single_dist_100_at_1(&ctx.env),
application_credit_cost: 0,
fee_bps_override: None,
manager: None,
};
let op = BytesN::random(&ctx.env);
let res = ctx.events.try_create_event(&params, &op);
Expand All @@ -190,6 +192,7 @@ fn create_rejects_missing_deadline() {
winner_distribution: single_dist_100_at_1(&ctx.env),
application_credit_cost: 0,
fee_bps_override: None,
manager: None,
};
let op = BytesN::random(&ctx.env);
let res = ctx.events.try_create_event(&params, &op);
Expand All @@ -214,6 +217,7 @@ fn create_rejects_distribution_with_multiple_positions() {
winner_distribution: dist,
application_credit_cost: 0,
fee_bps_override: None,
manager: None,
};
let op = BytesN::random(&ctx.env);
let res = ctx.events.try_create_event(&params, &op);
Expand Down
10 changes: 10 additions & 0 deletions contracts/events/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ pub struct CreateEventParams {
pub winner_distribution: Map<u32, u32>,
pub application_credit_cost: u32,
pub fee_bps_override: Option<u32>,
// Optional management authority. None => owner manages (legacy behavior).
// When set, this address authorizes select_winners + cancel instead of the
// owner, so funding source and management identity can differ.
pub manager: Option<Address>,
}

// ============================================================
Expand Down Expand Up @@ -209,6 +213,12 @@ pub enum DataKey {
NextEventId,
Event(u64),

// Per-event management authority override. When present, this address
// (not event.owner) authorizes select_winners + cancel. Lets an org fund
// from any wallet (owner) while keeping management bound to its canonical
// wallet. Absent => management falls back to event.owner (legacy events).
EventManager(u64),

// Per-event applicant list. Paged: ApplicantCount + ApplicantAt(idx).
// Slot index (1-based) for O(1) membership / O(1) swap-remove. Slot of
// 0 means absent. Caps at MAX_APPLICANTS_PER_EVENT to keep cancel-time
Expand Down
Loading