Skip to content

fix(events): namespace OpSeen by authorizing caller (follow-up to #68/#95) - #96

Merged
0xdevcollins merged 1 commit into
testnetfrom
fix/events-opseen-namespacing
Jul 23, 2026
Merged

fix(events): namespace OpSeen by authorizing caller (follow-up to #68/#95)#96
0xdevcollins merged 1 commit into
testnetfrom
fix/events-opseen-namespacing

Conversation

@0xdevcollins

@0xdevcollins 0xdevcollins commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #95. That PR namespaced the profile OpSeen but left the events contract's OpSeen in a single global namespace. This closes the matching cross-flow squat DoS on the events side.

Problem

Events OpSeen was keyed only by op_id (DataKey::OpSeen(BytesN<32>)), shared between permissionless entrypoints (submit, apply_to_bounty, add_funds) and privileged payout paths (select_winners, claim_prize, claim_milestone, start_cancel). An attacker who observes a pending privileged op_id can front-run a permissionless call with that same op_id, mark it seen, and make the manager's payout revert OpAlreadySeen — the same class #95 fixed on the profile side, under the same observability assumption the #68 threat model already accepts.

Fix

Namespace events OpSeen by the authorizing caller: DataKey::OpSeen(Address, BytesN<32>).

Entrypoint group Domain
create_event / add_funds owner / contributor (the require_auth'd caller)
submit / withdraw_submission / apply_to_bounty / withdraw_application applicant
select_winners / start_cancel resolved manager
claim_prize prize recipient
claim_milestone event owner
process_cancel_batch / finalize_cancel (permissionless cranks) contract's own address

A permissionless call now writes only its own domain and cannot burn a privileged op_id. require_unseen moved to just after each require_auth so the domain is the authenticated caller.

Semantics / risk

  • OpSeen is temporary() storage → no migration; old-shape entries expire. It is not the last DataKey variant, but changing a temporary-only variant's payload doesn't affect any persistent key's discriminant.
  • Idempotency is now per-domain: reusing one op_id across two different callers is allowed (and safe); true same-domain replay still reverts.
  • No public entrypoint signatures change.

Tests

  • New op_id_security::permissionless_apply_cannot_squat_select_winners_op_id: an attacker's apply_to_bounty with the owner's select_winners op_id no longer blocks the payout (would revert pre-fix).
  • prize_claim::op_id_replay_reverts reworked to a same-domain replay (one recipient, two positions) — the meaningful replay property under namespacing.

Verification

  • cargo test: 225 events + 66 profile green
  • make build (stellar-cli 27): events wasm 56,091 bytes (< 64 KB)
  • cargo fmt --check clean

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Prevented operation IDs used by one account or event action from incorrectly blocking valid operations in another context.
    • Improved replay protection across applications, withdrawals, cancellations, submissions, winner selection, and prize claims.
    • Ensured emitted application and withdrawal events include the applicant.
    • Prevented unauthorized users from reserving operation IDs needed by legitimate event owners.
  • Tests

    • Added regression coverage for operation ID front-running and same-domain replay attempts.

…95)

#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.
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d2b9dd6d-ce35-4d54-8af4-5fcf5742768a

📥 Commits

Reviewing files that changed from the base of the PR and between 24cade1 and 905a5fc.

📒 Files selected for processing (8)
  • contracts/events/src/bounty.rs
  • contracts/events/src/event_ops.rs
  • contracts/events/src/grant.rs
  • contracts/events/src/idempotency.rs
  • contracts/events/src/storage.rs
  • contracts/events/src/tests/op_id_security.rs
  • contracts/events/src/tests/prize_claim.rs
  • contracts/events/src/types.rs

📝 Walkthrough

Walkthrough

The events contract now scopes temporary idempotency markers by an address or the contract address. Entry points move checks after authorization or domain resolution, update emitted applicant fields, and add regression coverage for cross-domain operation identifiers.

Changes

Domain-scoped event idempotency

Layer / File(s) Summary
Composite idempotency storage contract
contracts/events/src/types.rs, contracts/events/src/storage.rs, contracts/events/src/idempotency.rs
DataKey::OpSeen, storage helpers, and idempotency helpers now use (domain, op_id) keys.
Authenticated event, bounty, grant, and submission flows
contracts/events/src/bounty.rs, contracts/events/src/event_ops.rs, contracts/events/src/grant.rs
Authenticated operations scope replay checks to the relevant owner, contributor, applicant, manager, or recipient, and update emitted applicant data where applicable.
Permissionless cancellation and prize flows
contracts/events/src/event_ops.rs, contracts/events/src/tests/op_id_security.rs, contracts/events/src/tests/prize_claim.rs
Cancellation batching uses the contract address as its domain; winner selection and prize claims use manager or recipient domains, with replay and anti-squatting tests updated.

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

Possibly related issues

Possibly related PRs

Poem

A bunny found an op-id key,
With domains neat as carrots three.
Applicants hop, owners cheer,
Cross-domain squatting disappears.
Prizes flow and tests agree—
Replay-proof code for you and me!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: namespacing OpSeen by the authorizing caller.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/events-opseen-namespacing

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.

@0xdevcollins
0xdevcollins merged commit 05e266c into testnet Jul 23, 2026
4 checks passed
0xdevcollins added a commit that referenced this pull request Jul 23, 2026
…ractmeta (#98)

Version stamps had drifted and were internally inconsistent: events
contractmeta said 1.2.0 while INITIAL_VERSION said 1.3.0, and neither
reflected the public-surface / storage changes merged since (#86 submission
cap, #88 manager two-step, #96 OpSeen namespacing). Profile was still 1.1.0
despite #95 namespacing its OpSeen.

Bump both contracts coherently — INITIAL_VERSION, contractmeta, and the
Cargo package version all set to:
  events  1.3.0 -> 1.4.0   (submission cap, manager two-step, OpSeen ns)
  profile 1.1.0 -> 1.2.0   (namespaced OpSeen)

version()-asserting admin tests updated to match. 225 events + 66 profile
tests green; make build OK (events 56,091 B, profile 15,893 B, both under
the 64 KB ceiling); fmt 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.

1 participant