diff --git a/extensions/tn_utils/precompiles.go b/extensions/tn_utils/precompiles.go index d4c13b98a..487ffadc5 100644 --- a/extensions/tn_utils/precompiles.go +++ b/extensions/tn_utils/precompiles.go @@ -5,6 +5,7 @@ import ( "encoding/binary" "fmt" "math" + "math/big" "github.com/trufnetwork/kwil-db/common" "github.com/trufnetwork/kwil-db/core/types" @@ -27,6 +28,7 @@ func buildPrecompile() precompiles.Precompile { encodeUintMethod("encode_uint64", 64), canonicalToDataPointsABIMethod(), forceLastArgFalseMethod(), + parseAttestationBooleanMethod(), }, } } @@ -426,3 +428,148 @@ func forceLastArgFalseHandler(ctx *common.EngineContext, app *common.App, inputs return resultFn([]any{modifiedArgsBytes}) } + +// parseAttestationBooleanMethod extracts a boolean result from an attestation's +// result_canonical field. This is used for prediction market settlement where +// attestations return boolean outcomes (YES=true, NO=false). +func parseAttestationBooleanMethod() precompiles.Method { + return precompiles.Method{ + Name: "parse_attestation_boolean", + AccessModifiers: []precompiles.Modifier{precompiles.VIEW, precompiles.PUBLIC}, + Parameters: []precompiles.PrecompileValue{ + precompiles.NewPrecompileValue("result_canonical", types.ByteaType, false), + }, + Returns: &precompiles.MethodReturn{ + IsTable: false, + Fields: []precompiles.PrecompileValue{ + precompiles.NewPrecompileValue("outcome", types.BoolType, false), + }, + }, + Handler: parseAttestationBooleanHandler, + } +} + +// parseAttestationBooleanHandler parses result_canonical to extract a boolean outcome. +// +// This handler supports both native boolean results and numeric results (standard stream format). +// For numeric results (from primitive streams): +// - value > 0 → TRUE (YES wins) +// - value == 0 → FALSE (NO wins) +// +// The result_canonical format is: +// - version (uint8, 1 byte) +// - algo (uint8, 1 byte) +// - height (uint64, 8 bytes) +// - length_prefix(data_provider) (4 bytes length + N bytes data) +// - length_prefix(stream) (4 bytes length + N bytes data) +// - action_id (uint16, 2 bytes) +// - length_prefix(args) (4 bytes length + N bytes data) +// - length_prefix(result_payload) (4 bytes length + N bytes data) +// +// We parse through the structure to reach result_payload, then decode the value. +func parseAttestationBooleanHandler(ctx *common.EngineContext, app *common.App, inputs []any, resultFn func([]any) error) error { + resultCanonical, err := toByteSliceAllowNil(inputs[0]) + if err != nil { + return fmt.Errorf("result_canonical must be bytea: %w", err) + } + if resultCanonical == nil || len(resultCanonical) == 0 { + return fmt.Errorf("result_canonical cannot be empty") + } + + // Parse the canonical format + offset := 0 + + // Skip version (1 byte) + if offset+1 > len(resultCanonical) { + return fmt.Errorf("invalid result_canonical: too short for version") + } + offset += 1 + + // Skip algo (1 byte) + if offset+1 > len(resultCanonical) { + return fmt.Errorf("invalid result_canonical: too short for algo") + } + offset += 1 + + // Skip height (8 bytes, big-endian uint64) + if offset+8 > len(resultCanonical) { + return fmt.Errorf("invalid result_canonical: too short for height") + } + offset += 8 + + // Skip length_prefix(data_provider) + if offset+4 > len(resultCanonical) { + return fmt.Errorf("invalid result_canonical: too short for data_provider length") + } + dpLength := binary.BigEndian.Uint32(resultCanonical[offset : offset+4]) + offset += 4 + int(dpLength) + if offset > len(resultCanonical) { + return fmt.Errorf("invalid result_canonical: data_provider data extends beyond buffer") + } + + // Skip length_prefix(stream) + if offset+4 > len(resultCanonical) { + return fmt.Errorf("invalid result_canonical: too short for stream length") + } + streamLength := binary.BigEndian.Uint32(resultCanonical[offset : offset+4]) + offset += 4 + int(streamLength) + if offset > len(resultCanonical) { + return fmt.Errorf("invalid result_canonical: stream data extends beyond buffer") + } + + // Skip action_id (2 bytes, big-endian uint16) + if offset+2 > len(resultCanonical) { + return fmt.Errorf("invalid result_canonical: too short for action_id") + } + offset += 2 + + // Skip length_prefix(args) + if offset+4 > len(resultCanonical) { + return fmt.Errorf("invalid result_canonical: too short for args length") + } + argsLength := binary.BigEndian.Uint32(resultCanonical[offset : offset+4]) + offset += 4 + int(argsLength) + if offset > len(resultCanonical) { + return fmt.Errorf("invalid result_canonical: args data extends beyond buffer") + } + + // Read length_prefix(result_payload) + if offset+4 > len(resultCanonical) { + return fmt.Errorf("invalid result_canonical: too short for result_payload length") + } + resultLength := binary.BigEndian.Uint32(resultCanonical[offset : offset+4]) + offset += 4 + if offset+int(resultLength) > len(resultCanonical) { + return fmt.Errorf("invalid result_canonical: result_payload data extends beyond buffer") + } + + resultPayload := resultCanonical[offset : offset+int(resultLength)] + + // Decode the result payload (ABI format: abi.encode(uint256[], int256[])) + // This format is used for EVM smart contract compatibility + decoded, err := dataPointsABIArgs.Unpack(resultPayload) + if err != nil { + return fmt.Errorf("failed to decode ABI result payload: %w", err) + } + + if len(decoded) != 2 { + return fmt.Errorf("expected 2 arrays (timestamps, values), got %d", len(decoded)) + } + + // Extract values array (second element) + values, ok := decoded[1].([]*big.Int) + if !ok { + return fmt.Errorf("values must be []*big.Int, got %T", decoded[1]) + } + + if len(values) == 0 { + return fmt.Errorf("result payload contains no values") + } + + // Use the latest value for settlement (last element) + // Prediction market pattern: value > 0 = YES (TRUE), value == 0 = NO (FALSE) + latestValue := values[len(values)-1] + outcome := latestValue.Sign() > 0 + + return resultFn([]any{outcome}) +} diff --git a/internal/migrations/032-order-book-actions.sql b/internal/migrations/032-order-book-actions.sql index 0d43d1122..63638b7e2 100644 --- a/internal/migrations/032-order-book-actions.sql +++ b/internal/migrations/032-order-book-actions.sql @@ -2128,3 +2128,145 @@ CREATE OR REPLACE ACTION change_ask( -- - Shares adjusted (pulled from or returned to holdings) -- - FIFO priority maintained }; + +-- ============================================================================= +-- settle_market: Settle a prediction market using attestation results +-- ============================================================================= +/** + * Settles a prediction market by retrieving the signed attestation and marking + * the winning outcome. This is a permissionless action - anyone can settle a + * market once the settle_time has been reached and the attestation is available. + * + * Settlement Process: + * 1. Validate market exists, not already settled, and settle_time reached + * 2. Query attestation by market hash (market.hash = attestation.attestation_hash) + * 3. Verify attestation has been signed + * 4. Parse result_canonical to extract boolean outcome (TRUE = YES wins, FALSE = NO wins) + * 5. Mark market as settled with winning_outcome and settled_at timestamp + * + * After settlement: + * - All trading is permanently blocked (buy, sell, split, cancel, change orders) + * - Users must call claim_payout() to redeem winning shares (Issue 8) + * - Market state is frozen and cannot be changed + * + * Parameters: + * - $query_id: Market ID from ob_queries.id + * + * Returns: Nothing (void action) + * + * Errors: + * - If market doesn't exist + * - If market is already settled + * - If settle_time has not been reached + * - If attestation doesn't exist for market hash + * - If attestation is not yet signed + * - If result parsing fails + * + * Examples: + * settle_market(1) -- Settle market ID 1 using its attestation + * + * Note: This action is part of Issue 7 (Manual Settlement). Automatic settlement + * via extension (Issue 7B) will call this action on a schedule. + */ +CREATE OR REPLACE ACTION settle_market( + $query_id INT +) PUBLIC { + -- ========================================================================== + -- SECTION 1: VALIDATE MARKET AND TIMING + -- ========================================================================== + + -- Validate query_id + if $query_id IS NULL OR $query_id < 1 { + ERROR('Invalid query_id'); + } + + -- Get market details + $market_hash BYTEA; + $settle_time INT8; + $settled BOOL; + $market_found BOOL := false; + + for $row in SELECT hash, settle_time, settled + FROM ob_queries + WHERE id = $query_id { + $market_hash := $row.hash; + $settle_time := $row.settle_time; + $settled := $row.settled; + $market_found := true; + } + + if NOT $market_found { + ERROR('Market does not exist (query_id: ' || $query_id::TEXT || ')'); + } + + -- Check if already settled + if $settled { + ERROR('Market has already been settled'); + } + + -- Check if settle_time has been reached + -- Note: @block_timestamp is unix epoch seconds of current block + if @block_timestamp < $settle_time { + ERROR('Settlement time not yet reached. settle_time: ' || $settle_time::TEXT || + ', current time: ' || @block_timestamp::TEXT); + } + + -- ========================================================================== + -- SECTION 2: RETRIEVE ATTESTATION + -- ========================================================================== + + -- Query attestation by hash + -- Note: market.hash should match attestation.attestation_hash + -- This links the market to the cryptographic attestation of the query result + $result_canonical BYTEA; + $signature BYTEA; + $attestation_found BOOL := false; + + for $row in SELECT result_canonical, signature + FROM attestations + WHERE attestation_hash = $market_hash + ORDER BY signed_height DESC + LIMIT 1 { + $result_canonical := $row.result_canonical; + $signature := $row.signature; + $attestation_found := true; + } + + if NOT $attestation_found { + ERROR('Attestation not found for market hash. Market cannot be settled without attestation.'); + } + + -- Verify attestation has been signed + if $signature IS NULL { + ERROR('Attestation not yet signed by validator. Please wait for signing to complete.'); + } + + -- ========================================================================== + -- SECTION 3: PARSE ATTESTATION RESULT + -- ========================================================================== + + -- Parse result_canonical to extract boolean outcome + -- Uses tn_utils.parse_attestation_boolean() precompile + -- Returns: TRUE (YES wins) or FALSE (NO wins) + $winning_outcome BOOL := tn_utils.parse_attestation_boolean($result_canonical); + + if $winning_outcome IS NULL { + ERROR('Failed to parse attestation result (result is NULL)'); + } + + -- ========================================================================== + -- SECTION 4: MARK MARKET AS SETTLED + -- ========================================================================== + + -- Update market with settlement information + UPDATE ob_queries + SET settled = true, + winning_outcome = $winning_outcome, + 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) +}; diff --git a/tests/streams/order_book/settlement_test.go b/tests/streams/order_book/settlement_test.go new file mode 100644 index 000000000..2b65361d2 --- /dev/null +++ b/tests/streams/order_book/settlement_test.go @@ -0,0 +1,718 @@ +//go:build kwiltest + +package order_book + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "github.com/trufnetwork/kwil-db/common" + kwilTypes "github.com/trufnetwork/kwil-db/core/types" + kwilTesting "github.com/trufnetwork/kwil-db/testing" + "github.com/trufnetwork/node/extensions/tn_utils" + "github.com/trufnetwork/node/internal/migrations" + testutils "github.com/trufnetwork/node/tests/streams/utils" + "github.com/trufnetwork/node/tests/streams/utils/setup" + "github.com/trufnetwork/sdk-go/core/util" + + attestationTests "github.com/trufnetwork/node/tests/streams/attestation" +) + +// NO_OUTCOME_VALUE represents a NO outcome in settlement tests. +// We use -1.0 instead of 0.0 because insert_records filters WHERE value != 0, +// which prevents inserting exact 0.0 values. The parse_attestation_boolean +// precompile uses value.Sign() > 0 to determine outcome, so negative values +// result in FALSE (NO outcome) while positive values result in TRUE (YES outcome). +const NO_OUTCOME_VALUE = "-1.000000000000000000" + +func TestSettlement(t *testing.T) { + owner := util.Unsafe_NewEthereumAddressFromString("0x1111111111111111111111111111111111111111") + + testutils.RunSchemaTest(t, kwilTesting.SchemaTest{ + Name: "ORDER_BOOK_07_Settlement", + SeedStatements: migrations.GetSeedScriptStatements(), + Owner: owner.Address(), + FunctionTests: []kwilTesting.TestFunc{ + // Happy path tests + testSettleMarketHappyPath(t), + testSettleMarketWithNoOutcome(t), + testSettleMarketWithMultipleDatapoints(t), + + // Error tests + testSettleMarketInvalidQueryID(t), + testSettleMarketAlreadySettled(t), + testSettleMarketTooEarly(t), + testSettleMarketNoAttestation(t), + testSettleMarketAttestationNotSigned(t), + }, + }, testutils.GetTestOptionsWithCache()) +} + +func testSettleMarketHappyPath(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + // Use a valid Ethereum address as deployer + deployer := util.Unsafe_NewEthereumAddressFromString("0x1111111111111111111111111111111111111111") + platform.Deployer = deployer.Bytes() + + // Setup attestation helper (handles ERC20 initialization) + helper := attestationTests.NewAttestationTestHelper(t, ctx, platform) + + // Create data provider + err := setup.CreateDataProvider(ctx, platform, deployer.Address()) + require.NoError(t, err) + + // Use simple stream ID (exactly 32 characters) + streamID := "stsettlementtest0000000000000000" + dataProvider := deployer.Address() + + // CRITICAL: create_stream + insert_records + get_record must share the SAME + // engine context to ensure inserted data is visible within the transaction. + // Subsequent operations (request_attestation, create_market, settle_market) + // intentionally use NEW contexts to simulate separate transactions. + engineCtx := helper.NewEngineContext() + + // Create primitive stream + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "create_stream", + []any{streamID, "primitive"}, + nil) + require.NoError(t, err) + + // Insert outcome data directly using the SAME engineCtx + // This ensures the data is in the same transaction context + eventTime := int64(1000) + + // Create decimal value (1.0 = YES outcome) + valueStr := "1.000000000000000000" // 1.0 with 18 decimal places + valueDecimal, err := kwilTypes.ParseDecimalExplicit(valueStr, 36, 18) + require.NoError(t, err) + + // Insert using the same engineCtx so data is visible to subsequent calls + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "insert_records", + []any{ + []string{dataProvider}, + []string{streamID}, + []int64{eventTime}, + []*kwilTypes.Decimal{valueDecimal}, + }, + nil) + require.NoError(t, err) + + // Verify data was inserted by querying directly (reuse same context) + var foundData bool + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "get_record", + []any{ + dataProvider, + streamID, + int64(500), + int64(1500), + nil, + false, + }, + func(row *common.Row) error { + t.Logf("Found data: event_time=%v, value=%v", row.Values[0], row.Values[1]) + foundData = true + return nil + }) + require.NoError(t, err) + require.True(t, foundData, "Data should be found in stream") + + // Request attestation for get_record + // Args: data_provider, stream_id, from, to, frozen_at, use_cache + argsBytes, err := tn_utils.EncodeActionArgs([]any{ + dataProvider, + streamID, + int64(500), // from (before our eventTime=1000) + int64(1500), // to (after our eventTime=1000) + nil, // frozen_at (NULL = latest) + false, // use_cache + }) + require.NoError(t, err) + + var requestTxID string + var attestationHash []byte + engineCtx = helper.NewEngineContext() + res, err := platform.Engine.Call(engineCtx, platform.DB, "", "request_attestation", + []any{ + dataProvider, + streamID, + "get_record", + argsBytes, + false, // encrypt_sig + nil, // max_fee + }, + func(row *common.Row) error { + requestTxID = row.Values[0].(string) + attestationHash = append([]byte(nil), row.Values[1].([]byte)...) + return nil + }) + require.NoError(t, err) + if res.Error != nil { + t.Logf("request_attestation error: %v", res.Error) + require.NoError(t, res.Error, "request_attestation failed") + } + require.NotEmpty(t, requestTxID) + require.NotEmpty(t, attestationHash) + t.Logf("Created attestation: txID=%s, hash=%x", requestTxID, attestationHash) + + // Sign the attestation + helper.SignAttestation(requestTxID) + + // Create market using the attestation hash + settleTime := int64(100) // Future timestamp + maxSpread := int64(5) + minOrderSize := int64(1) + var queryID int + + // Use timestamp 50 for market creation (before settleTime) + engineCtx = helper.NewEngineContext() + engineCtx.TxContext.BlockContext.Timestamp = 50 + createRes, err := platform.Engine.Call(engineCtx, platform.DB, "", "create_market", + []any{attestationHash, settleTime, maxSpread, minOrderSize}, + func(row *common.Row) error { + queryID = int(row.Values[0].(int64)) + return nil + }) + require.NoError(t, err) + if createRes.Error != nil { + t.Logf("create_market error: %v", createRes.Error) + require.NoError(t, createRes.Error) + } + require.Greater(t, queryID, 0, "queryID should be positive") + + // Settle the market with timestamp 200 (after settleTime) + engineCtx = helper.NewEngineContext() + engineCtx.TxContext.BlockContext.Timestamp = 200 + settleRes, err := platform.Engine.Call(engineCtx, platform.DB, "", "settle_market", + []any{queryID}, + nil) + require.NoError(t, err) + if settleRes.Error != nil { + t.Logf("settle_market error: %v", settleRes.Error) + } + require.Nil(t, settleRes.Error, "settle_market should succeed") + + // Verify market is settled + engineCtx = helper.NewEngineContext() + var settled bool + var winningOutcome *bool + err = platform.Engine.Execute(engineCtx, platform.DB, + `SELECT settled, winning_outcome FROM ob_queries WHERE id = $id`, + map[string]any{"id": queryID}, + func(row *common.Row) error { + settled = row.Values[0].(bool) + if row.Values[1] != nil { + outcome := row.Values[1].(bool) + winningOutcome = &outcome + } + return nil + }) + require.NoError(t, err) + require.True(t, settled, "market should be marked as settled") + require.NotNil(t, winningOutcome, "winning_outcome should be set") + require.True(t, *winningOutcome, "outcome should be TRUE (YES) since value was 1.0") + + return nil + } +} + +// ============================================================================= +// Test: NO Outcome (value = 0.0) +// ============================================================================= + +func testSettleMarketWithNoOutcome(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + deployer := util.Unsafe_NewEthereumAddressFromString("0x2222222222222222222222222222222222222222") + platform.Deployer = deployer.Bytes() + + helper := attestationTests.NewAttestationTestHelper(t, ctx, platform) + err := setup.CreateDataProvider(ctx, platform, deployer.Address()) + require.NoError(t, err) + + streamID := "stnooutcome000000000000000000000" + dataProvider := deployer.Address() + engineCtx := helper.NewEngineContext() + + // Create stream and insert NO outcome data (0.0) + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "create_stream", + []any{streamID, "primitive"}, nil) + require.NoError(t, err) + t.Logf("NO outcome - Created stream: %s", streamID) + + // NOTE: Using NO_OUTCOME_VALUE constant which represents -1.0 + // (parse_attestation_boolean uses: value.Sign() > 0, so -1.0 results in FALSE/NO) + valueDecimal, err := kwilTypes.ParseDecimalExplicit(NO_OUTCOME_VALUE, 36, 18) + require.NoError(t, err) + t.Logf("NO outcome - Parsed decimal value (using NO_OUTCOME_VALUE): %v", valueDecimal) + + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "insert_records", + []any{ + []string{dataProvider}, + []string{streamID}, + []int64{int64(1000)}, + []*kwilTypes.Decimal{valueDecimal}, + }, nil) + require.NoError(t, err) + t.Logf("NO outcome - Inserted record with value=-1.0") + + // Verify data was inserted + var foundData bool + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "get_record", + []any{dataProvider, streamID, int64(500), int64(1500), nil, false}, + func(row *common.Row) error { + t.Logf("NO outcome - Found data: event_time=%v, value=%v", row.Values[0], row.Values[1]) + foundData = true + return nil + }) + require.NoError(t, err) + require.True(t, foundData, "Data should be found in stream") + + // Request and sign attestation + argsBytes, err := tn_utils.EncodeActionArgs([]any{ + dataProvider, streamID, int64(500), int64(1500), nil, false, + }) + require.NoError(t, err) + + var requestTxID string + var attestationHash []byte + engineCtx = helper.NewEngineContext() + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "request_attestation", + []any{dataProvider, streamID, "get_record", argsBytes, false, nil}, + func(row *common.Row) error { + requestTxID = row.Values[0].(string) + attestationHash = append([]byte(nil), row.Values[1].([]byte)...) + return nil + }) + require.NoError(t, err) + helper.SignAttestation(requestTxID) + + // Create and settle market + var queryID int + engineCtx = helper.NewEngineContext() + engineCtx.TxContext.BlockContext.Timestamp = 50 + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "create_market", + []any{attestationHash, int64(100), int64(5), int64(1)}, + func(row *common.Row) error { + queryID = int(row.Values[0].(int64)) + return nil + }) + require.NoError(t, err) + + engineCtx = helper.NewEngineContext() + engineCtx.TxContext.BlockContext.Timestamp = 200 + settleRes, err := platform.Engine.Call(engineCtx, platform.DB, "", "settle_market", + []any{queryID}, nil) + require.NoError(t, err) + require.Nil(t, settleRes.Error) + + // Verify NO outcome (FALSE) + engineCtx = helper.NewEngineContext() + var winningOutcome *bool + err = platform.Engine.Execute(engineCtx, platform.DB, + `SELECT winning_outcome FROM ob_queries WHERE id = $id`, + map[string]any{"id": queryID}, + func(row *common.Row) error { + if row.Values[0] != nil { + outcome := row.Values[0].(bool) + winningOutcome = &outcome + } + return nil + }) + require.NoError(t, err) + require.NotNil(t, winningOutcome, "winning_outcome should be set") + require.False(t, *winningOutcome, "outcome should be FALSE (NO) since value was -1.0 (negative)") + + return nil + } +} + +// ============================================================================= +// Test: Multiple Datapoints (uses latest value) +// ============================================================================= + +func testSettleMarketWithMultipleDatapoints(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + deployer := util.Unsafe_NewEthereumAddressFromString("0x3333333333333333333333333333333333333333") + platform.Deployer = deployer.Bytes() + + helper := attestationTests.NewAttestationTestHelper(t, ctx, platform) + err := setup.CreateDataProvider(ctx, platform, deployer.Address()) + require.NoError(t, err) + + streamID := "stmultiple0000000000000000000000" + dataProvider := deployer.Address() + engineCtx := helper.NewEngineContext() + + // Create stream + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "create_stream", + []any{streamID, "primitive"}, nil) + require.NoError(t, err) + + // Insert multiple datapoints: -1.0 (NO), 0.5 (uncertain), 1.0 (YES) + // Test that parse_attestation_boolean uses the LATEST value (1.0) + // NOTE: Using NO_OUTCOME_VALUE constant for the NO outcome + valueNeg1, _ := kwilTypes.ParseDecimalExplicit(NO_OUTCOME_VALUE, 36, 18) + value05, _ := kwilTypes.ParseDecimalExplicit("0.500000000000000000", 36, 18) + value1, _ := kwilTypes.ParseDecimalExplicit("1.000000000000000000", 36, 18) + + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "insert_records", + []any{ + []string{dataProvider, dataProvider, dataProvider}, + []string{streamID, streamID, streamID}, + []int64{int64(1000), int64(2000), int64(3000)}, + []*kwilTypes.Decimal{valueNeg1, value05, value1}, + }, nil) + require.NoError(t, err) + t.Logf("Multiple datapoints - Inserted 3 values: -1.0 (NO), 0.5, 1.0 (YES)") + + // Request attestation for range containing all datapoints + argsBytes, err := tn_utils.EncodeActionArgs([]any{ + dataProvider, streamID, int64(500), int64(3500), nil, false, + }) + require.NoError(t, err) + + var requestTxID string + var attestationHash []byte + engineCtx = helper.NewEngineContext() + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "request_attestation", + []any{dataProvider, streamID, "get_record", argsBytes, false, nil}, + func(row *common.Row) error { + requestTxID = row.Values[0].(string) + attestationHash = append([]byte(nil), row.Values[1].([]byte)...) + return nil + }) + require.NoError(t, err) + helper.SignAttestation(requestTxID) + + // Create and settle market + var queryID int + engineCtx = helper.NewEngineContext() + engineCtx.TxContext.BlockContext.Timestamp = 50 + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "create_market", + []any{attestationHash, int64(100), int64(5), int64(1)}, + func(row *common.Row) error { + queryID = int(row.Values[0].(int64)) + return nil + }) + require.NoError(t, err) + + engineCtx = helper.NewEngineContext() + engineCtx.TxContext.BlockContext.Timestamp = 200 + settleRes, err := platform.Engine.Call(engineCtx, platform.DB, "", "settle_market", + []any{queryID}, nil) + require.NoError(t, err) + require.Nil(t, settleRes.Error) + + // Verify outcome is TRUE (uses latest value = 1.0) + engineCtx = helper.NewEngineContext() + var winningOutcome *bool + err = platform.Engine.Execute(engineCtx, platform.DB, + `SELECT winning_outcome FROM ob_queries WHERE id = $id`, + map[string]any{"id": queryID}, + func(row *common.Row) error { + if row.Values[0] != nil { + outcome := row.Values[0].(bool) + winningOutcome = &outcome + } + return nil + }) + require.NoError(t, err) + require.NotNil(t, winningOutcome) + require.True(t, *winningOutcome, "outcome should be TRUE (YES) - uses latest value") + + return nil + } +} + +// ============================================================================= +// Test: Invalid Query ID +// ============================================================================= + +func testSettleMarketInvalidQueryID(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + deployer := util.Unsafe_NewEthereumAddressFromString("0x4444444444444444444444444444444444444444") + platform.Deployer = deployer.Bytes() + + helper := attestationTests.NewAttestationTestHelper(t, ctx, platform) + + // Try to settle non-existent market + engineCtx := helper.NewEngineContext() + engineCtx.TxContext.BlockContext.Timestamp = 200 + settleRes, err := platform.Engine.Call(engineCtx, platform.DB, "", "settle_market", + []any{99999}, nil) + require.NoError(t, err) + require.NotNil(t, settleRes.Error, "should error on invalid query_id") + require.Contains(t, settleRes.Error.Error(), "Market does not exist") + + return nil + } +} + +// ============================================================================= +// Test: Already Settled +// ============================================================================= + +func testSettleMarketAlreadySettled(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + deployer := util.Unsafe_NewEthereumAddressFromString("0x5555555555555555555555555555555555555555") + platform.Deployer = deployer.Bytes() + + helper := attestationTests.NewAttestationTestHelper(t, ctx, platform) + err := setup.CreateDataProvider(ctx, platform, deployer.Address()) + require.NoError(t, err) + + streamID := "stalreadysettled0000000000000000" + dataProvider := deployer.Address() + engineCtx := helper.NewEngineContext() + + // Create stream, insert data, request attestation + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "create_stream", + []any{streamID, "primitive"}, nil) + require.NoError(t, err) + + valueDecimal, _ := kwilTypes.ParseDecimalExplicit("1.000000000000000000", 36, 18) + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "insert_records", + []any{ + []string{dataProvider}, + []string{streamID}, + []int64{int64(1000)}, + []*kwilTypes.Decimal{valueDecimal}, + }, nil) + require.NoError(t, err) + + argsBytes, _ := tn_utils.EncodeActionArgs([]any{ + dataProvider, streamID, int64(500), int64(1500), nil, false, + }) + + var requestTxID string + var attestationHash []byte + engineCtx = helper.NewEngineContext() + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "request_attestation", + []any{dataProvider, streamID, "get_record", argsBytes, false, nil}, + func(row *common.Row) error { + requestTxID = row.Values[0].(string) + attestationHash = append([]byte(nil), row.Values[1].([]byte)...) + return nil + }) + require.NoError(t, err) + helper.SignAttestation(requestTxID) + + // Create market + var queryID int + engineCtx = helper.NewEngineContext() + engineCtx.TxContext.BlockContext.Timestamp = 50 + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "create_market", + []any{attestationHash, int64(100), int64(5), int64(1)}, + func(row *common.Row) error { + queryID = int(row.Values[0].(int64)) + return nil + }) + require.NoError(t, err) + + // First settlement (should succeed) + engineCtx = helper.NewEngineContext() + engineCtx.TxContext.BlockContext.Timestamp = 200 + settleRes, err := platform.Engine.Call(engineCtx, platform.DB, "", "settle_market", + []any{queryID}, nil) + require.NoError(t, err) + require.Nil(t, settleRes.Error) + + // Second settlement (should fail) + engineCtx = helper.NewEngineContext() + engineCtx.TxContext.BlockContext.Timestamp = 300 + settleRes2, err := platform.Engine.Call(engineCtx, platform.DB, "", "settle_market", + []any{queryID}, nil) + require.NoError(t, err) + require.NotNil(t, settleRes2.Error, "should error when already settled") + require.Contains(t, settleRes2.Error.Error(), "Market has already been settled") + + return nil + } +} + +// ============================================================================= +// Test: Settlement Time Not Reached +// ============================================================================= + +func testSettleMarketTooEarly(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + deployer := util.Unsafe_NewEthereumAddressFromString("0x6666666666666666666666666666666666666666") + platform.Deployer = deployer.Bytes() + + helper := attestationTests.NewAttestationTestHelper(t, ctx, platform) + err := setup.CreateDataProvider(ctx, platform, deployer.Address()) + require.NoError(t, err) + + streamID := "sttooearly0000000000000000000000" + dataProvider := deployer.Address() + engineCtx := helper.NewEngineContext() + + // Create stream, insert data, request attestation + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "create_stream", + []any{streamID, "primitive"}, nil) + require.NoError(t, err) + + valueDecimal, _ := kwilTypes.ParseDecimalExplicit("1.000000000000000000", 36, 18) + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "insert_records", + []any{ + []string{dataProvider}, + []string{streamID}, + []int64{int64(1000)}, + []*kwilTypes.Decimal{valueDecimal}, + }, nil) + require.NoError(t, err) + + argsBytes, _ := tn_utils.EncodeActionArgs([]any{ + dataProvider, streamID, int64(500), int64(1500), nil, false, + }) + + var requestTxID string + var attestationHash []byte + engineCtx = helper.NewEngineContext() + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "request_attestation", + []any{dataProvider, streamID, "get_record", argsBytes, false, nil}, + func(row *common.Row) error { + requestTxID = row.Values[0].(string) + attestationHash = append([]byte(nil), row.Values[1].([]byte)...) + return nil + }) + require.NoError(t, err) + helper.SignAttestation(requestTxID) + + // Create market with settle_time = 1000 + var queryID int + engineCtx = helper.NewEngineContext() + engineCtx.TxContext.BlockContext.Timestamp = 50 + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "create_market", + []any{attestationHash, int64(1000), int64(5), int64(1)}, + func(row *common.Row) error { + queryID = int(row.Values[0].(int64)) + return nil + }) + require.NoError(t, err) + + // Try to settle too early (timestamp 500 < settle_time 1000) + engineCtx = helper.NewEngineContext() + engineCtx.TxContext.BlockContext.Timestamp = 500 + settleRes, err := platform.Engine.Call(engineCtx, platform.DB, "", "settle_market", + []any{queryID}, nil) + require.NoError(t, err) + require.NotNil(t, settleRes.Error, "should error when settling too early") + require.Contains(t, settleRes.Error.Error(), "Settlement time not yet reached") + + return nil + } +} + +// ============================================================================= +// Test: Attestation Not Found +// ============================================================================= + +func testSettleMarketNoAttestation(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + deployer := util.Unsafe_NewEthereumAddressFromString("0x7777777777777777777777777777777777777777") + platform.Deployer = deployer.Bytes() + + helper := attestationTests.NewAttestationTestHelper(t, ctx, platform) + + // Create market with fake attestation hash (no actual attestation exists) + fakeHash := make([]byte, 32) + for i := range fakeHash { + fakeHash[i] = 0xAA + } + + var queryID int + engineCtx := helper.NewEngineContext() + engineCtx.TxContext.BlockContext.Timestamp = 50 + _, err := platform.Engine.Call(engineCtx, platform.DB, "", "create_market", + []any{fakeHash, int64(100), int64(5), int64(1)}, + func(row *common.Row) error { + queryID = int(row.Values[0].(int64)) + return nil + }) + require.NoError(t, err) + + // Try to settle (should fail - no attestation) + engineCtx = helper.NewEngineContext() + engineCtx.TxContext.BlockContext.Timestamp = 200 + settleRes, err := platform.Engine.Call(engineCtx, platform.DB, "", "settle_market", + []any{queryID}, nil) + require.NoError(t, err) + require.NotNil(t, settleRes.Error, "should error when attestation not found") + require.Contains(t, settleRes.Error.Error(), "Attestation not found") + + return nil + } +} + +// ============================================================================= +// Test: Attestation Not Signed +// ============================================================================= + +func testSettleMarketAttestationNotSigned(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + deployer := util.Unsafe_NewEthereumAddressFromString("0x8888888888888888888888888888888888888888") + platform.Deployer = deployer.Bytes() + + helper := attestationTests.NewAttestationTestHelper(t, ctx, platform) + err := setup.CreateDataProvider(ctx, platform, deployer.Address()) + require.NoError(t, err) + + streamID := "stunsigned0000000000000000000000" + dataProvider := deployer.Address() + engineCtx := helper.NewEngineContext() + + // Create stream, insert data, request attestation (but don't sign it) + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "create_stream", + []any{streamID, "primitive"}, nil) + require.NoError(t, err) + + valueDecimal, _ := kwilTypes.ParseDecimalExplicit("1.000000000000000000", 36, 18) + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "insert_records", + []any{ + []string{dataProvider}, + []string{streamID}, + []int64{int64(1000)}, + []*kwilTypes.Decimal{valueDecimal}, + }, nil) + require.NoError(t, err) + + argsBytes, _ := tn_utils.EncodeActionArgs([]any{ + dataProvider, streamID, int64(500), int64(1500), nil, false, + }) + + var attestationHash []byte + engineCtx = helper.NewEngineContext() + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "request_attestation", + []any{dataProvider, streamID, "get_record", argsBytes, false, nil}, + func(row *common.Row) error { + attestationHash = append([]byte(nil), row.Values[1].([]byte)...) + return nil + }) + require.NoError(t, err) + // NOTE: Intentionally NOT signing the attestation + + // Create market + var queryID int + engineCtx = helper.NewEngineContext() + engineCtx.TxContext.BlockContext.Timestamp = 50 + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "create_market", + []any{attestationHash, int64(100), int64(5), int64(1)}, + func(row *common.Row) error { + queryID = int(row.Values[0].(int64)) + return nil + }) + require.NoError(t, err) + + // Try to settle (should fail - attestation not signed) + engineCtx = helper.NewEngineContext() + engineCtx.TxContext.BlockContext.Timestamp = 200 + settleRes, err := platform.Engine.Call(engineCtx, platform.DB, "", "settle_market", + []any{queryID}, nil) + require.NoError(t, err) + require.NotNil(t, settleRes.Error, "should error when attestation not signed") + require.Contains(t, settleRes.Error.Error(), "Attestation not yet signed") + + return nil + } +}