Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ require (
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
github.com/testcontainers/testcontainers-go v0.37.0
github.com/trufnetwork/kwil-db v0.10.3-0.20251022154021-25ca3428bace
github.com/trufnetwork/kwil-db/core v0.4.3-0.20251022154021-25ca3428bace
github.com/trufnetwork/kwil-db v0.10.3-0.20251204134443-d43bfe5b400b
github.com/trufnetwork/kwil-db/core v0.4.3-0.20251204134443-d43bfe5b400b
github.com/trufnetwork/sdk-go v0.3.2-0.20250630062504-841b40cdb709
go.uber.org/zap v1.27.0
golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -1212,10 +1212,10 @@ github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZ
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI=
github.com/trufnetwork/kwil-db v0.10.3-0.20251022154021-25ca3428bace h1:Cikf1fQSgTDPHU4yXhJgAKQ4qjn4PDF0znXPmVLK1b8=
github.com/trufnetwork/kwil-db v0.10.3-0.20251022154021-25ca3428bace/go.mod h1:LiBAC48uZl2B0IiLtD2hpOce7RNfpuDdghVAOc3u1Qo=
github.com/trufnetwork/kwil-db/core v0.4.3-0.20251022154021-25ca3428bace h1:CyU76505WNmFFZSB9w3007qAA8et3WxKqxEJcSh+Rfo=
github.com/trufnetwork/kwil-db/core v0.4.3-0.20251022154021-25ca3428bace/go.mod h1:HnOsh9+BN13LJCjiH0+XKaJzyjWKf+H9AofFFp90KwQ=
github.com/trufnetwork/kwil-db v0.10.3-0.20251204134443-d43bfe5b400b h1:IAYxPaxlsn7+63cvZjrCcQKoWeKOXNPqp9NGifecKqw=
github.com/trufnetwork/kwil-db v0.10.3-0.20251204134443-d43bfe5b400b/go.mod h1:LiBAC48uZl2B0IiLtD2hpOce7RNfpuDdghVAOc3u1Qo=
github.com/trufnetwork/kwil-db/core v0.4.3-0.20251204134443-d43bfe5b400b h1:CGtjMi1JqG/z1g+hnLVpYNS0yHz4Yif7Zflj6tzyTM8=
github.com/trufnetwork/kwil-db/core v0.4.3-0.20251204134443-d43bfe5b400b/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.3.2-0.20250630062504-841b40cdb709 h1:d9EqPXIjbq/atzEncK5dM3Z9oStx1BxCGuL/sjefeCw=
Expand Down
7 changes: 3 additions & 4 deletions internal/migrations/032-order-book-actions.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2265,8 +2265,7 @@ CREATE OR REPLACE ACTION settle_market(
settled_at = @block_timestamp
WHERE id = $query_id;

-- Success: Market settled
-- - All trading is now blocked
-- - winning_outcome is recorded (TRUE = YES wins, FALSE = NO wins)
-- - Users can now call claim_payout() to redeem winning shares (Issue 8)
-- Issue 8: Automatic atomic settlement processing
-- Process all payouts, refunds, and fee collection atomically
process_settlement($query_id, $winning_outcome);
};
164 changes: 164 additions & 0 deletions internal/migrations/033-order-book-settlement.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* MIGRATION 033: ORDER BOOK SETTLEMENT
*
* Automatic atomic settlement processing:
* - Bulk delete losing positions (efficient)
* - Pay winners (shares × $1.00 - 2% redemption fee)
* - Refund open buy orders (no fee)
* - Delete all positions atomically
* - Collect fees in vault (Issue 9 will distribute them)
*
* Implementation Note:
* Uses CTE + ARRAY_AGG to collect all payout data in a single query, then
* processes payouts via batch unlock. This avoids nested queries in the main
* settlement action (Kuneiform limitation: cannot call external functions
* like ethereum_bridge.unlock() inside FOR loops in the same action).
*
* The batch unlock helper (ob_batch_unlock_collateral) CAN loop with function
* calls because it's a separate action called ONCE with all aggregated data.
*
* Transaction Atomicity:
* All Kwil actions execute in a single database transaction. If ANY operation
* fails (including ethereum_bridge.unlock()), the ENTIRE action rolls back:
* - Database changes (position deletions, settled flag) are reverted
* - Blockchain state changes are NOT committed (Kwil's 2-phase approach)
* - The settled flag remains false, allowing the settlement extension to retry
*
* Retry Mechanism:
* The tn_settlement extension retries failed settlements (3 attempts with backoff).
* After exhaustion, the market remains unsettled and requires manual intervention
* or extension restart to resume retries. This is safe because:
* 1. The settled flag prevents duplicate settlement attempts within a transaction
* 2. Rollback ensures partial state never persists
* 3. Position data remains intact for retry attempts
*/

-- Batch unlock collateral for multiple wallets
-- This helper processes all unlocks in a single call, avoiding nested queries in settlement
CREATE OR REPLACE ACTION ob_batch_unlock_collateral(
$wallet_addresses TEXT[],
$amounts NUMERIC(78, 0)[]
) PRIVATE {
-- Validate input arrays have same length
if COALESCE(array_length($wallet_addresses), 0) != COALESCE(array_length($amounts), 0) {
ERROR('wallet_addresses and amounts arrays must have the same length');
}

-- Process each unlock (this is the ONLY place we loop with function calls)
-- This is safe because the settlement action calls THIS function once with all data
for $payout in
SELECT wallet, amount
FROM UNNEST($wallet_addresses, $amounts) AS u(wallet, amount)
{
ethereum_bridge.unlock($payout.wallet, $payout.amount);
}
};

-- Process settlement: Pay winners, refund open buys, collect fees
CREATE OR REPLACE ACTION process_settlement(
$query_id INT,
$winning_outcome BOOL
) PRIVATE {
$redemption_fee_bps INT := 200; -- 2% (200 basis points)
$total_fees_collected NUMERIC(78, 0) := '0'::NUMERIC(78, 0);
$one_token NUMERIC(78, 0) := '1000000000000000000'::NUMERIC(78, 0);

-- Step 1: Bulk delete all losing positions (efficient single operation)
-- Price semantics: price=0 (holdings), price>0 (open sells), price<0 (open buys)
-- Deletes losing outcome holdings and sells, which have zero value after settlement
-- This removes ~50% of positions upfront
DELETE FROM ob_positions
WHERE query_id = $query_id
AND outcome = NOT $winning_outcome
AND price >= 0; -- Holdings (price=0) and open sells (price>0) only

-- Step 2: Collect ALL payout data using CTE + ARRAY_AGG (digest pattern!)
-- Calculate payouts and aggregate into arrays in a SINGLE query
$wallet_addresses TEXT[];
$amounts NUMERIC(78, 0)[];

for $result in
WITH remaining_positions AS (
SELECT
p.participant_id,
p.outcome,
p.price,
p.amount,
'0x' || encode(part.wallet_address, 'hex') as wallet_address
FROM ob_positions p
JOIN ob_participants part ON p.participant_id = part.id
WHERE p.query_id = $query_id
),
calculated_values AS (
SELECT
wallet_address,
price,
-- Pre-calculate all monetary values to avoid CASE type issues
-- All amounts cast to NUMERIC(78, 0) to match ethereum_bridge.unlock() API
(amount::NUMERIC(78, 0) * $one_token)::NUMERIC(78, 0) as gross_winner_payout,
((amount::NUMERIC(78, 0) * $one_token * $redemption_fee_bps::NUMERIC(78, 0)) / 10000::NUMERIC(78, 0))::NUMERIC(78, 0) as winner_fee,
((amount::NUMERIC(78, 0) * abs(price)::NUMERIC(78, 0) * $one_token) / 100::NUMERIC(78, 0))::NUMERIC(78, 0) as refund_amount
FROM remaining_positions
),
payouts AS (
SELECT
wallet_address,
-- Remaining positions after Step 1 are:
-- 1. Winning holdings/sells (price >= 0): Pay shares × $1 - 2% fee
-- 2. Open buy orders (price < 0): Refund locked collateral, no fee
CASE
WHEN price >= 0 THEN
gross_winner_payout - winner_fee
ELSE
refund_amount
END as payout_amount,
CASE
WHEN price >= 0 THEN
winner_fee
ELSE
'0'::NUMERIC(78, 0)
END as fee_amount
FROM calculated_values
),
wallet_totals AS (
-- Group by wallet to handle multiple positions per user
SELECT
wallet_address,
SUM(payout_amount) as total_payout,
SUM(fee_amount) as total_fees
FROM payouts
GROUP BY wallet_address
),
aggregated AS (
SELECT
ARRAY_AGG(wallet_address ORDER BY wallet_address) as wallets,
ARRAY_AGG(total_payout::NUMERIC(78, 0) ORDER BY wallet_address) as amounts,
SUM(total_fees)::NUMERIC(78, 0) as total_fees
FROM wallet_totals
)
SELECT wallets, amounts, COALESCE(total_fees, 0::NUMERIC(78, 0)) as total_fees
FROM aggregated
{
$wallet_addresses := $result.wallets;
$amounts := $result.amounts;
$total_fees_collected := $result.total_fees;
}

-- Step 3: Delete all processed positions (set-based, no loop!)
DELETE FROM ob_positions WHERE query_id = $query_id;

-- Step 4: Process ALL payouts in a SINGLE batch call (no nested queries!)
if $wallet_addresses IS NOT NULL AND COALESCE(array_length($wallet_addresses), 0) > 0 {
ob_batch_unlock_collateral($wallet_addresses, $amounts);
}

-- Step 5: Fee distribution (Issue 9 will implement this)
-- Fees are automatically kept in the vault by deducting from unlocked amounts.
-- Winners receive (shares × $1 - 2% fee), so 2% remains locked in vault.
--
-- $total_fees_collected tracks the amount for future distribution:
-- TODO (Issue 9): Uncomment when distribute_fees() is implemented
-- distribute_fees($query_id, $total_fees_collected);
--
-- Verification: Check vault balance via ethereum_bridge queries
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading
Loading