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
69 changes: 52 additions & 17 deletions internal/migrations/032-order-book-actions.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1053,25 +1053,32 @@ CREATE OR REPLACE ACTION place_buy_order(

-- 1.4 Validate market exists and is not settled
$settled BOOL;
$settle_time INT8;
$market_found BOOL := false;

for $row in SELECT settled FROM ob_queries WHERE id = $query_id {
for $row in SELECT settled, settle_time FROM ob_queries WHERE id = $query_id {
$settled := $row.settled;
$settle_time := $row.settle_time;
$market_found := true;
}

if NOT $market_found {
ERROR('Market does not exist (query_id: ' || $query_id::TEXT || ')');
}

-- Note: Markets remain tradable until explicitly settled (settled=true).
-- The settle_time is metadata indicating when settlement CAN occur, not when it MUST.
-- Users can continue trading past settle_time until the settlement action is triggered.
-- This two-phase design allows flexibility in settlement timing.
-- Note: Markets remain tradable until settlement time is reached or explicitly settled (settled=true).
-- The settle_time is metadata indicating when settlement CAN occur, and now serves as a hard cutoff for trading.
-- Users cannot continue trading past settle_time.
-- This two-phase design allows flexibility in settlement timing while ensuring a fixed trading window.
if $settled {
ERROR('Market has already settled (no trading allowed)');
}

-- Trading Cutoff: Prevent new orders after settlement time
if @block_timestamp >= $settle_time {
ERROR('Trading is closed. Market has passed its settlement time.');
}

-- ==========================================================================
-- SECTION 2: CALCULATE COLLATERAL NEEDED
-- ==========================================================================
Expand Down Expand Up @@ -1244,25 +1251,32 @@ CREATE OR REPLACE ACTION place_sell_order(

-- 1.3 Validate market exists and is not settled
$settled BOOL;
$settle_time INT8;
$market_found BOOL := false;

for $row in SELECT settled FROM ob_queries WHERE id = $query_id {
for $row in SELECT settled, settle_time FROM ob_queries WHERE id = $query_id {
$settled := $row.settled;
$settle_time := $row.settle_time;
$market_found := true;
}

if NOT $market_found {
ERROR('Market does not exist (query_id: ' || $query_id::TEXT || ')');
}

-- Note: Markets remain tradable until explicitly settled (settled=true).
-- The settle_time is metadata indicating when settlement CAN occur, not when it MUST.
-- Users can continue trading past settle_time until the settlement action is triggered.
-- This two-phase design allows flexibility in settlement timing.
-- Note: Markets remain tradable until settlement time is reached or explicitly settled (settled=true).
-- The settle_time is metadata indicating when settlement CAN occur, and now serves as a hard cutoff for trading.
-- Users cannot continue trading past settle_time.
-- This two-phase design allows flexibility in settlement timing while ensuring a fixed trading window.
if $settled {
ERROR('Market has already settled (no trading allowed)');
}

-- Trading Cutoff: Prevent new orders after settlement time
if @block_timestamp >= $settle_time {
ERROR('Trading is closed. Market has passed its settlement time.');
}

-- ==========================================================================
-- SECTION 2: GET PARTICIPANT (NO AUTO-CREATE FOR SELLS)
-- ==========================================================================
Expand Down Expand Up @@ -1440,25 +1454,32 @@ CREATE OR REPLACE ACTION place_split_limit_order(

-- 1.4 Validate market exists and is not settled
$settled BOOL;
$settle_time INT8;
$market_found BOOL := false;

for $row in SELECT settled FROM ob_queries WHERE id = $query_id {
for $row in SELECT settled, settle_time FROM ob_queries WHERE id = $query_id {
$settled := $row.settled;
$settle_time := $row.settle_time;
$market_found := true;
}

if NOT $market_found {
ERROR('Market does not exist (query_id: ' || $query_id::TEXT || ')');
}

-- Note: Markets remain tradable until explicitly settled (settled=true).
-- The settle_time is metadata indicating when settlement CAN occur, not when it MUST.
-- Users can continue trading past settle_time until the settlement action is triggered.
-- This two-phase design allows flexibility in settlement timing.
-- Note: Markets remain tradable until settlement time is reached or explicitly settled (settled=true).
-- The settle_time is metadata indicating when settlement CAN occur, and now serves as a hard cutoff for trading.
-- Users cannot continue trading past settle_time.
-- This two-phase design allows flexibility in settlement timing while ensuring a fixed trading window.
if $settled {
ERROR('Market has already settled (no trading allowed)');
}

-- Trading Cutoff: Prevent new orders after settlement time
if @block_timestamp >= $settle_time {
ERROR('Trading is closed. Market has passed its settlement time.');
}

-- ==========================================================================
-- SECTION 2: CALCULATE COLLATERAL NEEDED
-- ==========================================================================
Expand Down Expand Up @@ -1897,8 +1918,10 @@ CREATE OR REPLACE ACTION change_bid(
-- ==========================================================================

$settled BOOL;
for $row in SELECT settled FROM ob_queries WHERE id = $query_id {
$settle_time INT8;
for $row in SELECT settled, settle_time FROM ob_queries WHERE id = $query_id {
$settled := $row.settled;
$settle_time := $row.settle_time;
}

if $settled IS NULL {
Expand All @@ -1909,6 +1932,11 @@ CREATE OR REPLACE ACTION change_bid(
ERROR('Cannot modify orders on settled market');
}

-- Trading Cutoff: Prevent modifying orders after settlement time
if @block_timestamp >= $settle_time {
ERROR('Trading is closed. Market has passed its settlement time.');
}

-- ==========================================================================
-- SECTION 4: GET OLD ORDER DETAILS
-- ==========================================================================
Expand Down Expand Up @@ -2142,8 +2170,10 @@ CREATE OR REPLACE ACTION change_ask(
-- ==========================================================================

$settled BOOL;
for $row in SELECT settled FROM ob_queries WHERE id = $query_id {
$settle_time INT8;
for $row in SELECT settled, settle_time FROM ob_queries WHERE id = $query_id {
$settled := $row.settled;
$settle_time := $row.settle_time;
}

if $settled IS NULL {
Expand All @@ -2154,6 +2184,11 @@ CREATE OR REPLACE ACTION change_ask(
ERROR('Cannot modify orders on settled market');
}

-- Trading Cutoff: Prevent modifying orders after settlement time
if @block_timestamp >= $settle_time {
ERROR('Trading is closed. Market has passed its settlement time.');
}

-- ==========================================================================
-- SECTION 4: GET OLD ORDER DETAILS
-- ==========================================================================
Expand Down
28 changes: 16 additions & 12 deletions internal/migrations/040-binary-attestation-actions.sql
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ INSERT INTO attestation_actions (action_name, action_id) VALUES
('value_equals', 9)
ON CONFLICT (action_name) DO NOTHING;

-- =============================================================================
-- Helper: Validate timestamp is not in the future
-- =============================================================================
CREATE OR REPLACE ACTION validate_not_before_timestamp($timestamp INT8) PRIVATE {
if $timestamp IS NULL {
ERROR('timestamp is required');
}
if @block_timestamp < $timestamp {
ERROR('Cannot resolve market before target timestamp. Current: ' || @block_timestamp::TEXT || ', Target: ' || $timestamp::TEXT);
}
};

-- =============================================================================
-- price_above_threshold: Returns TRUE if value > threshold at timestamp
-- =============================================================================
Expand Down Expand Up @@ -51,9 +63,7 @@ CREATE OR REPLACE ACTION price_above_threshold(
$effective_frozen_at INT8 := COALESCE($frozen_at, $max_int8);

-- Validate inputs
if $timestamp IS NULL {
ERROR('timestamp is required');
}
validate_not_before_timestamp($timestamp);
if $threshold IS NULL {
ERROR('threshold is required');
}
Expand Down Expand Up @@ -121,9 +131,7 @@ CREATE OR REPLACE ACTION price_below_threshold(
$effective_frozen_at INT8 := COALESCE($frozen_at, $max_int8);

-- Validate inputs
if $timestamp IS NULL {
ERROR('timestamp is required');
}
validate_not_before_timestamp($timestamp);
if $threshold IS NULL {
ERROR('threshold is required');
}
Expand Down Expand Up @@ -189,9 +197,7 @@ CREATE OR REPLACE ACTION value_in_range(
$effective_frozen_at INT8 := COALESCE($frozen_at, $max_int8);

-- Validate inputs
if $timestamp IS NULL {
ERROR('timestamp is required');
}
validate_not_before_timestamp($timestamp);
if $min_value IS NULL {
ERROR('min_value is required');
}
Expand Down Expand Up @@ -265,9 +271,7 @@ CREATE OR REPLACE ACTION value_equals(
$effective_tolerance NUMERIC(36, 18) := COALESCE($tolerance, 0::NUMERIC(36, 18));

-- Validate inputs
if $timestamp IS NULL {
ERROR('timestamp is required');
}
validate_not_before_timestamp($timestamp);
if $target IS NULL {
ERROR('target is required');
}
Expand Down
62 changes: 62 additions & 0 deletions tests/streams/order_book/binary_actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package order_book
import (
"context"
"testing"
"time"

"github.com/stretchr/testify/require"
"github.com/trufnetwork/kwil-db/common"
Expand Down Expand Up @@ -51,6 +52,7 @@ func TestBinaryActions(t *testing.T) {
// Error cases
testBinaryActionNoData(t),
testValueInRangeInvalidRange(t),
testBinaryActionFutureTimestamp(t),
},
}, testutils.GetTestOptionsWithCache())
}
Expand All @@ -74,6 +76,9 @@ func setupBinaryActionTest(t *testing.T, ctx context.Context, platform *kwilTest
// Use attestation helper for engine context after data provider is created
helper := attestationTests.NewAttestationTestHelper(t, ctx, platform)
engineCtx := helper.NewEngineContext()
// Set block timestamp to be after the event time so "past" queries work
// If eventTime is 1000, block time 2000 allows querying 1000
engineCtx.TxContext.BlockContext.Timestamp = eventTime + 1000

t.Logf("Setup: Creating stream %s", streamID)

Expand Down Expand Up @@ -614,6 +619,7 @@ func testBinaryActionNoData(t *testing.T) func(context.Context, *kwilTesting.Pla

helper := attestationTests.NewAttestationTestHelper(t, ctx, platform)
engineCtx := helper.NewEngineContext()
engineCtx.TxContext.BlockContext.Timestamp = 2000 // Ensure query is in the past

// Create stream but DON'T insert any data
_, err = platform.Engine.Call(engineCtx, platform.DB, "", "create_stream",
Expand Down Expand Up @@ -649,6 +655,7 @@ func testValueInRangeInvalidRange(t *testing.T) func(context.Context, *kwilTesti
eventTime := int64(1000)

engineCtx, dataProvider := setupBinaryActionTest(t, ctx, platform, streamID, eventTime, "100.000000000000000000")
engineCtx.TxContext.BlockContext.Timestamp = 2000 // Ensure query is in the past

// min > max is invalid
minDecimal, err := kwilTypes.ParseDecimalExplicit("200.000000000000000000", 36, 18)
Expand All @@ -674,3 +681,58 @@ func testValueInRangeInvalidRange(t *testing.T) func(context.Context, *kwilTesti
return nil
}
}

func testBinaryActionFutureTimestamp(t *testing.T) func(context.Context, *kwilTesting.Platform) error {
return func(ctx context.Context, platform *kwilTesting.Platform) error {
deployer := util.Unsafe_NewEthereumAddressFromString("0x1111111111111111111111111111111111111111")
platform.Deployer = deployer.Bytes()

streamID := "stbinaryfuture000000000000000001"
dataProvider := deployer.Address()

// Create data provider using setup helper
err := setup.CreateDataProvider(ctx, platform, dataProvider)
require.NoError(t, err)

helper := attestationTests.NewAttestationTestHelper(t, ctx, platform)
engineCtx := helper.NewEngineContext()

// Create stream
_, err = platform.Engine.Call(engineCtx, platform.DB, "", "create_stream",
[]any{streamID, "primitive"}, nil)
require.NoError(t, err)

// Insert some data for now
eventTime := time.Now().Unix()
valueDecimal, _ := kwilTypes.ParseDecimalExplicit("50000.000000000000000000", 36, 18)
_, err = platform.Engine.Call(engineCtx, platform.DB, "", "insert_records",
[]any{
[]string{dataProvider},
[]string{streamID},
[]int64{eventTime},
[]*kwilTypes.Decimal{valueDecimal},
}, nil)
require.NoError(t, err)

// Try to query for FUTURE timestamp (1 hour later)
futureTime := eventTime + 3600
thresholdDecimal, _ := kwilTypes.ParseDecimalExplicit("40000.000000000000000000", 36, 18)

res, err := platform.Engine.Call(engineCtx, platform.DB, "", "price_above_threshold",
[]any{
dataProvider,
streamID,
futureTime,
thresholdDecimal,
nil,
}, nil)
require.NoError(t, err)

// Should error due to premature resolution safeguard
require.NotNil(t, res.Error, "should error when querying future timestamp")
require.Contains(t, res.Error.Error(), "Cannot resolve market before target timestamp", "error should mention target timestamp")
t.Log("price_above_threshold with future timestamp: correctly returned error")

return nil
}
}
38 changes: 38 additions & 0 deletions tests/streams/order_book/buy_order_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ func TestPlaceBuyOrder(t *testing.T) {
testBuyOrderMultipleOrdersDifferentPrices(t),
testBuyOrderMultipleOrdersSamePrice(t),
testBuyOrderBalanceChanges(t),
testBuyOrderTradingClosed(t),
},
}, testutils.GetTestOptionsWithCache())
}
Expand Down Expand Up @@ -636,3 +637,40 @@ func getUSDCBalance(ctx context.Context, platform *kwilTesting.Platform, wallet

return balance, nil
}

// testBuyOrderTradingClosed tests that orders cannot be placed after settlement time
func testBuyOrderTradingClosed(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error {
return func(ctx context.Context, platform *kwilTesting.Platform) error {
userAddr := util.Unsafe_NewEthereumAddressFromString("0x9999999999999999999999999999999999999999")

err := giveBalance(ctx, platform, userAddr.Address(), "100000000000000000000")
require.NoError(t, err)

err = erc20bridge.ForTestingInitializeExtension(ctx, platform)
require.NoError(t, err)

// Create market with settlement time 2 seconds in the future
// Note: We need a slight delay to ensure we can create it before it expires
settleTime := time.Now().Add(2 * time.Second).Unix()

queryComponents, err := encodeQueryComponentsForTests(userAddr.Address(), "sttest00000000000000000000000008", "get_record", []byte{0x01})
require.NoError(t, err)

var marketID int64
err = callCreateMarket(ctx, platform, &userAddr, queryComponents, settleTime, 5, 20, func(row *common.Row) error {
marketID = row.Values[0].(int64)
return nil
})
require.NoError(t, err)

// Wait for settlement time to pass
time.Sleep(3 * time.Second)

// Try to place buy order - should fail
err = callPlaceBuyOrder(ctx, platform, &userAddr, int(marketID), true, 50, 10)
require.Error(t, err, "place_buy_order should fail after settle_time")
require.Contains(t, err.Error(), "Trading is closed", "error should mention trading closed")

return nil
}
}
Loading
Loading