diff --git a/internal/migrations/032-order-book-actions.sql b/internal/migrations/032-order-book-actions.sql index dd554c5c9..d99d988cd 100644 --- a/internal/migrations/032-order-book-actions.sql +++ b/internal/migrations/032-order-book-actions.sql @@ -541,6 +541,10 @@ CREATE OR REPLACE ACTION match_direct( -- Transfer payment from vault to seller ob_unlock_collateral($bridge, $seller_wallet_address, $seller_payment); + -- Record fill events (execution price = sell_price for direct matches) + ob_record_order_event($query_id, $buy_participant_id, 'direct_buy_fill', $outcome, $sell_price, $match_amount, $sell_participant_id); + ob_record_order_event($query_id, $sell_participant_id, 'direct_sell_fill', $outcome, $sell_price, $match_amount, $buy_participant_id); + -- Record sell impact for P&L ob_record_tx_impact($sell_participant_id, $outcome, -$match_amount, $seller_payment, FALSE); @@ -715,6 +719,10 @@ CREATE OR REPLACE ACTION match_mint( $mint_amount := $no_amount; } + -- Record mint fill events + ob_record_order_event($query_id, $yes_participant_id, 'mint_fill', TRUE, $yes_price, $mint_amount, $no_participant_id); + ob_record_order_event($query_id, $no_participant_id, 'mint_fill', FALSE, $no_price, $mint_amount, $yes_participant_id); + -- Delete fully matched buy orders FIRST DELETE FROM ob_positions WHERE query_id = $query_id @@ -887,6 +895,10 @@ CREATE OR REPLACE ACTION match_burn( $burn_amount := $no_amount; } + -- Record burn fill events + ob_record_order_event($query_id, $yes_participant_id, 'burn_fill', TRUE, $yes_price, $burn_amount, $no_participant_id); + ob_record_order_event($query_id, $no_participant_id, 'burn_fill', FALSE, $no_price, $burn_amount, $yes_participant_id); + -- Calculate payouts $yes_payout NUMERIC(78, 0) := ($burn_amount::NUMERIC(78, 0) * $yes_price::NUMERIC(78, 0) * @@ -1218,6 +1230,9 @@ CREATE OR REPLACE ACTION place_buy_order( SET amount = ob_positions.amount + EXCLUDED.amount, last_updated = EXCLUDED.last_updated; + -- Record order event + ob_record_order_event($query_id, $participant_id, 'buy_placed', $outcome, $price, $amount, NULL); + -- ========================================================================== -- SECTION 7: TRIGGER MATCHING ENGINE -- ========================================================================== @@ -1228,7 +1243,7 @@ CREATE OR REPLACE ACTION place_buy_order( -- ========================================================================== -- SECTION 8: CLEANUP & MATERIALIZE IMPACTS -- ========================================================================== - + ob_cleanup_tx_payouts($query_id); -- Success: Order placed (may be partially or fully matched by future matching engine) @@ -1406,6 +1421,9 @@ CREATE OR REPLACE ACTION place_sell_order( SET amount = ob_positions.amount + EXCLUDED.amount, last_updated = EXCLUDED.last_updated; + -- Record order event + ob_record_order_event($query_id, $participant_id, 'sell_placed', $outcome, $price, $amount, NULL); + -- ========================================================================== -- SECTION 5: TRIGGER MATCHING ENGINE -- ========================================================================== @@ -1662,6 +1680,10 @@ CREATE OR REPLACE ACTION place_split_limit_order( SET amount = ob_positions.amount + EXCLUDED.amount, last_updated = EXCLUDED.last_updated; + -- Record order events: YES holding + NO sell + ob_record_order_event($query_id, $participant_id, 'split_placed', TRUE, $true_price, $amount, NULL); + ob_record_order_event($query_id, $participant_id, 'split_placed', FALSE, $false_price, $amount, NULL); + -- ========================================================================== -- SECTION 8: TRIGGER MATCHING ENGINE -- ========================================================================== @@ -1859,6 +1881,15 @@ CREATE OR REPLACE ACTION cancel_order( last_updated = EXCLUDED.last_updated; } + -- Record cancel event (use absolute price for display) + $abs_cancel_price INT; + if $price < 0 { + $abs_cancel_price := -$price; + } else { + $abs_cancel_price := $price; + } + ob_record_order_event($query_id, $participant_id, 'cancelled', $outcome, $abs_cancel_price, $order_amount, NULL); + -- ========================================================================== -- SECTION 7: DELETE CANCELLED ORDER -- ========================================================================== @@ -2119,6 +2150,9 @@ CREATE OR REPLACE ACTION change_bid( ELSE EXCLUDED.last_updated -- Keep earlier timestamp (moved order was first) END; + -- Record bid change event (positive price for display) + ob_record_order_event($query_id, $participant_id, 'bid_changed', $outcome, $new_abs_price, $new_amount, NULL); + -- ========================================================================== -- SECTION 9: TRIGGER MATCHING ENGINE -- ========================================================================== @@ -2383,6 +2417,9 @@ CREATE OR REPLACE ACTION change_ask( ELSE EXCLUDED.last_updated -- Keep earlier timestamp (moved order was first) END; + -- Record ask change event + ob_record_order_event($query_id, $participant_id, 'ask_changed', $outcome, $new_price, $new_amount, NULL); + -- ========================================================================== -- SECTION 8: TRIGGER MATCHING ENGINE -- ========================================================================== diff --git a/internal/migrations/033-order-book-settlement.sql b/internal/migrations/033-order-book-settlement.sql index 64e5ed569..0daa05edc 100644 --- a/internal/migrations/033-order-book-settlement.sql +++ b/internal/migrations/033-order-book-settlement.sql @@ -280,6 +280,36 @@ CREATE OR REPLACE ACTION process_settlement( $outcomes BOOL[]; $total_fees NUMERIC(78, 0) := '0'::NUMERIC(78, 0); + -- First: record settlement events for ALL positions (winners + losers + open buys) + -- This uses a broader query than the payout calculation to include losing positions. + for $evt_row in + SELECT pos.participant_id, pos.price, pos.amount, pos.outcome + FROM ob_positions pos + WHERE pos.query_id = $query_id + ORDER BY pos.participant_id, pos.outcome, pos.price, pos.amount + { + $evt_pid INT := $evt_row.participant_id; + $evt_out BOOL := $evt_row.outcome; + $evt_amount INT8 := $evt_row.amount; + $evt_raw_price INT := $evt_row.price; + + -- Determine settlement price for the event: + -- - Winning holdings (price=0, winning outcome): 98 (redemption at $0.98) + -- - Winning sell orders (price>0, winning outcome): 98 (redeemed, not sold) + -- - Losing positions (wrong outcome, price>=0): 0 (worthless) + -- - Open buy orders (price<0): abs(price) (collateral refund at buy price) + $evt_settle_price INT; + if $evt_raw_price < 0 { + $evt_settle_price := -$evt_raw_price; + } else if $evt_out = $winning_outcome { + $evt_settle_price := 98; + } else { + $evt_settle_price := 0; + } + ob_record_order_event($query_id, $evt_pid, 'settled', $evt_out, $evt_settle_price, $evt_amount, NULL); + } + + -- Second: calculate payouts (original logic, only winners + open buys) for $res in WITH target_positions AS ( SELECT pos.participant_id, p.wallet_address, pos.price, pos.amount, pos.outcome @@ -287,11 +317,11 @@ CREATE OR REPLACE ACTION process_settlement( WHERE pos.query_id = $query_id AND ((pos.price >= 0 AND pos.outcome = $winning_outcome) OR (pos.price < 0)) ), payout_calculation AS ( - SELECT + SELECT participant_id, wallet_address, '0x' || encode(wallet_address, 'hex') as wallet_hex, - CASE + CASE WHEN price >= 0 THEN ((amount::NUMERIC(78, 0) * '1000000000000000000'::NUMERIC(78, 0) * 98::NUMERIC(78, 0)) / 100::NUMERIC(78, 0)) ELSE (amount::NUMERIC(78, 0) * (CASE WHEN price < 0 THEN -price ELSE price END)::NUMERIC(78, 0) * '10000000000000000'::NUMERIC(78, 0)) END as pay, diff --git a/internal/migrations/044-order-book-events.sql b/internal/migrations/044-order-book-events.sql new file mode 100644 index 000000000..daf06cff5 --- /dev/null +++ b/internal/migrations/044-order-book-events.sql @@ -0,0 +1,122 @@ +/* + * ORDER BOOK EVENT HISTORY + * + * Creates the ob_order_events table to record individual order events + * (placement, cancellation, fill, settlement) for wallet history queries. + * + * This table is EPHEMERAL on-chain — the indexer syncs events to permanent + * storage, then trim_order_events() deletes old rows to bound node storage. + * This follows the same pattern as tn_digest (write → index → trim). + */ + +-- ============================================================================= +-- ob_order_events: Individual order event log +-- ============================================================================= +CREATE TABLE IF NOT EXISTS ob_order_events ( + id INT8 PRIMARY KEY, + tx_hash BYTEA NOT NULL, + query_id INT NOT NULL, + participant_id INT NOT NULL, + event_type TEXT NOT NULL, + outcome BOOLEAN NOT NULL, + price INT NOT NULL, + amount INT8 NOT NULL, + counterparty_id INT, + block_height INT8 NOT NULL, + block_timestamp INT8 NOT NULL, + + FOREIGN KEY (query_id) REFERENCES ob_queries(id), + FOREIGN KEY (participant_id) REFERENCES ob_participants(id) +); + +-- Index for indexer sequential sync (same pattern as ob_net_impacts) +CREATE INDEX IF NOT EXISTS idx_ob_order_events_id ON ob_order_events(id); + +-- Index for trim operations (delete by block_height) +CREATE INDEX IF NOT EXISTS idx_ob_order_events_height ON ob_order_events(block_height); + +-- ============================================================================= +-- ob_record_order_event: Helper to insert a single order event +-- ============================================================================= +CREATE OR REPLACE ACTION ob_record_order_event( + $query_id INT, + $participant_id INT, + $event_type TEXT, + $outcome BOOLEAN, + $price INT, + $amount INT8, + $counterparty_id INT +) PRIVATE { + $next_id INT8; + for $row in SELECT COALESCE(MAX(id), 0::INT8) + 1 as val FROM ob_order_events { + $next_id := $row.val; + } + + INSERT INTO ob_order_events ( + id, tx_hash, query_id, participant_id, event_type, + outcome, price, amount, counterparty_id, + block_height, block_timestamp + ) VALUES ( + $next_id, decode(@txid, 'hex'), $query_id, $participant_id, $event_type, + $outcome, $price, $amount, $counterparty_id, + @height, @block_timestamp + ); +}; + +-- ============================================================================= +-- trim_order_events: Delete old events after indexer has synced them +-- ============================================================================= +/** + * Called by the tn_digest scheduler (leader-only) to trim old order events. + * Uses block_height cutoff with a configurable buffer to ensure the indexer + * has had time to sync before deletion. + * + * Parameters: + * - $preserve_blocks: Number of recent blocks to keep (e.g., 172800 = ~2 days) + * - $delete_cap: Maximum rows to delete per invocation (prevents large txs) + * + * Returns via NOTICE: deleted count, remaining count, has_more flag + */ +CREATE OR REPLACE ACTION trim_order_events( + $preserve_blocks INT8, + $delete_cap INT +) PUBLIC owner { + $cutoff INT8 := @height - $preserve_blocks; + + -- Don't trim if cutoff is negative (chain is younger than preserve window) + if $cutoff <= 0 { + NOTICE('trim_order_events: cutoff<=0, nothing to trim'); + RETURN; + } + + $count INT; + for $row in SELECT count(*)::INT as cnt FROM ob_order_events WHERE block_height < $cutoff { + $count := $row.cnt; + } + + if $count = 0 { + NOTICE('trim_order_events: deleted=0 remaining=0 has_more=false'); + RETURN; + } + + $to_delete INT; + if $count > $delete_cap { + $to_delete := $delete_cap; + } else { + $to_delete := $count; + } + + DELETE FROM ob_order_events + WHERE id IN ( + SELECT id FROM ob_order_events + WHERE block_height < $cutoff + ORDER BY id ASC + LIMIT $to_delete + ); + + $remaining INT := $count - $to_delete; + $has_more BOOL := $count > $delete_cap; + NOTICE('trim_order_events: deleted=' || $to_delete::TEXT + || ' remaining=' || $remaining::TEXT + || ' has_more=' || $has_more::TEXT); +}; diff --git a/tests/streams/order_book/order_events_test.go b/tests/streams/order_book/order_events_test.go new file mode 100644 index 000000000..46c33f6e4 --- /dev/null +++ b/tests/streams/order_book/order_events_test.go @@ -0,0 +1,717 @@ +//go:build kwiltest + +package order_book + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/trufnetwork/kwil-db/common" + coreauth "github.com/trufnetwork/kwil-db/core/crypto/auth" + kwilTypes "github.com/trufnetwork/kwil-db/core/types" + erc20bridge "github.com/trufnetwork/kwil-db/node/exts/erc20-bridge/erc20" + kwilTesting "github.com/trufnetwork/kwil-db/testing" + "github.com/trufnetwork/node/extensions/tn_utils" + "github.com/trufnetwork/node/internal/migrations" + attestationTests "github.com/trufnetwork/node/tests/streams/attestation" + testutils "github.com/trufnetwork/node/tests/streams/utils" + "github.com/trufnetwork/node/tests/streams/utils/setup" + "github.com/trufnetwork/sdk-go/core/util" +) + +// OrderEvent represents a row from ob_order_events +type OrderEvent struct { + ID int + QueryID int + ParticipantID int + EventType string + Outcome bool + Price int + Amount int64 + CounterpartyID *int64 + BlockHeight int64 + BlockTimestamp int64 +} + +func TestOrderEvents(t *testing.T) { + owner := util.Unsafe_NewEthereumAddressFromString("0x1111111111111111111111111111111111111111") + + testutils.RunSchemaTest(t, kwilTesting.SchemaTest{ + Name: "ORDER_BOOK_ORDER_EVENTS", + SeedStatements: migrations.GetSeedScriptStatements(), + Owner: owner.Address(), + FunctionTests: []kwilTesting.TestFunc{ + testOrderEventBuyPlaced(t), + testOrderEventSellPlaced(t), + testOrderEventSplitPlaced(t), + testOrderEventCancelled(t), + testOrderEventDirectFill(t), + testOrderEventMintFill(t), + testOrderEventBurnFill(t), + testOrderEventChangeBid(t), + testOrderEventChangeAsk(t), + testOrderEventSettlement(t), + }, + }, testutils.GetTestOptionsWithCache()) +} + +// getOrderEvents queries ob_order_events for a given market +func getOrderEvents(ctx context.Context, platform *kwilTesting.Platform, marketID int) ([]OrderEvent, error) { + tx := &common.TxContext{ + Ctx: ctx, + BlockContext: &common.BlockContext{Height: 1}, + TxID: platform.Txid(), + } + engineCtx := &common.EngineContext{TxContext: tx} + + var events []OrderEvent + err := platform.Engine.Execute( + engineCtx, + platform.DB, + "SELECT id, query_id, participant_id, event_type, outcome, price, amount, counterparty_id, block_height, block_timestamp FROM ob_order_events WHERE query_id = $query_id ORDER BY id ASC", + map[string]any{"$query_id": marketID}, + func(row *common.Row) error { + evt := OrderEvent{ + ID: int(row.Values[0].(int64)), + QueryID: int(row.Values[1].(int64)), + ParticipantID: int(row.Values[2].(int64)), + EventType: row.Values[3].(string), + Outcome: row.Values[4].(bool), + Price: int(row.Values[5].(int64)), + Amount: row.Values[6].(int64), + BlockHeight: row.Values[8].(int64), + BlockTimestamp: row.Values[9].(int64), + } + if row.Values[7] != nil { + cp := row.Values[7].(int64) + evt.CounterpartyID = &cp + } + events = append(events, evt) + return nil + }, + ) + return events, err +} + +// getOrderEventsByType filters events by type +func getOrderEventsByType(events []OrderEvent, eventType string) []OrderEvent { + var filtered []OrderEvent + for _, e := range events { + if e.EventType == eventType { + filtered = append(filtered, e) + } + } + return filtered +} + +// setupMarket creates a market and returns the market ID +func setupMarket(t *testing.T, ctx context.Context, platform *kwilTesting.Platform, user *util.EthereumAddress, streamSuffix string) int64 { + queryComponents, err := encodeQueryComponentsForTests( + user.Address(), + "sttest00000000000000000000"+streamSuffix, + "get_record", + []byte{0x01}, + ) + require.NoError(t, err) + + settleTime := time.Now().Add(1 * time.Hour).Unix() + var marketID int64 + err = callCreateMarket(ctx, platform, user, queryComponents, settleTime, 5, 1, + func(row *common.Row) error { + marketID = row.Values[0].(int64) + return nil + }) + require.NoError(t, err) + return marketID +} + +// testOrderEventBuyPlaced verifies that placing a buy order creates a buy_placed event +func testOrderEventBuyPlaced(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + lastBalancePoint = nil + lastTrufBalancePoint = nil + + err := erc20bridge.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err) + + user := util.Unsafe_NewEthereumAddressFromString("0xAA00000000000000000000000000000000000001") + err = giveBalance(ctx, platform, user.Address(), "500000000000000000000") + require.NoError(t, err) + + marketID := setupMarket(t, ctx, platform, &user, "ev000001") + + // Place buy order: YES @ $0.56, 10 shares + err = callPlaceBuyOrder(ctx, platform, &user, int(marketID), true, 56, 10) + require.NoError(t, err) + + // Verify event was recorded + events, err := getOrderEvents(ctx, platform, int(marketID)) + require.NoError(t, err) + + buyPlaced := getOrderEventsByType(events, "buy_placed") + require.Len(t, buyPlaced, 1, "should have exactly 1 buy_placed event") + + evt := buyPlaced[0] + require.Equal(t, int(marketID), evt.QueryID) + require.True(t, evt.Outcome, "outcome should be YES (true)") + require.Equal(t, 56, evt.Price, "price should be 56") + require.Equal(t, int64(10), evt.Amount, "amount should be 10") + require.Nil(t, evt.CounterpartyID, "no counterparty for placement") + require.NotZero(t, evt.BlockTimestamp) + + return nil + } +} + +// testOrderEventSellPlaced verifies that placing a sell order creates a sell_placed event +func testOrderEventSellPlaced(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + lastBalancePoint = nil + lastTrufBalancePoint = nil + + err := erc20bridge.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err) + + user := util.Unsafe_NewEthereumAddressFromString("0xBB00000000000000000000000000000000000001") + err = giveBalance(ctx, platform, user.Address(), "500000000000000000000") + require.NoError(t, err) + + marketID := setupMarket(t, ctx, platform, &user, "ev000002") + + // First buy to get holdings, then sell + err = callPlaceBuyOrder(ctx, platform, &user, int(marketID), true, 56, 10) + require.NoError(t, err) + + // We need a counterparty to match the buy so user gets holdings + // Instead, use split limit order which gives holdings directly + err = callPlaceSplitLimitOrder(ctx, platform, &user, int(marketID), 60, 20) + require.NoError(t, err) + + // Now sell some YES holdings + err = callPlaceSellOrder(ctx, platform, &user, int(marketID), true, 70, 5) + require.NoError(t, err) + + events, err := getOrderEvents(ctx, platform, int(marketID)) + require.NoError(t, err) + + sellPlaced := getOrderEventsByType(events, "sell_placed") + require.Len(t, sellPlaced, 1, "should have exactly 1 sell_placed event") + + evt := sellPlaced[0] + require.Equal(t, int(marketID), evt.QueryID) + require.True(t, evt.Outcome, "outcome should be YES (true)") + require.Equal(t, 70, evt.Price) + require.Equal(t, int64(5), evt.Amount) + require.Nil(t, evt.CounterpartyID) + + return nil + } +} + +// testOrderEventSplitPlaced verifies that split limit order creates 2 events (YES hold + NO sell) +func testOrderEventSplitPlaced(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + lastBalancePoint = nil + lastTrufBalancePoint = nil + + err := erc20bridge.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err) + + user := util.Unsafe_NewEthereumAddressFromString("0xCC00000000000000000000000000000000000001") + err = giveBalance(ctx, platform, user.Address(), "500000000000000000000") + require.NoError(t, err) + + marketID := setupMarket(t, ctx, platform, &user, "ev000003") + + // Place split limit order: true_price=60, amount=50 + err = callPlaceSplitLimitOrder(ctx, platform, &user, int(marketID), 60, 50) + require.NoError(t, err) + + events, err := getOrderEvents(ctx, platform, int(marketID)) + require.NoError(t, err) + + splitPlaced := getOrderEventsByType(events, "split_placed") + require.Len(t, splitPlaced, 2, "should have 2 split_placed events (YES hold + NO sell)") + + // One event for YES at true_price + var yesEvt, noEvt *OrderEvent + for i := range splitPlaced { + if splitPlaced[i].Outcome { + yesEvt = &splitPlaced[i] + } else { + noEvt = &splitPlaced[i] + } + } + + require.NotNil(t, yesEvt, "should have YES split_placed event") + require.Equal(t, 60, yesEvt.Price, "YES price should be true_price=60") + require.Equal(t, int64(50), yesEvt.Amount) + + require.NotNil(t, noEvt, "should have NO split_placed event") + require.Equal(t, 40, noEvt.Price, "NO price should be 100-60=40") + require.Equal(t, int64(50), noEvt.Amount) + + return nil + } +} + +// testOrderEventCancelled verifies that cancelling an order creates a cancelled event +func testOrderEventCancelled(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + lastBalancePoint = nil + lastTrufBalancePoint = nil + + err := erc20bridge.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err) + + user := util.Unsafe_NewEthereumAddressFromString("0xDD00000000000000000000000000000000000001") + err = giveBalance(ctx, platform, user.Address(), "500000000000000000000") + require.NoError(t, err) + + marketID := setupMarket(t, ctx, platform, &user, "ev000004") + + // Place buy order, then cancel it + err = callPlaceBuyOrder(ctx, platform, &user, int(marketID), true, 45, 20) + require.NoError(t, err) + + // Cancel the buy order (buy orders have negative price in ob_positions) + err = callCancelOrder(ctx, platform, &user, int(marketID), true, -45) + require.NoError(t, err) + + events, err := getOrderEvents(ctx, platform, int(marketID)) + require.NoError(t, err) + + cancelled := getOrderEventsByType(events, "cancelled") + require.Len(t, cancelled, 1, "should have exactly 1 cancelled event") + + evt := cancelled[0] + require.Equal(t, int(marketID), evt.QueryID) + require.True(t, evt.Outcome) + require.Equal(t, 45, evt.Price, "price should be absolute value") + require.Equal(t, int64(20), evt.Amount) + require.Nil(t, evt.CounterpartyID) + + return nil + } +} + +// testOrderEventDirectFill verifies that matching engine creates fill events +func testOrderEventDirectFill(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + lastBalancePoint = nil + lastTrufBalancePoint = nil + + err := erc20bridge.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err) + + user1 := util.Unsafe_NewEthereumAddressFromString("0xEE00000000000000000000000000000000000001") + user2 := util.Unsafe_NewEthereumAddressFromString("0xEE00000000000000000000000000000000000002") + + err = InjectDualBalance(ctx, platform, user1.Address(), "500000000000000000000") + require.NoError(t, err) + err = InjectDualBalance(ctx, platform, user2.Address(), "500000000000000000000") + require.NoError(t, err) + + marketID := setupMarket(t, ctx, platform, &user1, "ev000005") + + // User1: split limit to get YES holdings and create NO sell @ 40 + err = callPlaceSplitLimitOrder(ctx, platform, &user1, int(marketID), 60, 100) + require.NoError(t, err) + + // User1: sell YES shares @ 55 + err = callPlaceSellOrder(ctx, platform, &user1, int(marketID), true, 55, 50) + require.NoError(t, err) + + // User2: buy YES @ 55 should match user1's sell @ 55 (direct match) + err = callPlaceBuyOrder(ctx, platform, &user2, int(marketID), true, 55, 30) + require.NoError(t, err) + + events, err := getOrderEvents(ctx, platform, int(marketID)) + require.NoError(t, err) + + // Check for direct fill events + buyFills := getOrderEventsByType(events, "direct_buy_fill") + sellFills := getOrderEventsByType(events, "direct_sell_fill") + + require.Len(t, buyFills, 1, "should have 1 direct_buy_fill event") + require.Len(t, sellFills, 1, "should have 1 direct_sell_fill event") + + // Buy fill: user2 bought at execution price (= sell price = 55) + buyFill := buyFills[0] + require.Equal(t, 55, buyFill.Price, "execution price should be sell price") + require.Equal(t, int64(30), buyFill.Amount, "matched amount should be 30") + require.NotNil(t, buyFill.CounterpartyID, "should have counterparty") + + // Sell fill: user1 sold at execution price + sellFill := sellFills[0] + require.Equal(t, 55, sellFill.Price) + require.Equal(t, int64(30), sellFill.Amount) + require.NotNil(t, sellFill.CounterpartyID) + + // The counterparties should reference each other + require.NotEqual(t, buyFill.ParticipantID, sellFill.ParticipantID, + "buyer and seller should be different participants") + require.Equal(t, int64(sellFill.ParticipantID), *buyFill.CounterpartyID, + "buyer's counterparty should be seller") + require.Equal(t, int64(buyFill.ParticipantID), *sellFill.CounterpartyID, + "seller's counterparty should be buyer") + + // Also verify placement events exist + buyPlaced := getOrderEventsByType(events, "buy_placed") + sellPlaced := getOrderEventsByType(events, "sell_placed") + splitPlaced := getOrderEventsByType(events, "split_placed") + + require.GreaterOrEqual(t, len(buyPlaced), 1, "should have buy_placed events") + require.GreaterOrEqual(t, len(sellPlaced), 1, "should have sell_placed events") + require.Len(t, splitPlaced, 2, "should have 2 split_placed events") + + return nil + } +} + +// testOrderEventMintFill verifies mint match creates mint_fill events +func testOrderEventMintFill(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + lastBalancePoint = nil + lastTrufBalancePoint = nil + + err := erc20bridge.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err) + + user1 := util.Unsafe_NewEthereumAddressFromString("0xFF00000000000000000000000000000000000001") + user2 := util.Unsafe_NewEthereumAddressFromString("0xFF00000000000000000000000000000000000002") + + err = InjectDualBalance(ctx, platform, user1.Address(), "500000000000000000000") + require.NoError(t, err) + err = InjectDualBalance(ctx, platform, user2.Address(), "500000000000000000000") + require.NoError(t, err) + + marketID := setupMarket(t, ctx, platform, &user1, "ev000006") + + // Mint match: user1 buys YES@60, user2 buys NO@40 → complementary (60+40=100) + err = callPlaceBuyOrder(ctx, platform, &user1, int(marketID), true, 60, 50) + require.NoError(t, err) + + err = callPlaceBuyOrder(ctx, platform, &user2, int(marketID), false, 40, 30) + require.NoError(t, err) + + events, err := getOrderEvents(ctx, platform, int(marketID)) + require.NoError(t, err) + + mintFills := getOrderEventsByType(events, "mint_fill") + require.Len(t, mintFills, 2, "should have 2 mint_fill events (YES buyer + NO buyer)") + + // One event for YES side, one for NO side + var yesFill, noFill *OrderEvent + for i := range mintFills { + if mintFills[i].Outcome { + yesFill = &mintFills[i] + } else { + noFill = &mintFills[i] + } + } + + require.NotNil(t, yesFill, "should have YES mint_fill") + require.Equal(t, 60, yesFill.Price) + require.Equal(t, int64(30), yesFill.Amount, "minted amount = min(50, 30)") + require.NotNil(t, yesFill.CounterpartyID) + + require.NotNil(t, noFill, "should have NO mint_fill") + require.Equal(t, 40, noFill.Price) + require.Equal(t, int64(30), noFill.Amount) + require.NotNil(t, noFill.CounterpartyID) + + return nil + } +} + +// testOrderEventBurnFill verifies burn match creates burn_fill events +func testOrderEventBurnFill(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + lastBalancePoint = nil + lastTrufBalancePoint = nil + + err := erc20bridge.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err) + + user1 := util.Unsafe_NewEthereumAddressFromString("0xAB00000000000000000000000000000000000001") + user2 := util.Unsafe_NewEthereumAddressFromString("0xAB00000000000000000000000000000000000002") + + err = InjectDualBalance(ctx, platform, user1.Address(), "500000000000000000000") + require.NoError(t, err) + err = InjectDualBalance(ctx, platform, user2.Address(), "500000000000000000000") + require.NoError(t, err) + + marketID := setupMarket(t, ctx, platform, &user1, "ev000007") + + // User1: split@60 → YES holdings(100) + NO sell@40(100) + err = callPlaceSplitLimitOrder(ctx, platform, &user1, int(marketID), 60, 100) + require.NoError(t, err) + + // User2: split@40 → YES holdings(100) + NO sell@60(100) + err = callPlaceSplitLimitOrder(ctx, platform, &user2, int(marketID), 40, 100) + require.NoError(t, err) + + // User1: sell YES@60 (complementary to user2's NO sell@60? No — burn needs YES@P + NO@(100-P)) + // Burn match: YES sell@70 + NO sell@30 (70+30=100) + // User1 sells YES@70 + err = callPlaceSellOrder(ctx, platform, &user1, int(marketID), true, 70, 25) + require.NoError(t, err) + + // User2 sells NO@30 → burn match with user1's YES sell@70 (70+30=100) + // User2 needs NO holdings first. User2 already has NO sell@60 from split. + // Cancel that sell to get NO holdings back, then sell at price 30. + err = callCancelOrder(ctx, platform, &user2, int(marketID), false, 60) + require.NoError(t, err) + + err = callPlaceSellOrder(ctx, platform, &user2, int(marketID), false, 30, 25) + require.NoError(t, err) + + events, err := getOrderEvents(ctx, platform, int(marketID)) + require.NoError(t, err) + + burnFills := getOrderEventsByType(events, "burn_fill") + require.GreaterOrEqual(t, len(burnFills), 2, "should have at least 2 burn_fill events") + + // Verify burn fills have counterparties + for _, bf := range burnFills { + require.NotNil(t, bf.CounterpartyID, "burn fill should have counterparty") + require.Greater(t, bf.Amount, int64(0), "burn amount should be positive") + } + + return nil + } +} + +// testOrderEventChangeBid verifies change_bid creates bid_changed event +func testOrderEventChangeBid(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + lastBalancePoint = nil + lastTrufBalancePoint = nil + + err := erc20bridge.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err) + + user := util.Unsafe_NewEthereumAddressFromString("0xAC00000000000000000000000000000000000001") + err = giveBalance(ctx, platform, user.Address(), "500000000000000000000") + require.NoError(t, err) + + marketID := setupMarket(t, ctx, platform, &user, "ev000008") + + // Place buy order, then change it + err = callPlaceBuyOrder(ctx, platform, &user, int(marketID), true, 40, 20) + require.NoError(t, err) + + // Change bid from -40 to -55 + err = callChangeBid(ctx, platform, &user, int(marketID), true, -40, -55, 15) + require.NoError(t, err) + + events, err := getOrderEvents(ctx, platform, int(marketID)) + require.NoError(t, err) + + bidChanged := getOrderEventsByType(events, "bid_changed") + require.Len(t, bidChanged, 1, "should have 1 bid_changed event") + + evt := bidChanged[0] + require.Equal(t, 55, evt.Price, "new price should be 55 (absolute)") + require.Equal(t, int64(15), evt.Amount, "new amount should be 15") + require.Nil(t, evt.CounterpartyID) + + return nil + } +} + +// testOrderEventChangeAsk verifies change_ask creates ask_changed event +func testOrderEventChangeAsk(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + lastBalancePoint = nil + lastTrufBalancePoint = nil + + err := erc20bridge.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err) + + user := util.Unsafe_NewEthereumAddressFromString("0xAD00000000000000000000000000000000000001") + err = giveBalance(ctx, platform, user.Address(), "500000000000000000000") + require.NoError(t, err) + + marketID := setupMarket(t, ctx, platform, &user, "ev000009") + + // Get holdings via split, then sell, then change ask + err = callPlaceSplitLimitOrder(ctx, platform, &user, int(marketID), 50, 100) + require.NoError(t, err) + + // Sell YES@60 + err = callPlaceSellOrder(ctx, platform, &user, int(marketID), true, 60, 30) + require.NoError(t, err) + + // Change ask from 60 to 70 + err = callChangeAsk(ctx, platform, &user, int(marketID), true, 60, 70, 25) + require.NoError(t, err) + + events, err := getOrderEvents(ctx, platform, int(marketID)) + require.NoError(t, err) + + askChanged := getOrderEventsByType(events, "ask_changed") + require.Len(t, askChanged, 1, "should have 1 ask_changed event") + + evt := askChanged[0] + require.Equal(t, 70, evt.Price, "new price should be 70") + require.Equal(t, int64(25), evt.Amount, "new amount should be 25") + require.Nil(t, evt.CounterpartyID) + + return nil + } +} + +// testOrderEventSettlement verifies that settlement creates settled events for all positions +func testOrderEventSettlement(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + lastBalancePoint = nil + lastTrufBalancePoint = nil + + deployer := util.Unsafe_NewEthereumAddressFromString("0x1111111111111111111111111111111111111111") + platform.Deployer = deployer.Bytes() + + err := erc20bridge.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err) + + helper := attestationTests.NewAttestationTestHelper(t, ctx, platform) + + err = InjectDualBalance(ctx, platform, deployer.Address(), "1000000000000000000000") + require.NoError(t, err) + + user2 := util.Unsafe_NewEthereumAddressFromString("0xAE00000000000000000000000000000000000002") + err = InjectDualBalance(ctx, platform, user2.Address(), "1000000000000000000000") + require.NoError(t, err) + + // Re-initialize after injections to sync singleton + err = erc20bridge.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err) + + // Create data provider and stream + err = setup.CreateDataProvider(ctx, platform, deployer.Address()) + require.NoError(t, err) + + streamID := "stordereventsettletest0000000000" + dataProvider := deployer.Address() + engineCtx := helper.NewEngineContext() + + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "create_stream", + []any{streamID, "primitive"}, nil) + require.NoError(t, err) + + // Insert YES outcome data (value > 0 = YES wins) + valueDecimal, err := kwilTypes.ParseDecimalExplicit("1.000000000000000000", 36, 18) + require.NoError(t, err) + + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "insert_records", + []any{ + []string{dataProvider}, + []string{streamID}, + []int64{int64(1000)}, + []*kwilTypes.Decimal{valueDecimal}, + }, nil) + require.NoError(t, err) + + // Request and sign attestation + argsBytes, err := tn_utils.EncodeActionArgs([]any{ + dataProvider, streamID, int64(500), int64(1500), nil, false, + }) + require.NoError(t, err) + + var requestTxID string + engineCtx = helper.NewEngineContext() + res, err := platform.Engine.Call(engineCtx, platform.DB, "", "request_attestation", + []any{dataProvider, streamID, "get_record", argsBytes, false, nil}, + func(row *common.Row) error { + requestTxID = row.Values[0].(string) + return nil + }) + require.NoError(t, err) + require.Nil(t, res.Error, "request_attestation should succeed") + + helper.SignAttestation(requestTxID) + + // Create market + queryComponents, err := encodeQueryComponentsForTests(dataProvider, streamID, "get_record", argsBytes) + require.NoError(t, err) + + settleTime := time.Now().Add(1 * time.Hour).Unix() + var queryID int + + engineCtx = helper.NewEngineContext() + engineCtx.TxContext.BlockContext.Timestamp = time.Now().Unix() + createRes, err := platform.Engine.Call(engineCtx, platform.DB, "", "create_market", + []any{testExtensionName, queryComponents, settleTime, int64(5), int64(1)}, + func(row *common.Row) error { + queryID = int(row.Values[0].(int64)) + return nil + }) + require.NoError(t, err) + require.Nil(t, createRes.Error) + + // Deployer: split limit → YES holdings + NO sell + err = callPlaceSplitLimitOrder(ctx, platform, &deployer, queryID, 60, 50) + require.NoError(t, err) + + // User2: buy NO@40 (matches deployer's NO sell@40 from split — direct fill) + err = callPlaceBuyOrder(ctx, platform, &user2, queryID, false, 40, 30) + require.NoError(t, err) + + // User2 also has an open buy YES@30 that won't match + err = callPlaceBuyOrder(ctx, platform, &user2, queryID, true, 30, 10) + require.NoError(t, err) + + // Settle the market (YES wins since value was 1.0) + settleTx := &common.TxContext{ + Ctx: ctx, + BlockContext: &common.BlockContext{ + Height: 1, + Timestamp: settleTime + 1, + }, + Signer: deployer.Bytes(), + Caller: deployer.Address(), + TxID: platform.Txid(), + Authenticator: coreauth.EthPersonalSignAuth, + } + settleEngineCtx := &common.EngineContext{TxContext: settleTx} + + settleRes, err := platform.Engine.Call(settleEngineCtx, platform.DB, "", "settle_market", + []any{queryID}, nil) + require.NoError(t, err) + require.Nil(t, settleRes.Error, "settle_market should succeed") + + // Verify settlement events + events, err := getOrderEvents(ctx, platform, queryID) + require.NoError(t, err) + + settled := getOrderEventsByType(events, "settled") + require.GreaterOrEqual(t, len(settled), 2, "should have settled events for multiple positions") + + // Check settlement events cover all branches: + // - Winners (YES holdings, price>=0): settle at 98 + // - Losers (NO holdings, wrong outcome): settle at 0 + // - Open buy orders (price<0): refund at abs(price) + var foundWinnerAt98 bool + var foundLoserAt0 bool + var foundOpenBuyAt30 bool + for _, evt := range settled { + if evt.Outcome == true && evt.Price == 98 { + foundWinnerAt98 = true + } + if evt.Price == 0 { + foundLoserAt0 = true + } + if evt.Outcome == true && evt.Price == 30 { + foundOpenBuyAt30 = true + } + } + require.True(t, foundWinnerAt98, "should have winning settlement event with price=98") + require.True(t, foundLoserAt0, "should have losing settlement event with price=0") + require.True(t, foundOpenBuyAt30, "should have open buy order settlement event with price=30") + + return nil + } +}