diff --git a/extensions/tn_settlement/settlement_integration_test.go b/extensions/tn_settlement/settlement_integration_test.go index 635dd1e8..22664839 100644 --- a/extensions/tn_settlement/settlement_integration_test.go +++ b/extensions/tn_settlement/settlement_integration_test.go @@ -259,6 +259,9 @@ func testSettleMarketViaAction(t *testing.T) func(context.Context, *kwilTesting. return nil }) require.NoError(t, err) + for _, log := range createRes.Logs { + t.Logf("Engine log (create_market): %s", log) + } if createRes.Error != nil { t.Fatalf("create_market failed: %v", createRes.Error) } @@ -269,6 +272,9 @@ func testSettleMarketViaAction(t *testing.T) func(context.Context, *kwilTesting. settleRes, err := platform.Engine.Call(engineCtx, platform.DB, "", "settle_market", []any{queryID}, nil) require.NoError(t, err) + for _, log := range settleRes.Logs { + t.Logf("Engine log (settle_market): %s", log) + } require.Nil(t, settleRes.Error) // Verify settlement @@ -313,7 +319,7 @@ func testLoadSettlementConfig(t *testing.T) func(context.Context, *kwilTesting.P enabled, schedule, maxMarkets, retries, err := ops.LoadSettlementConfig(ctx) require.NoError(t, err) require.True(t, enabled, "should be true (enabled by migration 041)") - require.Equal(t, "0,30 * * * *", schedule, "should be 30-minute schedule from migration 041") + require.Equal(t, "*/5 * * * *", schedule, "should be 5-minute schedule from migration 041") require.Equal(t, 1000, maxMarkets, "should be 1000 from migration 041") require.Equal(t, 3, retries) @@ -460,6 +466,9 @@ func testMultipleMarketsProcessing(t *testing.T) func(context.Context, *kwilTest return nil }) require.NoError(t, err) + for _, log := range createRes.Logs { + t.Logf("Engine log (create_market %d): %s", i, log) + } if createRes.Error != nil { t.Fatalf("create_market %d failed: %v", i, createRes.Error) } @@ -486,6 +495,9 @@ func testMultipleMarketsProcessing(t *testing.T) func(context.Context, *kwilTest settleRes, err := platform.Engine.Call(engineCtx, platform.DB, "", "settle_market", []any{queryID}, nil) require.NoError(t, err) + for _, log := range settleRes.Logs { + t.Logf("Engine log (settle_market %d): %s", queryID, log) + } require.Nil(t, settleRes.Error) } diff --git a/extensions/tn_utils/precompiles.go b/extensions/tn_utils/precompiles.go index 3e9c991b..cf0980d0 100644 --- a/extensions/tn_utils/precompiles.go +++ b/extensions/tn_utils/precompiles.go @@ -4,11 +4,14 @@ import ( "bytes" "crypto/sha256" "encoding/binary" + "encoding/hex" "fmt" "math" "math/big" + "strings" "github.com/trufnetwork/kwil-db/common" + "github.com/trufnetwork/kwil-db/core/crypto" "github.com/trufnetwork/kwil-db/core/types" "github.com/trufnetwork/kwil-db/extensions/precompiles" "github.com/trufnetwork/sdk-go/core/contractsapi" @@ -34,6 +37,142 @@ func buildPrecompile() precompiles.Precompile { parseAttestationBooleanMethod(), computeAttestationHashMethod(), unpackQueryComponentsMethod(), + getCallerHexMethod(), + getCallerBytesMethod(), + getLeaderHexMethod(), + getLeaderBytesMethod(), + }, + } +} + +// getCallerHexMethod returns the current transaction caller as a 0x-prefixed hex string. +func getCallerHexMethod() precompiles.Method { + return precompiles.Method{ + Name: "get_caller_hex", + AccessModifiers: []precompiles.Modifier{precompiles.VIEW, precompiles.PUBLIC}, + Parameters: []precompiles.PrecompileValue{}, + Returns: &precompiles.MethodReturn{ + IsTable: false, + Fields: []precompiles.PrecompileValue{ + precompiles.NewPrecompileValue("caller_hex", types.TextType, false), + }, + }, + Handler: func(ctx *common.EngineContext, app *common.App, inputs []any, resultFn func([]any) error) error { + if ctx == nil || ctx.TxContext == nil { + return resultFn([]any{""}) + } + // If Caller is already a hex string (common in EVM), return it with 0x prefix if missing + caller := ctx.TxContext.Caller + if !strings.HasPrefix(caller, "0x") && !strings.HasPrefix(caller, "0X") { + // If it's a raw identifier, try to see if it's hex + if _, err := hex.DecodeString(caller); err == nil && len(caller) == 40 { + caller = "0x" + caller + } + } + return resultFn([]any{strings.ToLower(caller)}) + }, + } +} + +// getCallerBytesMethod returns the current transaction caller as raw bytes (normalized address). +func getCallerBytesMethod() precompiles.Method { + return precompiles.Method{ + Name: "get_caller_bytes", + AccessModifiers: []precompiles.Modifier{precompiles.VIEW, precompiles.PUBLIC}, + Parameters: []precompiles.PrecompileValue{}, + Returns: &precompiles.MethodReturn{ + IsTable: false, + Fields: []precompiles.PrecompileValue{ + precompiles.NewPrecompileValue("caller_bytes", types.ByteaType, false), + }, + }, + Handler: func(ctx *common.EngineContext, app *common.App, inputs []any, resultFn func([]any) error) error { + if ctx == nil || ctx.TxContext == nil { + return resultFn([]any{[]byte{}}) + } + + // Return normalized address bytes instead of raw public key bytes. + // Caller is the string identifier (hex address for EVM). + caller := ctx.TxContext.Caller + if strings.HasPrefix(caller, "0x") || strings.HasPrefix(caller, "0X") { + caller = caller[2:] + } + + if b, err := hex.DecodeString(caller); err == nil && len(b) == 20 { + return resultFn([]any{b}) + } + + // Fallback to Signer (public key) if not a hex address + return resultFn([]any{ctx.TxContext.Signer}) + }, + } +} + +// getLeaderHexMethod returns the current block leader as a 0x-prefixed hex string. +func getLeaderHexMethod() precompiles.Method { + return precompiles.Method{ + Name: "get_leader_hex", + AccessModifiers: []precompiles.Modifier{precompiles.VIEW, precompiles.PUBLIC}, + Parameters: []precompiles.PrecompileValue{}, + Returns: &precompiles.MethodReturn{ + IsTable: false, + Fields: []precompiles.PrecompileValue{ + precompiles.NewPrecompileValue("leader_hex", types.TextType, false), + }, + }, + Handler: func(ctx *common.EngineContext, app *common.App, inputs []any, resultFn func([]any) error) error { + if ctx == nil || ctx.TxContext == nil || ctx.TxContext.BlockContext == nil || ctx.TxContext.BlockContext.Proposer == nil { + return resultFn([]any{""}) + } + + // For prediction markets, we usually want the Ethereum address of the leader + // to transfer fees via the bridge. + pubkey := ctx.TxContext.BlockContext.Proposer + + if pubkey.Type() == crypto.KeyTypeSecp256k1 { + // Manually unmarshal to ensure we have the concrete type + secp, err := crypto.UnmarshalSecp256k1PublicKey(pubkey.Bytes()) + if err == nil { + addr := crypto.EthereumAddressFromPubKey(secp) + return resultFn([]any{"0x" + hex.EncodeToString(addr)}) + } + } + + // Fallback to raw hex of the public key + return resultFn([]any{"0x" + hex.EncodeToString(pubkey.Bytes())}) + }, + } +} + +// getLeaderBytesMethod returns the current block leader as raw bytes (normalized address). +func getLeaderBytesMethod() precompiles.Method { + return precompiles.Method{ + Name: "get_leader_bytes", + AccessModifiers: []precompiles.Modifier{precompiles.VIEW, precompiles.PUBLIC}, + Parameters: []precompiles.PrecompileValue{}, + Returns: &precompiles.MethodReturn{ + IsTable: false, + Fields: []precompiles.PrecompileValue{ + precompiles.NewPrecompileValue("leader_bytes", types.ByteaType, false), + }, + }, + Handler: func(ctx *common.EngineContext, app *common.App, inputs []any, resultFn func([]any) error) error { + if ctx == nil || ctx.TxContext == nil || ctx.TxContext.BlockContext == nil || ctx.TxContext.BlockContext.Proposer == nil { + return resultFn([]any{[]byte{}}) + } + + pubkey := ctx.TxContext.BlockContext.Proposer + if pubkey.Type() == crypto.KeyTypeSecp256k1 { + // Manually unmarshal to ensure we have the concrete type + secp, err := crypto.UnmarshalSecp256k1PublicKey(pubkey.Bytes()) + if err == nil { + addr := crypto.EthereumAddressFromPubKey(secp) + return resultFn([]any{addr}) + } + } + + // Fallback to raw bytes of the public key + return resultFn([]any{pubkey.Bytes()}) }, } } diff --git a/internal/migrations/024-attestation-actions.sql b/internal/migrations/024-attestation-actions.sql index 2e5b76d3..b027b331 100644 --- a/internal/migrations/024-attestation-actions.sql +++ b/internal/migrations/024-attestation-actions.sql @@ -42,9 +42,12 @@ CREATE OR REPLACE ACTION request_attestation( $caller_balance NUMERIC(78, 0); $leader_addr TEXT; + -- Normalizing caller and leader safely using precompiles + $caller_bytes BYTEA := tn_utils.get_caller_bytes(); + $lower_caller TEXT := tn_utils.get_caller_hex(); + -- Check if caller is exempt (has system:network_writer role) $is_exempt BOOL := FALSE; - $lower_caller TEXT := LOWER(@caller); FOR $row IN are_members_of('system', 'network_writer', ARRAY[$lower_caller]) { IF $row.wallet = $lower_caller AND $row.is_member { $is_exempt := TRUE; @@ -69,12 +72,12 @@ CREATE OR REPLACE ACTION request_attestation( ERROR('Insufficient balance for attestation. Required: 40 TRUF'); } - -- Verify leader address is available - IF @leader_sender IS NULL { + -- Safe leader address conversion + $leader_addr := tn_utils.get_leader_hex(); + IF $leader_addr = '' { ERROR('Leader address not available for fee transfer'); } - $leader_addr := encode(@leader_sender, 'hex')::TEXT; ethereum_bridge.transfer($leader_addr, $attestation_fee); } -- ===== END FEE COLLECTION ===== @@ -82,9 +85,8 @@ CREATE OR REPLACE ACTION request_attestation( -- Get current block height $created_height := @height; - -- Normalize caller address to bytes for storage - $caller_hex := LOWER(substring(@caller, 3, 40)); - $caller_bytes := decode($caller_hex, 'hex'); + -- Normalize caller address to bytes for storage (re-use safe normalization) + $caller_bytes := $caller_bytes; -- Already normalized above -- Normalize provider input and enforce length $provider_lower := LOWER($data_provider); @@ -161,7 +163,7 @@ CREATE OR REPLACE ACTION request_attestation( record_transaction_event( 6, $attestation_fee, - '0x' || $leader_addr, + $leader_addr, NULL ); diff --git a/internal/migrations/031-order-book-vault.sql b/internal/migrations/031-order-book-vault.sql index 657d4314..2868ad3a 100644 --- a/internal/migrations/031-order-book-vault.sql +++ b/internal/migrations/031-order-book-vault.sql @@ -117,7 +117,6 @@ PRIVATE RETURNS (participant_id INT) { -- ============================================================================= -- ob_get_participant_id: Get participant ID without creating (for lookups) --- ============================================================================= -- Returns the participant_id for a wallet address, or NULL if not found. -- Use this for read-only operations where we don't want to create records. CREATE OR REPLACE ACTION ob_get_participant_id($wallet_address TEXT) diff --git a/internal/migrations/032-order-book-actions.sql b/internal/migrations/032-order-book-actions.sql index d59985d0..018df02a 100644 --- a/internal/migrations/032-order-book-actions.sql +++ b/internal/migrations/032-order-book-actions.sql @@ -160,25 +160,21 @@ CREATE OR REPLACE ACTION create_market( ERROR('Leader address not available for fee transfer'); } + -- Safe leader address conversion (handles both TEXT and BYTEA leader_sender) + $leader_hex TEXT := tn_utils.get_leader_hex(); + if $leader_hex = '' { + ERROR('Leader address not available for fee transfer'); + } + -- Transfer fee to leader from TRUF bridge (hoodi_tt) - -- Note: Bridge operations throw ERROR on failure (insufficient balance, etc.) - -- so no explicit return value check is needed - $leader_hex TEXT := encode(@leader_sender, 'hex')::TEXT; hoodi_tt.transfer($leader_hex, $market_creation_fee); -- ========================================================================== -- CREATE MARKET -- ========================================================================== - -- Validate @caller format (must be 0x-prefixed Ethereum address) - -- Note: Kwil supports both Secp256k1 (EVM) and ED25519 signers. This action - -- requires a 0x-prefixed Ethereum address format for EVM compatibility. - if @caller IS NULL OR length(@caller) != 42 OR substring(LOWER(@caller), 1, 2) != '0x' { - ERROR('Invalid caller address format (expected 0x-prefixed Ethereum address)'); - } - - -- Convert caller address to bytes for storage - $caller_bytes BYTEA := decode(substring(LOWER(@caller), 3, 40), 'hex'); + -- Safe caller normalization (handles both TEXT and BYTEA @caller) + $caller_bytes BYTEA := tn_utils.get_caller_bytes(); -- Insert market record with MAX(id) + 1 pattern -- Note: This is safe in Kwil because transactions within a block are processed @@ -218,7 +214,7 @@ CREATE OR REPLACE ACTION create_market( record_transaction_event( 8, $market_creation_fee, - '0x' || $leader_hex, + $leader_hex, NULL ); @@ -1035,12 +1031,9 @@ CREATE OR REPLACE ACTION place_buy_order( -- 1.1 Get market bridge (will ERROR if market doesn't exist) $bridge TEXT := get_market_bridge($query_id); - -- 1.2 Validate @caller format (must be 0x-prefixed Ethereum address) - -- Note: Kwil supports both Secp256k1 (EVM) and ED25519 signers. This action - -- requires a 0x-prefixed Ethereum address format for EVM compatibility. - if @caller IS NULL OR length(@caller) != 42 OR substring(LOWER(@caller), 1, 2) != '0x' { - ERROR('Invalid caller address format (expected 0x-prefixed Ethereum address)'); - } + -- 1.2 Validate @caller format and normalize to bytes + -- Safe caller normalization (handles both TEXT and BYTEA @caller) + $caller_bytes BYTEA := tn_utils.get_caller_bytes(); -- 1.3 Validate parameters if $query_id IS NULL { @@ -1129,8 +1122,8 @@ CREATE OR REPLACE ACTION place_buy_order( -- SECTION 4: GET OR CREATE PARTICIPANT -- ========================================================================== - -- Convert @caller (TEXT like '0x1234...') to BYTEA (20 bytes) - $caller_bytes BYTEA := decode(substring(LOWER(@caller), 3, 40), 'hex'); + -- Safe caller normalization (already done in Section 1.2) + -- $caller_bytes is already available -- Try to get existing participant $participant_id INT; @@ -1244,12 +1237,8 @@ CREATE OR REPLACE ACTION place_sell_order( -- 1.0 Get market bridge (will ERROR if market doesn't exist) $bridge TEXT := get_market_bridge($query_id); - -- 1.1 Validate @caller format (must be 0x-prefixed Ethereum address) - -- Note: Kwil supports both Secp256k1 (EVM) and ED25519 signers. This action - -- requires a 0x-prefixed Ethereum address format for EVM compatibility. - if @caller IS NULL OR length(@caller) != 42 OR substring(LOWER(@caller), 1, 2) != '0x' { - ERROR('Invalid caller address format (expected 0x-prefixed Ethereum address)'); - } + -- Safe caller normalization using precompiles + $caller_bytes BYTEA := tn_utils.get_caller_bytes(); -- 1.2 Validate parameters if $query_id IS NULL { @@ -1304,8 +1293,8 @@ CREATE OR REPLACE ACTION place_sell_order( -- SECTION 2: GET PARTICIPANT (NO AUTO-CREATE FOR SELLS) -- ========================================================================== - -- Convert @caller (TEXT like '0x1234...') to BYTEA (20 bytes) - $caller_bytes BYTEA := decode(substring(LOWER(@caller), 3, 40), 'hex'); + -- Safe caller normalization (already done in Section 1.2) + -- $caller_bytes is already available -- Look up participant (DON'T auto-create for sells) -- If user has shares, they must already be a participant from previous buy/mint @@ -1459,12 +1448,9 @@ CREATE OR REPLACE ACTION place_split_limit_order( -- 1.1 Get market bridge (will ERROR if market doesn't exist) $bridge TEXT := get_market_bridge($query_id); - -- 1.2 Validate @caller format (must be 0x-prefixed Ethereum address) - -- Note: Kwil supports both Secp256k1 (EVM) and ED25519 signers. This action - -- requires a 0x-prefixed Ethereum address format for EVM compatibility. - if @caller IS NULL OR length(@caller) != 42 OR substring(LOWER(@caller), 1, 2) != '0x' { - ERROR('Invalid caller address format (expected 0x-prefixed Ethereum address)'); - } + -- 1.2 Validate @caller format and normalize to bytes + -- Safe caller normalization (handles both TEXT and BYTEA @caller) + $caller_bytes BYTEA := tn_utils.get_caller_bytes(); -- 1.3 Validate parameters if $query_id IS NULL { @@ -1549,8 +1535,8 @@ CREATE OR REPLACE ACTION place_split_limit_order( -- SECTION 4: GET OR CREATE PARTICIPANT -- ========================================================================== - -- Convert @caller (TEXT like '0x1234...') to BYTEA (20 bytes) - $caller_bytes BYTEA := decode(substring(LOWER(@caller), 3, 40), 'hex'); + -- Safe caller normalization (already done in Section 1.2) + -- $caller_bytes is already available -- Try to get existing participant $participant_id INT; @@ -1720,12 +1706,8 @@ CREATE OR REPLACE ACTION cancel_order( -- SECTION 1: VALIDATE CALLER -- ========================================================================== - -- Validate @caller format (must be 0x-prefixed Ethereum address) - -- Note: Kwil supports both Secp256k1 (EVM) and ED25519 signers. This action - -- requires a 0x-prefixed Ethereum address format for EVM compatibility. - if @caller IS NULL OR length(@caller) != 42 OR substring(LOWER(@caller), 1, 2) != '0x' { - ERROR('Invalid caller address format (expected 0x-prefixed Ethereum address)'); - } + -- Safe caller normalization using precompiles + $caller_bytes BYTEA := tn_utils.get_caller_bytes(); -- ========================================================================== -- SECTION 2: VALIDATE PARAMETERS @@ -1774,7 +1756,7 @@ CREATE OR REPLACE ACTION cancel_order( -- Get participant ID from caller's wallet address -- Note: Don't auto-create participant - they must exist if they have orders -- This uses the helper function from 031-order-book-vault.sql - $participant_id INT := ob_get_participant_id(@caller); + $participant_id INT := ob_get_participant_id(tn_utils.get_caller_hex()); if $participant_id IS NULL { ERROR('No participant record found for this wallet'); @@ -1935,13 +1917,8 @@ CREATE OR REPLACE ACTION change_bid( -- Get market bridge (will ERROR if market doesn't exist) $bridge TEXT := get_market_bridge($query_id); - -- ========================================================================== - -- SECTION 1: VALIDATE CALLER - -- ========================================================================== - - if @caller IS NULL OR length(@caller) != 42 OR substring(LOWER(@caller), 1, 2) != '0x' { - ERROR('Invalid caller address format (expected 0x-prefixed Ethereum address)'); - } + -- Safe caller normalization using precompiles + $caller_bytes BYTEA := tn_utils.get_caller_bytes(); -- ========================================================================== -- SECTION 2: VALIDATE PARAMETERS @@ -2007,7 +1984,7 @@ CREATE OR REPLACE ACTION change_bid( -- SECTION 4: GET OLD ORDER DETAILS -- ========================================================================== - $participant_id INT := ob_get_participant_id(@caller); + $participant_id INT := ob_get_participant_id(tn_utils.get_caller_hex()); if $participant_id IS NULL { ERROR('No participant record found for this wallet'); } @@ -2198,13 +2175,8 @@ CREATE OR REPLACE ACTION change_ask( -- Get market bridge (will ERROR if market doesn't exist) $bridge TEXT := get_market_bridge($query_id); - -- ========================================================================== - -- SECTION 1: VALIDATE CALLER - -- ========================================================================== - - if @caller IS NULL OR length(@caller) != 42 OR substring(LOWER(@caller), 1, 2) != '0x' { - ERROR('Invalid caller address format (expected 0x-prefixed Ethereum address)'); - } + -- Safe caller normalization using precompiles + $caller_bytes BYTEA := tn_utils.get_caller_bytes(); -- ========================================================================== -- SECTION 2: VALIDATE PARAMETERS @@ -2270,7 +2242,7 @@ CREATE OR REPLACE ACTION change_ask( -- SECTION 4: GET OLD ORDER DETAILS -- ========================================================================== - $participant_id INT := ob_get_participant_id(@caller); + $participant_id INT := ob_get_participant_id(tn_utils.get_caller_hex()); if $participant_id IS NULL { ERROR('No participant record found for this wallet. You must own shares before selling.'); } diff --git a/internal/migrations/033-order-book-settlement.sql b/internal/migrations/033-order-book-settlement.sql index 115d4408..151a4630 100644 --- a/internal/migrations/033-order-book-settlement.sql +++ b/internal/migrations/033-order-book-settlement.sql @@ -1,59 +1,53 @@ /** * MIGRATION 033: ORDER BOOK SETTLEMENT * - * Automatic atomic settlement processing: - * - Bulk delete losing positions (efficient) - * - Pay winners full $1.00 per share (no redemption fee) - * - Refund open buy orders - * - Delete all positions atomically - * - Zero-sum settlement: losers fund winners + * Refactored version to avoid nested queries and CTE variable errors. + * IMPORTANT: No direct use of $row.field in DML (INSERT/UPDATE/DELETE). */ --- 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) +-- ============================================================================ +-- Batch Unlock Helper +-- ============================================================================ + CREATE OR REPLACE ACTION ob_batch_unlock_collateral( - $query_id INT, -- Pass query_id for impact recording + $query_id INT, $bridge TEXT, - $wallet_addresses TEXT[], + $pids INT[], + $wallet_hexes TEXT[], $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) 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 + -- 1. Call precompile for each unlock (Safe in loops) for $payout in - SELECT wallet, amount, outcome - FROM UNNEST($wallet_addresses, $amounts, $outcomes) AS u(wallet, amount, outcome) + SELECT u.wallet_hex, u.amount + FROM UNNEST($wallet_hexes, $amounts) AS u(wallet_hex, amount) { - $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($wallet_hex, $amount); + hoodi_tt2.unlock($payout.wallet_hex, $payout.amount); } else if $bridge = 'sepolia_bridge' { - sepolia_bridge.unlock($wallet_hex, $amount); + sepolia_bridge.unlock($payout.wallet_hex, $payout.amount); } else if $bridge = 'ethereum_bridge' { - ethereum_bridge.unlock($wallet_hex, $amount); - } else { - ERROR('Invalid bridge in ob_batch_unlock_collateral: ' || COALESCE($bridge, 'NULL')); + ethereum_bridge.unlock($payout.wallet_hex, $payout.amount); } + } - -- 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; - } + -- 2. Record all impacts using a loop to avoid "unknown variable" in INSERT...SELECT + $next_id INT; + for $row in SELECT COALESCE(MAX(id), 0::INT) + 1 as val FROM ob_net_impacts { + $next_id := $row.val; + } - 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); + for $imp in + SELECT pid, outcome, amount + FROM UNNEST($pids, $outcomes, $amounts) AS u(pid, outcome, amount) + { + $cur_pid INT := $imp.pid; + $cur_outcome BOOL := $imp.outcome; + $cur_amount NUMERIC(78,0) := $imp.amount; + + if $cur_pid IS NOT NULL { + ob_record_net_impact($next_id, $query_id, $cur_pid, $cur_outcome, 0::INT8, $cur_amount, FALSE); + $next_id := $next_id + 1; } } }; @@ -62,337 +56,222 @@ CREATE OR REPLACE ACTION ob_batch_unlock_collateral( -- Fee Distribution to Liquidity Providers -- ============================================================================ -/** - * distribute_fees($query_id, $total_fees, $winning_outcome) - * - * Distributes redemption fees to liquidity providers based on sampled rewards. - */ CREATE OR REPLACE ACTION distribute_fees( $query_id INT, $total_fees NUMERIC(78, 0), $winning_outcome BOOL ) PRIVATE { - -- Get market details for fee split and unlock $bridge TEXT; $query_components BYTEA; + + $min_pid INT; + $remainder NUMERIC(78, 0) := '0'::NUMERIC(78, 0); + $pids INT[]; + $wallet_hexes TEXT[]; + $amounts NUMERIC(78, 0)[]; + $outcomes BOOL[]; + for $row in SELECT bridge, query_components FROM ob_queries WHERE id = $query_id { $bridge := $row.bridge; $query_components := $row.query_components; } - if $bridge IS NULL { - ERROR('Market not found for query_id: ' || $query_id::TEXT); - } - -- Step 0: Calculate Shares (75/12.5/12.5 split) + -- 75/12.5/12.5 split $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); - - -- Ensure 100% distribution: add any rounding dust to LP pool $lp_share := $lp_share + ($total_fees - $lp_share - (2::NUMERIC(78, 0) * $infra_share)); - -- Step 1: Payout Data Provider (0.25%) + -- Payout Data Provider $dp_addr BYTEA; - for $row in tn_utils.unpack_query_components($query_components) { - $dp_addr := $row.data_provider; - } - + for $row in tn_utils.unpack_query_components($query_components) { $dp_addr := $row.data_provider; } + $actual_dp_fees NUMERIC(78, 0) := '0'::NUMERIC(78, 0); if $dp_addr IS NOT NULL AND $infra_share > '0'::NUMERIC(78, 0) { - $dp_wallet TEXT := '0x' || encode($dp_addr, 'hex'); - if $bridge = 'hoodi_tt2' { - hoodi_tt2.unlock($dp_wallet, $infra_share); - $actual_dp_fees := $infra_share; - } else if $bridge = 'sepolia_bridge' { - sepolia_bridge.unlock($dp_wallet, $infra_share); - $actual_dp_fees := $infra_share; - } else if $bridge = 'ethereum_bridge' { - ethereum_bridge.unlock($dp_wallet, $infra_share); - $actual_dp_fees := $infra_share; - } + $dp_wallet_hex TEXT := '0x' || encode($dp_addr, 'hex'); + if $bridge = 'hoodi_tt2' { hoodi_tt2.unlock($dp_wallet_hex, $infra_share); } + else if $bridge = 'sepolia_bridge' { sepolia_bridge.unlock($dp_wallet_hex, $infra_share); } + else if $bridge = 'ethereum_bridge' { ethereum_bridge.unlock($dp_wallet_hex, $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; - } + 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); + $next_id_dp INT; + for $row in SELECT COALESCE(MAX(id), 0::INT) + 1 as val FROM ob_net_impacts { $next_id_dp := $row.val; } + ob_record_net_impact($next_id_dp, $query_id, $dp_pid, $winning_outcome, 0::INT8, $infra_share, FALSE); + $actual_dp_fees := $infra_share; } } - -- Step 2: Payout Validator (Leader) (0.25%) + -- Payout Validator (Leader) $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'); - if $bridge = 'hoodi_tt2' { - hoodi_tt2.unlock($validator_wallet, $infra_share); - $actual_validator_fees := $infra_share; - } else if $bridge = 'sepolia_bridge' { - sepolia_bridge.unlock($validator_wallet, $infra_share); - $actual_validator_fees := $infra_share; - } else if $bridge = 'ethereum_bridge' { - ethereum_bridge.unlock($validator_wallet, $infra_share); - $actual_validator_fees := $infra_share; - } + $val_wallet TEXT := tn_utils.get_leader_hex(); + if $val_wallet != '' AND $infra_share > '0'::NUMERIC(78, 0) { + if $bridge = 'hoodi_tt2' { hoodi_tt2.unlock($val_wallet, $infra_share); } + else if $bridge = 'sepolia_bridge' { sepolia_bridge.unlock($val_wallet, $infra_share); } + else if $bridge = 'ethereum_bridge' { ethereum_bridge.unlock($val_wallet, $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; - } + $leader_bytes BYTEA := tn_utils.get_leader_bytes(); + for $p in SELECT id FROM ob_participants WHERE wallet_address = $leader_bytes { $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); + $next_id_val INT; + for $row in SELECT COALESCE(MAX(id), 0::INT) + 1 as val FROM ob_net_impacts { $next_id_val := $row.val; } + ob_record_net_impact($next_id_val, $query_id, $val_pid, $winning_outcome, 0::INT8, $infra_share, FALSE); + $actual_validator_fees := $infra_share; } } - -- Step 3: Count distinct blocks sampled for this market + -- LP Distribution Pre-calculation $block_count INT := 0; - for $row in SELECT COUNT(DISTINCT block) as cnt FROM ob_rewards WHERE query_id = $query_id { - $block_count := $row.cnt; - } + for $row in SELECT COUNT(DISTINCT block) as cnt FROM ob_rewards WHERE query_id = $query_id { $block_count := $row.cnt; } - -- Default values for summary - $actual_fees_distributed NUMERIC(78, 0) := '0'::NUMERIC(78, 0); $lp_count INT := 0; + $actual_fees_distributed NUMERIC(78, 0) := '0'::NUMERIC(78, 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; - } - - $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 - if $block_count > 0 AND $lp_share > '0'::NUMERIC(78, 0) { + for $row in SELECT COALESCE(MAX(id), 0) + 1 as val FROM ob_fee_distributions { $distribution_id := $row.val; } - $wallet_addresses TEXT[] := ARRAY[]::TEXT[]; - $amounts NUMERIC(78, 0)[] := ARRAY[]::NUMERIC(78, 0)[]; - $outcomes BOOL[] := ARRAY[]::BOOL[]; + -- PRE-INSERT PARENT RECORD to satisfy FK for details + INSERT INTO ob_fee_distributions ( + id, query_id, total_fees_distributed, total_dp_fees, total_validator_fees, total_lp_count, block_count, distributed_at + ) VALUES ( + $distribution_id, $query_id, '0'::NUMERIC(78, 0), $actual_dp_fees, $actual_validator_fees, 0, $block_count, @block_timestamp + ); - -- Step 5: Calculate rewards with zero-loss distribution - for $row in SELECT MIN(participant_id) as mid FROM ob_rewards WHERE query_id = $query_id { - $min_participant_id := $row.mid; - } + if $block_count > 0 { + for $row in SELECT MIN(participant_id) as val FROM ob_rewards WHERE query_id = $query_id { $min_pid := $row.val; } - 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 ( - SELECT SUM(reward_percent)::NUMERIC(78, 20) as total_percent_numeric - FROM ob_rewards - WHERE query_id = $query_id - GROUP BY participant_id - ) AS pt + -- Calculate remainder + $total_calc NUMERIC(78, 0) := '0'::NUMERIC(78, 0); + for $res in + SELECT participant_id, SUM(reward_percent) as reward_percent + FROM ob_rewards WHERE query_id = $query_id GROUP BY participant_id { - $total_distributed_base := $row.total; + $reward_tmp NUMERIC(78, 0) := (($lp_share::NUMERIC(78, 20) * $res.reward_percent::NUMERIC(78, 20)) / (100::NUMERIC(78, 20) * $block_count::NUMERIC(78, 20)))::NUMERIC(78, 0); + $total_calc := $total_calc + $reward_tmp; } + $remainder := $lp_share - $total_calc; - $remainder := $lp_share - $total_distributed_base; - - -- Aggregate into arrays for batch processing - 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, - '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, - total_percent_numeric - FROM participant_totals + for $res in + WITH reward_summary AS ( + SELECT r.participant_id, p.wallet_address, SUM(r.reward_percent) as reward_percent + 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 ) - SELECT participant_id, wallet_address, wallet_hex, final_reward, total_percent_numeric - FROM calculated_rewards + SELECT participant_id, wallet_address, '0x' || encode(wallet_address, 'hex') as wallet_hex, reward_percent + FROM reward_summary ORDER BY wallet_address { - $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); + $reward_final NUMERIC(78, 0) := (($lp_share::NUMERIC(78, 20) * $res.reward_percent::NUMERIC(78, 20)) / (100::NUMERIC(78, 20) * $block_count::NUMERIC(78, 20)))::NUMERIC(78, 0); + if $res.participant_id = $min_pid { $reward_final := $reward_final + $remainder; } + + if $reward_final > '0'::NUMERIC(78, 0) { + $pids := array_append($pids, $res.participant_id); + $wallet_hexes := array_append($wallet_hexes, $res.wallet_hex); + $amounts := array_append($amounts, $reward_final); $outcomes := array_append($outcomes, $winning_outcome); + + -- Record audit detail (Parent exists now) + $curr_pid INT := $res.participant_id; + $curr_wallet BYTEA := $res.wallet_address; + $curr_reward NUMERIC(78,0) := $reward_final; + $curr_pct NUMERIC(10,2) := $res.reward_percent::NUMERIC(10, 2); + + INSERT INTO ob_fee_distribution_details (distribution_id, participant_id, wallet_address, reward_amount, total_reward_percent) + VALUES ($distribution_id, $curr_pid, $curr_wallet, $curr_reward, $curr_pct); } } - if $wallet_addresses IS NOT NULL AND COALESCE(array_length($wallet_addresses), 0) > 0 { - $lp_count := array_length($wallet_addresses); + if COALESCE(array_length($pids), 0) > 0 { + $lp_count := array_length($pids); $actual_fees_distributed := $lp_share; - - -- Step 6: Batch unlock to all qualifying LPs - ob_batch_unlock_collateral($query_id, $bridge, $wallet_addresses, $amounts, $outcomes); + + -- UPDATE SUMMARY with final values + UPDATE ob_fee_distributions + SET total_fees_distributed = $actual_fees_distributed, + total_lp_count = $lp_count + WHERE id = $distribution_id; + + ob_batch_unlock_collateral($query_id, $bridge, $pids, $wallet_hexes, $amounts, $outcomes); } } - -- 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 - ) VALUES ( - $distribution_id, $query_id, $actual_fees_distributed, $actual_dp_fees, $actual_validator_fees, $lp_count, $block_count, @block_timestamp - ); - - -- Step 7.5: Insert audit details (must be after summary for FK) - if $lp_count > 0 { - 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 - ); - } - } - } - - -- Step 8: Cleanup processed rewards (ONLY if actual distribution happened) - if $lp_count > 0 { - DELETE FROM ob_rewards WHERE query_id = $query_id; - } + if $lp_count > 0 { DELETE FROM ob_rewards WHERE query_id = $query_id; } }; -- ============================================================================ -- 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 { - -- SECTION 0: GET MARKET BRIDGE $bridge TEXT; - for $row in SELECT bridge FROM ob_queries WHERE id = $query_id { - $bridge := $row.bridge; - } + for $row in SELECT bridge FROM ob_queries WHERE id = $query_id { $bridge := $row.bridge; } - -- SECTION 1: CALCULATE PAYOUTS (Winners and Buy Refunds) - $wallet_addresses TEXT[]; + $pids INT[]; + $wallet_hexes 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 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 - ) + for $res in + WITH target_positions AS ( + SELECT pos.participant_id, p.wallet_address, pos.price, pos.amount, pos.outcome + 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) OR (pos.price < 0)) ), - 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 + payout_calculation AS ( + SELECT + participant_id, + wallet_address, + '0x' || encode(wallet_address, 'hex') as wallet_hex, + 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, + CASE WHEN price >= 0 THEN $winning_outcome ELSE outcome END as out, + CASE WHEN price >= 0 THEN ((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 target_positions ) - SELECT wallets, amounts, outcomes, COALESCE(fees, '0'::NUMERIC(78, 0)) as fees FROM aggregated + SELECT * FROM payout_calculation { - $wallet_addresses := $result.wallets; - $amounts := $result.amounts; - $outcomes := $result.outcomes; - $total_fees := $result.fees; + if $res.pay > '0'::NUMERIC(78, 0) { + $pids := array_append($pids, $res.participant_id); + $wallet_hexes := array_append($wallet_hexes, $res.wallet_hex); + $amounts := array_append($amounts, $res.pay); + $outcomes := array_append($outcomes, $res.out); + $total_fees := $total_fees + $res.fee; + } } - -- Step 2: Delete all positions for this market atomically DELETE FROM ob_positions WHERE query_id = $query_id; - -- 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($query_id, $bridge, $wallet_addresses, $amounts, $outcomes); + if COALESCE(array_length($pids), 0) > 0 { + ob_batch_unlock_collateral($query_id, $bridge, $pids, $wallet_hexes, $amounts, $outcomes); } - -- Step 4: Distribute collected fees - if $total_fees IS NOT NULL AND $total_fees > '0'::NUMERIC(78, 0) { + if $total_fees > '0'::NUMERIC(78, 0) { distribute_fees($query_id, $total_fees, $winning_outcome); } }; -// Public trigger CREATE OR REPLACE ACTION trigger_fee_distribution( $query_id INT, $total_fees TEXT ) PUBLIC { $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 { - $has_role := TRUE; - } + $lower_caller TEXT := tn_utils.get_caller_hex(); + 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'); } - -- 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; } - - if NOT $found { - ERROR('Market not found: ' || $query_id::TEXT); - } + if NOT $found { ERROR('Market not found: ' || $query_id::TEXT); } $fees NUMERIC(78, 0) := $total_fees::NUMERIC(78, 0); distribute_fees($query_id, $fees, $winning); } - diff --git a/internal/migrations/041-settlement-config-actions.sql b/internal/migrations/041-settlement-config-actions.sql index 1d275610..bee3f1f6 100644 --- a/internal/migrations/041-settlement-config-actions.sql +++ b/internal/migrations/041-settlement-config-actions.sql @@ -4,7 +4,7 @@ -- Uses existing network_writer role for access control. -- -- Changes: --- - Enables settlement with 30-minute schedule by default +-- - Enables settlement with 5-minute schedule by default -- - Adds update_settlement_config action (role-gated to network_writer) -- - Adds get_settlement_config action (public view) -- @@ -13,12 +13,12 @@ -- - Migration 015: system:network_writer role must exist -- ============================================================================= --- ENABLE SETTLEMENT BY DEFAULT (every 30 minutes: at 0 and 30 past the hour) +-- ENABLE SETTLEMENT BY DEFAULT (every 5 minutes) -- ============================================================================= UPDATE settlement_config SET enabled = true, - settlement_schedule = '0,30 * * * *', + settlement_schedule = '*/5 * * * *', max_markets_per_run = 1000, retry_attempts = 3, updated_at = 0 @@ -45,7 +45,9 @@ CREATE OR REPLACE ACTION update_settlement_config( $retry_attempts INT ) PUBLIC { -- Validate caller has network_writer role - $caller_addr TEXT := LOWER(@caller); + -- Safe caller normalization using precompiles + $caller_addr TEXT := tn_utils.get_caller_hex(); + $has_role BOOL := false; for $row in SELECT 1 FROM role_members diff --git a/internal/migrations/043-order-book-pnl.sql b/internal/migrations/043-order-book-pnl.sql index 15a9d2df..b0f79d2b 100644 --- a/internal/migrations/043-order-book-pnl.sql +++ b/internal/migrations/043-order-book-pnl.sql @@ -2,6 +2,8 @@ * ORDER BOOK P&L MIGRATION * * Creates the ob_net_impacts table to track the net change of every transaction. + * This version removes the redundant tx_hash from the temporary ob_tx_payouts table + * to avoid bytea/text comparison errors in Kuneiform. */ -- ============================================================================= @@ -36,7 +38,6 @@ CREATE INDEX IF NOT EXISTS idx_ob_net_impacts_query ON ob_net_impacts(query_id); -- ============================================================================= 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, @@ -49,12 +50,19 @@ 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; + $next_id INT; + for $row in SELECT COALESCE(MAX(id), 0::INT) + 1 as val FROM ob_tx_payouts { + $next_id := $row.val; + } + + INSERT INTO ob_tx_payouts ( + id, participant_id, outcome, shares_change, amount, is_negative + ) VALUES ( + $next_id, $participant_id, TRUE, 0::INT8, $amount, FALSE + ); }; --- Helper to record a full impact during matching +-- Internal helper to record impacts during matching/placement CREATE OR REPLACE ACTION ob_record_tx_impact( $participant_id INT, $outcome BOOLEAN, @@ -62,17 +70,35 @@ CREATE OR REPLACE ACTION ob_record_tx_impact( $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; + $next_id INT; + for $row in SELECT COALESCE(MAX(id), 0::INT) + 1 as val FROM ob_tx_payouts { + $next_id := $row.val; + } + + $actual_shares INT8 := $shares_change; + if $is_negative { + $actual_shares := - $shares_change; + } + + INSERT INTO ob_tx_payouts ( + id, participant_id, outcome, shares_change, amount, is_negative + ) VALUES ( + $next_id, $participant_id, $outcome, $actual_shares, $amount, $is_negative + ); }; -- Helper to materialize and cleanup impacts for current TX CREATE OR REPLACE ACTION ob_cleanup_tx_payouts( $query_id INT ) PRIVATE { + $next_id INT; + for $row in SELECT COALESCE(MAX(id), 0::INT) + 1 as val FROM ob_net_impacts { + $next_id := $row.val; + } + -- 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') { + -- Deterministic ordering is CRITICAL for stable ID allocation across validators + for $p in SELECT DISTINCT participant_id, outcome FROM ob_tx_payouts ORDER BY participant_id, outcome { -- Capture into local variables to avoid "unknown variable" error in nested loops $current_pid INT := $p.participant_id; $current_outcome BOOL := $p.outcome; @@ -80,7 +106,7 @@ CREATE OR REPLACE ACTION ob_cleanup_tx_payouts( $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 { + for $impact in SELECT shares_change, amount, is_negative FROM ob_tx_payouts WHERE 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); @@ -89,21 +115,24 @@ CREATE OR REPLACE ACTION ob_cleanup_tx_payouts( } } - -- 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); + -- Call record_net_impact with final net values if change occurred + if $net_shares != 0 OR $net_collateral != 0::NUMERIC(100,0) { + $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($next_id, $query_id, $current_pid, $current_outcome, $net_shares, $final_mag, $final_is_neg); + $next_id := $next_id + 1; } - - 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'); + DELETE FROM ob_tx_payouts; }; -- Helper to get total payout for a participant in current TX (Legacy helper) @@ -111,7 +140,7 @@ 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 { + for $row in SELECT amount, is_negative FROM ob_tx_payouts WHERE participant_id = $participant_id { if NOT $row.is_negative { $total := $total + $row.amount; } @@ -121,6 +150,7 @@ CREATE OR REPLACE ACTION ob_get_tx_payout( -- Internal helper to record impacts into audit trail CREATE OR REPLACE ACTION ob_record_net_impact( + $id INT, $query_id INT, $participant_id INT, $outcome BOOLEAN, @@ -128,11 +158,6 @@ CREATE OR REPLACE ACTION ob_record_net_impact( $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, @@ -143,9 +168,8 @@ CREATE OR REPLACE ACTION ob_record_net_impact( collateral_change, is_negative, timestamp - ) - SELECT - COALESCE(MAX(id), 0::INT) + 1, + ) VALUES ( + $id, decode(@txid, 'hex'), $query_id, $participant_id, @@ -154,5 +178,5 @@ CREATE OR REPLACE ACTION ob_record_net_impact( $collateral_change, $is_negative, @block_timestamp - FROM ob_net_impacts; + ); };