From b7c4d59e5578f802ffd36ed41d4e1a073ae9427b Mon Sep 17 00:00:00 2001 From: Michael Buntarman Date: Tue, 3 Mar 2026 17:06:42 +0700 Subject: [PATCH 1/3] chore: make reward distribution atomic --- deployments/infra/go.mod | 16 +- deployments/infra/go.sum | 6 + go.mod | 4 +- go.sum | 4 + .../migrations/033-order-book-settlement.sql | 210 ++++++++---------- .../migrations/034-order-book-rewards.sql | 138 +++++------- internal/migrations/036-order-book-audit.sql | 10 +- .../order_book/fee_distribution_audit_test.go | 15 +- 8 files changed, 172 insertions(+), 231 deletions(-) diff --git a/deployments/infra/go.mod b/deployments/infra/go.mod index 6fef3e18e..d68a40528 100644 --- a/deployments/infra/go.mod +++ b/deployments/infra/go.mod @@ -1,8 +1,6 @@ module github.com/trufnetwork/node/infra -go 1.24.1 - -toolchain go1.24.4 +go 1.25.3 require ( github.com/BurntSushi/toml v1.5.0 @@ -15,7 +13,7 @@ require ( github.com/aws/jsii-runtime-go v1.110.0 github.com/caarlos0/env/v11 v11.3.1 github.com/sebdah/goldie/v2 v2.5.5 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 github.com/trufnetwork/node v1.2.0 go.uber.org/zap v1.27.0 gopkg.in/yaml.v3 v3.0.1 @@ -42,7 +40,7 @@ require ( github.com/spf13/cast v1.7.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa // indirect - golang.org/x/sync v0.15.0 // indirect + golang.org/x/sync v0.18.0 // indirect ) require ( @@ -53,9 +51,9 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/yuin/goldmark v1.4.13 // indirect - golang.org/x/crypto v0.38.0 // indirect + golang.org/x/crypto v0.44.0 // indirect golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect - golang.org/x/mod v0.24.0 // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/tools v0.31.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/tools v0.38.0 // indirect ) diff --git a/deployments/infra/go.sum b/deployments/infra/go.sum index ed9c4b6e3..727a8a012 100644 --- a/deployments/infra/go.sum +++ b/deployments/infra/go.sum @@ -78,6 +78,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -90,6 +91,7 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa h1:t2QcU6V556bFjYgu4L6C+6VrCPyJZ+eyRsABUPs1mz4= golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk= golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= @@ -97,21 +99,25 @@ golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPI golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/go.mod b/go.mod index 5cb4a6278..d526d0fdb 100644 --- a/go.mod +++ b/go.mod @@ -17,8 +17,8 @@ require ( github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.37.0 - github.com/trufnetwork/kwil-db v0.10.3-0.20260216231327-01b863886682 - github.com/trufnetwork/kwil-db/core v0.4.3-0.20260216231327-01b863886682 + github.com/trufnetwork/kwil-db v0.10.3-0.20260303100144-0119418a1a7c + github.com/trufnetwork/kwil-db/core v0.4.3-0.20260303100144-0119418a1a7c github.com/trufnetwork/sdk-go v0.6.4-0.20260224122406-a741343e2f37 go.uber.org/zap v1.27.0 golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa diff --git a/go.sum b/go.sum index 9528bce59..b082cfa9d 100644 --- a/go.sum +++ b/go.sum @@ -1242,8 +1242,12 @@ github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPD github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI= github.com/trufnetwork/kwil-db v0.10.3-0.20260216231327-01b863886682 h1:Gqee9/lNZMohOQEq8McpjLXMhpD60CqBxVlZPYdFdL4= github.com/trufnetwork/kwil-db v0.10.3-0.20260216231327-01b863886682/go.mod h1:LiBAC48uZl2B0IiLtD2hpOce7RNfpuDdghVAOc3u1Qo= +github.com/trufnetwork/kwil-db v0.10.3-0.20260303100144-0119418a1a7c h1:lvyTdrm1gzLqCmS+sqsg2JZWnoWo0ORKKBL8Z91C/JU= +github.com/trufnetwork/kwil-db v0.10.3-0.20260303100144-0119418a1a7c/go.mod h1:LiBAC48uZl2B0IiLtD2hpOce7RNfpuDdghVAOc3u1Qo= github.com/trufnetwork/kwil-db/core v0.4.3-0.20260216231327-01b863886682 h1:iaxXr8D3dU79MBhmS/uCuBhnlc+gbLvCvV6GtAz3ukw= github.com/trufnetwork/kwil-db/core v0.4.3-0.20260216231327-01b863886682/go.mod h1:HnOsh9+BN13LJCjiH0+XKaJzyjWKf+H9AofFFp90KwQ= +github.com/trufnetwork/kwil-db/core v0.4.3-0.20260303100144-0119418a1a7c h1:O5pyUJqZNNIi/l1vXc9fxycdEU9OxF5z8UQprTn4zZE= +github.com/trufnetwork/kwil-db/core v0.4.3-0.20260303100144-0119418a1a7c/go.mod h1:HnOsh9+BN13LJCjiH0+XKaJzyjWKf+H9AofFFp90KwQ= github.com/trufnetwork/openzeppelin-merkle-tree-go v0.0.2 h1:DCq8MzbWH0wZmICNmMVsSzUHUPl+2vqRhluEABjxl88= github.com/trufnetwork/openzeppelin-merkle-tree-go v0.0.2/go.mod h1:Y0MJpPp9QXU5vC6Gpoilql2NkgmGNcbHm9HYC2v2N8s= github.com/trufnetwork/sdk-go v0.6.4-0.20260224122406-a741343e2f37 h1:VD/GWxLTshaXpLukEc1SXbG7QA9HrFzF8JvxJAJ/x7Q= diff --git a/internal/migrations/033-order-book-settlement.sql b/internal/migrations/033-order-book-settlement.sql index 0b96dd565..0b08a8c3a 100644 --- a/internal/migrations/033-order-book-settlement.sql +++ b/internal/migrations/033-order-book-settlement.sql @@ -121,11 +121,6 @@ CREATE OR REPLACE ACTION distribute_fees( $query_id INT, $total_fees NUMERIC(78, 0) ) PRIVATE { - -- Early return if no fees to distribute - if $total_fees = '0'::NUMERIC(78, 0) { - RETURN; - } - -- Get market's bridge for unlock operations $bridge TEXT; for $row in SELECT bridge FROM ob_queries WHERE id = $query_id { @@ -141,144 +136,116 @@ CREATE OR REPLACE ACTION distribute_fees( $block_count := $row.cnt; } - -- Edge case: No samples recorded → fees remain in vault (safe accumulation) - if $block_count = 0 { - RETURN; + -- Step 2: Generate distribution ID and create summary record ALWAYS + -- This provides visibility into settled markets even if no rewards were distributed + -- or if zero fees were collected. + $distribution_id INT; + for $row in SELECT COALESCE(MAX(id), 0) + 1 as next_id FROM ob_fee_distributions { + $distribution_id := $row.next_id; } - -- Step 2-4: Calculate rewards with zero-loss distribution - -- Improved algorithm: Calculate total percentage first, then distribute - -- Remainder (dust) is given to the first participant to ensure all fees are distributed + -- Default values for summary + $actual_fees_distributed NUMERIC(78, 0) := '0'::NUMERIC(78, 0); + $lp_count INT := 0; + + -- If we have samples AND fees to distribute, calculate rewards $wallet_addresses TEXT[]; $amounts NUMERIC(78, 0)[]; - for $result in - WITH participant_totals AS ( - -- Sum each participant's reward percentages across all sampled blocks - -- Cast to INT to truncate decimal (64.00 → 64) - SELECT - r.participant_id, - p.wallet_address, - SUM(r.reward_percent)::INT as total_percent_int - 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 ( - -- Calculate base reward using integer division: (total_fees * total_percent_int) / (100 * block_count) - -- This truncates fractional rewards, creating "dust" that will be distributed to first LP - SELECT - participant_id, - wallet_address, - (($total_fees * total_percent_int::NUMERIC(78, 0)) / (100::NUMERIC(78, 0) * $block_count::NUMERIC(78, 0)))::NUMERIC(78, 0) as base_reward - FROM participant_totals - ), - total_check AS ( - -- Calculate total distributed to find dust (remainder from integer division) - SELECT COALESCE(SUM(base_reward)::NUMERIC(78, 0), '0'::NUMERIC(78, 0)) as total_distributed - FROM calculated_rewards - ), - with_remainder AS ( - -- Distribute remainder to first participant (lowest participant_id) - -- This ensures zero fee loss - all settlement fees go to LPs as intended - SELECT - participant_id, - wallet_address, - base_reward + CASE - WHEN participant_id = (SELECT MIN(participant_id) FROM calculated_rewards) - THEN $total_fees - (SELECT total_distributed FROM total_check) - ELSE '0'::NUMERIC(78, 0) - END as final_reward - FROM calculated_rewards - ), - aggregated AS ( - -- Aggregate into arrays for batch processing (same pattern as process_settlement) - 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 with_remainder - ) - SELECT wallets, amounts FROM aggregated - { - $wallet_addresses := $result.wallets; - $amounts := $result.amounts; - } - - -- Step 5: Batch unlock to all LPs (single call, no loops) - if $wallet_addresses IS NOT NULL AND COALESCE(array_length($wallet_addresses), 0) > 0 { - ob_batch_unlock_collateral($bridge, $wallet_addresses, $amounts); - } + if $block_count > 0 AND $total_fees > '0'::NUMERIC(78, 0) { + -- Step 3: Calculate rewards with zero-loss distribution + for $result in + WITH participant_totals AS ( + SELECT + r.participant_id, + p.wallet_address, + SUM(r.reward_percent)::INT as total_percent_int + 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, + (($total_fees * total_percent_int::NUMERIC(78, 0)) / (100::NUMERIC(78, 0) * $block_count::NUMERIC(78, 0)))::NUMERIC(78, 0) as base_reward + FROM participant_totals + ), + total_check AS ( + SELECT COALESCE(SUM(base_reward)::NUMERIC(78, 0), '0'::NUMERIC(78, 0)) as total_distributed + FROM calculated_rewards + ), + with_remainder AS ( + SELECT + participant_id, + wallet_address, + base_reward + CASE + WHEN participant_id = (SELECT MIN(participant_id) FROM calculated_rewards) + THEN $total_fees - (SELECT total_distributed FROM total_check) + ELSE '0'::NUMERIC(78, 0) + END as final_reward + FROM calculated_rewards + ), + 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 with_remainder + ) + SELECT wallets, amounts FROM aggregated + { + $wallet_addresses := $result.wallets; + $amounts := $result.amounts; + } - -- Step 5.5: CREATE AUDIT RECORDS - -- Insert distribution summary and per-LP details BEFORE deleting ob_rewards. - -- This ensures full traceability for compliance and user verification. + if $wallet_addresses IS NOT NULL AND COALESCE(array_length($wallet_addresses), 0) > 0 { + $lp_count := array_length($wallet_addresses); + $actual_fees_distributed := $total_fees; - -- Only create audit if distribution actually occurred - if $wallet_addresses IS NOT NULL AND COALESCE(array_length($wallet_addresses), 0) > 0 { - -- Generate distribution ID (MAX+1 pattern, safe in Kwil sequential execution) - $distribution_id INT; - for $row in SELECT COALESCE(MAX(id), 0) + 1 as next_id FROM ob_fee_distributions { - $distribution_id := $row.next_id; + -- Step 4: Batch unlock to all qualifying LPs + ob_batch_unlock_collateral($bridge, $wallet_addresses, $amounts); } + } - -- Insert distribution summary - INSERT INTO ob_fee_distributions ( - id, - query_id, - total_fees_distributed, - total_lp_count, - block_count, - distributed_at - ) VALUES ( - $distribution_id, - $query_id, - $total_fees, - COALESCE(array_length($wallet_addresses), 0), - $block_count, - @block_timestamp - ); - - -- Insert per-LP details - -- Match the distributed amounts (from arrays) with participant data from ob_rewards - -- This creates audit records showing exactly who got what + -- Step 5: Insert distribution summary + INSERT INTO ob_fee_distributions ( + id, + query_id, + total_fees_distributed, + total_lp_count, + block_count, + distributed_at + ) VALUES ( + $distribution_id, + $query_id, + $actual_fees_distributed, + $lp_count, + $block_count, + @block_timestamp + ); + + -- Step 6: Insert per-LP details (only if LPs exist) + if $lp_count > 0 { $idx INT := 1; for $w_row in SELECT wallet FROM UNNEST($wallet_addresses) AS w(wallet) { $wallet_hex TEXT := $w_row.wallet; - - -- Get corresponding amount (arrays are same length, ordered by participant_id) + $reward_amount NUMERIC(78, 0); - for $a_row in - SELECT amount - FROM UNNEST($amounts) AS a(amount) - LIMIT 1 OFFSET ($idx - 1) - { + for $a_row in SELECT amount FROM UNNEST($amounts) AS a(amount) LIMIT 1 OFFSET ($idx - 1) { $reward_amount := $a_row.amount; } - -- Get participant info by matching wallet address $pid INT; $wallet_bytes BYTEA; $total_reward_pct NUMERIC(10, 2); - for $p_data in - SELECT - p.id, - p.wallet_address - FROM ob_participants p - WHERE '0x' || encode(p.wallet_address, 'hex') = $wallet_hex - { + 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; - - -- Calculate total_reward_percent: average percentage across all sampled blocks - -- Sum of percentages / block_count = normalized to 0-100 range + $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 - { + 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); } @@ -303,8 +270,7 @@ CREATE OR REPLACE ACTION distribute_fees( } } - -- Step 6: Cleanup - delete processed rewards to save storage - -- NOW SAFE: Audit records created above preserve distribution history + -- Step 7: Cleanup DELETE FROM ob_rewards WHERE query_id = $query_id; }; diff --git a/internal/migrations/034-order-book-rewards.sql b/internal/migrations/034-order-book-rewards.sql index aa238131d..3dd241520 100644 --- a/internal/migrations/034-order-book-rewards.sql +++ b/internal/migrations/034-order-book-rewards.sql @@ -122,7 +122,6 @@ CREATE OR REPLACE ACTION sample_lp_rewards( $block INT8 ) PRIVATE { -- Check if this block was already sampled to prevent duplicate key errors - -- This handles retries, race conditions, and scheduler overlap for $row in SELECT 1 FROM ob_rewards WHERE query_id = $query_id AND block = $block LIMIT 1 { RETURN; } @@ -136,13 +135,13 @@ CREATE OR REPLACE ACTION sample_lp_rewards( ERROR('Market is already settled'); } - -- Calculate midpoint (reference line 34-46) + -- Calculate midpoint $best_bid INT; $best_ask INT; for $row in SELECT price FROM ob_positions WHERE query_id = $query_id AND outcome = TRUE AND price < 0 - ORDER BY price ASC LIMIT 1 + ORDER BY price DESC LIMIT 1 { $best_bid := $row.price; } @@ -154,18 +153,16 @@ CREATE OR REPLACE ACTION sample_lp_rewards( $best_ask := $row.price; } + -- If no two-sided liquidity, no rewards if $best_bid IS NULL OR $best_ask IS NULL { RETURN; } - $x_mid INT := ($best_ask + (-$best_bid)) / 2; - - -- Dynamic spread (reference line 48-63) - $x_spread_base INT := $x_mid - (100 - $x_mid); - if $x_spread_base < 0 { - $x_spread_base := -$x_spread_base; - } + -- Midpoint is (BestAsk + BestBidMagnitude) / 2 + $x_mid INT := ($best_ask + ABS($best_bid)) / 2; + -- Dynamic spread + $x_spread_base INT := ABS($x_mid - (100 - $x_mid)); $x_spread INT; if $x_spread_base < 30 { $x_spread := 5; @@ -177,14 +174,16 @@ CREATE OR REPLACE ACTION sample_lp_rewards( RETURN; } - -- Calculate scores (reference line 65-147) - for $pos in - SELECT - p1.participant_id, - p1.price as p1_price, - p1.outcome as p1_outcome, - p1.amount, - p2.price as p2_price + -- Step 1: Calculate Global Total Score + -- We join positions to find pairs (Outcome1, Outcome2) with same amount + -- Qualification: EffectivePrice1 + EffectivePrice2 = 100 + $global_total_score NUMERIC(78, 20) := 0::NUMERIC(78, 20); + for $row in + SELECT SUM( + p1.amount::NUMERIC(78, 20) * + (($x_spread - ABS($x_mid - (CASE WHEN p1.outcome = TRUE THEN (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END) ELSE (100 - (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END)) END)))::NUMERIC(78, 20) * ($x_spread - ABS($x_mid - (CASE WHEN p1.outcome = TRUE THEN (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END) ELSE (100 - (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END)) END)))::NUMERIC(78, 20))::NUMERIC(78, 20) / + ($x_spread * $x_spread)::NUMERIC(78, 20) + ) as total FROM ob_positions p1 JOIN ob_positions p2 ON p1.query_id = p2.query_id @@ -192,82 +191,47 @@ CREATE OR REPLACE ACTION sample_lp_rewards( AND p1.outcome != p2.outcome AND p1.amount = p2.amount WHERE p1.query_id = $query_id + AND (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END + CASE WHEN p2.price = 0 THEN 100 - ABS(p1.price) ELSE ABS(p2.price) END) = 100 + AND (p1.price != 0 OR p2.price != 0) + AND ABS($x_mid - (CASE WHEN p1.outcome = TRUE THEN (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END) ELSE (100 - (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END)) END)) < $x_spread { - $pid INT := $pos.participant_id; - $amount INT := $pos.amount; - - -- The specification requires pairing a SELL order with a BUY order - -- such that Magnitude(Ask) + Magnitude(Bid) = 100. - -- Formula: Price1 = 100 + Price2 (where Price1 > 0 and Price2 < 0) - if $pos.p1_price == 100 + $pos.p2_price { - -- Assign prices based on outcome - $yes_price INT; - $no_price INT; - if $pos.p1_outcome == TRUE { - $yes_price := $pos.p1_price; - $no_price := $pos.p2_price; - } else { - $yes_price := $pos.p2_price; - $no_price := $pos.p1_price; - } - - -- Convert to positive magnitudes for distance check (abs workaround) - $yes_mag INT := $yes_price; - if $yes_mag < 0 { $yes_mag := -$yes_mag; } - $no_mag INT := $no_price; - if $no_mag < 0 { $no_mag := -$no_mag; } - - -- Calculate distances from midpoint magnitudes - $yes_dist INT := $x_mid - $yes_mag; - if $yes_dist < 0 { $yes_dist := -$yes_dist; } - - $no_dist INT := (100 - $x_mid) - $no_mag; - if $no_dist < 0 { $no_dist := -$no_dist; } - - -- Filter by spread (reference line 135-146) - if $yes_dist < $x_spread AND $no_dist < $x_spread { - -- Get minimum distance (reference uses LEAST()) - $min_dist INT := $yes_dist; - if $no_dist < $yes_dist { - $min_dist := $no_dist; - } - - -- Calculate score (reference line 74: amount * POWER((spread - dist) / spread, 2)) - $spread_minus_dist INT := $x_spread - $min_dist; - $score NUMERIC(20,4) := ( - $amount::NUMERIC(20,4) * - ($spread_minus_dist * $spread_minus_dist)::NUMERIC(20,4) / - ($x_spread * $x_spread)::NUMERIC(20,4) - ); - - INSERT INTO ob_rewards (query_id, participant_id, block, reward_percent) - VALUES ($query_id, $pid, $block, $score::NUMERIC(5,2)); - } + if $row.total IS NOT NULL { + $global_total_score := $row.total::NUMERIC(78, 20); } } - -- Normalize to percentages (reference final SELECT) - $total NUMERIC(20,4) := 0::NUMERIC(20,4); - for $row in SELECT SUM(reward_percent) as total FROM ob_rewards - WHERE query_id = $query_id AND block = $block - { - $total := $row.total::NUMERIC(20,4); + if $global_total_score <= 0::NUMERIC(78, 20) { + RETURN; } - if $total > 0::NUMERIC(20,4) { - for $row in SELECT participant_id, reward_percent FROM ob_rewards - WHERE query_id = $query_id AND block = $block - { - $pid INT := $row.participant_id; - $raw NUMERIC(20,4) := $row.reward_percent::NUMERIC(20,4); - $pct NUMERIC(5,2) := (($raw / $total)::NUMERIC(20,4) * 100.0)::NUMERIC(5,2); - - DELETE FROM ob_rewards - WHERE query_id = $query_id AND participant_id = $pid AND block = $block; + -- Step 2: Calculate and Insert Normalized Participant Scores + -- One insert per participant per block to avoid PK violation + for $row in + SELECT + p1.participant_id, + SUM( + p1.amount::NUMERIC(78, 20) * + (($x_spread - ABS($x_mid - (CASE WHEN p1.outcome = TRUE THEN (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END) ELSE (100 - (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END)) END)))::NUMERIC(78, 20) * ($x_spread - ABS($x_mid - (CASE WHEN p1.outcome = TRUE THEN (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END) ELSE (100 - (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END)) END)))::NUMERIC(78, 20))::NUMERIC(78, 20) / + ($x_spread * $x_spread)::NUMERIC(78, 20) + ) as participant_score + FROM ob_positions p1 + JOIN ob_positions p2 + ON p1.query_id = p2.query_id + AND p1.participant_id = p2.participant_id + AND p1.outcome != p2.outcome + AND p1.amount = p2.amount + WHERE p1.query_id = $query_id + AND (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END + CASE WHEN p2.price = 0 THEN 100 - ABS(p1.price) ELSE ABS(p2.price) END) = 100 + AND (p1.price != 0 OR p2.price != 0) + AND ABS($x_mid - (CASE WHEN p1.outcome = TRUE THEN (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END) ELSE (100 - (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END)) END)) < $x_spread + GROUP BY p1.participant_id + { + $pid INT := $row.participant_id; + $score NUMERIC(78, 20) := $row.participant_score::NUMERIC(78, 20); + $pct NUMERIC(5,2) := (($score / $global_total_score) * 100.0::NUMERIC(78, 20))::NUMERIC(5,2); - INSERT INTO ob_rewards (query_id, participant_id, block, reward_percent) - VALUES ($query_id, $pid, $block, $pct); - } + INSERT INTO ob_rewards (query_id, participant_id, block, reward_percent) + VALUES ($query_id, $pid, $block, $pct); } }; diff --git a/internal/migrations/036-order-book-audit.sql b/internal/migrations/036-order-book-audit.sql index 7952b99af..c002a7f70 100644 --- a/internal/migrations/036-order-book-audit.sql +++ b/internal/migrations/036-order-book-audit.sql @@ -54,9 +54,9 @@ CREATE TABLE IF NOT EXISTS ob_fee_distributions ( block_count INT NOT NULL, distributed_at INT8 NOT NULL, FOREIGN KEY (query_id) REFERENCES ob_queries(id) ON DELETE CASCADE, - CHECK (total_fees_distributed >= 0), - CHECK (total_lp_count > 0), - CHECK (block_count > 0) + CONSTRAINT chk_ob_fd_fees CHECK (total_fees_distributed >= 0), + CONSTRAINT chk_ob_fd_lp_count CHECK (total_lp_count >= 0), + CONSTRAINT chk_ob_fd_block_count CHECK (block_count >= 0) ); /** @@ -89,8 +89,8 @@ CREATE TABLE IF NOT EXISTS ob_fee_distribution_details ( PRIMARY KEY (distribution_id, participant_id), FOREIGN KEY (distribution_id) REFERENCES ob_fee_distributions(id) ON DELETE CASCADE, FOREIGN KEY (participant_id) REFERENCES ob_participants(id), - CHECK (reward_amount > 0), - CHECK (total_reward_percent > 0) + CONSTRAINT chk_ob_fdd_reward CHECK (reward_amount > 0), + CONSTRAINT chk_ob_fdd_percent CHECK (total_reward_percent > 0) ); -- ============================================================================ diff --git a/tests/streams/order_book/fee_distribution_audit_test.go b/tests/streams/order_book/fee_distribution_audit_test.go index 00c5159fd..0e961c198 100644 --- a/tests/streams/order_book/fee_distribution_audit_test.go +++ b/tests/streams/order_book/fee_distribution_audit_test.go @@ -284,17 +284,20 @@ func testAuditNoLPs(t *testing.T) func(context.Context, *kwilTesting.Platform) e } engineCtx := &common.EngineContext{TxContext: tx, OverrideAuthz: true} - // Query distribution summary - should have 0 rows + // Query distribution summary - should have 1 row with 0 LPs var rowCount int + var lpCount int _, err = platform.Engine.Call(engineCtx, platform.DB, "", "get_distribution_summary", []any{int(marketID)}, func(row *common.Row) error { + lpCount = int(row.Values[2].(int64)) rowCount++ return nil }) require.NoError(t, err) - require.Equal(t, 0, rowCount, "Should have 0 distribution records (no LPs)") + require.Equal(t, 1, rowCount, "Should have 1 distribution record even with no LPs") + require.Equal(t, 0, lpCount, "LP count should be 0") - t.Logf("✅ No audit record created when no LPs (fees stayed in vault)") + t.Logf("✅ Audit record correctly created with 0 LPs") return nil } @@ -340,7 +343,7 @@ func testAuditZeroFees(t *testing.T) func(context.Context, *kwilTesting.Platform } engineCtx := &common.EngineContext{TxContext: tx, OverrideAuthz: true} - // Query distribution summary - should have 0 rows + // Query distribution summary - should have 1 row var rowCount int _, err = platform.Engine.Call(engineCtx, platform.DB, "", "get_distribution_summary", []any{int(marketID)}, func(row *common.Row) error { @@ -348,9 +351,9 @@ func testAuditZeroFees(t *testing.T) func(context.Context, *kwilTesting.Platform return nil }) require.NoError(t, err) - require.Equal(t, 0, rowCount, "Should have 0 distribution records (zero fees)") + require.Equal(t, 1, rowCount, "Should have 1 distribution record even with zero fees") - t.Logf("✅ No audit record created when zero fees collected") + t.Logf("✅ Audit record correctly created with zero fees") return nil } From 45ca2a176721aed0c61792d95e3f6d1963b36f54 Mon Sep 17 00:00:00 2001 From: Michael Buntarman Date: Tue, 3 Mar 2026 18:25:31 +0700 Subject: [PATCH 2/3] chore: apply patches --- extensions/tn_lp_rewards/tn_lp_rewards.go | 2 +- .../migrations/033-order-book-settlement.sql | 108 ++++++++++-------- .../migrations/034-order-book-rewards.sql | 37 ++---- .../order_book/fee_distribution_audit_test.go | 4 + .../order_book/lp_rewards_config_test.go | 4 +- 5 files changed, 72 insertions(+), 83 deletions(-) diff --git a/extensions/tn_lp_rewards/tn_lp_rewards.go b/extensions/tn_lp_rewards/tn_lp_rewards.go index 6ee8ce08e..7fa1ae32c 100644 --- a/extensions/tn_lp_rewards/tn_lp_rewards.go +++ b/extensions/tn_lp_rewards/tn_lp_rewards.go @@ -19,7 +19,7 @@ const ( // Default configuration DefaultSamplingIntervalBlocks = 10 - DefaultMaxMarketsPerRun = 50 + DefaultMaxMarketsPerRun = 1000 ) // Extension holds the singleton state for LP rewards sampling diff --git a/internal/migrations/033-order-book-settlement.sql b/internal/migrations/033-order-book-settlement.sql index 0b08a8c3a..2518ec208 100644 --- a/internal/migrations/033-order-book-settlement.sql +++ b/internal/migrations/033-order-book-settlement.sql @@ -154,46 +154,59 @@ CREATE OR REPLACE ACTION distribute_fees( if $block_count > 0 AND $total_fees > '0'::NUMERIC(78, 0) { -- Step 3: Calculate rewards with zero-loss distribution + -- We calculate all distribution arrays in a SINGLE query using simplified logic + -- to avoid performance bottlenecks in the Kwil engine. + + -- 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((($total_fees::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 + { + $total_distributed_base := $row.total; + } + + $remainder NUMERIC(78, 0) := $total_fees - $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)::INT as total_percent_int - 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, - (($total_fees * total_percent_int::NUMERIC(78, 0)) / (100::NUMERIC(78, 0) * $block_count::NUMERIC(78, 0)))::NUMERIC(78, 0) as base_reward - FROM participant_totals - ), - total_check AS ( - SELECT COALESCE(SUM(base_reward)::NUMERIC(78, 0), '0'::NUMERIC(78, 0)) as total_distributed - FROM calculated_rewards - ), - with_remainder AS ( - SELECT - participant_id, - wallet_address, - base_reward + CASE - WHEN participant_id = (SELECT MIN(participant_id) FROM calculated_rewards) - THEN $total_fees - (SELECT total_distributed FROM total_check) - ELSE '0'::NUMERIC(78, 0) - END as final_reward - FROM calculated_rewards - ), - 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 with_remainder - ) - SELECT wallets, amounts FROM aggregated + 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, + (($total_fees::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 + 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 { $wallet_addresses := $result.wallets; $amounts := $result.amounts; @@ -227,14 +240,9 @@ CREATE OR REPLACE ACTION distribute_fees( -- Step 6: Insert per-LP details (only if LPs exist) if $lp_count > 0 { - $idx INT := 1; - for $w_row in SELECT wallet FROM UNNEST($wallet_addresses) AS w(wallet) { - $wallet_hex TEXT := $w_row.wallet; - - $reward_amount NUMERIC(78, 0); - for $a_row in SELECT amount FROM UNNEST($amounts) AS a(amount) LIMIT 1 OFFSET ($idx - 1) { - $reward_amount := $a_row.amount; - } + 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; @@ -265,13 +273,13 @@ CREATE OR REPLACE ACTION distribute_fees( $reward_amount, $total_reward_pct ); - - $idx := $idx + 1; } } -- Step 7: Cleanup - DELETE FROM ob_rewards WHERE query_id = $query_id; + 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 diff --git a/internal/migrations/034-order-book-rewards.sql b/internal/migrations/034-order-book-rewards.sql index 3dd241520..9d036b6a8 100644 --- a/internal/migrations/034-order-book-rewards.sql +++ b/internal/migrations/034-order-book-rewards.sql @@ -92,30 +92,9 @@ CREATE INDEX IF NOT EXISTS idx_ob_rewards_query_block ON ob_rewards(query_id, bl * - Score = amount * ((spread - min_dist) / spread)² * - Reward percent = (participant_score / total_score) * 100 * - * The minimum distance approach rewards balanced liquidity provision. - * For paired positions with equal amounts, both sides contribute equally - * since they share the same minimum distance from midpoint. - * * Parameters: * - $query_id: Market to sample (must not be settled) * - $block: Block height for this sample (used as key in ob_rewards table) - * - * Behavior: - * - Inserts rows into ob_rewards table with reward percentages - * - Returns nothing on success - * - Errors if market is already settled - * - Returns empty if no qualifying LPs (no orders, too wide spread, or ineligible midpoint) - * - * Example Usage: - * -- External system samples every 50 blocks - * sample_lp_rewards(query_id := 1, block := 1000); - * sample_lp_rewards(query_id := 1, block := 1050); - * sample_lp_rewards(query_id := 1, block := 1100); - * - * -- At settlement, distribute_fees() reads these 3 samples - * -- If total_fees = 1000 TRUF: - * -- reward_per_block = 1000 / 3 = 333.33 TRUF - * -- Each LP gets: 333.33 * (avg of their reward_percents) */ CREATE OR REPLACE ACTION sample_lp_rewards( $query_id INT, @@ -175,15 +154,14 @@ CREATE OR REPLACE ACTION sample_lp_rewards( } -- Step 1: Calculate Global Total Score - -- We join positions to find pairs (Outcome1, Outcome2) with same amount - -- Qualification: EffectivePrice1 + EffectivePrice2 = 100 + -- We calculate the total sum of scores for all qualifying pairs $global_total_score NUMERIC(78, 20) := 0::NUMERIC(78, 20); - for $row in + for $totals in SELECT SUM( p1.amount::NUMERIC(78, 20) * (($x_spread - ABS($x_mid - (CASE WHEN p1.outcome = TRUE THEN (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END) ELSE (100 - (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END)) END)))::NUMERIC(78, 20) * ($x_spread - ABS($x_mid - (CASE WHEN p1.outcome = TRUE THEN (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END) ELSE (100 - (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END)) END)))::NUMERIC(78, 20))::NUMERIC(78, 20) / ($x_spread * $x_spread)::NUMERIC(78, 20) - ) as total + )::NUMERIC(78, 20) as total FROM ob_positions p1 JOIN ob_positions p2 ON p1.query_id = p2.query_id @@ -195,8 +173,8 @@ CREATE OR REPLACE ACTION sample_lp_rewards( AND (p1.price != 0 OR p2.price != 0) AND ABS($x_mid - (CASE WHEN p1.outcome = TRUE THEN (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END) ELSE (100 - (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END)) END)) < $x_spread { - if $row.total IS NOT NULL { - $global_total_score := $row.total::NUMERIC(78, 20); + if $totals.total IS NOT NULL { + $global_total_score := $totals.total; } } @@ -213,7 +191,7 @@ CREATE OR REPLACE ACTION sample_lp_rewards( p1.amount::NUMERIC(78, 20) * (($x_spread - ABS($x_mid - (CASE WHEN p1.outcome = TRUE THEN (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END) ELSE (100 - (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END)) END)))::NUMERIC(78, 20) * ($x_spread - ABS($x_mid - (CASE WHEN p1.outcome = TRUE THEN (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END) ELSE (100 - (CASE WHEN p1.price = 0 THEN 100 - ABS(p2.price) ELSE ABS(p1.price) END)) END)))::NUMERIC(78, 20))::NUMERIC(78, 20) / ($x_spread * $x_spread)::NUMERIC(78, 20) - ) as participant_score + )::NUMERIC(78, 20) as participant_score FROM ob_positions p1 JOIN ob_positions p2 ON p1.query_id = p2.query_id @@ -227,7 +205,7 @@ CREATE OR REPLACE ACTION sample_lp_rewards( GROUP BY p1.participant_id { $pid INT := $row.participant_id; - $score NUMERIC(78, 20) := $row.participant_score::NUMERIC(78, 20); + $score NUMERIC(78, 20) := $row.participant_score; $pct NUMERIC(5,2) := (($score / $global_total_score) * 100.0::NUMERIC(78, 20))::NUMERIC(5,2); INSERT INTO ob_rewards (query_id, participant_id, block, reward_percent) @@ -268,4 +246,3 @@ CREATE OR REPLACE ACTION sample_all_active_lp_rewards( sample_lp_rewards($market.id, $block); } }; - diff --git a/tests/streams/order_book/fee_distribution_audit_test.go b/tests/streams/order_book/fee_distribution_audit_test.go index 0e961c198..0cca36e29 100644 --- a/tests/streams/order_book/fee_distribution_audit_test.go +++ b/tests/streams/order_book/fee_distribution_audit_test.go @@ -348,6 +348,10 @@ func testAuditZeroFees(t *testing.T) func(context.Context, *kwilTesting.Platform _, err = platform.Engine.Call(engineCtx, platform.DB, "", "get_distribution_summary", []any{int(marketID)}, func(row *common.Row) error { rowCount++ + // Verify zero values in the summary + // get_distribution_summary returns (distribution_id, total_fees_distributed, total_lp_count, block_count, distributed_at) + require.Equal(t, "0", row.Values[1].(*kwilTypes.Decimal).String(), "Total fees should be 0") + require.Equal(t, int64(0), row.Values[2].(int64), "Total LP count should be 0") return nil }) require.NoError(t, err) diff --git a/tests/streams/order_book/lp_rewards_config_test.go b/tests/streams/order_book/lp_rewards_config_test.go index f1ae5e383..9c8c0eacf 100644 --- a/tests/streams/order_book/lp_rewards_config_test.go +++ b/tests/streams/order_book/lp_rewards_config_test.go @@ -403,7 +403,7 @@ func testExtensionConfigLoad(t *testing.T) func(context.Context, *kwilTesting.Pl maxMarkets = int64(v) case *types.Decimal: // Handle if it's stored as NUMERIC - maxMarkets = 50 // default + maxMarkets = 1000 // default } found = true return nil @@ -418,7 +418,7 @@ func testExtensionConfigLoad(t *testing.T) func(context.Context, *kwilTesting.Pl // Verify config values are valid require.True(t, enabled) require.Equal(t, int64(10), samplingInterval) - require.Equal(t, int64(50), maxMarkets) + require.Equal(t, int64(1000), maxMarkets) return nil } From 123173e11e1c49d9677a5b56a3bb413e38c7a06f Mon Sep 17 00:00:00 2001 From: Michael Buntarman Date: Tue, 3 Mar 2026 20:04:12 +0700 Subject: [PATCH 3/3] chore: apply suggestion --- internal/migrations/034-order-book-rewards.sql | 2 +- tests/streams/order_book/lp_rewards_config_test.go | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/migrations/034-order-book-rewards.sql b/internal/migrations/034-order-book-rewards.sql index 9d036b6a8..375c796eb 100644 --- a/internal/migrations/034-order-book-rewards.sql +++ b/internal/migrations/034-order-book-rewards.sql @@ -120,7 +120,7 @@ CREATE OR REPLACE ACTION sample_lp_rewards( for $row in SELECT price FROM ob_positions WHERE query_id = $query_id AND outcome = TRUE AND price < 0 - ORDER BY price DESC LIMIT 1 + ORDER BY price ASC LIMIT 1 { $best_bid := $row.price; } diff --git a/tests/streams/order_book/lp_rewards_config_test.go b/tests/streams/order_book/lp_rewards_config_test.go index 9c8c0eacf..628a24bd3 100644 --- a/tests/streams/order_book/lp_rewards_config_test.go +++ b/tests/streams/order_book/lp_rewards_config_test.go @@ -5,6 +5,7 @@ package order_book import ( "context" "fmt" + "strconv" "testing" "time" @@ -403,7 +404,11 @@ func testExtensionConfigLoad(t *testing.T) func(context.Context, *kwilTesting.Pl maxMarkets = int64(v) case *types.Decimal: // Handle if it's stored as NUMERIC - maxMarkets = 1000 // default + var err error + maxMarkets, err = strconv.ParseInt(v.String(), 10, 64) + if err != nil { + return fmt.Errorf("failed to parse max_markets_per_run as decimal: %w", err) + } } found = true return nil