Skip to content

feat(events): pull-model prize claims for Single-release events#90

Merged
0xdevcollins merged 3 commits into
testnetfrom
feat/61-pull-claim-prizes
Jul 20, 2026
Merged

feat(events): pull-model prize claims for Single-release events#90
0xdevcollins merged 3 commits into
testnetfrom
feat/61-pull-claim-prizes

Conversation

@0xdevcollins

@0xdevcollins 0xdevcollins commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements the pull/claim payout model chosen for #61: select_winners records prizes, and each winner claims individually via the new claim_prize(event_id, position, op_id) entrypoint. The per-event winner ceiling is now bounded by the winner_distribution, not by one transaction's resource budget.

This is the in-house replacement for the approach explored in #83, designed around the constraints that review surfaced there.

How each #83 review finding is addressed

Finding Resolution here
Extending persisted Winner bricks deployed rows (verified UnexpectedSize trap) Winner/EventRecord untouched. All new state lives in appended DataKeys (EventPrizeAward, EventUnclaimedPrizes, EventPrizeBaseEscrow, EventPrizeClaimExpiry). A regression test writes a 1.2.0-shape row and reads it back, so any future Winner field addition turns CI red.
No-deadline events could lock escrow forever; deadline-based escape hatch was vacuous (deadline passes before selection) 90-day claim window anchored at selection time, refreshed per batch. Cancel is blocked while unclaimed prizes exist in-window (O(1) counter check), and proceeds after expiry, sweeping unclaimed amounts through the existing refund path.
Unvalidated 500 cap vs per-tx entry limits Per-call cap stays at the known-safe 50; scale comes from batched selection (each position awardable exactly once, amounts anchored to the first-batch baseline, so over-commitment is arithmetically impossible). A BACKLOG entry tracks measuring real headroom via simulateTransaction before raising the constant.
Zero dedicated tests 13-test prize_claim suite: claim math with recipient and fee-account deltas, auth negatives (#73 convention) plus a positive env.auths() assertion, double-claim, op-replay, unawarded-position, Multi-release rejection, cancel gate, window-expiry sweep, window refresh, top-up residual math, and the legacy-row decode guard.
ABI/event churn (claim_milestone signature, WinnerPaid removal) claim_milestone unchanged. WinnerPaid still fires when money moves (now inside claim_prize), so indexers keep working. The only additive change is the new entrypoint.
No version bump / migration story 1.3.0. No data migration needed — new keys only; migrate() dispatch intentionally stays empty.

Behavior changes for the backend

  • select_winners no longer pays or completes the event; the event completes when the final prize claim drains escrow. Anything keying off Completed immediately after selection must switch to claim-driven flow.
  • New entrypoint claim_prize(event_id, position, op_id) — the recorded recipient signs.
  • Profile side-effects (bootstrap/reputation/earnings) are now best-effort at claim time: a profile-contract failure can no longer block a payout.
  • Single-release selection is batchable (≤50/call); re-awarding a taken position reverts. Pre-1.3.0 events keep one-shot semantics.

Verification

  • cargo test --workspace: 276 tests green (210 events + 66 profile), including the 13 new prize-claim tests
  • cargo fmt --check clean
  • make build: boundless_events.wasm = 51,276 bytes (64 KB ceiling holds)
  • Storage-compat regression test proves 1.2.0 winner rows decode through the new code paths

Closes #61

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added pull-model prize claiming for single-release events via a winner-initiated claim_prize, available during a 90-day claim window.
    • Winner selection now records prizes in a way that supports batching while preventing duplicate awards for the same position.
    • Cancellation is now blocked while claimable single-release prizes remain unclaimed; after the claim window expires, cancellation can proceed with unclaimed refunds.
  • Bug Fixes

    • Preserved decoding compatibility for previously stored winner records after upgrading to version 1.3.0.

select_winners now records prizes; each winner claims in their own
transaction via the new claim_prize(event_id, position, op_id)
entrypoint, so the winner count per event is bounded by the
winner_distribution instead of one transaction's resource budget.

Design decisions, in response to the constraints that sank PR #83:

- No persisted-struct changes. Winner and EventRecord are untouched;
  all new state lives under appended DataKeys (EventPrizeAward,
  EventUnclaimedPrizes, EventPrizeBaseEscrow, EventPrizeClaimExpiry).
  A regression test writes a 1.2.0-shape Winner row and reads it back,
  so any future field addition to Winner turns CI red instead of
  bricking deployed rows.
- Selection is batchable at 50/call: each position is awardable once
  (the award key is the replay lock) and amounts anchor to the escrow
  baseline captured at the first batch, so the sum of awards can never
  exceed the baseline. Pre-1.3.0 events keep one-shot semantics.
- Liveness: a 90-day claim window anchored at selection time (not the
  event deadline, which typically passes before selection). Within the
  window, start_cancel rejects while prizes are unclaimed (O(1)
  counter, no row scans); after expiry, cancellation sweeps unclaimed
  amounts through the normal refund path. Escrow can never strand.
- claim_prize releases funds first, then runs profile calls as
  best-effort try_ invocations, so a broken profile contract cannot
  block payouts. WinnerPaid still fires at the moment money moves, so
  indexers are unchanged; claim_milestone keeps its signature.
- Version 1.3.0. No data migration needed (new keys only). The Error
  enum is now at the 50-case XDR cap, noted in BACKLOG.

Tests: 13-test prize_claim suite (claim math with recipient + fee
deltas, auth negatives per the #73 convention, double-claim/op-replay,
cancel gate + window expiry sweep + refresh, Multi-release rejection,
legacy row decode guard); existing Single-flow tests updated to claim
after select. cargo test --workspace: 276 green. make build:
boundless_events.wasm 51,276 bytes, under the 64 KB ceiling.

Closes #61

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@0xdevcollins, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 52 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f457fa05-225b-4099-be85-a0c6eeaf8838

📥 Commits

Reviewing files that changed from the base of the PR and between 9f925a3 and 5ef52f9.

📒 Files selected for processing (6)
  • AGENTS.md
  • CLAUDE.md
  • contracts/events/src/errors.rs
  • contracts/events/src/event_ops.rs
  • contracts/events/src/tests/prize_claim.rs
  • contracts/events/src/types.rs
📝 Walkthrough

Walkthrough

This change introduces version 1.3.0 single-release pull-model prize claims. Winner selection records anchored awards and claim metadata, recipients claim prizes separately, cancellation observes claim windows, and tests cover accounting, replay protection, batching, compatibility, and lifecycle behavior.

Changes

Single-release prize claim flow

Layer / File(s) Summary
Versioned prize contracts and storage
contracts/events/Cargo.toml, contracts/events/src/{admin,errors,types,storage,lib}.rs
Version 1.3.0 adds prize-award types, persistent claim state, storage helpers, and the claim_prize contract method.
Batch selection and cancellation window
contracts/events/src/event_ops.rs, BACKLOG.md
select_winners anchors awards to baseline escrow, supports untaken-position batches, and refreshes the claim window; cancellation is blocked while eligible prizes remain unclaimed.
Prize claim execution
contracts/events/src/event_ops.rs
claim_prize validates authorization, position anchoring, and replay state, then releases escrow, updates completion state, emits payment events, and performs best-effort profile effects.
Lifecycle and compatibility validation
contracts/events/src/tests/*
Tests cover deferred payouts, split accounting, batching, replay protection, cancellation expiry, multi-release rejection, version updates, and legacy winner-row decoding.
Repository guidance
AGENTS.md, CLAUDE.md
Repository instructions add commit metadata rules and source guidance for the Stellar development skill.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Poem

I’m a bunny with prizes tucked away,
Winners hop to claim them another day.
Locks guard each carrot, one by one,
Escrow shrinks until the race is done.
Version 1.3.0 hops happily!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning AGENTS.md and CLAUDE.md policy-only edits are unrelated to the payout redesign and fall outside #61's scope. Remove the AGENTS.md/CLAUDE.md policy changes or move them to a separate documentation PR.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately reflects the main change: adding pull-model prize claims for Single-release events.
Linked Issues check ✅ Passed The PR replaces push payouts with a claim-based flow, adds separate prize claims, and enables larger winner sets via batching, satisfying #61.
Docstring Coverage ✅ Passed Docstring coverage is 85.71% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/61-pull-claim-prizes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
contracts/events/src/event_ops.rs (1)

565-565: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Redundant set_event write-back in select_winners.

event is fetched immutably at line 565 and is never mutated anywhere in this function (Single or Multi branch) — the binding itself had to change to non-mut precisely because nothing assigns to it. The storage::set_event(env, event_id, &event) call at line 724 therefore persists an unchanged record. Since reads already refresh TTL (per the touch_event_persistent convention used by every getter in storage.rs), this write appears to be pure overhead on every select_winners invocation — counter to this PR's own goal of shaving per-call resource costs.

♻️ Proposed fix
     let winners_count = winners.len();
-    storage::set_event(env, event_id, &event);
 
     evt::WinnersSelected {

Also applies to: 622-650, 723-724

🤖 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/event_ops.rs` at line 565, Remove the redundant
storage::set_event write-back from select_winners, since the event fetched
through storage::get_event is never mutated in either winner-selection branch.
Keep the immutable event binding and existing get_event call unchanged, relying
on the getter’s TTL refresh behavior.
🤖 Prompt for all review comments with 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.

Nitpick comments:
In `@contracts/events/src/event_ops.rs`:
- Line 565: Remove the redundant storage::set_event write-back from
select_winners, since the event fetched through storage::get_event is never
mutated in either winner-selection branch. Keep the immutable event binding and
existing get_event call unchanged, relying on the getter’s TTL refresh behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d6b08370-a19d-4e3d-9565-39ef8533c560

📥 Commits

Reviewing files that changed from the base of the PR and between f0f33dd and 56ecff9.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (17)
  • BACKLOG.md
  • contracts/events/Cargo.toml
  • contracts/events/src/admin.rs
  • contracts/events/src/errors.rs
  • contracts/events/src/event_ops.rs
  • contracts/events/src/lib.rs
  • contracts/events/src/storage.rs
  • contracts/events/src/tests/admin.rs
  • contracts/events/src/tests/bounty_pillar.rs
  • contracts/events/src/tests/cancel_refund.rs
  • contracts/events/src/tests/contributions.rs
  • contracts/events/src/tests/cross_contract.rs
  • contracts/events/src/tests/escrow_fee_math.rs
  • contracts/events/src/tests/hackathon_pillar.rs
  • contracts/events/src/tests/mod.rs
  • contracts/events/src/tests/prize_claim.rs
  • contracts/events/src/types.rs

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 20, 2026
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 20, 2026
Cut narration and version/PR tags from the #61 pull-model code down to
comments that state a real constraint (reentrancy ordering, decode-compat
trap, the selection-time window anchor). Add a comment-sparingly rule to
CLAUDE.md and AGENTS.md so future changes hold the line.
@0xdevcollins
0xdevcollins merged commit 57aa591 into testnet Jul 20, 2026
3 of 4 checks passed
0xdevcollins added a commit to Michaelkingsdev/boundless-contract that referenced this pull request Jul 20, 2026
…rror-enum cap

Brings the two-step manager delegation up to current testnet (boundlessfi#90 pull-model
claims, boundlessfi#84 snapshots) and makes it build and pass there.

Error enum: testnet is at the 50-case contracterror cap, so a new
PendingManagerMismatch variant would not compile. Instead generalize the
two admin-rotation variants — PendingAdminMismatch/PendingAdminExpired ->
PendingRotationMismatch/PendingRotationExpired (discriminants 12/13
unchanged, so no on-chain code change) — and share them across both
two-step rotations. accept_manager now distinguishes no-pending
(Mismatch) from expired (Expired), which is more precise than the
original single-variant usage.

Conflict resolution:
- types.rs / storage.rs / event_ops.rs: PendingManager keys and helpers
  ordered after the 1.3.0 prize keys; append-only, no reorder.
- cross_contract.rs: kept the new manager suite; the accepted-manager
  test now claims the prize before asserting Completed, since under the
  pull model select_winners records rather than pays.

Verified: cargo test 216 events + 66 profile green; make build OK
(events wasm 56,202 bytes, < 64 KB); fmt + clippy clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Competition/bounty payouts capped at 50 winners (MAX_WINNERS_PER_SELECT) — push-based select_winners can't scale

1 participant