diff --git a/extensions/tn_utils/datapoints.go b/extensions/tn_utils/datapoints.go index 2fc0efcd1..b9e6e4e0a 100644 --- a/extensions/tn_utils/datapoints.go +++ b/extensions/tn_utils/datapoints.go @@ -5,12 +5,16 @@ import ( "math/big" gethAbi "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/trufnetwork/kwil-db/common" "github.com/trufnetwork/kwil-db/core/types" ) const dataPointTargetScale uint16 = 18 -var dataPointsABIArgs gethAbi.Arguments +var ( + dataPointsABIArgs gethAbi.Arguments + booleanABIArgs gethAbi.Arguments +) func init() { uint256Slice, err := gethAbi.NewType("uint256[]", "", nil) @@ -25,16 +29,86 @@ func init() { {Type: uint256Slice}, {Type: int256Slice}, } + + // Boolean ABI type for binary action results + boolType, err := gethAbi.NewType("bool", "", nil) + if err != nil { + panic(fmt.Sprintf("tn_utils: failed to initialise bool ABI type: %v", err)) + } + booleanABIArgs = gethAbi.Arguments{ + {Type: boolType}, + } } // EncodeDataPointsABI converts the canonical result serialization produced by -// call_dispatch into abi.encode(uint256[] timestamps, int256[] values). +// call_dispatch into ABI-encoded format. +// +// For numeric data actions (get_record, get_index, etc.): +// - Returns abi.encode(uint256[] timestamps, int256[] values) +// +// For binary actions (price_above_threshold, value_in_range, etc.): +// - Returns abi.encode(bool result) +// +// Binary results are auto-detected: single row with single boolean column. func EncodeDataPointsABI(canonical []byte) ([]byte, error) { rows, err := DecodeQueryResultCanonical(canonical) if err != nil { return nil, err } + // Check if this is a binary action result (single row, single boolean column) + if isBooleanResult(rows) { + return encodeBooleanResult(rows[0]) + } + + // Standard numeric datapoints encoding + return encodeNumericDataPoints(rows) +} + +// isBooleanResult checks if the result is from a binary action +// Binary actions return exactly 1 row with 1 boolean column +func isBooleanResult(rows []*common.Row) bool { + if len(rows) != 1 { + return false + } + if len(rows[0].Values) != 1 { + return false + } + + // Check if the value is a boolean + switch rows[0].Values[0].(type) { + case bool, *bool: + return true + default: + return false + } +} + +// encodeBooleanResult encodes a single boolean result as abi.encode(bool) +func encodeBooleanResult(row *common.Row) ([]byte, error) { + var result bool + + switch v := row.Values[0].(type) { + case bool: + result = v + case *bool: + if v == nil { + return nil, fmt.Errorf("boolean result is NULL") + } + result = *v + default: + return nil, fmt.Errorf("expected boolean result, got %T", row.Values[0]) + } + + packed, err := booleanABIArgs.Pack(result) + if err != nil { + return nil, fmt.Errorf("abi encode boolean: %w", err) + } + return packed, nil +} + +// encodeNumericDataPoints encodes numeric datapoints as abi.encode(uint256[], int256[]) +func encodeNumericDataPoints(rows []*common.Row) ([]byte, error) { timestamps := make([]*big.Int, 0, len(rows)) values := make([]*big.Int, 0, len(rows)) diff --git a/extensions/tn_utils/precompiles.go b/extensions/tn_utils/precompiles.go index 91d1fa83d..e95bed47b 100644 --- a/extensions/tn_utils/precompiles.go +++ b/extensions/tn_utils/precompiles.go @@ -455,10 +455,9 @@ func parseAttestationBooleanMethod() precompiles.Method { // 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) +// This handler supports both: +// 1. Binary action results (action_id 6-9): Direct boolean encoded as abi.encode(bool) +// 2. Numeric results (action_id 1-5): Interpreted as value > 0 = TRUE, value == 0 = FALSE // // The result_canonical format is: // - version (uint8, 1 byte) @@ -470,7 +469,7 @@ func parseAttestationBooleanMethod() precompiles.Method { // - 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. +// We parse through the structure to reach result_payload, then decode based on action_id. func parseAttestationBooleanHandler(ctx *common.EngineContext, app *common.App, inputs []any, resultFn func([]any) error) error { resultCanonical, err := toByteSliceAllowNil(inputs[0]) if err != nil { @@ -521,10 +520,11 @@ func parseAttestationBooleanHandler(ctx *common.EngineContext, app *common.App, return fmt.Errorf("invalid result_canonical: stream data extends beyond buffer") } - // Skip action_id (2 bytes, big-endian uint16) + // Read action_id (2 bytes, big-endian uint16) - we need this to determine decoding format if offset+2 > len(resultCanonical) { return fmt.Errorf("invalid result_canonical: too short for action_id") } + actionID := binary.BigEndian.Uint16(resultCanonical[offset : offset+2]) offset += 2 // Skip length_prefix(args) @@ -549,8 +549,49 @@ func parseAttestationBooleanHandler(ctx *common.EngineContext, app *common.App, 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 + // Check if this is a binary action (action_id 6-9) + // Binary actions return abi.encode(bool) directly + if IsBinaryAction(actionID) { + return parseBinaryActionResult(resultPayload, resultFn) + } + + // Validate action_id is in supported range before decoding + if actionID < 1 || actionID > 9 { + return fmt.Errorf("unsupported action_id %d", actionID) + } + + // Numeric action (action_id 1-5) - decode as abi.encode(uint256[], int256[]) + return parseNumericActionResult(resultPayload, resultFn) +} + +// parseBinaryActionResult decodes abi.encode(bool) and returns the boolean directly +func parseBinaryActionResult(resultPayload []byte, resultFn func([]any) error) error { + // ABI-encoded bool is 32 bytes (padded) + if len(resultPayload) != 32 { + return fmt.Errorf("binary action result must be 32 bytes (abi-encoded bool), got %d", len(resultPayload)) + } + + // Decode using the boolean ABI args + decoded, err := booleanABIArgs.Unpack(resultPayload) + if err != nil { + return fmt.Errorf("failed to decode boolean ABI result: %w", err) + } + + if len(decoded) != 1 { + return fmt.Errorf("expected 1 value from boolean decode, got %d", len(decoded)) + } + + outcome, ok := decoded[0].(bool) + if !ok { + return fmt.Errorf("decoded value is not boolean, got %T", decoded[0]) + } + + return resultFn([]any{outcome}) +} + +// parseNumericActionResult decodes abi.encode(uint256[], int256[]) and interprets as boolean +// value > 0 = TRUE (YES wins), value == 0 = FALSE (NO wins) +func parseNumericActionResult(resultPayload []byte, resultFn func([]any) error) error { decoded, err := dataPointsABIArgs.Unpack(resultPayload) if err != nil { return fmt.Errorf("failed to decode ABI result payload: %w", err) @@ -705,20 +746,28 @@ func computeAttestationHashHandler(ctx *common.EngineContext, app *common.App, i // getActionIDNumber maps action name to numeric ID (must match attestation_actions table) func getActionIDNumber(actionName string) (uint16, error) { actionMap := map[string]uint16{ + // Numeric data actions (return TABLE(event_time INT8, value NUMERIC)) "get_record": 1, "get_index": 2, "get_change_over_time": 3, "get_last_record": 4, "get_first_record": 5, - // Future binary actions will be added here: - // "price_above_threshold": 6, - // "price_below_threshold": 7, - // "value_in_range": 8, + // Binary actions (return TABLE(result BOOLEAN)) - for prediction market settlement + "price_above_threshold": 6, + "price_below_threshold": 7, + "value_in_range": 8, + "value_equals": 9, } id, ok := actionMap[actionName] if !ok { - return 0, fmt.Errorf("unknown action: %s (must be one of: get_record, get_index, get_change_over_time, get_last_record, get_first_record)", actionName) + return 0, fmt.Errorf("unknown action: %s (valid actions: get_record, get_index, get_change_over_time, get_last_record, get_first_record, price_above_threshold, price_below_threshold, value_in_range, value_equals)", actionName) } return id, nil } + +// IsBinaryAction returns true if the action ID corresponds to a binary action +// that returns TABLE(result BOOLEAN) instead of TABLE(event_time INT8, value NUMERIC) +func IsBinaryAction(actionID uint16) bool { + return actionID >= 6 && actionID <= 9 +} diff --git a/internal/migrations/040-binary-attestation-actions.sql b/internal/migrations/040-binary-attestation-actions.sql new file mode 100644 index 000000000..b1ac6d0e2 --- /dev/null +++ b/internal/migrations/040-binary-attestation-actions.sql @@ -0,0 +1,318 @@ +/* + * BINARY ATTESTATION ACTIONS + * + * Adds binary query actions for prediction market settlement. + * These actions return TRUE/FALSE based on stream data comparisons, + * enabling fully automatic settlement without manual intervention. + * + * Actions: + * - price_above_threshold: Returns TRUE if value > threshold + * - price_below_threshold: Returns TRUE if value < threshold + * - value_in_range: Returns TRUE if min <= value <= max + * - value_equals: Returns TRUE if value equals target (within tolerance) + */ + +-- ============================================================================= +-- Register new action types in attestation_actions table +-- ============================================================================= +INSERT INTO attestation_actions (action_name, action_id) VALUES + ('price_above_threshold', 6), + ('price_below_threshold', 7), + ('value_in_range', 8), + ('value_equals', 9) +ON CONFLICT (action_name) DO NOTHING; + +-- ============================================================================= +-- price_above_threshold: Returns TRUE if value > threshold at timestamp +-- ============================================================================= +-- +-- Use case: "Will BTC exceed $100,000 by Dec 31?" +-- +-- Parameters: +-- $data_provider: The data provider address (0x-prefixed hex) +-- $stream_id: The stream ID (32 characters) +-- $timestamp: Unix timestamp to check the value at +-- $threshold: The threshold value to compare against +-- $frozen_at: Optional frozen_at timestamp for historical queries +-- +-- Returns: Single row with boolean result column +-- +CREATE OR REPLACE ACTION price_above_threshold( + $data_provider TEXT, + $stream_id TEXT, + $timestamp INT8, + $threshold NUMERIC(36, 18), + $frozen_at INT8 +) PUBLIC VIEW RETURNS TABLE ( + result BOOLEAN +) { + $data_provider := LOWER($data_provider); + $max_int8 INT8 := 9223372036854775000; + $effective_frozen_at INT8 := COALESCE($frozen_at, $max_int8); + + -- Validate inputs + if $timestamp IS NULL { + ERROR('timestamp is required'); + } + if $threshold IS NULL { + ERROR('threshold is required'); + } + + -- Get stream reference using helper function (same as get_record_primitive) + $stream_ref := get_stream_id($data_provider, $stream_id); + if $stream_ref IS NULL { + ERROR('Stream does not exist: data_provider=' || $data_provider || ' stream_id=' || $stream_id); + } + + -- Get value at or before the timestamp + -- Look back up to 1 day (86400 seconds) to find the most recent value + $value NUMERIC(36, 18) := NULL; + $found BOOLEAN := FALSE; + $lookback_start INT8 := $timestamp - 86400; + + -- Query primitive events directly using stream_ref + for $row in SELECT pe.value + FROM primitive_events pe + WHERE pe.stream_ref = $stream_ref + AND pe.event_time <= $timestamp + AND pe.event_time >= $lookback_start + AND pe.created_at <= $effective_frozen_at + ORDER BY pe.event_time DESC, pe.created_at DESC + LIMIT 1 + { + $value := $row.value; + $found := TRUE; + } + + -- If no primitive data found, try composed stream via get_record + if NOT $found { + for $row in get_record($data_provider, $stream_id, $lookback_start, $timestamp, $effective_frozen_at, FALSE) { + $value := $row.value; + $found := TRUE; + } + } + + if NOT $found OR $value IS NULL { + ERROR('No data found for stream ' || $stream_id || + ' at timestamp ' || $timestamp::TEXT || + '. Data provider: ' || $data_provider); + } + + RETURN NEXT $value > $threshold; +}; + +-- ============================================================================= +-- price_below_threshold: Returns TRUE if value < threshold at timestamp +-- ============================================================================= +-- +-- Use case: "Will unemployment rate drop below 4%?" +-- +CREATE OR REPLACE ACTION price_below_threshold( + $data_provider TEXT, + $stream_id TEXT, + $timestamp INT8, + $threshold NUMERIC(36, 18), + $frozen_at INT8 +) PUBLIC VIEW RETURNS TABLE ( + result BOOLEAN +) { + $data_provider := LOWER($data_provider); + $max_int8 INT8 := 9223372036854775000; + $effective_frozen_at INT8 := COALESCE($frozen_at, $max_int8); + + -- Validate inputs + if $timestamp IS NULL { + ERROR('timestamp is required'); + } + if $threshold IS NULL { + ERROR('threshold is required'); + } + + -- Get stream reference + $stream_ref := get_stream_id($data_provider, $stream_id); + if $stream_ref IS NULL { + ERROR('Stream does not exist: data_provider=' || $data_provider || ' stream_id=' || $stream_id); + } + + $value NUMERIC(36, 18) := NULL; + $found BOOLEAN := FALSE; + $lookback_start INT8 := $timestamp - 86400; + + -- Query primitive events directly + for $row in SELECT pe.value + FROM primitive_events pe + WHERE pe.stream_ref = $stream_ref + AND pe.event_time <= $timestamp + AND pe.event_time >= $lookback_start + AND pe.created_at <= $effective_frozen_at + ORDER BY pe.event_time DESC, pe.created_at DESC + LIMIT 1 + { + $value := $row.value; + $found := TRUE; + } + + -- If no primitive data found, try composed stream + if NOT $found { + for $row in get_record($data_provider, $stream_id, $lookback_start, $timestamp, $effective_frozen_at, FALSE) { + $value := $row.value; + $found := TRUE; + } + } + + if NOT $found OR $value IS NULL { + ERROR('No data found for stream ' || $stream_id || + ' at timestamp ' || $timestamp::TEXT); + } + + RETURN NEXT $value < $threshold; +}; + +-- ============================================================================= +-- value_in_range: Returns TRUE if min <= value <= max at timestamp +-- ============================================================================= +-- +-- Use case: "Will BTC stay between $90k-$110k on settlement date?" +-- +CREATE OR REPLACE ACTION value_in_range( + $data_provider TEXT, + $stream_id TEXT, + $timestamp INT8, + $min_value NUMERIC(36, 18), + $max_value NUMERIC(36, 18), + $frozen_at INT8 +) PUBLIC VIEW RETURNS TABLE ( + result BOOLEAN +) { + $data_provider := LOWER($data_provider); + $max_int8 INT8 := 9223372036854775000; + $effective_frozen_at INT8 := COALESCE($frozen_at, $max_int8); + + -- Validate inputs + if $timestamp IS NULL { + ERROR('timestamp is required'); + } + if $min_value IS NULL { + ERROR('min_value is required'); + } + if $max_value IS NULL { + ERROR('max_value is required'); + } + if $min_value > $max_value { + ERROR('Invalid range: min_value (' || $min_value::TEXT || + ') > max_value (' || $max_value::TEXT || ')'); + } + + -- Get stream reference + $stream_ref := get_stream_id($data_provider, $stream_id); + if $stream_ref IS NULL { + ERROR('Stream does not exist: data_provider=' || $data_provider || ' stream_id=' || $stream_id); + } + + $value NUMERIC(36, 18) := NULL; + $found BOOLEAN := FALSE; + $lookback_start INT8 := $timestamp - 86400; + + -- Query primitive events directly + for $row in SELECT pe.value + FROM primitive_events pe + WHERE pe.stream_ref = $stream_ref + AND pe.event_time <= $timestamp + AND pe.event_time >= $lookback_start + AND pe.created_at <= $effective_frozen_at + ORDER BY pe.event_time DESC, pe.created_at DESC + LIMIT 1 + { + $value := $row.value; + $found := TRUE; + } + + -- If no primitive data found, try composed stream + if NOT $found { + for $row in get_record($data_provider, $stream_id, $lookback_start, $timestamp, $effective_frozen_at, FALSE) { + $value := $row.value; + $found := TRUE; + } + } + + if NOT $found OR $value IS NULL { + ERROR('No data found for stream ' || $stream_id || + ' at timestamp ' || $timestamp::TEXT); + } + + RETURN NEXT ($value >= $min_value AND $value <= $max_value); +}; + +-- ============================================================================= +-- value_equals: Returns TRUE if value equals target (within tolerance) +-- ============================================================================= +-- +-- Use case: "Will Fed rate be exactly 5.25%?" (with 0 tolerance for exact match) +-- +CREATE OR REPLACE ACTION value_equals( + $data_provider TEXT, + $stream_id TEXT, + $timestamp INT8, + $target NUMERIC(36, 18), + $tolerance NUMERIC(36, 18), + $frozen_at INT8 +) PUBLIC VIEW RETURNS TABLE ( + result BOOLEAN +) { + $data_provider := LOWER($data_provider); + $max_int8 INT8 := 9223372036854775000; + $effective_frozen_at INT8 := COALESCE($frozen_at, $max_int8); + $effective_tolerance NUMERIC(36, 18) := COALESCE($tolerance, 0::NUMERIC(36, 18)); + + -- Validate inputs + if $timestamp IS NULL { + ERROR('timestamp is required'); + } + if $target IS NULL { + ERROR('target is required'); + } + if $effective_tolerance < 0::NUMERIC(36, 18) { + ERROR('Tolerance cannot be negative: ' || $effective_tolerance::TEXT); + } + + -- Get stream reference + $stream_ref := get_stream_id($data_provider, $stream_id); + if $stream_ref IS NULL { + ERROR('Stream does not exist: data_provider=' || $data_provider || ' stream_id=' || $stream_id); + } + + $value NUMERIC(36, 18) := NULL; + $found BOOLEAN := FALSE; + $lookback_start INT8 := $timestamp - 86400; + + -- Query primitive events directly + for $row in SELECT pe.value + FROM primitive_events pe + WHERE pe.stream_ref = $stream_ref + AND pe.event_time <= $timestamp + AND pe.event_time >= $lookback_start + AND pe.created_at <= $effective_frozen_at + ORDER BY pe.event_time DESC, pe.created_at DESC + LIMIT 1 + { + $value := $row.value; + $found := TRUE; + } + + -- If no primitive data found, try composed stream + if NOT $found { + for $row in get_record($data_provider, $stream_id, $lookback_start, $timestamp, $effective_frozen_at, FALSE) { + $value := $row.value; + $found := TRUE; + } + } + + if NOT $found OR $value IS NULL { + ERROR('No data found for stream ' || $stream_id || + ' at timestamp ' || $timestamp::TEXT); + } + + -- Check if value is within tolerance of target + $diff NUMERIC(36, 18) := abs($value - $target); + RETURN NEXT $diff <= $effective_tolerance; +}; diff --git a/tests/extensions/erc20/erc20_bridge_injection_test.go b/tests/extensions/erc20/erc20_bridge_injection_test.go index b25cdc906..071600232 100644 --- a/tests/extensions/erc20/erc20_bridge_injection_test.go +++ b/tests/extensions/erc20/erc20_bridge_injection_test.go @@ -71,8 +71,8 @@ func TestERC20BridgeInjectedTransferAffectsBalance(t *testing.T) { func TestERC20BridgeInjectedDepositCreditsRecipient(t *testing.T) { seedAndRun(t, "erc20_bridge_injected_deposit_recipient", func(ctx context.Context, platform *kwilTesting.Platform) error { chain := "sepolia" - escrow := "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" - erc20 := "0x2222222222222222222222222222222222222222" + escrow := TestEscrowA // Use consistent escrow from common_test.go + erc20 := TestERC20 depositor := "0xabc0000000000000000000000000000000000004" recipient := "0xabc0000000000000000000000000000000000005" value := "1500000000000000000" diff --git a/tests/streams/order_book/binary_actions_test.go b/tests/streams/order_book/binary_actions_test.go new file mode 100644 index 000000000..4b249409a --- /dev/null +++ b/tests/streams/order_book/binary_actions_test.go @@ -0,0 +1,676 @@ +//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/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" +) + +// TestBinaryActions tests the new binary attestation actions +// that return TRUE/FALSE for prediction market settlement +func TestBinaryActions(t *testing.T) { + owner := util.Unsafe_NewEthereumAddressFromString("0x1111111111111111111111111111111111111111") + + testutils.RunSchemaTest(t, kwilTesting.SchemaTest{ + Name: "BINARY_ACTIONS_01_AllActions", + SeedStatements: migrations.GetSeedScriptStatements(), + Owner: owner.Address(), + FunctionTests: []kwilTesting.TestFunc{ + // price_above_threshold tests + testPriceAboveThresholdTrue(t), + testPriceAboveThresholdFalse(t), + testPriceAboveThresholdExactlyAt(t), + + // price_below_threshold tests + testPriceBelowThresholdTrue(t), + testPriceBelowThresholdFalse(t), + + // value_in_range tests + testValueInRangeTrue(t), + testValueInRangeFalseBelow(t), + testValueInRangeFalseAbove(t), + testValueInRangeBoundary(t), + + // value_equals tests + testValueEqualsExact(t), + testValueEqualsWithTolerance(t), + testValueEqualsOutsideTolerance(t), + + // Error cases + testBinaryActionNoData(t), + testValueInRangeInvalidRange(t), + }, + }, testutils.GetTestOptionsWithCache()) +} + +// setupBinaryActionTest creates a data provider and stream with test data +// Returns the engineCtx that should be used for all subsequent operations +func setupBinaryActionTest(t *testing.T, ctx context.Context, platform *kwilTesting.Platform, streamID string, eventTime int64, value string) (*common.EngineContext, string) { + deployer := util.Unsafe_NewEthereumAddressFromString("0x1111111111111111111111111111111111111111") + platform.Deployer = deployer.Bytes() + + dataProvider := deployer.Address() + + t.Logf("Setup: Creating data provider %s", dataProvider) + + // Create data provider using setup helper (grants required roles) + err := setup.CreateDataProvider(ctx, platform, dataProvider) + require.NoError(t, err, "failed to create data provider") + + t.Logf("Setup: Data provider created, creating engine context") + + // Use attestation helper for engine context after data provider is created + helper := attestationTests.NewAttestationTestHelper(t, ctx, platform) + engineCtx := helper.NewEngineContext() + + t.Logf("Setup: Creating stream %s", streamID) + + // Create stream + createRes, err := platform.Engine.Call(engineCtx, platform.DB, "", "create_stream", + []any{streamID, "primitive"}, nil) + require.NoError(t, err, "failed to create stream") + if createRes.Error != nil { + t.Logf("Setup: create_stream action error: %v", createRes.Error) + require.NoError(t, createRes.Error, "create_stream failed") + } + + t.Logf("Setup: Stream created, inserting data") + + // Insert data + valueDecimal, err := kwilTypes.ParseDecimalExplicit(value, 36, 18) + require.NoError(t, err, "failed to parse value decimal") + + insertRes, err := platform.Engine.Call(engineCtx, platform.DB, "", "insert_records", + []any{ + []string{dataProvider}, + []string{streamID}, + []int64{eventTime}, + []*kwilTypes.Decimal{valueDecimal}, + }, nil) + require.NoError(t, err, "failed to insert records") + if insertRes.Error != nil { + t.Logf("Setup: insert_records action error: %v", insertRes.Error) + require.NoError(t, insertRes.Error, "insert_records failed") + } + + t.Logf("Setup: Data inserted successfully") + + return engineCtx, dataProvider +} + +// ============================================================================= +// price_above_threshold tests +// ============================================================================= + +func testPriceAboveThresholdTrue(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + streamID := "stpriceabovetest1000000000000000" + eventTime := int64(1000) + + engineCtx, dataProvider := setupBinaryActionTest(t, ctx, platform, streamID, eventTime, "55000.000000000000000000") + + // Debug: Verify the stream exists by calling get_record first + t.Logf("Debug: Checking if stream %s exists for provider %s", streamID, dataProvider) + var foundData bool + debugRes, debugErr := platform.Engine.Call(engineCtx, platform.DB, "", "get_record", + []any{ + dataProvider, + streamID, + int64(500), + int64(1500), + nil, + false, + }, + func(row *common.Row) error { + t.Logf("Debug: Found data - event_time=%v, value=%v", row.Values[0], row.Values[1]) + foundData = true + return nil + }) + if debugErr != nil { + t.Logf("Debug: get_record error: %v", debugErr) + } + if debugRes != nil && debugRes.Error != nil { + t.Logf("Debug: get_record action error: %v", debugRes.Error) + } + t.Logf("Debug: Found data = %v", foundData) + + // Call price_above_threshold with threshold = 50000 + thresholdDecimal, err := kwilTypes.ParseDecimalExplicit("50000.000000000000000000", 36, 18) + require.NoError(t, err) + + var result bool + res, err := platform.Engine.Call(engineCtx, platform.DB, "", "price_above_threshold", + []any{ + dataProvider, + streamID, + eventTime, + thresholdDecimal, + nil, // frozen_at + }, + func(row *common.Row) error { + result = row.Values[0].(bool) + return nil + }) + require.NoError(t, err) + if res.Error != nil { + t.Logf("price_above_threshold error: %v", res.Error) + require.NoError(t, res.Error, "price_above_threshold failed") + } + + require.True(t, result, "55000 > 50000 should be TRUE") + t.Log("price_above_threshold: 55000 > 50000 = TRUE (correct)") + + return nil + } +} + +func testPriceAboveThresholdFalse(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + streamID := "stpriceabovetest2000000000000000" + eventTime := int64(1000) + + engineCtx, dataProvider := setupBinaryActionTest(t, ctx, platform, streamID, eventTime, "45000.000000000000000000") + + thresholdDecimal, err := kwilTypes.ParseDecimalExplicit("50000.000000000000000000", 36, 18) + require.NoError(t, err) + + var result bool + res, err := platform.Engine.Call(engineCtx, platform.DB, "", "price_above_threshold", + []any{ + dataProvider, + streamID, + eventTime, + thresholdDecimal, + nil, + }, + func(row *common.Row) error { + result = row.Values[0].(bool) + return nil + }) + require.NoError(t, err) + if res.Error != nil { + require.NoError(t, res.Error, "price_above_threshold failed") + } + + require.False(t, result, "45000 > 50000 should be FALSE") + t.Log("price_above_threshold: 45000 > 50000 = FALSE (correct)") + + return nil + } +} + +func testPriceAboveThresholdExactlyAt(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + streamID := "stpriceabovetest3000000000000000" + eventTime := int64(1000) + + engineCtx, dataProvider := setupBinaryActionTest(t, ctx, platform, streamID, eventTime, "50000.000000000000000000") + + thresholdDecimal, err := kwilTypes.ParseDecimalExplicit("50000.000000000000000000", 36, 18) + require.NoError(t, err) + + var result bool + res, err := platform.Engine.Call(engineCtx, platform.DB, "", "price_above_threshold", + []any{ + dataProvider, + streamID, + eventTime, + thresholdDecimal, + nil, + }, + func(row *common.Row) error { + result = row.Values[0].(bool) + return nil + }) + require.NoError(t, err) + if res.Error != nil { + require.NoError(t, res.Error, "price_above_threshold failed") + } + + // 50000 > 50000 is FALSE (not strictly greater) + require.False(t, result, "50000 > 50000 should be FALSE (not strictly greater)") + t.Log("price_above_threshold: 50000 > 50000 = FALSE (correct, not strictly greater)") + + return nil + } +} + +// ============================================================================= +// price_below_threshold tests +// ============================================================================= + +func testPriceBelowThresholdTrue(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + streamID := "stpricebelowtest1000000000000000" + eventTime := int64(1000) + + engineCtx, dataProvider := setupBinaryActionTest(t, ctx, platform, streamID, eventTime, "3.500000000000000000") + + thresholdDecimal, err := kwilTypes.ParseDecimalExplicit("4.000000000000000000", 36, 18) + require.NoError(t, err) + + var result bool + res, err := platform.Engine.Call(engineCtx, platform.DB, "", "price_below_threshold", + []any{ + dataProvider, + streamID, + eventTime, + thresholdDecimal, + nil, + }, + func(row *common.Row) error { + result = row.Values[0].(bool) + return nil + }) + require.NoError(t, err) + if res.Error != nil { + require.NoError(t, res.Error, "price_below_threshold failed") + } + + require.True(t, result, "3.5 < 4.0 should be TRUE") + t.Log("price_below_threshold: 3.5 < 4.0 = TRUE (correct)") + + return nil + } +} + +func testPriceBelowThresholdFalse(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + streamID := "stpricebelowtest2000000000000000" + eventTime := int64(1000) + + engineCtx, dataProvider := setupBinaryActionTest(t, ctx, platform, streamID, eventTime, "5.000000000000000000") + + thresholdDecimal, err := kwilTypes.ParseDecimalExplicit("4.000000000000000000", 36, 18) + require.NoError(t, err) + + var result bool + res, err := platform.Engine.Call(engineCtx, platform.DB, "", "price_below_threshold", + []any{ + dataProvider, + streamID, + eventTime, + thresholdDecimal, + nil, + }, + func(row *common.Row) error { + result = row.Values[0].(bool) + return nil + }) + require.NoError(t, err) + if res.Error != nil { + require.NoError(t, res.Error, "price_below_threshold failed") + } + + require.False(t, result, "5.0 < 4.0 should be FALSE") + t.Log("price_below_threshold: 5.0 < 4.0 = FALSE (correct)") + + return nil + } +} + +// ============================================================================= +// value_in_range tests +// ============================================================================= + +func testValueInRangeTrue(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + streamID := "stvalueinrangetest00000000000001" + eventTime := int64(1000) + + engineCtx, dataProvider := setupBinaryActionTest(t, ctx, platform, streamID, eventTime, "100000.000000000000000000") + + minDecimal, err := kwilTypes.ParseDecimalExplicit("90000.000000000000000000", 36, 18) + require.NoError(t, err) + maxDecimal, err := kwilTypes.ParseDecimalExplicit("110000.000000000000000000", 36, 18) + require.NoError(t, err) + + var result bool + res, err := platform.Engine.Call(engineCtx, platform.DB, "", "value_in_range", + []any{ + dataProvider, + streamID, + eventTime, + minDecimal, + maxDecimal, + nil, + }, + func(row *common.Row) error { + result = row.Values[0].(bool) + return nil + }) + require.NoError(t, err) + if res.Error != nil { + require.NoError(t, res.Error, "value_in_range failed") + } + + require.True(t, result, "100000 in [90000, 110000] should be TRUE") + t.Log("value_in_range: 100000 in [90000, 110000] = TRUE (correct)") + + return nil + } +} + +func testValueInRangeFalseBelow(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + streamID := "stvalueinrangetest00000000000002" + eventTime := int64(1000) + + engineCtx, dataProvider := setupBinaryActionTest(t, ctx, platform, streamID, eventTime, "80000.000000000000000000") + + minDecimal, err := kwilTypes.ParseDecimalExplicit("90000.000000000000000000", 36, 18) + require.NoError(t, err) + maxDecimal, err := kwilTypes.ParseDecimalExplicit("110000.000000000000000000", 36, 18) + require.NoError(t, err) + + var result bool + res, err := platform.Engine.Call(engineCtx, platform.DB, "", "value_in_range", + []any{ + dataProvider, + streamID, + eventTime, + minDecimal, + maxDecimal, + nil, + }, + func(row *common.Row) error { + result = row.Values[0].(bool) + return nil + }) + require.NoError(t, err) + if res.Error != nil { + require.NoError(t, res.Error, "value_in_range failed") + } + + require.False(t, result, "80000 in [90000, 110000] should be FALSE") + t.Log("value_in_range: 80000 in [90000, 110000] = FALSE (correct)") + + return nil + } +} + +func testValueInRangeFalseAbove(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + streamID := "stvalueinrangetest00000000000003" + eventTime := int64(1000) + + engineCtx, dataProvider := setupBinaryActionTest(t, ctx, platform, streamID, eventTime, "120000.000000000000000000") + + minDecimal, err := kwilTypes.ParseDecimalExplicit("90000.000000000000000000", 36, 18) + require.NoError(t, err) + maxDecimal, err := kwilTypes.ParseDecimalExplicit("110000.000000000000000000", 36, 18) + require.NoError(t, err) + + var result bool + res, err := platform.Engine.Call(engineCtx, platform.DB, "", "value_in_range", + []any{ + dataProvider, + streamID, + eventTime, + minDecimal, + maxDecimal, + nil, + }, + func(row *common.Row) error { + result = row.Values[0].(bool) + return nil + }) + require.NoError(t, err) + if res.Error != nil { + require.NoError(t, res.Error, "value_in_range failed") + } + + require.False(t, result, "120000 in [90000, 110000] should be FALSE") + t.Log("value_in_range: 120000 in [90000, 110000] = FALSE (correct)") + + return nil + } +} + +func testValueInRangeBoundary(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + streamID := "stvalueinrangetest00000000000004" + eventTime := int64(1000) + + engineCtx, dataProvider := setupBinaryActionTest(t, ctx, platform, streamID, eventTime, "90000.000000000000000000") + + minDecimal, err := kwilTypes.ParseDecimalExplicit("90000.000000000000000000", 36, 18) + require.NoError(t, err) + maxDecimal, err := kwilTypes.ParseDecimalExplicit("110000.000000000000000000", 36, 18) + require.NoError(t, err) + + var result bool + res, err := platform.Engine.Call(engineCtx, platform.DB, "", "value_in_range", + []any{ + dataProvider, + streamID, + eventTime, + minDecimal, + maxDecimal, + nil, + }, + func(row *common.Row) error { + result = row.Values[0].(bool) + return nil + }) + require.NoError(t, err) + if res.Error != nil { + require.NoError(t, res.Error, "value_in_range failed") + } + + // Boundary is inclusive: 90000 >= 90000 AND 90000 <= 110000 + require.True(t, result, "90000 in [90000, 110000] should be TRUE (inclusive boundary)") + t.Log("value_in_range: 90000 in [90000, 110000] = TRUE (boundary is inclusive)") + + return nil + } +} + +// ============================================================================= +// value_equals tests +// ============================================================================= + +func testValueEqualsExact(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + streamID := "stvalueequalstest000000000000001" + eventTime := int64(1000) + + engineCtx, dataProvider := setupBinaryActionTest(t, ctx, platform, streamID, eventTime, "5.250000000000000000") + + targetDecimal, err := kwilTypes.ParseDecimalExplicit("5.250000000000000000", 36, 18) + require.NoError(t, err) + toleranceDecimal, err := kwilTypes.ParseDecimalExplicit("0.000000000000000000", 36, 18) + require.NoError(t, err) + + var result bool + res, err := platform.Engine.Call(engineCtx, platform.DB, "", "value_equals", + []any{ + dataProvider, + streamID, + eventTime, + targetDecimal, + toleranceDecimal, + nil, + }, + func(row *common.Row) error { + result = row.Values[0].(bool) + return nil + }) + require.NoError(t, err) + if res.Error != nil { + require.NoError(t, res.Error, "value_equals failed") + } + + require.True(t, result, "5.25 == 5.25 (tolerance 0) should be TRUE") + t.Log("value_equals: 5.25 == 5.25 (tolerance 0) = TRUE (correct)") + + return nil + } +} + +func testValueEqualsWithTolerance(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + streamID := "stvalueequalstest000000000000002" + eventTime := int64(1000) + + engineCtx, dataProvider := setupBinaryActionTest(t, ctx, platform, streamID, eventTime, "5.270000000000000000") + + targetDecimal, err := kwilTypes.ParseDecimalExplicit("5.250000000000000000", 36, 18) + require.NoError(t, err) + toleranceDecimal, err := kwilTypes.ParseDecimalExplicit("0.050000000000000000", 36, 18) + require.NoError(t, err) + + var result bool + res, err := platform.Engine.Call(engineCtx, platform.DB, "", "value_equals", + []any{ + dataProvider, + streamID, + eventTime, + targetDecimal, + toleranceDecimal, + nil, + }, + func(row *common.Row) error { + result = row.Values[0].(bool) + return nil + }) + require.NoError(t, err) + if res.Error != nil { + require.NoError(t, res.Error, "value_equals failed") + } + + require.True(t, result, "5.27 == 5.25 (tolerance 0.05) should be TRUE") + t.Log("value_equals: 5.27 == 5.25 (tolerance 0.05) = TRUE (correct)") + + return nil + } +} + +func testValueEqualsOutsideTolerance(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + streamID := "stvalueequalstest000000000000003" + eventTime := int64(1000) + + engineCtx, dataProvider := setupBinaryActionTest(t, ctx, platform, streamID, eventTime, "5.350000000000000000") + + targetDecimal, err := kwilTypes.ParseDecimalExplicit("5.250000000000000000", 36, 18) + require.NoError(t, err) + toleranceDecimal, err := kwilTypes.ParseDecimalExplicit("0.050000000000000000", 36, 18) + require.NoError(t, err) + + var result bool + res, err := platform.Engine.Call(engineCtx, platform.DB, "", "value_equals", + []any{ + dataProvider, + streamID, + eventTime, + targetDecimal, + toleranceDecimal, + nil, + }, + func(row *common.Row) error { + result = row.Values[0].(bool) + return nil + }) + require.NoError(t, err) + if res.Error != nil { + require.NoError(t, res.Error, "value_equals failed") + } + + require.False(t, result, "5.35 == 5.25 (tolerance 0.05) should be FALSE") + t.Log("value_equals: 5.35 == 5.25 (tolerance 0.05) = FALSE (correct)") + + return nil + } +} + +// ============================================================================= +// Error cases +// ============================================================================= + +func testBinaryActionNoData(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 := "stbinarynodata000000000000000000" + 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 but DON'T insert any data + _, err = platform.Engine.Call(engineCtx, platform.DB, "", "create_stream", + []any{streamID, "primitive"}, nil) + require.NoError(t, err) + + thresholdDecimal, err := kwilTypes.ParseDecimalExplicit("50000.000000000000000000", 36, 18) + require.NoError(t, err) + + // Should fail because no data exists + res, err := platform.Engine.Call(engineCtx, platform.DB, "", "price_above_threshold", + []any{ + dataProvider, + streamID, + int64(1000), + thresholdDecimal, + nil, + }, nil) + require.NoError(t, err) // Engine call itself shouldn't fail + + // But the action should return an error + require.NotNil(t, res.Error, "should error when no data exists") + require.Contains(t, res.Error.Error(), "No data found") + t.Log("price_above_threshold with no data: correctly returned error") + + return nil + } +} + +func testValueInRangeInvalidRange(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + streamID := "strangeinvalidtest00000000000001" + eventTime := int64(1000) + + engineCtx, dataProvider := setupBinaryActionTest(t, ctx, platform, streamID, eventTime, "100.000000000000000000") + + // min > max is invalid + minDecimal, err := kwilTypes.ParseDecimalExplicit("200.000000000000000000", 36, 18) + require.NoError(t, err) + maxDecimal, err := kwilTypes.ParseDecimalExplicit("100.000000000000000000", 36, 18) + require.NoError(t, err) + + res, err := platform.Engine.Call(engineCtx, platform.DB, "", "value_in_range", + []any{ + dataProvider, + streamID, + eventTime, + minDecimal, + maxDecimal, + nil, + }, nil) + require.NoError(t, err) + + require.NotNil(t, res.Error, "should error when min > max") + require.Contains(t, res.Error.Error(), "Invalid range") + t.Log("value_in_range with min > max: correctly returned error") + + return nil + } +} diff --git a/tests/streams/utils/erc20/helper.go b/tests/streams/utils/erc20/helper.go index c5a914294..f8edcea19 100644 --- a/tests/streams/utils/erc20/helper.go +++ b/tests/streams/utils/erc20/helper.go @@ -66,6 +66,14 @@ func GetUserBalance(ctx context.Context, platform *kwilTesting.Platform, extensi case "sepolia_bridge": chainName = "sepolia" escrowAddr = "0x80d9b3b6941367917816d36748c88b303f7f1415" + case "erc20_bridge_test": + // Test-only alias used in tests/extensions/erc20/ tests + chainName = "sepolia" + escrowAddr = "0x1111111111111111111111111111111111111111" + case "hoodi_bridge_test": + // Test-only alias used in tests/extensions/erc20/erc20_bridge_hoodi_test.go + chainName = "hoodi" + escrowAddr = "0x3333333333333333333333333333333333333333" default: return "", fmt.Errorf("unknown extension alias: %s", extensionAlias) }