Skip to content

feat(events): emit per-loser outcome loss events on settlement (Closes #168)#1

Open
CodingAngel1 wants to merge 1 commit into
mainfrom
feat/issue-168-loss-outcome-events
Open

feat(events): emit per-loser outcome loss events on settlement (Closes #168)#1
CodingAngel1 wants to merge 1 commit into
mainfrom
feat/issue-168-loss-outcome-events

Conversation

@CodingAngel1

Copy link
Copy Markdown
Owner

Summary

Closes TevaLabs#168 — emits an explicit ("outcome", "loss") event per losing participant during competitive settlement, complementing the implicit winner signal from pending-winnings accumulation and the explicit ("round", "fallback") / ("round", "cancelled") refund events. Analytics, user-notification services, and indexers no longer need to infer losses from the absence of payout events.

Why this matters

Before this PR there was no on-chain log of losses at all. Observers had to maintain a derived view: "user did not appear in any ("claim","winnings") AND had a position in this round → loss." This breaks on batches, partial claims, lost keys, and any team that is reading events fresh. The new event makes loss a first-class observable.

What changed

  1. New event ("outcome", "loss")(user, round_id, mode, amount, side, predicted_price)

    • UpDown (mode=0): side carries the user's failing direction (0=Up, 1=Down). predicted_price is fixed at 0 (the field is only meaningful in Precision mode).
    • Precision (mode=1): predicted_price carries the user's revealed guess (4-decimal scale). side is fixed at 0. For participants who only committed and did not reveal, predicted_price is published as 0 — the guess is unknowable on-chain until reveal — documented in the schema as a uniform-payload convention.
  2. Emitted on every competitive settlement path

    • _record_winnings_indexed — primary UpDown after-migration path
    • _record_winnings_legacy — UpDown legacy bulk-map fallback (migration data)
    • _resolve_precision_mode — primary Precision after-migration path
    • _resolve_precision_legacy — Precision legacy bulk-map fallback (migration data)
  3. Suppressed on every refund/cancel path — no loss event is emitted when stakes are returned whole. Those cases use their own events instead:

    • final_price == start_price → emits nothing extra (refund to pending winnings, may be claimed via ("claim","winnings"))
    • one-sided pool → emits ("pool","onesided")
    • min-participants fallback → emits ("round","fallback")
    • admin cancellation → emits ("round","cancelled")
  4. _resolve_precision_mode precision optimisation — the loser branch now caches a per-participant Vec<bool> is_winner_mask in the same winner-detection pass that already cached (amount, predicted_price). The loser branch now reads O(N) winner state instead of the previous O(N²) winners.iter().any(|w| w.user == user) lookup. Snapshot accesses on the cache use .unwrap(); the four parallel caches (participants, participant_amounts, participant_prices, is_winner_mask) are pushed exactly once per first-pass iteration so drift between the two passes is impossible — were it to ever occur, .unwrap() would fail loudly rather than silently publish a 0-stake loss event that would corrupt downstream accounting.

  5. Schema & docs

  6. Tests — 7 new tests under tests::resolution::LOSS OUTCOME EVENT TESTS (Issue #168):

    • test_outcome_loss_event_updown_indexed_path — 4 participants, 2 winners, 2 losers → exactly 2 loss events; per-loser assertions on (mode, round_id, amount, side).
    • test_outcome_loss_event_updown_legacy_path — drives the legacy UpDownPositions bulk map to take the legacy winnings path; asserts 1 loss event with side=1 (Down loser).
    • test_outcome_loss_event_precision_indexed_path — 3 participants (1 winner + 1 revealed loser + 1 unrevealed loser) → asserts that the unrevealed commit loser is still emitted with predicted_price=0.
    • test_outcome_loss_event_precision_legacy_path — drives the legacy PrecisionPositions bulk map; asserts per-loser (amount, predicted_price) and explicitly verifies that winners never appear in loss events.
    • test_outcome_loss_event_not_emitted_on_refundfinal_price == start_price → 0 loss events.
    • test_outcome_loss_event_not_emitted_on_min_participants_fallbackmin_participants not met → 0 loss events.
    • test_outcome_loss_event_not_emitted_on_cancel — admin cancel_round → 0 loss events.
    • test_outcome_loss_event_count_matches_outcomes_across_modes — runs an UpDown round then a Precision round in the same fixture and asserts (a) the total loss-event count matches the per-round loser count, (b) the winner never appears in loss events, (c) UpDown loss events are ordered before Precision loss events within a single test, locking the inter-round ordering invariant.

Two helper functions live alongside the tests: count_outcome_loss_events(env) filters env.events() by topic, and collect_outcome_loss_events(env) decodes the (Address, u64, u32, I128, u32, u128) payload for indexed assertions.

Linked issues

Validation

Note: the local environment used to author this PR did not have a Cargo toolchain on PATH, so cargo check / clippy / fmt were not run locally. CI will run the standard validation matrix.

  • cargo test --workspace
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo fmt --all -- --check
  • cd bindings && npm ci && npm run build

Status fields left unchecked because CI must run the suite. Please run the matrix before merge.

Schema impact

The schema change is additive under the protocol's "new optional events do not bump the version" rule (see top of docs/EVENT_SCHEMA.md). Existing indexers keep working unchanged — they will simply ignore ("outcome","loss") until they opt in. New indexers / dApps gain a single, deterministic place to learn about each losing participant.

Edge cases covered

  • Pricing-side correctness: Tests assert the on-chain payload for both modes (mode=0/mode=1) per round, including the unrevealed-commitment loser in Precision.
  • Refund vs. competitive settlement distinction: Each of the four refund paths has an explicit "no loss event" test.
  • Legacy bulk-map compatibility: Tests for both UpDown legacy and Precision legacy use env.as_contract to seed the old UpDownPositions / PrecisionPositions bulk maps and verify the loss event still fires on those paths.
  • Winner never appears in loss events: Asserted explicitly in the precision legacy test and in the cross-mode count-invariant test.
  • Loss event precedes _update_stats_loss: Documented invariant in the contract comments; ordering-within-round is observable in the test ledger.

Governance checklist

  • I reviewed CONTRIBUTING.md for workflow expectations
  • I checked CODEOWNERS impact for touched paths (contracts/src/contract.rs, contracts/src/tests/resolution.rs, docs/EVENT_SCHEMA.md, docs/event_schema_guide.md)
  • I followed SUPPORT.md disclosure guidance — no security-sensitive surface is changed (additive event only; existing refund/cancel/claim flows are unchanged)

Reviewer notes

  • The four modified files total +839/-8 LOC.
  • _resolve_precision_mode is the most invasive change; the additional state (is_winner_mask) is purely local to that function and has no contract ABI impact.
  • For maximum confidence, please run: cargo test -p virtual-token-contract tests::resolution::outcome (targets both test_outcome_loss_event_* and the adjacent resolution tests).

…TevaLabs#168)

Adds a per-loser ("outcome","loss") event during competitive settlement so
analytics, user-notification services, and indexers no longer need to infer
losses from the absence of payout events.

Emitted in 4 settlement paths (UpDown indexed + legacy, Precision indexed +
legacy). Suppressed on refund paths (price-unchanged, one-sided pool, min
participants fallback, admin cancellation) -- those emit their own refund
events instead.

Payload: (user, round_id, mode=0|1, amount, side|0, predicted_price|0)
- UpDown: side carries losing direction (0=Up,1=Down); predicted_price=0
- Precision: predicted_price carries revealed guess; side=0; for unrevealed
  commitments predicted_price is published as 0 (guess unknowable on-chain).

In _resolve_precision_mode, the winner/loser-mark loop now caches a
per-participant Vec<bool> is_winner_mask alongside the existing amount/price
snapshots, so the loser branch reads O(N) winner state instead of an
O(N^2) winners.iter().any() lookup. Drift between the four parallel caches
is impossible by construction (each iteration pushes exactly once), so
snapshot lookups use unwrap() rather than unwrap_or(0) -- a 0-stake loss
event from drift would silently corrupt downstream accounting.

Tests: 7 new tests in tests::resolution covering all 4 settlement paths,
3 negative paths, and a cross-mode count-invariant test.
Docs: EVENT_SCHEMA.md gains the canonical ("outcome","loss") entry;
event_schema_guide.md is updated to summarise the addition.

Refs TevaLabs#168
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.

Observability: emit explicit participant loss outcome events during settlement

1 participant