diff --git a/internal/migrations/032-order-book-actions.sql b/internal/migrations/032-order-book-actions.sql index ee17ecf0..d59985d0 100644 --- a/internal/migrations/032-order-book-actions.sql +++ b/internal/migrations/032-order-book-actions.sql @@ -532,6 +532,10 @@ CREATE OR REPLACE ACTION match_direct( -- Transfer payment from vault to seller ob_unlock_collateral($bridge, $seller_wallet_address, $seller_payment); + -- Record impacts for P&L + ob_record_tx_impact($sell_participant_id, $outcome, -$match_amount, $seller_payment, FALSE); + ob_record_tx_impact($buy_participant_id, $outcome, $match_amount, 0::NUMERIC(78,0), FALSE); + -- Transfer shares from seller to buyer -- Step 1: Delete fully matched orders FIRST (prevents amount=0 constraint violation) DELETE FROM ob_positions @@ -699,6 +703,10 @@ CREATE OR REPLACE ACTION match_mint( ON CONFLICT (query_id, participant_id, outcome, price) DO UPDATE SET amount = ob_positions.amount + EXCLUDED.amount; + -- Record impacts for P&L + ob_record_tx_impact($yes_participant_id, true, $mint_amount, 0::NUMERIC(78,0), FALSE); + ob_record_tx_impact($no_participant_id, false, $mint_amount, 0::NUMERIC(78,0), FALSE); + -- Reduce buy orders (only if partial fill) UPDATE ob_positions SET amount = amount - $mint_amount @@ -859,6 +867,10 @@ CREATE OR REPLACE ACTION match_burn( ob_unlock_collateral($bridge, $yes_wallet_address, $yes_payout); ob_unlock_collateral($bridge, $no_wallet_address, $no_payout); + -- Record impacts for P&L + ob_record_tx_impact($yes_participant_id, TRUE, -$burn_amount, $yes_payout, FALSE); + ob_record_tx_impact($no_participant_id, FALSE, -$burn_amount, $no_payout, FALSE); + -- Delete fully matched sell orders FIRST DELETE FROM ob_positions WHERE query_id = $query_id @@ -1140,6 +1152,8 @@ CREATE OR REPLACE ACTION place_buy_order( } } + + -- ========================================================================== -- SECTION 5: LOCK COLLATERAL (bridge-specific) -- ========================================================================== @@ -1154,6 +1168,9 @@ CREATE OR REPLACE ACTION place_buy_order( ethereum_bridge.lock($collateral_needed); } + -- Record initial impact (collateral spent) + ob_record_tx_impact($participant_id, $outcome, 0::INT8, $collateral_needed, TRUE); + -- ========================================================================== -- SECTION 6: INSERT BUY ORDER (UPSERT) -- ========================================================================== @@ -1178,6 +1195,12 @@ CREATE OR REPLACE ACTION place_buy_order( -- Attempt to match this buy order with existing sell orders match_orders($query_id, $outcome, $price, $bridge); + -- ========================================================================== + -- SECTION 8: CLEANUP & MATERIALIZE IMPACTS + -- ========================================================================== + + ob_cleanup_tx_payouts($query_id); + -- Success: Order placed (may be partially or fully matched by future matching engine) }; @@ -1320,6 +1343,8 @@ CREATE OR REPLACE ACTION place_sell_order( ' shares, trying to sell: ' || $amount::TEXT); } + + -- ========================================================================== -- SECTION 4: MOVE SHARES FROM HOLDING TO SELL ORDER -- ========================================================================== @@ -1362,6 +1387,12 @@ CREATE OR REPLACE ACTION place_sell_order( -- Attempt to match this sell order with existing buy orders match_orders($query_id, $outcome, $price, $bridge); + -- ========================================================================== + -- SECTION 6: CLEANUP & MATERIALIZE IMPACTS + -- ========================================================================== + + ob_cleanup_tx_payouts($query_id); + -- Success: Order placed (may be partially or fully matched by future matching engine) }; @@ -1541,6 +1572,8 @@ CREATE OR REPLACE ACTION place_split_limit_order( } } + + -- ========================================================================== -- SECTION 5: LOCK COLLATERAL (bridge-specific) -- ========================================================================== @@ -1555,10 +1588,26 @@ CREATE OR REPLACE ACTION place_split_limit_order( ethereum_bridge.lock($collateral_needed); } + -- Record initial impacts: + -- Calculate split collateral (50/50 split for YES/NO legs) + $collateral_per_leg NUMERIC(78, 0) := $collateral_needed / 2::NUMERIC(78, 0); + -- Handle dust: add remainder to YES leg if odd amount + $collateral_yes NUMERIC(78, 0) := $collateral_per_leg + ($collateral_needed - (2::NUMERIC(78, 0) * $collateral_per_leg)); + + -- 1. Collateral lock (split between outcomes) + ob_record_tx_impact($participant_id, TRUE, 0::INT8, $collateral_yes, TRUE); + ob_record_tx_impact($participant_id, FALSE, 0::INT8, $collateral_per_leg, TRUE); + + -- 2. Mint YES shares + ob_record_tx_impact($participant_id, TRUE, $amount, 0::NUMERIC(78,0), FALSE); + -- 3. Mint NO shares + ob_record_tx_impact($participant_id, FALSE, $amount, 0::NUMERIC(78,0), FALSE); + -- ========================================================================== - -- SECTION 6: MINT YES SHARES (HOLDING) + -- SECTION 7: CREATE POSITIONS -- ========================================================================== + -- Mint YES shares and hold them (not for sale) -- These are stored with price = 0 to indicate holding (not listed) -- @@ -1598,6 +1647,12 @@ CREATE OR REPLACE ACTION place_split_limit_order( -- Match is attempted on the FALSE (NO) outcome at the false_price match_orders($query_id, FALSE, $false_price, $bridge); + -- ========================================================================== + -- SECTION 9: CLEANUP & MATERIALIZE IMPACTS + -- ========================================================================== + + ob_cleanup_tx_payouts($query_id); + -- Success: Split order placed -- - YES shares held at price=0 (not for sale) -- - NO shares listed for sale at price=false_price @@ -1725,6 +1780,8 @@ CREATE OR REPLACE ACTION cancel_order( ERROR('No participant record found for this wallet'); } + + -- ========================================================================== -- SECTION 5: GET ORDER DETAILS -- ========================================================================== @@ -1766,6 +1823,9 @@ CREATE OR REPLACE ACTION cancel_order( -- Unlock collateral back to user using helper from 031-order-book-vault.sql -- Passes bridge parameter to unlock from correct bridge ob_unlock_collateral($bridge, @caller, $refund_amount); + + -- Record impact for refund (Buy orders) + ob_record_tx_impact($participant_id, $outcome, 0::INT8, $refund_amount, FALSE); } -- For sell orders (price > 0): Return shares to holding wallet @@ -1792,6 +1852,12 @@ CREATE OR REPLACE ACTION cancel_order( AND outcome = $outcome AND price = $price; + -- ========================================================================== + -- SECTION 8: CLEANUP & MATERIALIZE IMPACTS + -- ========================================================================== + + ob_cleanup_tx_payouts($query_id); + -- Success: Order cancelled -- - For buy orders: Collateral has been refunded -- - For sell orders: Shares have been returned to holdings @@ -1999,13 +2065,18 @@ CREATE OR REPLACE ACTION change_bid( ethereum_bridge.lock($collateral_delta); } - } else if $collateral_delta < $zero { + -- Record initial impact (lock) + ob_record_tx_impact($participant_id, $outcome, 0::INT8, $collateral_delta, TRUE); + } else if $collateral_delta < $zero { -- New order needs LESS collateral -- Unlock excess amount $unlock_amount NUMERIC(78, 0) := $zero - $collateral_delta; -- Make positive ob_unlock_collateral($bridge, @caller, $unlock_amount); - } - -- If $collateral_delta = 0, no collateral adjustment needed + + -- Record initial impact (refund) + ob_record_tx_impact($participant_id, $outcome, 0::INT8, $unlock_amount, FALSE); + } + -- If $collateral_delta = 0, no collateral adjustment needed -- ========================================================================== -- SECTION 7: DELETE OLD ORDER @@ -2042,6 +2113,12 @@ CREATE OR REPLACE ACTION change_bid( -- Note: match_orders expects positive price (1-99), so use $new_abs_price not $new_price match_orders($query_id, $outcome, $new_abs_price, $bridge); + -- ========================================================================== + -- SECTION 10: CLEANUP & MATERIALIZE IMPACTS + -- ========================================================================== + + ob_cleanup_tx_payouts($query_id); + -- Success: Buy order price modified atomically -- - Old order deleted, new order placed with preserved timestamp -- - Collateral adjusted (net change only) @@ -2304,6 +2381,12 @@ CREATE OR REPLACE ACTION change_ask( -- Try to match new order immediately match_orders($query_id, $outcome, $new_price, $bridge); + -- ========================================================================== + -- SECTION 9: CLEANUP & MATERIALIZE IMPACTS + -- ========================================================================== + + ob_cleanup_tx_payouts($query_id); + -- Success: Sell order price modified atomically -- - Old order deleted, new order placed with preserved timestamp -- - Shares adjusted (pulled from or returned to holdings) diff --git a/internal/migrations/033-order-book-settlement.sql b/internal/migrations/033-order-book-settlement.sql index b0fb43ce..115d4408 100644 --- a/internal/migrations/033-order-book-settlement.sql +++ b/internal/migrations/033-order-book-settlement.sql @@ -7,61 +7,54 @@ * - Refund open buy orders * - Delete all positions atomically * - Zero-sum settlement: losers fund winners - * - * Implementation Note: - * Uses CTE + ARRAY_AGG to collect all payout data in a single query, then - * processes payouts via batch unlock. This avoids nested queries in the main - * settlement action (Kuneiform limitation: cannot call external functions - * like ethereum_bridge.unlock() inside FOR loops in the same action). - * - * The batch unlock helper (ob_batch_unlock_collateral) CAN loop with function - * calls because it's a separate action called ONCE with all aggregated data. - * - * Transaction Atomicity: - * All Kwil actions execute in a single database transaction. If ANY operation - * fails (including ethereum_bridge.unlock()), the ENTIRE action rolls back: - * - Database changes (position deletions, settled flag) are reverted - * - Blockchain state changes are NOT committed (Kwil's 2-phase approach) - * - The settled flag remains false, allowing the settlement extension to retry - * - * Retry Mechanism: - * The tn_settlement extension retries failed settlements (3 attempts with backoff). - * After exhaustion, the market remains unsettled and requires manual intervention - * or extension restart to resume retries. This is safe because: - * 1. The settled flag prevents duplicate settlement attempts within a transaction - * 2. Rollback ensures partial state never persists - * 3. Position data remains intact for retry attempts */ -- Batch unlock collateral for multiple wallets -- This helper processes all unlocks in a single call, avoiding nested queries in settlement -- The $bridge parameter specifies which bridge to use (hoodi_tt2, sepolia_bridge, ethereum_bridge) CREATE OR REPLACE ACTION ob_batch_unlock_collateral( + $query_id INT, -- Pass query_id for impact recording $bridge TEXT, $wallet_addresses TEXT[], - $amounts NUMERIC(78, 0)[] + $amounts NUMERIC(78, 0)[], + $outcomes BOOL[] ) PRIVATE { -- Validate input arrays have same length - if COALESCE(array_length($wallet_addresses), 0) != COALESCE(array_length($amounts), 0) { - ERROR('wallet_addresses and amounts arrays must have the same length'); + if COALESCE(array_length($wallet_addresses), 0) != COALESCE(array_length($amounts), 0) OR + COALESCE(array_length($wallet_addresses), 0) != COALESCE(array_length($outcomes), 0) { + ERROR('wallet_addresses, amounts and outcomes arrays must have the same length'); } - -- Process each unlock (this is the ONLY place we loop with function calls) - -- This is safe because the settlement action calls THIS function once with all data + -- Process each unlock for $payout in - SELECT wallet, amount - FROM UNNEST($wallet_addresses, $amounts) AS u(wallet, amount) + SELECT wallet, amount, outcome + FROM UNNEST($wallet_addresses, $amounts, $outcomes) AS u(wallet, amount, outcome) { + $wallet_hex TEXT := $payout.wallet; + $amount NUMERIC(78,0) := $payout.amount; + $current_outcome BOOL := $payout.outcome; + -- Use the correct bridge based on market configuration if $bridge = 'hoodi_tt2' { - hoodi_tt2.unlock($payout.wallet, $payout.amount); + hoodi_tt2.unlock($wallet_hex, $amount); } else if $bridge = 'sepolia_bridge' { - sepolia_bridge.unlock($payout.wallet, $payout.amount); + sepolia_bridge.unlock($wallet_hex, $amount); } else if $bridge = 'ethereum_bridge' { - ethereum_bridge.unlock($payout.wallet, $payout.amount); + ethereum_bridge.unlock($wallet_hex, $amount); } else { ERROR('Invalid bridge in ob_batch_unlock_collateral: ' || COALESCE($bridge, 'NULL')); } + + -- Record impact for P&L + $pid INT; + for $p in SELECT id FROM ob_participants WHERE '0x' || encode(wallet_address, 'hex') = $wallet_hex { + $pid := $p.id; + } + + if $pid IS NOT NULL { + -- Use the actual outcome for this specific payout + ob_record_net_impact($query_id, $pid, $current_outcome, 0::INT8, $amount, FALSE); + } } }; @@ -70,56 +63,14 @@ CREATE OR REPLACE ACTION ob_batch_unlock_collateral( -- ============================================================================ /** - * distribute_fees($query_id, $total_fees) + * distribute_fees($query_id, $total_fees, $winning_outcome) * * Distributes redemption fees to liquidity providers based on sampled rewards. - * Called automatically at the end of process_settlement() with the 2% fees - * collected from winning position redemptions. - * - * Fee Source: - * - 2% redemption fee is collected from winning positions at settlement - * - Winners receive $0.98 per share, the $0.02 goes to LP fee pool - * - This fee pool is distributed proportionally to LPs based on ob_rewards samples - * - * DYNAMIC REWARDS MODEL: - * Uses the ob_rewards table populated by periodic sample_lp_rewards() calls. - * Fees are distributed proportionally across all sampled blocks. - * - * Zero-Loss Distribution Algorithm: - * 1. total_percent = SUM(reward_percent) across all blocks for each participant - * 2. base_reward = (total_fees * total_percent) / (100 * block_count) - * 3. dust = total_fees - SUM(base_reward) - * 4. first_participant gets base_reward + dust (ensures all fees distributed) - * - * This approach minimizes truncation (single point vs per-block) and ensures - * zero fee loss by giving the remainder to the first participant. - * - * AUDIT TRAIL: - * Before deleting ob_rewards, creates immutable records in: - * - ob_fee_distributions: Summary (query_id, total_fees, LP count, timestamp) - * - ob_fee_distribution_details: Per-LP rewards (participant_id, amount, percent) - * - * This ensures full traceability for compliance and user verification. - * - * Parameters: - * - $query_id: Market ID - * - $total_fees: Total trading fees to distribute, in wei - * - * Behavior: - * - No samples → fees remain in vault (safe accumulation), NO audit record - * - Distributes proportionally across sampled blocks with zero loss - * - Creates audit records in ob_fee_distributions tables - * - Deletes processed rewards from ob_rewards table - * - * Dependencies: - * - ob_rewards table (created in migration 034) - * - ob_fee_distributions tables (created in migration 036) - * - ob_batch_unlock_collateral() helper (defined above) - * - ethereum_bridge.unlock() (from Migration 031) */ CREATE OR REPLACE ACTION distribute_fees( $query_id INT, - $total_fees NUMERIC(78, 0) + $total_fees NUMERIC(78, 0), + $winning_outcome BOOL ) PRIVATE { -- Get market details for fee split and unlock $bridge TEXT; @@ -133,8 +84,6 @@ CREATE OR REPLACE ACTION distribute_fees( } -- Step 0: Calculate Shares (75/12.5/12.5 split) - -- Target: 1.5% (LPs), 0.25% (DP), 0.25% (Validator) out of 2.0% total fees - -- Split of the 2.0% pool: 75% LPs, 12.5% DP, 12.5% Validator $lp_share NUMERIC(78, 0) := ($total_fees * 75::NUMERIC(78, 0)) / 100::NUMERIC(78, 0); $infra_share NUMERIC(78, 0) := ($total_fees * 125::NUMERIC(78, 0)) / 1000::NUMERIC(78, 0); @@ -160,10 +109,18 @@ CREATE OR REPLACE ACTION distribute_fees( ethereum_bridge.unlock($dp_wallet, $infra_share); $actual_dp_fees := $infra_share; } + + -- Record DP reward impact (against winning side) + $dp_pid INT; + for $p in SELECT id FROM ob_participants WHERE wallet_address = $dp_addr { + $dp_pid := $p.id; + } + if $dp_pid IS NOT NULL { + ob_record_net_impact($query_id, $dp_pid, $winning_outcome, 0::INT8, $infra_share, FALSE); + } } -- Step 2: Payout Validator (Leader) (0.25%) - -- Use @leader_sender to incentivize active block production $actual_validator_fees NUMERIC(78, 0) := '0'::NUMERIC(78, 0); if @leader_sender IS NOT NULL AND $infra_share > '0'::NUMERIC(78, 0) { $validator_wallet TEXT := '0x' || encode(@leader_sender, 'hex'); @@ -177,6 +134,15 @@ CREATE OR REPLACE ACTION distribute_fees( ethereum_bridge.unlock($validator_wallet, $infra_share); $actual_validator_fees := $infra_share; } + + -- Record Validator reward impact (against winning side) + $val_pid INT; + for $p in SELECT id FROM ob_participants WHERE wallet_address = @leader_sender { + $val_pid := $p.id; + } + if $val_pid IS NOT NULL { + ob_record_net_impact($query_id, $val_pid, $winning_outcome, 0::INT8, $infra_share, FALSE); + } } -- Step 3: Count distinct blocks sampled for this market @@ -185,30 +151,30 @@ CREATE OR REPLACE ACTION distribute_fees( $block_count := $row.cnt; } - -- Step 4: Generate distribution ID and create summary record ALWAYS + -- Default values for summary + $actual_fees_distributed NUMERIC(78, 0) := '0'::NUMERIC(78, 0); + $lp_count INT := 0; $distribution_id INT; for $row in SELECT COALESCE(MAX(id), 0) + 1 as next_id FROM ob_fee_distributions { $distribution_id := $row.next_id; } - -- Default values for summary - $actual_fees_distributed NUMERIC(78, 0) := '0'::NUMERIC(78, 0); - $lp_count INT := 0; + $total_distributed_base NUMERIC(78, 0) := '0'::NUMERIC(78, 0); + $remainder NUMERIC(78, 0) := '0'::NUMERIC(78, 0); + $min_participant_id INT; -- If we have samples AND fees to distribute, calculate rewards - $wallet_addresses TEXT[]; - $amounts NUMERIC(78, 0)[]; - if $block_count > 0 AND $lp_share > '0'::NUMERIC(78, 0) { + + $wallet_addresses TEXT[] := ARRAY[]::TEXT[]; + $amounts NUMERIC(78, 0)[] := ARRAY[]::NUMERIC(78, 0)[]; + $outcomes BOOL[] := ARRAY[]::BOOL[]; + -- Step 5: Calculate rewards with zero-loss distribution - -- Get the first participant ID to handle the remainder (dust) - $min_participant_id INT; for $row in SELECT MIN(participant_id) as mid FROM ob_rewards WHERE query_id = $query_id { $min_participant_id := $row.mid; } - -- Calculate total distributed to find the remainder - $total_distributed_base NUMERIC(78, 0) := '0'::NUMERIC(78, 0); for $row in SELECT SUM((($lp_share::NUMERIC(78, 20) * total_percent_numeric) / (100::NUMERIC(78, 20) * $block_count::NUMERIC(78, 20)))::NUMERIC(78, 0))::NUMERIC(78, 0) as total FROM ( @@ -221,7 +187,7 @@ CREATE OR REPLACE ACTION distribute_fees( $total_distributed_base := $row.total; } - $remainder NUMERIC(78, 0) := $lp_share - $total_distributed_base; + $remainder := $lp_share - $total_distributed_base; -- Aggregate into arrays for batch processing for $result in @@ -239,21 +205,26 @@ CREATE OR REPLACE ACTION distribute_fees( SELECT participant_id, wallet_address, + '0x' || encode(wallet_address, 'hex') as wallet_hex, (($lp_share::NUMERIC(78, 20) * total_percent_numeric) / (100::NUMERIC(78, 20) * $block_count::NUMERIC(78, 20)))::NUMERIC(78, 0) + - (CASE WHEN participant_id = $min_participant_id THEN $remainder ELSE '0'::NUMERIC(78, 0) END) as final_reward + (CASE WHEN participant_id = $min_participant_id THEN $remainder ELSE '0'::NUMERIC(78, 0) END) as final_reward, + total_percent_numeric FROM participant_totals - ), - aggregated AS ( - SELECT - ARRAY_AGG('0x' || encode(wallet_address, 'hex') ORDER BY participant_id) as wallets, - ARRAY_AGG(final_reward ORDER BY participant_id) as amounts - FROM calculated_rewards - WHERE final_reward > '0'::NUMERIC(78, 0) ) - SELECT wallets, amounts FROM aggregated + SELECT participant_id, wallet_address, wallet_hex, final_reward, total_percent_numeric + FROM calculated_rewards { - $wallet_addresses := $result.wallets; - $amounts := $result.amounts; + $current_wallet_hex TEXT := $result.wallet_hex; + $current_final_reward NUMERIC(78, 0) := $result.final_reward; + $current_pid INT := $result.participant_id; + $current_wallet_addr BYTEA := $result.wallet_address; + $current_reward_percent NUMERIC(10, 2) := $result.total_percent_numeric::NUMERIC(10, 2); + + if $current_final_reward > '0'::NUMERIC(78, 0) { + $wallet_addresses := array_append($wallet_addresses, $current_wallet_hex); + $amounts := array_append($amounts, $current_final_reward); + $outcomes := array_append($outcomes, $winning_outcome); + } } if $wallet_addresses IS NOT NULL AND COALESCE(array_length($wallet_addresses), 0) > 0 { @@ -261,262 +232,167 @@ CREATE OR REPLACE ACTION distribute_fees( $actual_fees_distributed := $lp_share; -- Step 6: Batch unlock to all qualifying LPs - ob_batch_unlock_collateral($bridge, $wallet_addresses, $amounts); + ob_batch_unlock_collateral($query_id, $bridge, $wallet_addresses, $amounts, $outcomes); } } - -- Step 7: Insert distribution summary + -- Step 7: Insert distribution summary (ALWAYS, even if $lp_count=0) INSERT INTO ob_fee_distributions ( - id, - query_id, - total_fees_distributed, - total_dp_fees, - total_validator_fees, - total_lp_count, - block_count, - distributed_at + id, query_id, total_fees_distributed, total_dp_fees, total_validator_fees, total_lp_count, block_count, distributed_at ) VALUES ( - $distribution_id, - $query_id, - $actual_fees_distributed, - $actual_dp_fees, - $actual_validator_fees, - $lp_count, - $block_count, - @block_timestamp + $distribution_id, $query_id, $actual_fees_distributed, $actual_dp_fees, $actual_validator_fees, $lp_count, $block_count, @block_timestamp ); - -- Step 8: Insert per-LP details (only if LPs exist) + -- Step 7.5: Insert audit details (must be after summary for FK) if $lp_count > 0 { - for $payout in SELECT wallet, amount FROM UNNEST($wallet_addresses, $amounts) AS p(wallet, amount) { - $wallet_hex TEXT := $payout.wallet; - $reward_amount NUMERIC(78, 0) := $payout.amount; - - $pid INT; - $wallet_bytes BYTEA; - $total_reward_pct NUMERIC(10, 2); - - for $p_data in SELECT id, wallet_address FROM ob_participants WHERE '0x' || encode(wallet_address, 'hex') = $wallet_hex { - $pid := $p_data.id; - $wallet_bytes := $p_data.wallet_address; - - $total_reward_pct := 0::NUMERIC(10,2); - for $pct_row in SELECT SUM(reward_percent::NUMERIC(10,2))::NUMERIC(10,2) as sum_pct FROM ob_rewards WHERE query_id = $query_id AND participant_id = $pid { - if $pct_row.sum_pct IS NOT NULL { - $total_reward_pct := $pct_row.sum_pct / $block_count::NUMERIC(10,2); - } - } + for $result in + WITH participant_totals AS ( + SELECT + r.participant_id, + p.wallet_address, + SUM(r.reward_percent)::NUMERIC(78, 20) as total_percent_numeric + FROM ob_rewards r + JOIN ob_participants p ON r.participant_id = p.id + WHERE r.query_id = $query_id + GROUP BY r.participant_id, p.wallet_address + ), + calculated_rewards AS ( + SELECT + participant_id, + wallet_address, + (($lp_share::NUMERIC(78, 20) * total_percent_numeric) / (100::NUMERIC(78, 20) * $block_count::NUMERIC(78, 20)))::NUMERIC(78, 0) + + (CASE WHEN participant_id = $min_participant_id THEN $remainder ELSE '0'::NUMERIC(78, 0) END) as final_reward, + total_percent_numeric + FROM participant_totals + ) + SELECT participant_id, wallet_address, final_reward, total_percent_numeric + FROM calculated_rewards + { + $det_final_reward NUMERIC(78, 0) := $result.final_reward; + $det_pid INT := $result.participant_id; + $det_wallet_addr BYTEA := $result.wallet_address; + $det_reward_percent NUMERIC(10, 2) := $result.total_percent_numeric::NUMERIC(10, 2); + + if $det_final_reward > '0'::NUMERIC(78, 0) { + INSERT INTO ob_fee_distribution_details ( + distribution_id, participant_id, wallet_address, reward_amount, total_reward_percent + ) VALUES ( + $distribution_id, $det_pid, $det_wallet_addr, $det_final_reward, $det_reward_percent + ); } - - INSERT INTO ob_fee_distribution_details ( - distribution_id, - participant_id, - wallet_address, - reward_amount, - total_reward_percent - ) VALUES ( - $distribution_id, - $pid, - $wallet_bytes, - $reward_amount, - $total_reward_pct - ); } } - -- Step 9: Cleanup + -- Step 8: Cleanup processed rewards (ONLY if actual distribution happened) if $lp_count > 0 { DELETE FROM ob_rewards WHERE query_id = $query_id; } }; --- Process settlement: Pay winners (minus 2% fee), refund open buys, distribute LP rewards --- --- Fee Model (per Latest.md authoritative design): --- - 2% redemption fee is collected from winning positions at settlement --- - This fee is distributed to Liquidity Providers based on sampled rewards (ob_rewards) --- - Open buy orders are refunded in full (no fee) --- - Losing positions get nothing (deleted) --- --- This follows the Polymarket model where LPs are compensated for providing liquidity --- through a percentage of settlement redemptions. +-- ============================================================================ +-- Main Settlement Process +-- ============================================================================ + +/** + * process_settlement($query_id, $winning_outcome) + * + * Internal helper to handle the state changes and payouts for settlement. + */ CREATE OR REPLACE ACTION process_settlement( $query_id INT, $winning_outcome BOOL ) PRIVATE { - $one_token NUMERIC(78, 0) := '1000000000000000000'::NUMERIC(78, 0); - $fee_rate INT := 2; -- 2% redemption fee for LP rewards - - -- Get market's bridge for unlock operations + -- SECTION 0: GET MARKET BRIDGE $bridge TEXT; for $row in SELECT bridge FROM ob_queries WHERE id = $query_id { $bridge := $row.bridge; } - if $bridge IS NULL { - ERROR('Market not found for query_id: ' || $query_id::TEXT); - } - -- Step 1: Bulk delete all losing positions (efficient single operation) - -- Price semantics: price=0 (holdings), price>0 (open sells), price<0 (open buys) - -- Deletes losing outcome holdings and sells, which have zero value after settlement - -- This removes ~50% of positions upfront - DELETE FROM ob_positions - WHERE query_id = $query_id - AND outcome = NOT $winning_outcome - AND price >= 0; -- Holdings (price=0) and open sells (price>0) only - - -- Step 2: Collect ALL payout data using CTE + ARRAY_AGG (digest pattern!) - -- Calculate payouts (with 2% fee for winners) and aggregate into arrays in a SINGLE query + -- SECTION 1: CALCULATE PAYOUTS (Winners and Buy Refunds) $wallet_addresses TEXT[]; $amounts NUMERIC(78, 0)[]; + $outcomes BOOL[]; $total_fees NUMERIC(78, 0) := '0'::NUMERIC(78, 0); + -- Aggregate ALL payouts in one query to avoid nested loops for $result in - WITH remaining_positions AS ( - SELECT - p.participant_id, - p.outcome, - p.price, - p.amount, - '0x' || encode(part.wallet_address, 'hex') as wallet_address - FROM ob_positions p - JOIN ob_participants part ON p.participant_id = part.id - WHERE p.query_id = $query_id - ), - calculated_values AS ( - SELECT - wallet_address, - price, - amount::NUMERIC(78, 0) as amount_numeric, - -- Pre-calculate all monetary values to avoid CASE type issues - -- All amounts cast to NUMERIC(78, 0) to match ethereum_bridge.unlock() API - -- Winners get 98% (100% - 2% fee) per share - -- Gross payout = amount * $1.00 - (amount::NUMERIC(78, 0) * $one_token)::NUMERIC(78, 0) as gross_payout, - -- Net payout = gross * (100 - fee_rate) / 100 = gross * 98 / 100 - ((amount::NUMERIC(78, 0) * $one_token * (100 - $fee_rate)::NUMERIC(78, 0)) / 100::NUMERIC(78, 0))::NUMERIC(78, 0) as winner_payout, - -- Fee = gross * fee_rate / 100 = gross * 2 / 100 - ((amount::NUMERIC(78, 0) * $one_token * $fee_rate::NUMERIC(78, 0)) / 100::NUMERIC(78, 0))::NUMERIC(78, 0) as fee_amount, - -- Refund for open buys (full amount, no fee) - ((amount::NUMERIC(78, 0) * abs(price)::NUMERIC(78, 0) * $one_token) / 100::NUMERIC(78, 0))::NUMERIC(78, 0) as refund_amount - FROM remaining_positions - ), - payouts AS ( - SELECT - wallet_address, - price, - -- Remaining positions after Step 1 are: - -- 1. Winning holdings/sells (price >= 0): Pay shares × $0.98 (2% fee) - -- 2. Open buy orders (price < 0): Refund locked collateral (no fee) - CASE - WHEN price >= 0 THEN winner_payout - ELSE refund_amount - END as payout_amount, - -- Track fees (only from winning positions, not refunds) - CASE - WHEN price >= 0 THEN fee_amount - ELSE '0'::NUMERIC(78, 0) - END as fee_collected - FROM calculated_values - ), - wallet_totals AS ( - -- Group by wallet to handle multiple positions per user - SELECT - wallet_address, - SUM(payout_amount)::NUMERIC(78, 0) as total_payout - FROM payouts - GROUP BY wallet_address - ), - fee_total AS ( - -- Calculate total fees collected from all winning positions - SELECT COALESCE(SUM(fee_collected)::NUMERIC(78, 0), '0'::NUMERIC(78, 0)) as fees - FROM payouts - ), - aggregated AS ( - SELECT - ARRAY_AGG(wallet_address ORDER BY wallet_address) as wallets, - ARRAY_AGG(total_payout::NUMERIC(78, 0) ORDER BY wallet_address) as amounts, - (SELECT fees FROM fee_total) as total_fees - FROM wallet_totals - ) - SELECT wallets, amounts, total_fees - FROM aggregated + WITH calculated_payouts AS ( + SELECT + '0x' || encode(p.wallet_address, 'hex') as wallet, + CASE + WHEN pos.price >= 0 THEN ((pos.amount::NUMERIC(78, 0) * '1000000000000000000'::NUMERIC(78, 0) * 98::NUMERIC(78, 0)) / 100::NUMERIC(78, 0)) + ELSE (pos.amount::NUMERIC(78, 0) * abs(pos.price)::NUMERIC(78, 0) * '10000000000000000'::NUMERIC(78, 0)) + END as amount, + CASE + WHEN pos.price >= 0 THEN $winning_outcome + ELSE pos.outcome + END as outcome, + CASE + WHEN pos.price >= 0 THEN ((pos.amount::NUMERIC(78, 0) * '1000000000000000000'::NUMERIC(78, 0) * 2::NUMERIC(78, 0)) / 100::NUMERIC(78, 0)) + ELSE '0'::NUMERIC(78, 0) + END as fee + FROM ob_positions pos + JOIN ob_participants p ON pos.participant_id = p.id + WHERE pos.query_id = $query_id + AND ( + (pos.price >= 0 AND pos.outcome = $winning_outcome) -- Winners + OR (pos.price < 0) -- All open buy orders get refunded + ) + ), + aggregated AS ( + SELECT + ARRAY_AGG(wallet ORDER BY wallet, outcome) as wallets, + ARRAY_AGG(amount::NUMERIC(78, 0) ORDER BY wallet, outcome) as amounts, + ARRAY_AGG(outcome ORDER BY wallet, outcome) as outcomes, + SUM(fee)::NUMERIC(78, 0) as fees + FROM calculated_payouts + ) + SELECT wallets, amounts, outcomes, COALESCE(fees, '0'::NUMERIC(78, 0)) as fees FROM aggregated { $wallet_addresses := $result.wallets; $amounts := $result.amounts; - $total_fees := $result.total_fees; + $outcomes := $result.outcomes; + $total_fees := $result.fees; } - -- Step 3: Delete all processed positions (set-based, no loop!) + -- Step 2: Delete all positions for this market atomically DELETE FROM ob_positions WHERE query_id = $query_id; - -- Step 4: Process ALL payouts in a SINGLE batch call (no nested queries!) + -- Step 3: Process ALL payouts in a SINGLE batch call if $wallet_addresses IS NOT NULL AND COALESCE(array_length($wallet_addresses), 0) > 0 { - ob_batch_unlock_collateral($bridge, $wallet_addresses, $amounts); + ob_batch_unlock_collateral($query_id, $bridge, $wallet_addresses, $amounts, $outcomes); } - -- Step 5: Distribute collected fees to Liquidity Providers - -- The 2% redemption fee is distributed proportionally based on sampled LP rewards - -- If no LP rewards were sampled, fees remain in the vault (safe accumulation) + -- Step 4: Distribute collected fees if $total_fees IS NOT NULL AND $total_fees > '0'::NUMERIC(78, 0) { - distribute_fees($query_id, $total_fees); + distribute_fees($query_id, $total_fees, $winning_outcome); } }; --- ============================================================================= --- trigger_fee_distribution: Public action to manually trigger LP fee distribution --- ============================================================================= -/** - * Manually triggers fee distribution for a market. Only callable by network_writer role. - * - * NOTE: In normal operation, fees are distributed automatically during settlement - * via process_settlement() which calls distribute_fees() with the 2% redemption fees. - * - * This manual trigger is provided for: - * - Recovery scenarios if settlement failed partway through - * - Additional LP incentive programs funded externally - * - Testing and debugging - * - * Parameters: - * - $query_id: Market ID - * - $total_fees: Total fees to distribute (in wei, e.g., "1000000000000000000" for 1 token) - * - * Prerequisites: - * - Market must have LP rewards sampled (ob_rewards records) - * - Caller must have network_writer role - */ +// Public trigger CREATE OR REPLACE ACTION trigger_fee_distribution( $query_id INT, $total_fees TEXT ) PUBLIC { - -- Check caller has network_writer role $has_role BOOL := FALSE; - - for $row in SELECT 1 FROM role_members - WHERE owner = 'system' - AND role_name = 'network_writer' - AND wallet = LOWER(@caller) - LIMIT 1 - { + for $row in SELECT 1 FROM role_members WHERE owner = 'system' AND role_name = 'network_writer' AND wallet = LOWER(@caller) LIMIT 1 { $has_role := TRUE; } - - if $has_role = FALSE { - ERROR('Only network_writer can trigger fee distribution'); + if $has_role = FALSE { ERROR('Only network_writer can trigger fee distribution'); } + + -- Query market truth for winning outcome + $winning BOOL; + $found BOOL := FALSE; + for $row in SELECT winning_outcome FROM ob_queries WHERE id = $query_id { + $winning := $row.winning_outcome; + $found := TRUE; } - -- Validate query_id - if $query_id IS NULL OR $query_id < 1 { - ERROR('Invalid query_id'); + if NOT $found { + ERROR('Market not found: ' || $query_id::TEXT); } - -- Convert total_fees string to NUMERIC $fees NUMERIC(78, 0) := $total_fees::NUMERIC(78, 0); - - if $fees IS NULL OR $fees < 0 { - ERROR('Invalid total_fees amount'); - } - - -- Call the private distribute_fees action - distribute_fees($query_id, $fees); + distribute_fees($query_id, $fees, $winning); } + diff --git a/internal/migrations/043-order-book-pnl.sql b/internal/migrations/043-order-book-pnl.sql new file mode 100644 index 00000000..15a9d2df --- /dev/null +++ b/internal/migrations/043-order-book-pnl.sql @@ -0,0 +1,158 @@ +/* + * ORDER BOOK P&L MIGRATION + * + * Creates the ob_net_impacts table to track the net change of every transaction. + */ + +-- ============================================================================= +-- ob_net_impacts: Transaction Summary Audit Trail +-- ============================================================================= +CREATE TABLE IF NOT EXISTS ob_net_impacts ( + id INT PRIMARY KEY, + tx_hash BYTEA NOT NULL, + query_id INT NOT NULL, + participant_id INT NOT NULL, + outcome BOOLEAN NOT NULL, + shares_change INT8 NOT NULL, -- Net shares gained (+) or lost (-) + collateral_change NUMERIC(78,0), -- Net collateral magnitude + is_negative BOOLEAN NOT NULL, -- TRUE if collateral was spent, FALSE if received/refunded + timestamp INT8 NOT NULL, + + FOREIGN KEY (query_id) REFERENCES ob_queries(id) ON DELETE CASCADE, + FOREIGN KEY (participant_id) REFERENCES ob_participants(id) ON DELETE CASCADE +); + +-- Index for indexer to efficiently sync by ID +CREATE INDEX IF NOT EXISTS idx_ob_net_impacts_id ON ob_net_impacts(id); + +-- Index for user portfolio queries +CREATE INDEX IF NOT EXISTS idx_ob_net_impacts_participant ON ob_net_impacts(participant_id); + +-- Index for market history +CREATE INDEX IF NOT EXISTS idx_ob_net_impacts_query ON ob_net_impacts(query_id); + +-- ============================================================================= +-- ob_tx_payouts: Temporary table to accumulate impacts during matching +-- ============================================================================= +CREATE TABLE IF NOT EXISTS ob_tx_payouts ( + id INT PRIMARY KEY, -- Surrogate key needed for Kwil + tx_hash BYTEA NOT NULL, + participant_id INT NOT NULL, + outcome BOOLEAN NOT NULL, + shares_change INT8 NOT NULL, + amount NUMERIC(78,0) NOT NULL, + is_negative BOOLEAN NOT NULL +); + +-- Helper to record a payout (legacy signature, defaults to TRUE outcome) +CREATE OR REPLACE ACTION ob_record_tx_payout( + $participant_id INT, + $amount NUMERIC(78,0) +) PRIVATE { + INSERT INTO ob_tx_payouts (id, tx_hash, participant_id, outcome, shares_change, amount, is_negative) + SELECT COALESCE(MAX(id), 0::INT) + 1, decode(@txid, 'hex'), $participant_id, TRUE, 0::INT8, $amount, FALSE + FROM ob_tx_payouts; +}; + +-- Helper to record a full impact during matching +CREATE OR REPLACE ACTION ob_record_tx_impact( + $participant_id INT, + $outcome BOOLEAN, + $shares_change INT8, + $amount NUMERIC(78,0), + $is_negative BOOLEAN +) PRIVATE { + INSERT INTO ob_tx_payouts (id, tx_hash, participant_id, outcome, shares_change, amount, is_negative) + SELECT COALESCE(MAX(id), 0::INT) + 1, decode(@txid, 'hex'), $participant_id, $outcome, $shares_change, $amount, $is_negative + FROM ob_tx_payouts; +}; + +-- Helper to materialize and cleanup impacts for current TX +CREATE OR REPLACE ACTION ob_cleanup_tx_payouts( + $query_id INT +) PRIVATE { + -- Iterate over all touched (participant, outcome) pairs in this TX + for $p in SELECT DISTINCT participant_id, outcome FROM ob_tx_payouts WHERE tx_hash = decode(@txid, 'hex') { + -- Capture into local variables to avoid "unknown variable" error in nested loops + $current_pid INT := $p.participant_id; + $current_outcome BOOL := $p.outcome; + + $net_shares INT8 := 0; + $net_collateral NUMERIC(100,0) := 0::NUMERIC(100,0); + + for $impact in SELECT shares_change, amount, is_negative FROM ob_tx_payouts WHERE tx_hash = decode(@txid, 'hex') AND participant_id = $current_pid AND outcome = $current_outcome { + $net_shares := $net_shares + $impact.shares_change; + if $impact.is_negative { + $net_collateral := $net_collateral - $impact.amount::NUMERIC(100,0); + } else { + $net_collateral := $net_collateral + $impact.amount::NUMERIC(100,0); + } + } + + -- Call record_net_impact with final net values + $final_is_neg BOOL := FALSE; + $final_mag NUMERIC(78,0) := 0::NUMERIC(78,0); + + if $net_collateral < 0::NUMERIC(100,0) { + $final_is_neg := TRUE; + $final_mag := (0::NUMERIC(100,0) - $net_collateral)::NUMERIC(78,0); + } else { + $final_mag := $net_collateral::NUMERIC(78,0); + } + + ob_record_net_impact($query_id, $current_pid, $current_outcome, $net_shares, $final_mag, $final_is_neg); + } + + DELETE FROM ob_tx_payouts WHERE tx_hash = decode(@txid, 'hex'); +}; + +-- Helper to get total payout for a participant in current TX (Legacy helper) +CREATE OR REPLACE ACTION ob_get_tx_payout( + $participant_id INT +) PRIVATE RETURNS (total_payout NUMERIC(78,0)) { + $total NUMERIC(78,0) := 0::NUMERIC(78,0); + for $row in SELECT amount, is_negative FROM ob_tx_payouts WHERE tx_hash = decode(@txid, 'hex') AND participant_id = $participant_id { + if NOT $row.is_negative { + $total := $total + $row.amount; + } + } + RETURN $total; +}; + +-- Internal helper to record impacts into audit trail +CREATE OR REPLACE ACTION ob_record_net_impact( + $query_id INT, + $participant_id INT, + $outcome BOOLEAN, + $shares_change INT8, + $collateral_change NUMERIC(78,0), + $is_negative BOOLEAN +) PRIVATE { + -- Skip if no net change + if $shares_change = 0 AND $collateral_change = 0::NUMERIC(78,0) { + RETURN; + } + + INSERT INTO ob_net_impacts ( + id, + tx_hash, + query_id, + participant_id, + outcome, + shares_change, + collateral_change, + is_negative, + timestamp + ) + SELECT + COALESCE(MAX(id), 0::INT) + 1, + decode(@txid, 'hex'), + $query_id, + $participant_id, + $outcome, + $shares_change, + $collateral_change, + $is_negative, + @block_timestamp + FROM ob_net_impacts; +}; diff --git a/tests/streams/order_book/fee_distribution_audit_test.go b/tests/streams/order_book/fee_distribution_audit_test.go index dafe2b0a..c72bebf5 100644 --- a/tests/streams/order_book/fee_distribution_audit_test.go +++ b/tests/streams/order_book/fee_distribution_audit_test.go @@ -83,7 +83,7 @@ func testAuditRecordCreation(t *testing.T) func(context.Context, *kwilTesting.Pl // Fund vault and call distribute_fees totalFees := new(big.Int).Mul(big.NewInt(10), big.NewInt(1e18)) // 10 TRUF - err = fundVaultAndDistributeFees(t, ctx, platform, &user1, int(marketID), totalFees) + err = fundVaultAndDistributeFees(t, ctx, platform, &user1, int(marketID), totalFees, true) require.NoError(t, err) // Verify audit summary record exists @@ -222,7 +222,7 @@ func testAuditMultiBlock(t *testing.T) func(context.Context, *kwilTesting.Platfo // Distribute fees totalFees := new(big.Int).Mul(big.NewInt(30), big.NewInt(1e18)) - err = fundVaultAndDistributeFees(t, ctx, platform, &user1, int(marketID), totalFees) + err = fundVaultAndDistributeFees(t, ctx, platform, &user1, int(marketID), totalFees, true) require.NoError(t, err) // Verify audit summary @@ -286,7 +286,7 @@ func testAuditNoLPs(t *testing.T) func(context.Context, *kwilTesting.Platform) e // Don't sample LP rewards (no LP samples) totalFees := new(big.Int).Mul(big.NewInt(10), big.NewInt(1e18)) - err = fundVaultAndDistributeFees(t, ctx, platform, &user1, int(marketID), totalFees) + err = fundVaultAndDistributeFees(t, ctx, platform, &user1, int(marketID), totalFees, true) require.NoError(t, err) // Verify NO audit summary record @@ -354,7 +354,7 @@ func testAuditZeroFees(t *testing.T) func(context.Context, *kwilTesting.Platform // Call distribute_fees with $0 fees (early return) zeroFees := big.NewInt(0) - err = fundVaultAndDistributeFees(t, ctx, platform, &user1, int(marketID), zeroFees) + err = fundVaultAndDistributeFees(t, ctx, platform, &user1, int(marketID), zeroFees, true) require.NoError(t, err) // Verify NO audit record (zero fees early return) @@ -431,7 +431,7 @@ func testAuditDataIntegrity(t *testing.T) func(context.Context, *kwilTesting.Pla // Distribute totalFees := new(big.Int).Mul(big.NewInt(10), big.NewInt(1e18)) - err = fundVaultAndDistributeFees(t, ctx, platform, &user1, int(marketID), totalFees) + err = fundVaultAndDistributeFees(t, ctx, platform, &user1, int(marketID), totalFees, true) require.NoError(t, err) // Get final USDC balances @@ -570,7 +570,7 @@ func setupLPScenario(t *testing.T, ctx context.Context, platform *kwilTesting.Pl // fundVaultAndDistributeFees funds the vault and calls distribute_fees func fundVaultAndDistributeFees(t *testing.T, ctx context.Context, platform *kwilTesting.Platform, - user *util.EthereumAddress, marketID int, totalFees *big.Int) error { + user *util.EthereumAddress, marketID int, totalFees *big.Int, winningOutcome bool) error { // Fund vault if fees > 0 (use USDC-only since vault doesn't need TRUF) if totalFees.Sign() > 0 { @@ -609,7 +609,7 @@ func fundVaultAndDistributeFees(t *testing.T, ctx context.Context, platform *kwi } engineCtx := &common.EngineContext{TxContext: tx, OverrideAuthz: true} - res, err := platform.Engine.Call(engineCtx, platform.DB, "", "distribute_fees", []any{marketID, totalFeesDecimal}, nil) + res, err := platform.Engine.Call(engineCtx, platform.DB, "", "distribute_fees", []any{marketID, totalFeesDecimal, winningOutcome}, nil) if err != nil { return err } diff --git a/tests/streams/order_book/fee_distribution_test.go b/tests/streams/order_book/fee_distribution_test.go index 3a6c2e76..5b8d26b6 100644 --- a/tests/streams/order_book/fee_distribution_test.go +++ b/tests/streams/order_book/fee_distribution_test.go @@ -164,7 +164,7 @@ func testDistribution1Block2LPs(t *testing.T) func(context.Context, *kwilTesting platform.DB, "", "distribute_fees", - []any{int(marketID), totalFeesDecimal}, + []any{int(marketID), totalFeesDecimal, true}, nil, ) require.NoError(t, err) @@ -351,7 +351,7 @@ func testDistribution3Blocks2LPs(t *testing.T) func(context.Context, *kwilTestin platform.DB, "", "distribute_fees", - []any{int(marketID), totalFeesDecimal}, + []any{int(marketID), totalFeesDecimal, true}, nil, ) require.NoError(t, err) @@ -494,7 +494,7 @@ func testDistributionNoSamples(t *testing.T) func(context.Context, *kwilTesting. platform.DB, "", "distribute_fees", - []any{int(marketID), totalFeesDecimal}, + []any{int(marketID), totalFeesDecimal, true}, nil, ) require.NoError(t, err) @@ -622,7 +622,7 @@ func testDistributionZeroFees(t *testing.T) func(context.Context, *kwilTesting.P platform.DB, "", "distribute_fees", - []any{int(marketID), zeroFeesDecimal}, + []any{int(marketID), zeroFeesDecimal, true}, nil, ) require.NoError(t, err) @@ -751,7 +751,7 @@ func testDistribution1LP(t *testing.T) func(context.Context, *kwilTesting.Platfo platform.DB, "", "distribute_fees", - []any{int(marketID), totalFeesDecimal}, + []any{int(marketID), totalFeesDecimal, true}, nil, ) require.NoError(t, err) diff --git a/tests/streams/order_book/market_creation_test.go b/tests/streams/order_book/market_creation_test.go index c2e26070..66841aa6 100644 --- a/tests/streams/order_book/market_creation_test.go +++ b/tests/streams/order_book/market_creation_test.go @@ -49,9 +49,7 @@ const ( ) var ( - twoTRUF = mustParseBigInt(marketFee) - trufPointCounter int64 = 100 // Counter for TRUF bridge (hoodi_tt) - usdcPointCounter int64 = 10000 // Counter for USDC bridge (hoodi_tt2) - far apart to avoid conflicts + twoTRUF = mustParseBigInt(marketFee) ) func mustParseBigInt(s string) *big.Int { @@ -423,8 +421,8 @@ func giveBalance(ctx context.Context, platform *kwilTesting.Platform, wallet str orderedsync.ForTestingReset() // Inject TRUF balance (use separate counter per bridge, starting fresh each test) - trufPointCounter++ - fmt.Printf("DEBUG giveBalance: Injecting TRUF at point %d\n", trufPointCounter) + (*trufPointCounter)++ + fmt.Printf("DEBUG giveBalance: Injecting TRUF at point %d\n", *trufPointCounter) err := testerc20.InjectERC20Transfer( ctx, platform, @@ -434,7 +432,7 @@ func giveBalance(ctx context.Context, platform *kwilTesting.Platform, wallet str wallet, wallet, amountStr, - trufPointCounter, + *trufPointCounter, nil, // No chaining ) if err != nil { @@ -442,8 +440,8 @@ func giveBalance(ctx context.Context, platform *kwilTesting.Platform, wallet str } // Inject USDC balance (use separate counter) - usdcPointCounter++ - fmt.Printf("DEBUG giveBalance: Injecting USDC at point %d\n", usdcPointCounter) + (*usdcPointCounter)++ + fmt.Printf("DEBUG giveBalance: Injecting USDC at point %d\n", *usdcPointCounter) err = testerc20.InjectERC20Transfer( ctx, platform, @@ -453,7 +451,7 @@ func giveBalance(ctx context.Context, platform *kwilTesting.Platform, wallet str wallet, wallet, amountStr, - usdcPointCounter, + *usdcPointCounter, nil, // No chaining - separate topic from TRUF ) if err != nil { diff --git a/tests/streams/order_book/matching_engine_test.go b/tests/streams/order_book/matching_engine_test.go index c79a9fdf..bfc11c0d 100644 --- a/tests/streams/order_book/matching_engine_test.go +++ b/tests/streams/order_book/matching_engine_test.go @@ -19,19 +19,11 @@ import ( "github.com/trufnetwork/sdk-go/core/util" ) -// balancePointTracker tracks the previous point for chaining ERC20 deposits -var ( - balancePointCounter int64 = 100 - lastBalancePoint *int64 - trufBalancePointCounter int64 = 200 - lastTrufBalancePoint *int64 -) - // giveBalanceChained gives balance (BOTH TRUF and USDC) with proper linked-list chaining for ordered-sync func giveBalanceChained(ctx context.Context, platform *kwilTesting.Platform, wallet string, amountStr string) error { // Inject TRUF balance (for market creation fees) - trufPointCounter++ - trufPoint := trufPointCounter + trufBalancePointCounter++ + trufPoint := trufBalancePointCounter from := ensureNonZeroAddress(wallet) @@ -58,62 +50,11 @@ func giveBalanceChained(ctx context.Context, platform *kwilTesting.Platform, wal lastTrufBalancePoint = &p // Inject USDC balance (for market collateral) - balancePointCounter++ - usdcPoint := balancePointCounter - - err = testerc20.InjectERC20Transfer( - ctx, - platform, - testUSDCChain, - testUSDCEscrow, - testUSDCERC20, - from, - wallet, - amountStr, - usdcPoint, - lastBalancePoint, // Chain to previous USDC point - ) - + err = giveUSDCBalanceChained(ctx, platform, wallet, amountStr) if err != nil { - return fmt.Errorf("failed to inject USDC: %w", err) + return err } - // Update USDC lastPoint for next call - q := usdcPoint - lastBalancePoint = &q - - return nil -} - -// giveUSDCBalanceChained gives USDC only balance with proper linked-list chaining for ordered-sync -// Use this for vault/escrow funding where TRUF is not needed -func giveUSDCBalanceChained(ctx context.Context, platform *kwilTesting.Platform, wallet string, amountStr string) error { - balancePointCounter++ - usdcPoint := balancePointCounter - - from := ensureNonZeroAddress(wallet) - - err := testerc20.InjectERC20Transfer( - ctx, - platform, - testUSDCChain, - testUSDCEscrow, - testUSDCERC20, - from, - wallet, - amountStr, - usdcPoint, - lastBalancePoint, // Chain to previous USDC point - ) - - if err != nil { - return fmt.Errorf("failed to inject USDC: %w", err) - } - - // Update USDC lastPoint for next call - q := usdcPoint - lastBalancePoint = &q - return nil } diff --git a/tests/streams/order_book/pnl_impact_test.go b/tests/streams/order_book/pnl_impact_test.go new file mode 100644 index 00000000..bdaf94d2 --- /dev/null +++ b/tests/streams/order_book/pnl_impact_test.go @@ -0,0 +1,344 @@ +//go:build kwiltest + +package order_book + +import ( + "context" + "fmt" + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/trufnetwork/kwil-db/common" + coreauth "github.com/trufnetwork/kwil-db/core/crypto/auth" + "github.com/trufnetwork/kwil-db/core/crypto" + kwilTesting "github.com/trufnetwork/kwil-db/testing" + "github.com/trufnetwork/node/internal/migrations" + testutils "github.com/trufnetwork/node/tests/streams/utils" + erc20bridge "github.com/trufnetwork/kwil-db/node/exts/erc20-bridge/erc20" + "github.com/trufnetwork/sdk-go/core/util" + "github.com/trufnetwork/kwil-db/core/types" +) + +type NetImpact struct { + ID int + TxHash []byte + QueryID int + ParticipantID int + Outcome bool + SharesChange int64 + CollateralChange *big.Int + IsNegative bool + Timestamp int64 +} + +// TestPnLImpact verifies that the ob_net_impacts table is correctly populated +func TestPnLImpact(t *testing.T) { + testutils.RunSchemaTest(t, kwilTesting.SchemaTest{ + Name: "ORDER_BOOK_PnLImpact", + SeedStatements: migrations.GetSeedScriptStatements(), + FunctionTests: []kwilTesting.TestFunc{ + testPnLImpactTrading(t), + testPnLImpactSettlement(t), + }, + }, testutils.GetTestOptionsWithCache()) +} + +func testPnLImpactTrading(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + // Reset balance point tracker + lastBalancePoint = nil + lastTrufBalancePoint = nil + + userA := util.Unsafe_NewEthereumAddressFromString("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + userB := util.Unsafe_NewEthereumAddressFromString("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + + // 1. Setup: Give balances (both TRUF and USDC) FIRST + err := InjectDualBalance(ctx, platform, userA.Address(), "100000000000000000000") // 100 TRUF/USDC + require.NoError(t, err) + + err = InjectDualBalance(ctx, platform, userB.Address(), "100000000000000000000") // 100 TRUF/USDC + require.NoError(t, err) + + // Initialize ERC20 extension AFTER injection so it sees the initial points + err = erc20bridge.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err) + + // 2. Create Market + queryComponents, err := encodeQueryComponentsForTests(userA.Address(), "st_pnl_test_000000000000000000001", "get_record", nil) + require.NoError(t, err) + settleTime := time.Now().Add(24 * time.Hour).Unix() + + var marketID int64 + err = callCreateMarket(ctx, platform, &userA, queryComponents, settleTime, 5, 1, func(row *common.Row) error { + marketID = row.Values[0].(int64) + return nil + }) + require.NoError(t, err) + + // 3. User A places Buy Order: 10 shares @ $0.60 + // Collateral: 10 * 60 * 10^16 = 6 * 10^18 + err = callPlaceBuyOrder(ctx, platform, &userA, int(marketID), true, 60, 10) + require.NoError(t, err) + + // Verify User A impact: Collateral -6 TRUF, Shares 0 (not filled yet) + impacts, err := getNetImpacts(ctx, platform, int(marketID)) + require.NoError(t, err) + require.Len(t, impacts, 1, "should have 1 impact record") + require.Equal(t, int64(0), impacts[0].SharesChange) + require.Equal(t, toWei("-6").String(), impacts[0].CollateralChange.String()) + + // 4. User B places Split Order: 10 pairs @ $0.60 + // Mint 10 pairs (locks 10 TRUF) -> holds 10 YES, holds 10 NO (listed) + // Collateral is split 50/50 between outcomes (-5 each) + err = callPlaceSplitLimitOrder(ctx, platform, &userB, int(marketID), 60, 10) + require.NoError(t, err) + + impacts, err = getNetImpacts(ctx, platform, int(marketID)) + require.NoError(t, err) + require.Len(t, impacts, 3) + + // Index 1 & 2 are User B's split order impacts (YES and NO) + var userBSplitYes *NetImpact + var userBSplitNo *NetImpact + for i := 1; i < 3; i++ { + if impacts[i].Outcome { + userBSplitYes = &impacts[i] + } else { + userBSplitNo = &impacts[i] + } + } + require.NotNil(t, userBSplitYes) + require.NotNil(t, userBSplitNo) + require.Equal(t, int64(10), userBSplitYes.SharesChange) + require.Equal(t, toWei("-5").String(), userBSplitYes.CollateralChange.String()) + require.Equal(t, int64(10), userBSplitNo.SharesChange) + require.Equal(t, toWei("-5").String(), userBSplitNo.CollateralChange.String()) + + // 5. User B sells YES holdings to User A + err = callPlaceSellOrder(ctx, platform, &userB, int(marketID), true, 60, 10) + require.NoError(t, err) + + // Verify impacts after match + impacts, err = getNetImpacts(ctx, platform, int(marketID)) + require.NoError(t, err) + require.Len(t, impacts, 5) + + // Index 3 & 4 should be the match impacts for Seller (B) and Buyer (A) + var matchSeller *NetImpact + var matchBuyer *NetImpact + for i := 3; i < 5; i++ { + if impacts[i].SharesChange < 0 { + matchSeller = &impacts[i] + } else { + matchBuyer = &impacts[i] + } + } + + require.NotNil(t, matchSeller, "match seller impact not found") + require.NotNil(t, matchBuyer, "match buyer impact not found") + + // Seller (B) lost 10 YES shares, gained 6 TRUF + require.Equal(t, int64(-10), matchSeller.SharesChange) + require.Equal(t, toWei("6").String(), matchSeller.CollateralChange.String()) + + // Buyer (A) gained 10 YES shares, 0 collateral change + require.Equal(t, int64(10), matchBuyer.SharesChange) + require.Equal(t, toWei("0").String(), matchBuyer.CollateralChange.String()) + + // 6. Test Cancel Order + err = callPlaceBuyOrder(ctx, platform, &userA, int(marketID), true, 50, 10) + require.NoError(t, err) + + err = callCancelOrder(ctx, platform, &userA, int(marketID), true, -50) + require.NoError(t, err) + + impacts, err = getNetImpacts(ctx, platform, int(marketID)) + require.NoError(t, err) + require.Len(t, impacts, 7) + + // Last impact should be the cancel refund (+5 TRUF) + lastImpact := impacts[len(impacts)-1] + require.Equal(t, int64(0), lastImpact.SharesChange) + require.Equal(t, toWei("5").String(), lastImpact.CollateralChange.String()) + + return nil + } +} + +func testPnLImpactSettlement(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + // Reset balance point tracker + lastBalancePoint = nil + lastTrufBalancePoint = nil + + // Use a real key for User A so we can use them as block proposer (Leader) and Data Provider + userAPub := NewTestProposerPub(t) + userA := util.Unsafe_NewEthereumAddressFromString(fmt.Sprintf("0x%x", crypto.EthereumAddressFromPubKey(userAPub))) + + // Setup FIRST: Inject balance + err := InjectDualBalance(ctx, platform, userA.Address(), "100000000000000000000") + require.NoError(t, err) + + // Initialize extension + err = erc20bridge.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err) + + // Create market + queryComponents, err := encodeQueryComponentsForTests(userA.Address(), "st_pnl_settle_000000000000000000001", "get_record", nil) + require.NoError(t, err) + settleTime := time.Now().Add(1 * time.Hour).Unix() + + var marketID int64 + err = callCreateMarket(ctx, platform, &userA, queryComponents, settleTime, 5, 1, func(row *common.Row) error { + marketID = row.Values[0].(int64) + return nil + }) + require.NoError(t, err) + + // User A buys 10 shares of YES @ $1.00 (via split order for simplicity) + err = callPlaceSplitLimitOrder(ctx, platform, &userA, int(marketID), 50, 10) + require.NoError(t, err) + + // Settle market with YES winning + // Pass userA as the proposer + err = callProcessSettlementWithProposer(t, ctx, platform, int(marketID), true, userAPub) + require.NoError(t, err) + + // Verify settlement impact + impacts, err := getNetImpacts(ctx, platform, int(marketID)) + require.NoError(t, err) + + // Expected impacts: + // 1. Initial split (NO): +10 shares, -5 TRUF (split collateral) + // 2. Initial split (YES): +10 shares, -5 TRUF (split collateral) + // 3. Settlement payout (YES): +9.8 TRUF + // 4. DP reward (YES): +0.025 TRUF + // 5. Validator reward (YES): +0.025 TRUF + require.Len(t, impacts, 5) + + // Get User A participant ID + var userAPID int + err = platform.Engine.Execute( + &common.EngineContext{TxContext: &common.TxContext{Ctx: ctx, BlockContext: &common.BlockContext{Height: 1}, TxID: platform.Txid()}}, + platform.DB, + "SELECT id FROM ob_participants WHERE '0x' || encode(wallet_address, 'hex') = $wallet", + map[string]any{"$wallet": userA.Address()}, + func(row *common.Row) error { + userAPID = int(row.Values[0].(int64)) + return nil + }, + ) + require.NoError(t, err) + + // Find payout impact + var settlementPayout *NetImpact + var dpReward *NetImpact + var validatorReward *NetImpact + for _, imp := range impacts { + if imp.SharesChange == 0 && !imp.IsNegative && imp.ParticipantID == userAPID { + // Settlement payout, DP reward and Validator reward are all for User A in this test + if imp.CollateralChange.String() == toWei("9.8").String() { + settlementPayout = &imp + } else if imp.CollateralChange.String() == "25000000000000000" { + if dpReward == nil { + dpReward = &imp + } else { + validatorReward = &imp + } + } + } + } + + require.NotNil(t, settlementPayout, "settlement payout impact not found") + require.Equal(t, true, settlementPayout.Outcome, "payout should be recorded against winning outcome") + + require.NotNil(t, dpReward, "dp reward impact not found") + require.Equal(t, true, dpReward.Outcome, "dp reward should be recorded against winning outcome") + + require.NotNil(t, validatorReward, "validator reward impact not found") + require.Equal(t, true, validatorReward.Outcome, "validator reward should be recorded against winning outcome") + + return nil + } +} + +// ===== HELPERS ===== + +func getNetImpacts(ctx context.Context, platform *kwilTesting.Platform, marketID int) ([]NetImpact, error) { + tx := &common.TxContext{ + Ctx: ctx, + BlockContext: &common.BlockContext{Height: 1}, + TxID: platform.Txid(), + } + engineCtx := &common.EngineContext{TxContext: tx} + + var impacts []NetImpact + err := platform.Engine.Execute( + engineCtx, + platform.DB, + "SELECT id, tx_hash, query_id, participant_id, outcome, shares_change, collateral_change, is_negative, timestamp FROM ob_net_impacts WHERE query_id = $query_id ORDER BY id ASC", + map[string]any{"$query_id": marketID}, + func(row *common.Row) error { + mag := row.Values[6].(*types.Decimal).BigInt() + isNeg := row.Values[7].(bool) + + colChange := new(big.Int).Set(mag) + if isNeg { + colChange.Neg(colChange) + } + + imp := NetImpact{ + ID: int(row.Values[0].(int64)), + TxHash: row.Values[1].([]byte), + QueryID: int(row.Values[2].(int64)), + ParticipantID: int(row.Values[3].(int64)), + Outcome: row.Values[4].(bool), + SharesChange: row.Values[5].(int64), + CollateralChange: colChange, + IsNegative: isNeg, + Timestamp: row.Values[8].(int64), + } + impacts = append(impacts, imp) + return nil + }, + ) + + return impacts, err +} + +func callProcessSettlementWithProposer(t require.TestingT, ctx context.Context, platform *kwilTesting.Platform, marketID int, winningOutcome bool, proposer crypto.PublicKey) error { + tx := &common.TxContext{ + Ctx: ctx, + BlockContext: &common.BlockContext{ + Height: 1, + Timestamp: time.Now().Unix(), + Proposer: proposer, + }, + TxID: platform.Txid(), + Authenticator: coreauth.EthPersonalSignAuth, + } + engineCtx := &common.EngineContext{TxContext: tx, OverrideAuthz: true} + + res, err := platform.Engine.Call( + engineCtx, + platform.DB, + "", + "process_settlement", + []any{marketID, winningOutcome}, + nil, + ) + if err != nil { + return err + } + if res.Error != nil { + return res.Error + } + return nil +} + +func callProcessSettlement(t require.TestingT, ctx context.Context, platform *kwilTesting.Platform, marketID int, winningOutcome bool) error { + pub := NewTestProposerPub(t) + return callProcessSettlementWithProposer(t, ctx, platform, marketID, winningOutcome, pub) +} diff --git a/tests/streams/order_book/queries_test.go b/tests/streams/order_book/queries_test.go index 19229dd3..c460ce31 100644 --- a/tests/streams/order_book/queries_test.go +++ b/tests/streams/order_book/queries_test.go @@ -33,10 +33,8 @@ const ( // and shared across all test files in this package var ( - queriesPointCounter int64 = 200 // Start from 200 to avoid conflicts - lastBalancePointQueries *int64 // For chaining sepolia_bridge deposits - queriesTrufPointCounter int64 = 300 // Separate counter for TRUF - lastTrufBalancePointQueries *int64 // For chaining TRUF deposits + lastBalancePointQueries *int64 // For chaining sepolia_bridge deposits + lastTrufBalancePointQueries *int64 // For chaining TRUF deposits ) func TestQueries(t *testing.T) { @@ -875,8 +873,8 @@ func testGetUserCollateralMixed(t *testing.T) func(context.Context, *kwilTesting func giveBalanceQueries(ctx context.Context, platform *kwilTesting.Platform, wallet string, amountStr string) error { // Inject TRUF first (for market creation fee - always from hoodi_tt) - queriesTrufPointCounter++ - trufPoint := queriesTrufPointCounter + (*trufPointCounter)++ + trufPoint := *trufPointCounter err := testerc20.InjectERC20Transfer( ctx, platform, @@ -896,8 +894,8 @@ func giveBalanceQueries(ctx context.Context, platform *kwilTesting.Platform, wal lastTrufBalancePointQueries = &p // Inject sepolia_bridge tokens (for market collateral) - queriesPointCounter++ - currentPoint := queriesPointCounter + (*usdcPointCounter)++ + currentPoint := *usdcPointCounter err = testerc20.InjectERC20Transfer( ctx, platform, diff --git a/tests/streams/order_book/test_helpers_orderbook.go b/tests/streams/order_book/test_helpers_orderbook.go index 473bbc49..c348a0b4 100644 --- a/tests/streams/order_book/test_helpers_orderbook.go +++ b/tests/streams/order_book/test_helpers_orderbook.go @@ -3,15 +3,110 @@ package order_book import ( + "context" "fmt" gethAbi "github.com/ethereum/go-ethereum/accounts/abi" gethCommon "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/require" "github.com/trufnetwork/kwil-db/core/crypto" + kwilTesting "github.com/trufnetwork/kwil-db/testing" "github.com/trufnetwork/node/extensions/tn_utils" + testerc20 "github.com/trufnetwork/node/tests/streams/utils/erc20" ) +var ( + balancePointCounter int64 = 1000 + lastBalancePoint *int64 + trufBalancePointCounter int64 = 2000 + lastTrufBalancePoint *int64 + + // Legacy names for compatibility with some files + usdcPointCounter = &balancePointCounter + trufPointCounter = &trufBalancePointCounter +) + +// InjectDualBalance injects balance to BOTH bridges with proper chaining: +// 1. hoodi_tt (TRUF) for market creation fees +// 2. hoodi_tt2 (USDC) for market collateral/trading +func InjectDualBalance(ctx context.Context, platform *kwilTesting.Platform, wallet string, amountStr string) error { + from := ensureNonZeroAddress(wallet) + + // 1. Inject TRUF balance + trufBalancePointCounter++ + trufPoint := trufBalancePointCounter + err := testerc20.InjectERC20Transfer( + ctx, + platform, + testTRUFChain, + testTRUFEscrow, + testTRUFERC20, + from, + wallet, + amountStr, + trufPoint, + lastTrufBalancePoint, + ) + if err != nil { + return fmt.Errorf("failed to inject TRUF: %w", err) + } + lastTrufBalancePoint = &trufPoint + + // 2. Inject USDC balance + balancePointCounter++ + usdcPoint := balancePointCounter + err = testerc20.InjectERC20Transfer( + ctx, + platform, + testUSDCChain, + testUSDCEscrow, + testUSDCERC20, + from, + wallet, + amountStr, + usdcPoint, + lastBalancePoint, + ) + if err != nil { + return fmt.Errorf("failed to inject USDC: %w", err) + } + lastBalancePoint = &usdcPoint + + return nil +} + +// giveUSDCBalanceChained gives USDC only balance with proper linked-list chaining for ordered-sync +// Use this for vault/escrow funding where TRUF is not needed +func giveUSDCBalanceChained(ctx context.Context, platform *kwilTesting.Platform, wallet string, amountStr string) error { + balancePointCounter++ + usdcPoint := balancePointCounter + + from := ensureNonZeroAddress(wallet) + + err := testerc20.InjectERC20Transfer( + ctx, + platform, + testUSDCChain, + testUSDCEscrow, + testUSDCERC20, + from, + wallet, + amountStr, + usdcPoint, + lastBalancePoint, // Chain to previous USDC point + ) + + if err != nil { + return fmt.Errorf("failed to inject USDC: %w", err) + } + + // Update USDC lastPoint for next call + q := usdcPoint + lastBalancePoint = &q + + return nil +} + // NewTestProposerPub generates a new proposer public key for testing. func NewTestProposerPub(t require.TestingT) *crypto.Secp256k1PublicKey { _, pubGeneric, err := crypto.GenerateSecp256k1Key(nil)