Skip to content

Commit 05e266c

Browse files
authored
fix(events): namespace OpSeen by authorizing caller (follow-up to #68/#95) (#96)
#95 namespaced the profile OpSeen but left the events contract's OpSeen global. Permissionless entrypoints (submit, apply_to_bounty, add_funds) mark caller-supplied op_ids in the same namespace as privileged payout paths (select_winners, claim_prize, claim_milestone, start_cancel), so an attacker who observes a pending privileged op_id could front-run a permissionless call with it and revert the payout with OpAlreadySeen — the same cross-flow squat DoS #95 fixed next door. Key OpSeen by (authorizing caller, op_id): DataKey::OpSeen(Address, BytesN<32>). Each entrypoint passes the address it require_auth'd; the two permissionless cranks (process_cancel_batch, finalize_cancel) namespace under the contract's own address. A permissionless call now writes its own domain and can't burn a privileged op_id. OpSeen is temporary() storage, so the key-shape change needs no migration; old-shape entries expire. No public entrypoint signatures change. op_id idempotency is now per-domain: cross-caller reuse of the same op_id is allowed (and safe), true same-domain replay still reverts. Tests: op_id_security::permissionless_apply_cannot_squat_select_winners_op_id (attacker's apply with the owner's op_id no longer blocks the payout); prize_claim::op_id_replay_reverts reworked to a same-domain replay. 225 events + 66 profile green; make build OK (events 56,091 B < 64 KB); fmt clean.
1 parent 24cade1 commit 05e266c

8 files changed

Lines changed: 99 additions & 45 deletions

File tree

contracts/events/src/bounty.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,12 @@ pub fn apply(
2626
op_id: BytesN<32>,
2727
) -> Result<(), Error> {
2828
admin::require_not_paused(env)?;
29-
idempotency::require_unseen(env, &op_id)?;
3029

3130
let event = storage::get_event(env, bounty_id).ok_or(Error::EventNotFound)?;
3231
require_active_bounty(env, &event)?;
3332

3433
applicant.require_auth();
34+
idempotency::require_unseen(env, &applicant, &op_id)?;
3535

3636
storage::append_applicant(env, bounty_id, &applicant, MAX_APPLICANTS_PER_EVENT)?;
3737

@@ -41,11 +41,11 @@ pub fn apply(
4141

4242
evt::Applied {
4343
event_id: bounty_id,
44-
applicant,
44+
applicant: applicant.clone(),
4545
}
4646
.publish(env);
4747

48-
idempotency::mark_seen(env, &op_id);
48+
idempotency::mark_seen(env, &applicant, &op_id);
4949
Ok(())
5050
}
5151

@@ -59,12 +59,12 @@ pub fn withdraw_application(
5959
op_id: BytesN<32>,
6060
) -> Result<(), Error> {
6161
admin::require_not_paused(env)?;
62-
idempotency::require_unseen(env, &op_id)?;
6362

6463
let event = storage::get_event(env, bounty_id).ok_or(Error::EventNotFound)?;
6564
require_active_bounty(env, &event)?;
6665

6766
applicant.require_auth();
67+
idempotency::require_unseen(env, &applicant, &op_id)?;
6868

6969
if storage::get_submission(env, bounty_id, &applicant).is_some() {
7070
return Err(Error::SubmissionAlreadyExists);
@@ -74,11 +74,11 @@ pub fn withdraw_application(
7474

7575
evt::ApplicationWithdrawn {
7676
event_id: bounty_id,
77-
applicant,
77+
applicant: applicant.clone(),
7878
}
7979
.publish(env);
8080

81-
idempotency::mark_seen(env, &op_id);
81+
idempotency::mark_seen(env, &applicant, &op_id);
8282
Ok(())
8383
}
8484

contracts/events/src/event_ops.rs

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,9 @@ fn get_or_init_non_owner_total(env: &Env, event_id: u64) -> Result<i128, Error>
5656

5757
pub fn create_event(env: &Env, params: CreateEventParams, op_id: BytesN<32>) -> Result<u64, Error> {
5858
admin::require_not_paused(env)?;
59-
idempotency::require_unseen(env, &op_id)?;
6059

6160
params.owner.require_auth();
61+
idempotency::require_unseen(env, &params.owner, &op_id)?;
6262

6363
token_whitelist::require_supported(env, &params.token)?;
6464

@@ -182,7 +182,7 @@ pub fn create_event(env: &Env, params: CreateEventParams, op_id: BytesN<32>) ->
182182
.publish(env);
183183
}
184184

185-
idempotency::mark_seen(env, &op_id);
185+
idempotency::mark_seen(env, &params.owner, &op_id);
186186
Ok(id)
187187
}
188188

@@ -273,7 +273,6 @@ pub fn add_funds(
273273
op_id: BytesN<32>,
274274
) -> Result<(), Error> {
275275
admin::require_not_paused(env)?;
276-
idempotency::require_unseen(env, &op_id)?;
277276

278277
if amount <= 0 {
279278
return Err(Error::InvalidContributionAmount);
@@ -288,6 +287,7 @@ pub fn add_funds(
288287
}
289288

290289
from.require_auth();
290+
idempotency::require_unseen(env, &from, &op_id)?;
291291

292292
let is_non_owner = from != event.owner;
293293
let prior_contribution = if is_non_owner {
@@ -330,13 +330,13 @@ pub fn add_funds(
330330

331331
evt::FundsAdded {
332332
event_id,
333-
contributor: from,
333+
contributor: from.clone(),
334334
amount: credited,
335335
new_remaining: event.remaining_escrow,
336336
}
337337
.publish(env);
338338

339-
idempotency::mark_seen(env, &op_id);
339+
idempotency::mark_seen(env, &from, &op_id);
340340
Ok(())
341341
}
342342

@@ -345,7 +345,6 @@ pub fn add_funds(
345345
// ============================================================
346346
pub fn start_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<(), Error> {
347347
admin::require_not_paused(env)?;
348-
idempotency::require_unseen(env, &op_id)?;
349348

350349
let mut event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?;
351350
if !matches!(event.status, EventStatus::Active) {
@@ -366,7 +365,9 @@ pub fn start_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<(), E
366365
}
367366
}
368367

369-
resolve_manager(env, event_id, &event.owner).require_auth();
368+
let manager = resolve_manager(env, event_id, &event.owner);
369+
manager.require_auth();
370+
idempotency::require_unseen(env, &manager, &op_id)?;
370371

371372
let remaining = event.remaining_escrow;
372373
let count = storage::contributor_count(env, event_id);
@@ -395,7 +396,7 @@ pub fn start_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<(), E
395396
storage::set_event(env, event_id, &event);
396397
storage::set_non_owner_contribution_total(env, event_id, 0);
397398
evt::EventCancelled { id: event_id }.publish(env);
398-
idempotency::mark_seen(env, &op_id);
399+
idempotency::mark_seen(env, &manager, &op_id);
399400
return Ok(());
400401
}
401402

@@ -410,7 +411,7 @@ pub fn start_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<(), E
410411
event.status = EventStatus::Cancelling;
411412
storage::set_event(env, event_id, &event);
412413

413-
idempotency::mark_seen(env, &op_id);
414+
idempotency::mark_seen(env, &manager, &op_id);
414415
Ok(())
415416
}
416417

@@ -421,7 +422,10 @@ pub fn process_cancel_batch(
421422
op_id: BytesN<32>,
422423
) -> Result<u32, Error> {
423424
admin::require_not_paused(env)?;
424-
idempotency::require_unseen(env, &op_id)?;
425+
// Permissionless crank: no caller to authorize, so namespace under the
426+
// contract's own address — isolated from every user/privileged domain.
427+
let domain = env.current_contract_address();
428+
idempotency::require_unseen(env, &domain, &op_id)?;
425429

426430
let event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?;
427431
if !matches!(event.status, EventStatus::Cancelling) {
@@ -475,13 +479,14 @@ pub fn process_cancel_batch(
475479
storage::set_cancellation_state(env, event_id, &state);
476480
let remaining_to_process = state.count_at_start.saturating_sub(state.next_idx);
477481

478-
idempotency::mark_seen(env, &op_id);
482+
idempotency::mark_seen(env, &domain, &op_id);
479483
Ok(remaining_to_process)
480484
}
481485

482486
pub fn finalize_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<(), Error> {
483487
admin::require_not_paused(env)?;
484-
idempotency::require_unseen(env, &op_id)?;
488+
let domain = env.current_contract_address();
489+
idempotency::require_unseen(env, &domain, &op_id)?;
485490

486491
let mut event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?;
487492
if !matches!(event.status, EventStatus::Cancelling) {
@@ -516,7 +521,7 @@ pub fn finalize_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<()
516521

517522
evt::EventCancelled { id: event_id }.publish(env);
518523

519-
idempotency::mark_seen(env, &op_id);
524+
idempotency::mark_seen(env, &domain, &op_id);
520525
Ok(())
521526
}
522527

@@ -531,7 +536,6 @@ pub fn submit(
531536
op_id: BytesN<32>,
532537
) -> Result<(), Error> {
533538
admin::require_not_paused(env)?;
534-
idempotency::require_unseen(env, &op_id)?;
535539

536540
let event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?;
537541
if !matches!(event.status, EventStatus::Active) {
@@ -547,6 +551,7 @@ pub fn submit(
547551
}
548552

549553
applicant.require_auth();
554+
idempotency::require_unseen(env, &applicant, &op_id)?;
550555

551556
// Reused rather than adding a new variant — stays inside the
552557
// contracterror 50-variant cap (see BACKLOG.md L7 for precedent).
@@ -583,12 +588,12 @@ pub fn submit(
583588

584589
evt::Submitted {
585590
event_id,
586-
applicant,
591+
applicant: applicant.clone(),
587592
content_uri,
588593
}
589594
.publish(env);
590595

591-
idempotency::mark_seen(env, &op_id);
596+
idempotency::mark_seen(env, &applicant, &op_id);
592597
Ok(())
593598
}
594599

@@ -602,7 +607,6 @@ pub fn withdraw_submission(
602607
op_id: BytesN<32>,
603608
) -> Result<(), Error> {
604609
admin::require_not_paused(env)?;
605-
idempotency::require_unseen(env, &op_id)?;
606610

607611
let event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?;
608612
if !matches!(event.status, EventStatus::Active) {
@@ -615,6 +619,7 @@ pub fn withdraw_submission(
615619
}
616620

617621
applicant.require_auth();
622+
idempotency::require_unseen(env, &applicant, &op_id)?;
618623

619624
if storage::get_submission(env, event_id, &applicant).is_none() {
620625
return Err(Error::SubmissionNotFound);
@@ -624,11 +629,11 @@ pub fn withdraw_submission(
624629

625630
evt::SubmissionWithdrawn {
626631
event_id,
627-
applicant,
632+
applicant: applicant.clone(),
628633
}
629634
.publish(env);
630635

631-
idempotency::mark_seen(env, &op_id);
636+
idempotency::mark_seen(env, &applicant, &op_id);
632637
Ok(())
633638
}
634639

@@ -642,7 +647,6 @@ pub fn select_winners(
642647
op_id: BytesN<32>,
643648
) -> Result<(), Error> {
644649
admin::require_not_paused(env)?;
645-
idempotency::require_unseen(env, &op_id)?;
646650

647651
let event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?;
648652
if !matches!(event.status, EventStatus::Active) {
@@ -652,7 +656,9 @@ pub fn select_winners(
652656
return Err(Error::InvalidPillar);
653657
}
654658

655-
resolve_manager(env, event_id, &event.owner).require_auth();
659+
let manager = resolve_manager(env, event_id, &event.owner);
660+
manager.require_auth();
661+
idempotency::require_unseen(env, &manager, &op_id)?;
656662

657663
let existing_count = storage::winner_count(env, event_id);
658664
match event.release_kind {
@@ -804,7 +810,7 @@ pub fn select_winners(
804810
}
805811
.publish(env);
806812

807-
idempotency::mark_seen(env, &op_id);
813+
idempotency::mark_seen(env, &manager, &op_id);
808814
Ok(())
809815
}
810816

@@ -818,7 +824,6 @@ pub fn claim_prize(
818824
op_id: BytesN<32>,
819825
) -> Result<(), Error> {
820826
admin::require_not_paused(env)?;
821-
idempotency::require_unseen(env, &op_id)?;
822827

823828
let mut event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?;
824829
if !matches!(event.status, EventStatus::Active) {
@@ -831,6 +836,7 @@ pub fn claim_prize(
831836
let award =
832837
storage::get_prize_award(env, event_id, position).ok_or(Error::InvalidWinnerPosition)?;
833838
award.recipient.require_auth();
839+
idempotency::require_unseen(env, &award.recipient, &op_id)?;
834840

835841
let anchor =
836842
storage::winner_at(env, event_id, award.anchor_idx).ok_or(Error::InvalidWinnerPosition)?;
@@ -870,7 +876,7 @@ pub fn claim_prize(
870876
event.status = EventStatus::Completed;
871877
}
872878
storage::set_event(env, event_id, &event);
873-
idempotency::mark_seen(env, &op_id);
879+
idempotency::mark_seen(env, &award.recipient, &op_id);
874880

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

contracts/events/src/grant.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ pub fn claim_milestone(
2828
op_id: BytesN<32>,
2929
) -> Result<(), Error> {
3030
admin::require_not_paused(env)?;
31-
idempotency::require_unseen(env, &op_id)?;
3231

3332
let mut event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?;
3433
if !matches!(event.status, EventStatus::Active) {
@@ -45,6 +44,7 @@ pub fn claim_milestone(
4544
}
4645

4746
event.owner.require_auth();
47+
idempotency::require_unseen(env, &event.owner, &op_id)?;
4848

4949
if matches!(event.pillar, Pillar::Crowdfunding) {
5050
let admin = storage::get_admin(env)?;
@@ -163,6 +163,6 @@ pub fn claim_milestone(
163163
}
164164
.publish(env);
165165

166-
idempotency::mark_seen(env, &op_id);
166+
idempotency::mark_seen(env, &event.owner, &op_id);
167167
Ok(())
168168
}

contracts/events/src/idempotency.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
#![allow(dead_code)]
22

3-
use soroban_sdk::{xdr::ToXdr, Bytes, BytesN, Env};
3+
use soroban_sdk::{xdr::ToXdr, Address, Bytes, BytesN, Env};
44

55
use crate::errors::Error;
66
use crate::storage;
77

8-
pub fn require_unseen(env: &Env, op_id: &BytesN<32>) -> Result<(), Error> {
9-
if storage::is_op_seen(env, op_id) {
8+
// OpSeen is namespaced by the authorizing caller so that a permissionless
9+
// entrypoint (submit, apply, add_funds) cannot pre-mark an op_id and block a
10+
// privileged one (select_winners, claim, cancel) that shares it. Pass the
11+
// address that require_auth'd this call as the domain.
12+
pub fn require_unseen(env: &Env, domain: &Address, op_id: &BytesN<32>) -> Result<(), Error> {
13+
if storage::is_op_seen(env, domain, op_id) {
1014
return Err(Error::OpAlreadySeen);
1115
}
1216
Ok(())
1317
}
1418

15-
pub fn mark_seen(env: &Env, op_id: &BytesN<32>) {
16-
storage::mark_op_seen(env, op_id);
19+
pub fn mark_seen(env: &Env, domain: &Address, op_id: &BytesN<32>) {
20+
storage::mark_op_seen(env, domain, op_id);
1721
}
1822

1923
pub fn id_base(env: &Env) -> u64 {

contracts/events/src/storage.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -754,15 +754,15 @@ pub fn clear_cancellation_state(env: &Env, id: u64) {
754754
// ============================================================
755755
// IDEMPOTENCY (temporary; auto-TTL)
756756
// ============================================================
757-
pub fn is_op_seen(env: &Env, op_id: &BytesN<32>) -> bool {
757+
pub fn is_op_seen(env: &Env, domain: &Address, op_id: &BytesN<32>) -> bool {
758758
env.storage()
759759
.temporary()
760-
.get(&DataKey::OpSeen(op_id.clone()))
760+
.get(&DataKey::OpSeen(domain.clone(), op_id.clone()))
761761
.unwrap_or(false)
762762
}
763763

764-
pub fn mark_op_seen(env: &Env, op_id: &BytesN<32>) {
764+
pub fn mark_op_seen(env: &Env, domain: &Address, op_id: &BytesN<32>) {
765765
env.storage()
766766
.temporary()
767-
.set(&DataKey::OpSeen(op_id.clone()), &true);
767+
.set(&DataKey::OpSeen(domain.clone(), op_id.clone()), &true);
768768
}

0 commit comments

Comments
 (0)