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
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Never add "Co-Authored-By" lines to commits

Comment sparingly. A comment earns its place only by stating something the
code cannot: an invariant, a security-relevant ordering, a compatibility
trap, or a non-obvious "why". Do not narrate what the code does, restate the
function name, tag lines with version or PR numbers, or add banners over
self-evident blocks. When in doubt, delete it. Match the comment density of
the surrounding file.
5 changes: 4 additions & 1 deletion BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ See `docs/audit-2026-06-stellar-skill.md` for full findings.

## P1 (post-launch)

- [ ] `select_winners` re-run semantics: today rejected; revisit if a real customer wants "append-only" behavior. Open question, not blocking.
- [x] `select_winners` re-run semantics: 1.3.0 (#61) made Single-release selection batchable — each position is awardable exactly once (per-position `EventPrizeAward` key is the replay lock), amounts stay anchored to the baseline captured at the first batch, and pre-1.3.0 events remain one-shot. (2026-07-18)
- [ ] Per-event prize claim window: `PRIZE_CLAIM_WINDOW_SECS` (90 days) is a module constant for now because adding a field to `EventRecord` requires a storage migration. Move it onto `EventRecord` at the next real migration window, per the per-event-config rule in CLAUDE.md.
- [ ] `Error` enum is at the 50-case XDR spec cap. The next error needs consolidation of an existing variant; plan this before the audit freeze.
- [ ] Measure real `select_winners` batch headroom with `simulateTransaction` on testnet (entry writes per winner: row + award key). The 50/call cap is conservative; raise it only from measured numbers.
- [ ] Grant committee multi-sig primitive (dedicated signer set + quorum at the contract level vs the current address-level multi-sig).
- [ ] Bounty Showdown participation badge on the boundless-profile contract for non-winning finalists.
- [ ] Multi-token support audit: verify the whitelist mechanism handles tokens with non-Stellar 7-decimal scales (currently assumed uniform).
Expand Down
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The Stellar Development Foundation publishes a Claude Code skill that bundles cu

After install, the seven sub-skills (`soroban`, `dapp`, `assets`, `data`, `agentic-payments`, `zk-proofs`, `standards`) become available across sessions. Lean on `soroban/` for contract changes and audit prep; lean on `dapp/` and `assets/` only when the work crosses into the frontend wallet or trustline flows.

Source: https://github.com/stellar/stellar-dev-skill
Source: <https://github.com/stellar/stellar-dev-skill>

## Hard rules

Expand All @@ -22,6 +22,7 @@ Source: https://github.com/stellar/stellar-dev-skill
- **Per-event configuration over global constants.** Anything sales might want to vary per program (fees, windows, caps) belongs on `EventRecord` or its variant payload, not in module constants.
- **Tests cover the math.** Every payout split (single + multi-position + sweep) has a test that asserts both the recipient and the fee account deltas.
- **Snapshots are inspection tooling, not history.** `test_snapshots/` is gitignored: snapshots are derived artifacts that `cargo test` regenerates from any commit, and committing them made PR diffs so large that the security scanners skipped them. When reviewing a storage-layout or auth change, regenerate locally and inspect the snapshot diff — but never commit snapshot files.
- **Comment sparingly.** A comment earns its place only by stating a constraint the code cannot: an invariant, a security ordering, a compatibility trap, a non-obvious "why". Do not narrate what the code does, restate the function name, tag changes with version/PR numbers, or leave banners over self-evident blocks. When in doubt, delete it — dense explanatory comments read as AI-generated and make review harder, not easier. Match the density of the surrounding file.

## Build, test, deploy

Expand Down Expand Up @@ -49,3 +50,6 @@ cargo build --release --target wasm32-unknown-unknown
```

Update `BACKLOG.md` if your PR closes one of the entries there.

@AGENTS.md
Never add "Co-Authored-By" lines to commits
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion contracts/events/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "boundless-events"
version = "1.2.0"
version = "1.3.0"
edition = "2021"
publish = false

Expand Down
2 changes: 1 addition & 1 deletion contracts/events/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280;
const UPGRADE_TIMELOCK_LEDGERS: u32 = 0;
const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400;

pub const INITIAL_VERSION: &str = "1.2.0";
pub const INITIAL_VERSION: &str = "1.3.0";

// ============================================================
// INITIALIZATION
Expand Down
3 changes: 3 additions & 0 deletions contracts/events/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,7 @@ pub enum Error {
Paused = 70,

ProfileCallFailed = 80,

// Enum is at the 50-case XDR cap; consolidate before adding another.
PrizeAlreadyClaimed = 91,
}
208 changes: 167 additions & 41 deletions contracts/events/src/event_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,17 @@ use crate::storage;
use crate::token_whitelist;
use crate::types::{
CancellationBranch, CancellationState, CreateEventParams, EventRecord, EventStatus, Pillar,
ReleaseKind, Submission, Winner, WinnerSpec,
PrizeAward, ReleaseKind, Submission, Winner, WinnerSpec,
};

const MAX_TITLE_LEN: u32 = 120;

const MAX_WINNERS_PER_SELECT: u32 = 50;

// Anchored at selection time, not the event deadline (which usually passes
// before winners are selected). A per-event override needs a migration.
pub const PRIZE_CLAIM_WINDOW_SECS: u64 = 90 * 24 * 60 * 60;

pub const MAX_APPLICANTS_PER_EVENT: u32 = 5_000;
pub const MAX_CONTRIBUTORS_PER_EVENT: u32 = 5_000;

Expand Down Expand Up @@ -269,6 +274,17 @@ pub fn start_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<(), E
return Err(Error::CancellationAlreadyStarted);
}

// Block cancel while prizes are unclaimed and the window is open;
// after it expires, unclaimed amounts sweep out via the refund path.
if matches!(event.release_kind, ReleaseKind::Single)
&& storage::unclaimed_prize_count(env, event_id) > 0
{
let expiry = storage::get_prize_claim_expiry(env, event_id).unwrap_or(0);
if env.ledger().timestamp() <= expiry {
return Err(Error::WinnersAlreadySelected);
}
}

resolve_manager(env, event_id, &event.owner).require_auth();

let remaining = event.remaining_escrow;
Expand Down Expand Up @@ -535,7 +551,7 @@ pub fn select_winners(
admin::require_not_paused(env)?;
idempotency::require_unseen(env, &op_id)?;

let mut event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?;
let event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?;
if !matches!(event.status, EventStatus::Active) {
return Err(Error::EventNotActive);
}
Expand All @@ -546,12 +562,23 @@ pub fn select_winners(
resolve_manager(env, event_id, &event.owner).require_auth();

let existing_count = storage::winner_count(env, event_id);
for idx in 0..existing_count {
if let Some(w) = storage::winner_at(env, event_id, idx) {
if w.milestone.is_none() {
match event.release_kind {
ReleaseKind::Single => {
// Winner rows but no base-escrow key means a pre-1.3.0 push-model
// event: keep it one-shot. New events award each position once.
if existing_count > 0 && storage::get_prize_base_escrow(env, event_id).is_none() {
return Err(Error::WinnersAlreadySelected);
}
}
ReleaseKind::Multi(_) => {
for idx in 0..existing_count {
if let Some(w) = storage::winner_at(env, event_id, idx) {
if w.milestone.is_none() {
return Err(Error::WinnersAlreadySelected);
}
}
}
}
}

if winners.is_empty() {
Expand Down Expand Up @@ -579,21 +606,31 @@ pub fn select_winners(
seen_positions.push_back(spec.position);
}

let profile = profile_client::client(env);
let now = env.ledger().timestamp();
let reason_win = Symbol::new(env, "win");

match event.release_kind {
ReleaseKind::Single => {
let escrow_at_select = event.remaining_escrow;
// Amounts are fixed against the escrow baseline captured at the
// first selection; claim_prize does the transfer and profile calls.
let base_escrow = match storage::get_prize_base_escrow(env, event_id) {
Some(b) => b,
None => {
let b = event.remaining_escrow;
storage::set_prize_base_escrow(env, event_id, b);
b
}
};

let mut total_owed: i128 = 0;
for spec in winners.iter() {
if storage::get_prize_award(env, event_id, spec.position).is_some() {
return Err(Error::DuplicateWinnerPosition);
}
let percent = event
.winner_distribution
.get(spec.position)
.ok_or(Error::InvalidDistribution)? as i128;
let amount = escrow_at_select.saturating_mul(percent) / 100_i128;
let amount = base_escrow.saturating_mul(percent) / 100_i128;
if amount <= 0 {
return Err(Error::InvalidDistribution);
}
Expand All @@ -604,32 +641,13 @@ pub fn select_winners(
}

for (idx, spec) in winners.iter().enumerate() {
let sub_idx = idx as u8;
let percent = event
.winner_distribution
.get(spec.position)
.ok_or(Error::InvalidDistribution)? as i128;
let amount = escrow_at_select.saturating_mul(percent) / 100_i128;

escrow::release(env, &event.token, &spec.recipient, amount);
event.remaining_escrow = event.remaining_escrow.saturating_sub(amount);

let bootstrap_op =
idempotency::derive_child_indexed(env, &op_id, tag::BOOTSTRAP, sub_idx);
profile.bootstrap(&spec.recipient, &bootstrap_op);

let rep_op = idempotency::derive_child_indexed(env, &op_id, tag::BUMP_REP, sub_idx);
profile.bump_reputation(
&spec.recipient,
&spec.reputation_bump,
&reason_win,
&rep_op,
);

let earnings_op =
idempotency::derive_child_indexed(env, &op_id, tag::REGISTER_EARNINGS, sub_idx);
profile.register_earnings(&spec.recipient, &event.token, &amount, &earnings_op);
let amount = base_escrow.saturating_mul(percent) / 100_i128;

let anchor_idx = existing_count + (idx as u32);
storage::append_winner(
env,
event_id,
Expand All @@ -638,22 +656,33 @@ pub fn select_winners(
position: spec.position,
amount,
milestone: None,
paid_at: Some(now),
paid_at: None,
},
);

evt::WinnerPaid {
storage::set_prize_award(
env,
event_id,
recipient: spec.recipient.clone(),
position: spec.position,
amount,
milestone: None,
}
.publish(env);
spec.position,
&PrizeAward {
recipient: spec.recipient.clone(),
anchor_idx,
reputation_bump: spec.reputation_bump,
},
);
}

if event.remaining_escrow == 0 {
event.status = EventStatus::Completed;
let unclaimed = storage::unclaimed_prize_count(env, event_id);
storage::set_unclaimed_prize_count(
env,
event_id,
unclaimed.saturating_add(winners.len()),
);

// Extend the window so a later batch's winners get the full term.
let expiry = now.saturating_add(PRIZE_CLAIM_WINDOW_SECS);
let cur = storage::get_prize_claim_expiry(env, event_id).unwrap_or(0);
if expiry > cur {
storage::set_prize_claim_expiry(env, event_id, expiry);
}
}
ReleaseKind::Multi(_) => {
Expand Down Expand Up @@ -686,6 +715,103 @@ pub fn select_winners(
Ok(())
}

// ============================================================
// CLAIM PRIZE (pull model for Single-release events; #61)
// ============================================================
pub fn claim_prize(
env: &Env,
event_id: u64,
position: u32,
op_id: BytesN<32>,
) -> Result<(), Error> {
admin::require_not_paused(env)?;
idempotency::require_unseen(env, &op_id)?;

let mut event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?;
if !matches!(event.status, EventStatus::Active) {
return Err(Error::EventNotActive);
}
if !matches!(event.release_kind, ReleaseKind::Single) {
return Err(Error::InvalidReleaseKind);
}

let award =
storage::get_prize_award(env, event_id, position).ok_or(Error::InvalidWinnerPosition)?;
award.recipient.require_auth();

let anchor =
storage::winner_at(env, event_id, award.anchor_idx).ok_or(Error::InvalidWinnerPosition)?;
if anchor.recipient != award.recipient || anchor.position != position {
return Err(Error::InvalidWinnerPosition);
}
if anchor.paid_at.is_some() {
return Err(Error::PrizeAlreadyClaimed);
}
let amount = anchor.amount;
if amount <= 0 {
return Err(Error::InvalidDistribution);
}
if amount > event.remaining_escrow {
return Err(Error::InsufficientEscrow);
}

let now = env.ledger().timestamp();
storage::set_winner_at(
env,
event_id,
award.anchor_idx,
&Winner {
recipient: anchor.recipient.clone(),
position,
amount,
milestone: None,
paid_at: Some(now),
},
);

let unclaimed = storage::unclaimed_prize_count(env, event_id);
storage::set_unclaimed_prize_count(env, event_id, unclaimed.saturating_sub(1));

event.remaining_escrow = event.remaining_escrow.saturating_sub(amount);
if event.remaining_escrow == 0 {
event.status = EventStatus::Completed;
}
storage::set_event(env, event_id, &event);
idempotency::mark_seen(env, &op_id);

// State written above; release last so a reentrant token can't double-claim.
escrow::release(env, &event.token, &award.recipient, amount);

evt::WinnerPaid {
event_id,
recipient: award.recipient.clone(),
position,
amount,
milestone: None,
}
.publish(env);

// Best-effort: the payout is final, so a profile failure must not revert it.
let profile = profile_client::client(env);
let reason_win = Symbol::new(env, "win");

let bootstrap_op = idempotency::derive_child(env, &op_id, tag::BOOTSTRAP);
let _ = profile.try_bootstrap(&award.recipient, &bootstrap_op);

let rep_op = idempotency::derive_child(env, &op_id, tag::BUMP_REP);
let _ = profile.try_bump_reputation(
&award.recipient,
&award.reputation_bump,
&reason_win,
&rep_op,
);

let earnings_op = idempotency::derive_child(env, &op_id, tag::REGISTER_EARNINGS);
let _ = profile.try_register_earnings(&award.recipient, &event.token, &amount, &earnings_op);

Ok(())
}

// ============================================================
// READS
// ============================================================
Expand Down
9 changes: 9 additions & 0 deletions contracts/events/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,15 @@ impl EventsContract {
event_ops::select_winners(&env, event_id, winners, op_id)
}

pub fn claim_prize(
env: Env,
event_id: u64,
position: u32,
op_id: BytesN<32>,
) -> Result<(), Error> {
event_ops::claim_prize(&env, event_id, position, op_id)
}

// ============================================================
// MANAGEMENT AUTHORITY (manager != funder/owner)
// ============================================================
Expand Down
Loading
Loading