Skip to content

feat(contract): implement attendee dispute resolution and escrow freeze workflow#128

Open
0xDeon wants to merge 9 commits into
BuidlZone-Labs:mainfrom
0xDeon:feat/attendee-dispute-resolution
Open

feat(contract): implement attendee dispute resolution and escrow freeze workflow#128
0xDeon wants to merge 9 commits into
BuidlZone-Labs:mainfrom
0xDeon:feat/attendee-dispute-resolution

Conversation

@0xDeon

@0xDeon 0xDeon commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Closes #125

Summary

  • Added attendee-initiated dispute workflow (raise_dispute) — permissionless, operates by ticket_id alone for anonymous ticket support
  • Added escrow freeze logic — disputed payments excluded from all settlement paths (withdraw, release_if_expired, withdraw_revenue)
  • Added admin resolution workflow — approve_refund (refunds attendee), reject_dispute (organizer keeps funds)
  • Added timeout settlement path — timeout_dispute releases frozen funds after 14 days with no resolution
  • Added DisputeOutcome guard — prevents re-disputes and admin refunds after organizer-wins outcomes
  • Added DisputeAlreadyOpen guard in refund() — blocks refunds while a dispute is actively open
  • Added contract events — DisputeRaised, DisputeApproved, DisputeRejected, DisputeTimedOut
  • Fixed revenue decrement in approve_refund to write the correct storage key (EventTokenRevenue)
  • Added comprehensive test suite — 109 tests passing (24 new dispute tests)

Addresses the trust-gap in the original dispute hook specification from #113 by implementing a full attendee-initiated flow with proper escrow protection across all settlement paths.

Testing

  • Dispute creation — all 3 reason codes (EventNoShow, TicketNotDelivered, DescriptionMismatch)
  • Dispute approval — token refund, revenue decrement, dispute record removed
  • Dispute rejection — organizer funds preserved, re-dispute blocked, admin refund blocked
  • Timeout settlement — 14-day deadline enforced, re-dispute blocked
  • Escrow freeze validation — withdraw, release_if_expired, and withdraw_revenue all skip disputed payments
  • Anonymous dispute flow — no wallet ownership proof required
  • Permission checks — non-admin approve/reject rejected
  • State machine — duplicate disputes, already-resolved disputes blocked

Summary by CodeRabbit

  • New Features
    • Added ticket dispute handling: raise, approve, reject, and time out, with a new way to query dispute details.
    • Introduced dispute reasons and dispute status tracking for ticket payments.
    • Added dispute-related contract event notifications for dispute lifecycle changes.
  • Bug Fixes
    • Prevented disputed payments from being included in organizer withdrawals and revenue releases.
    • Blocked refunds when a dispute is active or when the dispute was already resolved in the organizer’s favor.
    • Tightened validation around dispute timing, event completion requirements, and ticket eligibility.

@0xDeon
0xDeon marked this pull request as draft June 22, 2026 16:59
@0xDeon
0xDeon force-pushed the feat/attendee-dispute-resolution branch from 4f82f67 to b3bcf31 Compare June 27, 2026 10:43
@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1940f28a-1945-4406-829a-3357e26003bd

📥 Commits

Reviewing files that changed from the base of the PR and between b3bcf31 and 269967d.

📒 Files selected for processing (1)
  • contracts/payments/src/test.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • contracts/payments/src/test.rs

📝 Walkthrough

Walkthrough

Adds dispute types, storage, events, payout guards, dispute lifecycle methods, and tests for raising, resolving, timing out, and blocking disputes.

Changes

Ticket dispute flow

Layer / File(s) Summary
Contract surface
contracts/payments/src/types.rs, contracts/payments/src/errors.rs
Adds dispute reason/status types, the dispute record shape, and dispute/event-ending payment errors.
State and events
contracts/payments/src/storage.rs, contracts/payments/src/events.rs
Adds dispute storage keys/helpers, payment dispute flags, dispute outcomes, and dispute event payloads and emitters.
Payout guards
contracts/payments/src/lib.rs
Makes held-payment collection, refunds, and revenue withdrawal skip disputed payments and settle releasable balances.
Dispute API
contracts/payments/src/lib.rs
Adds raise_dispute, approve_refund, reject_dispute, timeout_dispute, and get_dispute.
Dispute tests
contracts/payments/src/test.rs
Adds dispute setup plus success, validation, permission, timeout, freeze, revenue, anonymous-ticket, and refund-blocking tests.

Sequence Diagram(s)

sequenceDiagram
  participant Attendee
  participant PaymentsContract
  participant Storage
  participant Events
  participant Admin

  Attendee->>PaymentsContract: raise_dispute(ticket_id, reason_code)
  PaymentsContract->>Storage: save_dispute(TicketDispute)
  PaymentsContract->>Events: emit_dispute_raised(...)

  Admin->>PaymentsContract: approve_refund(ticket_id)
  PaymentsContract->>Storage: remove_dispute(ticket_id)
  PaymentsContract->>Events: emit_dispute_approved(...)

  Admin->>PaymentsContract: reject_dispute(ticket_id)
  PaymentsContract->>Storage: set_dispute_outcome(payment_id)
  PaymentsContract->>Events: emit_dispute_rejected(...)

  Attendee->>PaymentsContract: timeout_dispute(ticket_id)
  PaymentsContract->>Storage: remove_dispute(ticket_id)
  PaymentsContract->>Events: emit_dispute_timed_out(...)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • DioChuks

Poem

I thumped by the ledger, nose in the breeze,
Disputes now hop through escrow with ease.
Refunds, rejects, and timeouts chime,
A bunny-approved balance at bedtime. 🐰

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description is missing most required template sections, including storage impact, security, and acceptance criteria. Fill out the repository template sections, especially linked issue, storage impact, on-chain/off-chain behavior, security, test coverage, and acceptance criteria.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: attendee dispute resolution with escrow freezing.
Linked Issues check ✅ Passed The changes match #125: dispute initiation, required reason codes, 7-day window, escrow freeze, admin resolution, 14-day timeout, and anonymous ticket support.
Out of Scope Changes check ✅ Passed The change set stays focused on dispute handling, escrow freezing, storage, events, and tests with no clear unrelated additions.
Docstring Coverage ✅ Passed Docstring coverage is 90.74% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@0xDeon
0xDeon marked this pull request as ready for review June 27, 2026 11:04

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

Actionable comments posted: 10

🧹 Nitpick comments (3)
contracts/payments/src/test.rs (3)

3343-3380: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

mock_all_auths() hides whether raise_dispute is truly permissionless.

This test would still pass if raise_dispute accidentally required payer or admin auth, because the environment auto-satisfies every auth check. For the anonymous-ticket acceptance criterion, run this case without mocked auth and assert the call succeeds with only ticket_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/payments/src/test.rs` around lines 3343 - 3380, The
`test_anonymous_ticket_dispute_works` case is using `env.mock_all_auths()`,
which can mask missing authorization on `raise_dispute`. Update this test to
avoid blanket mocked auth so it truly verifies permissionless behavior, then
call `client.raise_dispute` with only the `ticket_id` and assert it succeeds
without any payer/admin auth being auto-satisfied.

3112-3135: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

This negative case conflates two independent gates.

It skips both the past event_end_ledger setup and the Completed status, so the test cannot tell which check is actually enforcing the failure. Split this into separate cases so the dispute-window behavior is validated against event_end_ledger specifically.

🤖 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/payments/src/test.rs` around lines 3112 - 3135, The negative test
in the dispute flow is covering two separate conditions at once, so it is not
isolating the `event_end_ledger` gate. Update the `try_raise_dispute` test
around `setup_contract_with_token`, `pay_for_ticket`, and `get_owner_tickets` so
one case sets up a past `event_end_ledger` while keeping the status
non-`Completed`, and a separate case covers the status check independently. Keep
the assertions focused on `PaymentError::EventNotEnded` for the dispute-window
path and use distinct test cases to make the failure source unambiguous.

3169-3196: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

These permission tests do not verify require_auth().

With env.mock_all_auths(), they only prove that a non-admin address is rejected. An implementation that skips Soroban auth entirely but accepts the stored admin address would still pass. Add a case that uses the admin address without mocked auth, or assert the exact auths with mock_auths.

🤖 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/payments/src/test.rs` around lines 3169 - 3196, The permission
tests in the dispute refund/reject cases only check that a non-admin is denied,
but they do not prove `require_auth()` is enforced in
`test_approve_refund_non_admin_rejected` and
`test_reject_dispute_non_admin_rejected`. Update these tests to verify Soroban
auth by either asserting the exact calls with `mock_auths` or adding a case that
uses the stored admin address without `mock_all_auths` and confirms the auth
requirement is triggered in the relevant `client.try_approve_refund` and
`client.try_reject_dispute` paths.
🤖 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.

Inline comments:
In `@contracts/payments/src/lib.rs`:
- Around line 1599-1605: The dispute flow in TicketDispute creation and the
approval path should use only the remaining escrow balance, not payment.amount.
Update the dispute escrow calculation and the related approval/refund logic so
it derives the refundable amount from payment.amount minus any existing
refunded_amount, and make sure the approve/dispute handling updates
refunded_amount consistently when marking the payment as Refunded. Use the
TicketDispute, refund approval, and dispute-opening code paths to keep partial
admin refunds from being counted twice.
- Around line 1686-1694: `reject_dispute` and `timeout_dispute` currently only
clear the dispute state, which can leave organizer-won disputes stuck in `Held`
and prevent payout. Update the dispute-resolution flow in those functions to
explicitly release the escrow/funds and transition the payment out of the held
state after removing the dispute, while still preserving the permanent dispute
outcome bookkeeping. Use the existing `reject_dispute`, `timeout_dispute`,
`storage::clear_payment_dispute`, `storage::remove_dispute`, and
`storage::set_dispute_outcome` paths to locate the fix.
- Around line 1165-1174: The release path in the held-payment flow is
subtracting the full payment amount instead of the net held balance, which can
overpay after partial refunds and leave revenue ledgers inconsistent. Update the
logic around the release handling in the payment release function(s) that use
collect_held_payments_for_token and the related refund-held path to compute and
transfer only the remaining releasable net amount after refunded_amount. Also
decrement both the token-level and event-level revenue aggregates by that same
net released amount, and ensure the payment status/held state is updated
consistently after the partial release.
- Around line 1640-1661: The refund logic in approve_refund is using
storage::get_accepted_token and the current accepted token instead of the token
originally used for the payment. Update the refund flow to read the token from
the stored payment record (the payment/token fields on the payment struct) and
use that token for token::Client::new, transfer, and token revenue adjustments.
Keep the revenue updates in sync with the original token and ticket.event_id so
multi-token refunds debit the correct asset and per-token totals.
- Around line 280-282: The disputed-payment filter in the payments helper now
returns only undisputed items, but callers such as withdraw and
release_if_expired still clear token revenue afterward, which can erase escrow
accounting for active disputes. Update the logic around the shared
payment-collection flow in lib.rs so revenue is only zeroed for payments that
are actually released, and ensure disputed entries remain accounted for until
their ticket is resolved. Use the existing payment selection path around
storage::is_payment_disputed and the withdraw/release_if_expired call sites to
keep escrow and revenue state consistent.
- Around line 1570-1593: The dispute-window check in the payments flow is using
a fallback to the current timestamp when escrow metadata is missing, which
prevents the window from ever closing and bypasses the configured event end.
Update the logic around the event status and `event_end_time` in the
payment/refund path so it always anchors to the configured event end (for
example via the stored event end ledger/time on `ticket.event_id`) instead of
`env.ledger().timestamp()`, and make sure disputes only become valid after
`event_end_ledger` while still enforcing `DISPUTE_WINDOW_SECS`.

In `@contracts/payments/src/storage.rs`:
- Around line 781-795: The organizer-win dispute marker is currently being
written with a TTL in set_dispute_outcome, so it can expire and stop blocking
later re-disputes/refunds. Update set_dispute_outcome and the
is_dispute_outcome_set flow in storage.rs so the DataKey::DisputeOutcome entry
is stored permanently without extend_ttl, keeping reject_dispute and
timeout_dispute guards effective for the lifetime of the payment.
- Around line 705-735: The dispute state in save_dispute and
mark_payment_disputed is expiring via TTL without being refreshed on reads,
which can let active disputes disappear unexpectedly. Update the storage flow in
contracts/payments/src/storage.rs so TicketDispute and PaymentDisputed remain
valid for the full dispute lifecycle, either by removing the short TTLs or by
refreshing them from the dispute read/settlement paths used by get_dispute,
has_active_dispute, collect_held_payments_for_token, and refund. Ensure
timeout_dispute still clears the state explicitly rather than relying on expiry.

In `@contracts/payments/src/test.rs`:
- Around line 3050-3076: The timeout dispute test is asserting the wrong
post-condition by expecting the payment to remain Held after timeout; update
test_timeout_dispute_happy_path to verify the settlement outcome of
timeout_dispute instead. Use the existing setup_dispute_scenario, raise_dispute,
and timeout_dispute flow, then assert the organizer/contract balances (and any
released/settled status if tracked) reflect the disputed share being
auto-released, while still confirming the dispute is removed.

In `@contracts/payments/src/types.rs`:
- Around line 24-30: TicketDispute::escrow_amount is currently storing the
original payment amount instead of the remaining locked escrow, which breaks
partial-refund handling. Update raise_dispute() to persist the still-locked
balance (payment.amount minus refunded_amount) and make approve_refund() consume
that same remaining escrow value when transferring and subtracting funds. Ensure
the TicketDispute invariant is that escrow_amount always reflects the remaining
held balance, not the initial ticket price.

---

Nitpick comments:
In `@contracts/payments/src/test.rs`:
- Around line 3343-3380: The `test_anonymous_ticket_dispute_works` case is using
`env.mock_all_auths()`, which can mask missing authorization on `raise_dispute`.
Update this test to avoid blanket mocked auth so it truly verifies
permissionless behavior, then call `client.raise_dispute` with only the
`ticket_id` and assert it succeeds without any payer/admin auth being
auto-satisfied.
- Around line 3112-3135: The negative test in the dispute flow is covering two
separate conditions at once, so it is not isolating the `event_end_ledger` gate.
Update the `try_raise_dispute` test around `setup_contract_with_token`,
`pay_for_ticket`, and `get_owner_tickets` so one case sets up a past
`event_end_ledger` while keeping the status non-`Completed`, and a separate case
covers the status check independently. Keep the assertions focused on
`PaymentError::EventNotEnded` for the dispute-window path and use distinct test
cases to make the failure source unambiguous.
- Around line 3169-3196: The permission tests in the dispute refund/reject cases
only check that a non-admin is denied, but they do not prove `require_auth()` is
enforced in `test_approve_refund_non_admin_rejected` and
`test_reject_dispute_non_admin_rejected`. Update these tests to verify Soroban
auth by either asserting the exact calls with `mock_auths` or adding a case that
uses the stored admin address without `mock_all_auths` and confirms the auth
requirement is triggered in the relevant `client.try_approve_refund` and
`client.try_reject_dispute` paths.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: dd8f3a22-6280-424c-b830-858d2a61a679

📥 Commits

Reviewing files that changed from the base of the PR and between a1b3246 and b3bcf31.

📒 Files selected for processing (6)
  • contracts/payments/src/errors.rs
  • contracts/payments/src/events.rs
  • contracts/payments/src/lib.rs
  • contracts/payments/src/storage.rs
  • contracts/payments/src/test.rs
  • contracts/payments/src/types.rs

Comment on lines +280 to +282
if storage::is_payment_disputed(env, payment.payment_id) {
continue;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don’t zero disputed revenue after returning only undisputed payments.

This helper now returns a subset, but callers like withdraw and release_if_expired still zero token revenue after releasing that subset. Any active disputed ticket keeps funds in escrow while its accounting is erased, so later resolution can drive revenue negative or fail invariants.

🛠️ Suggested direction
-            storage::set_event_token_revenue(&env, &event_id, &payout_token, 0);
+            // `payments_to_release` excludes disputed payments, so only subtract
+            // the released amount and preserve disputed escrow revenue.
-                    storage::set_event_token_revenue(&env, &event_id, &token_address, 0);
+                    let current_token_rev =
+                        storage::get_event_token_revenue(&env, &event_id, &token_address);
+                    storage::set_event_token_revenue(
+                        &env,
+                        &event_id,
+                        &token_address,
+                        current_token_rev - token_total,
+                    );
🤖 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/payments/src/lib.rs` around lines 280 - 282, The disputed-payment
filter in the payments helper now returns only undisputed items, but callers
such as withdraw and release_if_expired still clear token revenue afterward,
which can erase escrow accounting for active disputes. Update the logic around
the shared payment-collection flow in lib.rs so revenue is only zeroed for
payments that are actually released, and ensure disputed entries remain
accounted for until their ticket is resolved. Use the existing payment selection
path around storage::is_payment_disputed and the withdraw/release_if_expired
call sites to keep escrow and revenue state consistent.

Comment on lines +1165 to +1174
let (releasable, payments_to_release) =
collect_held_payments_for_token(&env, &event_id, &token_address)?;
if releasable <= 0 {
return Err(PaymentError::InvalidAmount);
}

// Calculate platform fee
// Calculate platform fee on releasable amount only
let fee_bps = storage::get_platform_fee_bps(&env) as i128;
let fee_amount = revenue * fee_bps / 10_000;
let organizer_amount = revenue - fee_amount;
let fee_amount = releasable * fee_bps / 10_000;
let organizer_amount = releasable - fee_amount;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Release net held balances and update both revenue ledgers.

refund(..., Some(amount)) can leave a payment Held with refunded_amount > 0. This new path releases/subtracts payment.amount, which can overpay and make token revenue negative; it also never decrements aggregate event revenue.

🛠️ Suggested fix
-        // Mark released payments and update per-token revenue for each one
+        // Mark released payments and update revenue for each net escrow balance.
+        let mut released_total = 0i128;
         for i in 0..payments_to_release.len() {
             let mut payment = payments_to_release
                 .get(i)
                 .ok_or(PaymentError::PaymentNotFound)?;
+            let payment_releasable = payment.amount - payment.refunded_amount;
+            if payment_releasable <= 0 {
+                continue;
+            }
             payment.status = PaymentStatus::Released;
             storage::update_payment(&env, &payment)?;
             let current_token_rev =
                 storage::get_event_token_revenue(&env, &event_id, &payment.token);
             storage::set_event_token_revenue(
                 &env,
                 &event_id,
                 &payment.token,
-                current_token_rev - payment.amount,
+                current_token_rev - payment_releasable,
             );
+            released_total += payment_releasable;
         }
+
+        let current_event_rev = storage::get_event_revenue(&env, &event_id);
+        storage::set_event_revenue(&env, &event_id, current_event_rev - released_total);

Also applies to: 1193-1207

🤖 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/payments/src/lib.rs` around lines 1165 - 1174, The release path in
the held-payment flow is subtracting the full payment amount instead of the net
held balance, which can overpay after partial refunds and leave revenue ledgers
inconsistent. Update the logic around the release handling in the payment
release function(s) that use collect_held_payments_for_token and the related
refund-held path to compute and transfer only the remaining releasable net
amount after refunded_amount. Also decrement both the token-level and
event-level revenue aggregates by that same net released amount, and ensure the
payment status/held state is updated consistently after the partial release.

Comment on lines +1570 to +1593
let event_ended = match storage::get_event_status(&env, &ticket.event_id) {
Some(EventStatus::Completed) | Some(EventStatus::Cancelled) => true,
_ => {
if let Ok(meta) = storage::get_escrow_meta(&env, &ticket.event_id) {
env.ledger().timestamp() >= meta.event_end_time
} else {
false
}
}
};
if !event_ended {
return Err(PaymentError::EventNotEnded);
}

let event_end_time = if let Ok(meta) = storage::get_escrow_meta(&env, &ticket.event_id) {
meta.event_end_time
} else {
env.ledger().timestamp()
};

let now = env.ledger().timestamp();
if now > event_end_time + DISPUTE_WINDOW_SECS {
return Err(PaymentError::DisputeWindowClosed);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Anchor the dispute window to the configured event end.

For completed/cancelled events without escrow metadata, event_end_time falls back to now, so the 7-day window never closes. This also does not enforce the issue requirement that disputes open only after event_end_ledger.

🛠️ Suggested direction
+        let config = storage::get_event_config(&env, &ticket.event_id)
+            .ok_or(PaymentError::InvalidOrganizer)?;
+        if env.ledger().sequence() < config.event_end_ledger {
+            return Err(PaymentError::EventNotEnded);
+        }
+
         let event_end_time = if let Ok(meta) = storage::get_escrow_meta(&env, &ticket.event_id) {
             meta.event_end_time
         } else {
-            env.ledger().timestamp()
+            return Err(PaymentError::EscrowNotConfigured);
         };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let event_ended = match storage::get_event_status(&env, &ticket.event_id) {
Some(EventStatus::Completed) | Some(EventStatus::Cancelled) => true,
_ => {
if let Ok(meta) = storage::get_escrow_meta(&env, &ticket.event_id) {
env.ledger().timestamp() >= meta.event_end_time
} else {
false
}
}
};
if !event_ended {
return Err(PaymentError::EventNotEnded);
}
let event_end_time = if let Ok(meta) = storage::get_escrow_meta(&env, &ticket.event_id) {
meta.event_end_time
} else {
env.ledger().timestamp()
};
let now = env.ledger().timestamp();
if now > event_end_time + DISPUTE_WINDOW_SECS {
return Err(PaymentError::DisputeWindowClosed);
}
let event_ended = match storage::get_event_status(&env, &ticket.event_id) {
Some(EventStatus::Completed) | Some(EventStatus::Cancelled) => true,
_ => {
if let Ok(meta) = storage::get_escrow_meta(&env, &ticket.event_id) {
env.ledger().timestamp() >= meta.event_end_time
} else {
false
}
}
};
if !event_ended {
return Err(PaymentError::EventNotEnded);
}
let config = storage::get_event_config(&env, &ticket.event_id)
.ok_or(PaymentError::InvalidOrganizer)?;
if env.ledger().sequence() < config.event_end_ledger {
return Err(PaymentError::EventNotEnded);
}
let event_end_time = if let Ok(meta) = storage::get_escrow_meta(&env, &ticket.event_id) {
meta.event_end_time
} else {
return Err(PaymentError::EscrowNotConfigured);
};
let now = env.ledger().timestamp();
if now > event_end_time + DISPUTE_WINDOW_SECS {
return Err(PaymentError::DisputeWindowClosed);
}
🤖 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/payments/src/lib.rs` around lines 1570 - 1593, The dispute-window
check in the payments flow is using a fallback to the current timestamp when
escrow metadata is missing, which prevents the window from ever closing and
bypasses the configured event end. Update the logic around the event status and
`event_end_time` in the payment/refund path so it always anchors to the
configured event end (for example via the stored event end ledger/time on
`ticket.event_id`) instead of `env.ledger().timestamp()`, and make sure disputes
only become valid after `event_end_ledger` while still enforcing
`DISPUTE_WINDOW_SECS`.

Comment on lines +1599 to +1605
let dispute = TicketDispute {
ticket_id,
reason: reason.clone(),
opened_at: now,
status: DisputeStatus::Open,
escrow_amount: payment.amount,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Store and refund only the remaining escrow balance.

A partial admin refund before dispute leaves refunded_amount non-zero. Freezing payment.amount and then approving that full amount can double-refund; approval also marks Refunded without updating refunded_amount.

🛠️ Suggested fix
+        let escrow_amount = payment.amount - payment.refunded_amount;
+        if escrow_amount <= 0 {
+            return Err(PaymentError::InvalidAmount);
+        }
+
         let dispute = TicketDispute {
             ticket_id,
             reason: reason.clone(),
             opened_at: now,
             status: DisputeStatus::Open,
-            escrow_amount: payment.amount,
+            escrow_amount,
         };
         let ticket = storage::get_ticket(&env, ticket_id)?;
         let mut payment = storage::get_payment(&env, ticket.payment_id)?;
+        if payment.status != PaymentStatus::Held {
+            return Err(PaymentError::PaymentAlreadyProcessed);
+        }
+        let remaining = payment.amount - payment.refunded_amount;
+        if dispute.escrow_amount <= 0 || dispute.escrow_amount > remaining {
+            return Err(PaymentError::InvalidAmount);
+        }

         let token_address = storage::get_accepted_token(&env)?;
         let token_client = token::Client::new(&env, &token_address);
@@
-        payment.status = PaymentStatus::Refunded;
+        payment.refunded_amount += dispute.escrow_amount;
+        payment.status = PaymentStatus::Refunded;

Also applies to: 1637-1649

🤖 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/payments/src/lib.rs` around lines 1599 - 1605, The dispute flow in
TicketDispute creation and the approval path should use only the remaining
escrow balance, not payment.amount. Update the dispute escrow calculation and
the related approval/refund logic so it derives the refundable amount from
payment.amount minus any existing refunded_amount, and make sure the
approve/dispute handling updates refunded_amount consistently when marking the
payment as Refunded. Use the TicketDispute, refund approval, and dispute-opening
code paths to keep partial admin refunds from being counted twice.

Comment on lines +1640 to +1661
let token_address = storage::get_accepted_token(&env)?;
let token_client = token::Client::new(&env, &token_address);
token_client.transfer(
&env.current_contract_address(),
&payment.payer,
&dispute.escrow_amount,
);

payment.status = PaymentStatus::Refunded;
storage::update_payment(&env, &payment)?;

let revenue = storage::get_event_revenue(&env, &ticket.event_id);
storage::set_event_revenue(&env, &ticket.event_id, revenue - dispute.escrow_amount);

let token_revenue =
storage::get_event_token_revenue(&env, &ticket.event_id, &token_address);
storage::set_event_token_revenue(
&env,
&ticket.event_id,
&token_address,
token_revenue - dispute.escrow_amount,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Refund using the original payment token.

approve_refund transfers and decrements revenue for get_accepted_token(), but the payment record already stores the token paid. In multi-token flows, this refunds the wrong asset and corrupts per-token revenue.

🛠️ Suggested fix
-        let token_address = storage::get_accepted_token(&env)?;
-        let token_client = token::Client::new(&env, &token_address);
+        let token_client = token::Client::new(&env, &payment.token);
         token_client.transfer(
             &env.current_contract_address(),
             &payment.payer,
             &dispute.escrow_amount,
         );
@@
         let token_revenue =
-            storage::get_event_token_revenue(&env, &ticket.event_id, &token_address);
+            storage::get_event_token_revenue(&env, &ticket.event_id, &payment.token);
         storage::set_event_token_revenue(
             &env,
             &ticket.event_id,
-            &token_address,
+            &payment.token,
             token_revenue - dispute.escrow_amount,
         );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let token_address = storage::get_accepted_token(&env)?;
let token_client = token::Client::new(&env, &token_address);
token_client.transfer(
&env.current_contract_address(),
&payment.payer,
&dispute.escrow_amount,
);
payment.status = PaymentStatus::Refunded;
storage::update_payment(&env, &payment)?;
let revenue = storage::get_event_revenue(&env, &ticket.event_id);
storage::set_event_revenue(&env, &ticket.event_id, revenue - dispute.escrow_amount);
let token_revenue =
storage::get_event_token_revenue(&env, &ticket.event_id, &token_address);
storage::set_event_token_revenue(
&env,
&ticket.event_id,
&token_address,
token_revenue - dispute.escrow_amount,
);
let token_client = token::Client::new(&env, &payment.token);
token_client.transfer(
&env.current_contract_address(),
&payment.payer,
&dispute.escrow_amount,
);
payment.status = PaymentStatus::Refunded;
storage::update_payment(&env, &payment)?;
let revenue = storage::get_event_revenue(&env, &ticket.event_id);
storage::set_event_revenue(&env, &ticket.event_id, revenue - dispute.escrow_amount);
let token_revenue =
storage::get_event_token_revenue(&env, &ticket.event_id, &payment.token);
storage::set_event_token_revenue(
&env,
&ticket.event_id,
&payment.token,
token_revenue - dispute.escrow_amount,
);
🤖 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/payments/src/lib.rs` around lines 1640 - 1661, The refund logic in
approve_refund is using storage::get_accepted_token and the current accepted
token instead of the token originally used for the payment. Update the refund
flow to read the token from the stored payment record (the payment/token fields
on the payment struct) and use that token for token::Client::new, transfer, and
token revenue adjustments. Keep the revenue updates in sync with the original
token and ticket.event_id so multi-token refunds debit the correct asset and
per-token totals.

Comment on lines +1686 to +1694
let ticket = storage::get_ticket(&env, ticket_id)?;

storage::clear_payment_dispute(&env, ticket.payment_id);
storage::remove_dispute(&env, ticket_id);

// Block re-disputes and admin refunds on this payment permanently
storage::set_dispute_outcome(&env, ticket.payment_id);

events::emit_dispute_rejected(&env, ticket_id, ticket.event_id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

Release organizer-won disputes instead of only clearing the freeze.

reject_dispute and timeout_dispute leave the payment Held. Since payout paths skip disputed payments and may already mark the event withdrawn/auto-released, clearing the flag can strand the disputed escrow indefinitely. The timeout objective also requires releasing funds after 14 days.

🛠️ Suggested direction
         let ticket = storage::get_ticket(&env, ticket_id)?;
+        let mut payment = storage::get_payment(&env, ticket.payment_id)?;
+        let organizer = storage::get_event_config(&env, &ticket.event_id)
+            .ok_or(PaymentError::InvalidOrganizer)?
+            .organizer;
+        let release_amount = payment.amount - payment.refunded_amount;
+        if release_amount <= 0 {
+            return Err(PaymentError::InvalidAmount);
+        }
+
+        let token_client = token::Client::new(&env, &payment.token);
+        token_client.transfer(
+            &env.current_contract_address(),
+            &organizer,
+            &release_amount,
+        );
+        payment.status = PaymentStatus::Released;
+        storage::update_payment(&env, &payment)?;

         storage::clear_payment_dispute(&env, ticket.payment_id);
         storage::remove_dispute(&env, ticket_id);

Also applies to: 1714-1722

🤖 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/payments/src/lib.rs` around lines 1686 - 1694, `reject_dispute` and
`timeout_dispute` currently only clear the dispute state, which can leave
organizer-won disputes stuck in `Held` and prevent payout. Update the
dispute-resolution flow in those functions to explicitly release the
escrow/funds and transition the payment out of the held state after removing the
dispute, while still preserving the permanent dispute outcome bookkeeping. Use
the existing `reject_dispute`, `timeout_dispute`,
`storage::clear_payment_dispute`, `storage::remove_dispute`, and
`storage::set_dispute_outcome` paths to locate the fix.

Comment on lines +705 to +735
pub fn save_dispute(env: &Env, dispute: &TicketDispute) {
let key = DataKey::TicketDispute(dispute.ticket_id);
env.storage().persistent().set(&key, dispute);
env.storage()
.persistent()
.extend_ttl(&key, 60 * 60 * 24 * 30, 60 * 60 * 24 * 30 * 2);
}

/// Get a dispute record by ticket_id.
pub fn get_dispute(env: &Env, ticket_id: u64) -> Result<TicketDispute, PaymentError> {
env.storage()
.persistent()
.get(&DataKey::TicketDispute(ticket_id))
.ok_or(PaymentError::DisputeNotFound)
}

/// Check if a ticket has an active dispute.
pub fn has_active_dispute(env: &Env, ticket_id: u64) -> bool {
env.storage()
.persistent()
.has(&DataKey::TicketDispute(ticket_id))
}

/// Mark a payment as disputed (blocks settlement).
pub fn mark_payment_disputed(env: &Env, payment_id: u64) {
let key = DataKey::PaymentDisputed(payment_id);
env.storage().persistent().set(&key, &true);
env.storage()
.persistent()
.extend_ttl(&key, 60 * 60 * 24 * 30, 60 * 60 * 24 * 30 * 2);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Active disputes should not expire out from under the escrow freeze.

Both the dispute record and the PaymentDisputed flag are stored with short TTLs, and none of the read paths refresh them. If nobody calls timeout_dispute(), these keys can disappear on their own, after which collect_held_payments_for_token() and refund() stop treating the payment as disputed even though no reject/timeout path ever ran. That silently unfreezes funds outside the contract’s dispute workflow.

🤖 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/payments/src/storage.rs` around lines 705 - 735, The dispute state
in save_dispute and mark_payment_disputed is expiring via TTL without being
refreshed on reads, which can let active disputes disappear unexpectedly. Update
the storage flow in contracts/payments/src/storage.rs so TicketDispute and
PaymentDisputed remain valid for the full dispute lifecycle, either by removing
the short TTLs or by refreshing them from the dispute read/settlement paths used
by get_dispute, has_active_dispute, collect_held_payments_for_token, and refund.
Ensure timeout_dispute still clears the state explicitly rather than relying on
expiry.

Comment on lines +781 to +795
pub fn set_dispute_outcome(env: &Env, payment_id: u64) {
let key = DataKey::DisputeOutcome(payment_id);
env.storage().persistent().set(&key, &true);
env.storage()
.persistent()
.extend_ttl(&key, 60 * 60 * 24 * 30, 60 * 60 * 24 * 30 * 2);
}

/// Returns true if a payment's dispute was resolved in organizer's favor (rejected or timed out).
pub fn is_dispute_outcome_set(env: &Env, payment_id: u64) -> bool {
env.storage()
.persistent()
.get::<DataKey, bool>(&DataKey::DisputeOutcome(payment_id))
.unwrap_or(false)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

The “permanent” organizer-win marker is expiring.

reject_dispute() and timeout_dispute() rely on is_dispute_outcome_set() to permanently block later re-disputes/refunds, but set_dispute_outcome() gives that key a 60-day TTL. Once it ages out, the guard disappears and the same payment becomes eligible again. This flag needs non-expiring storage.

🤖 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/payments/src/storage.rs` around lines 781 - 795, The organizer-win
dispute marker is currently being written with a TTL in set_dispute_outcome, so
it can expire and stop blocking later re-disputes/refunds. Update
set_dispute_outcome and the is_dispute_outcome_set flow in storage.rs so the
DataKey::DisputeOutcome entry is stored permanently without extend_ttl, keeping
reject_dispute and timeout_dispute guards effective for the lifetime of the
payment.

Comment on lines +3050 to +3076
#[test]
fn test_timeout_dispute_happy_path() {
let env = Env::default();
env.mock_all_auths();

let (_admin, client, _token, _contract_id, ticket_id, payment_id, _payer, _event_end_time) =
setup_dispute_scenario(&env);

client.raise_dispute(&ticket_id, &0u32);

let dispute = client.get_dispute(&ticket_id);
let opened_at = dispute.opened_at;

// Advance past DISPUTE_TIMEOUT_SECS (1209600)
env.ledger().with_mut(|li| {
li.timestamp = opened_at + 1_209_600 + 1;
});

client.timeout_dispute(&ticket_id);

// Dispute record removed
let result = client.try_get_dispute(&ticket_id);
assert_eq!(result.err(), Some(Ok(PaymentError::DisputeNotFound)));

// Payment still Held (organizer can withdraw later)
let payment = client.get_payment(&payment_id);
assert_eq!(payment.status, PaymentStatus::Held);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Timeout should assert settlement, not a still-Held payment.

Issue #125 says the disputed share is auto-released to the organizer after 14 days. This test currently blesses an implementation that only deletes the dispute and leaves the funds unsettled. Assert the organizer/contract balances after timeout_dispute (and a released state if one is tracked), rather than expecting PaymentStatus::Held.

🤖 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/payments/src/test.rs` around lines 3050 - 3076, The timeout dispute
test is asserting the wrong post-condition by expecting the payment to remain
Held after timeout; update test_timeout_dispute_happy_path to verify the
settlement outcome of timeout_dispute instead. Use the existing
setup_dispute_scenario, raise_dispute, and timeout_dispute flow, then assert the
organizer/contract balances (and any released/settled status if tracked) reflect
the disputed share being auto-released, while still confirming the dispute is
removed.

Comment on lines +24 to +30
pub struct TicketDispute {
pub ticket_id: u64,
pub reason: DisputeReason,
pub opened_at: u64,
pub status: DisputeStatus,
pub escrow_amount: i128,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Persist the still-locked balance here, not the original ticket price.

raise_dispute() currently stores payment.amount in this field, while refund() can leave a payment Held with refunded_amount > 0. approve_refund() then transfers and subtracts dispute.escrow_amount, so a partially refunded payment can be refunded twice and event revenue can go negative. This struct should represent the remaining escrow (amount - refunded_amount) and the raise/approve paths should follow that invariant.

🤖 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/payments/src/types.rs` around lines 24 - 30,
TicketDispute::escrow_amount is currently storing the original payment amount
instead of the remaining locked escrow, which breaks partial-refund handling.
Update raise_dispute() to persist the still-locked balance (payment.amount minus
refunded_amount) and make approve_refund() consume that same remaining escrow
value when transferring and subtracting funds. Ensure the TicketDispute
invariant is that escrow_amount always reflects the remaining held balance, not
the initial ticket price.

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.

Implement attendee-initiated dispute and refund request hook

1 participant