diff --git a/extensions/tn_utils/precompiles.go b/extensions/tn_utils/precompiles.go index d48d690e..f2700e88 100644 --- a/extensions/tn_utils/precompiles.go +++ b/extensions/tn_utils/precompiles.go @@ -470,7 +470,7 @@ func callDispatchHandler(ctx *common.EngineContext, app *common.App, inputs []an } var rows []*common.Row - _, err = app.Engine.Call(ctx, app.DB, "main", actionName, args, func(row *common.Row) error { + res, err := app.Engine.Call(ctx, app.DB, "main", actionName, args, func(row *common.Row) error { rows = append(rows, row) return nil }) @@ -478,6 +478,16 @@ func callDispatchHandler(ctx *common.EngineContext, app *common.App, inputs []an return fmt.Errorf("action '%s' call failed: %w", actionName, err) } + // An ERROR() raised inside the dispatched action arrives in the call result, not in err. + // Dropping it let request_attestation succeed over an empty row set: the caller paid the fee, + // an attestation was stored that no consumer can parse, and the reason it failed was lost. Every + // attestation action reaches its own validation this way — "no data found", "wallet not allowed + // to read", a stale value — so this is the difference between a refusal and a silent bad + // attestation. + if res != nil && res.Error != nil { + return fmt.Errorf("action '%s' call failed: %w", actionName, res.Error) + } + resultBytes, err := EncodeQueryResultCanonical(rows) if err != nil { return fmt.Errorf("failed to encode results from action '%s': %w", actionName, err) @@ -735,6 +745,12 @@ func forceLastArgFalseHandler(ctx *common.EngineContext, app *common.App, inputs // - 90 days of daily data = 90 rows, hourly = 2,160 rows (both safe) const MaxAttestationDateRangeSeconds int64 = 90 * 24 * 60 * 60 // 7,776,000 seconds +// IndexChangeInRangeActionID is the attestation action id of index_change_in_range, registered by +// migration 055. Untyped so it can serve both the int64 comparison in the validation handler and the +// uint16 entry in getActionIDNumber, which have to agree with each other and with the +// attestation_actions row. +const IndexChangeInRangeActionID = 12 + // validateAttestationDateRangeMethod validates attestation action eligibility: // - Actions 1-3 (get_record, get_index, get_change_over_time) are BLOCKED — they return // multiple rows and are not allowed for attestation. @@ -742,6 +758,8 @@ const MaxAttestationDateRangeSeconds int64 = 90 * 24 * 60 * 60 // 7,776,000 seco // - Actions 6-9 (binary) return a single boolean — no validation needed. // - Actions 10-11 (get_high_value, get_low_value) are single-row range queries — date range // validated (max 90 days, both from and to required). +// - Action 12 (index_change_in_range) returns a single boolean from two point lookups — only +// its time_interval is validated; the 90-day range rule does not apply. func validateAttestationDateRangeMethod() precompiles.Method { return precompiles.Method{ Name: "validate_attestation_date_range", @@ -767,6 +785,15 @@ func validateAttestationDateRangeHandler(ctx *common.EngineContext, app *common. return fmt.Errorf("action %d not allowed for attestation: use get_last_record, get_first_record, get_high_value, get_low_value, or binary actions", actionID) } + // index_change_in_range reads two single-point anchors rather than a range, so the 90-day rule + // below does not describe it. What does need checking before dispatch is the interval, since + // that is what places the second anchor. The remaining argument checks (the bounds) are + // semantic rather than cost-related and live in the action body, where a NULL decimal is + // unambiguous. + if actionID == IndexChangeInRangeActionID { + return validateIndexChangeInRangeArgs(inputs[1]) + } + // Only validate date range for range-based single-row actions (IDs 10-11: get_high_value, get_low_value). // Actions 4-5 are single-point (LIMIT 1), actions 6-9 are binary (single bool) — no validation needed. if actionID != 10 && actionID != 11 { @@ -818,6 +845,47 @@ func validateAttestationDateRangeHandler(ctx *common.EngineContext, app *common. return nil } +// validateIndexChangeInRangeArgs checks the interval of an index_change_in_range request before the +// action is dispatched. +// +// Signature: (data_provider, stream_id, timestamp, base_time, time_interval, min_change, max_change, +// frozen_at), so time_interval sits at index 4. +// +// A non-positive interval would place the comparison anchor at or after the settlement time, which +// is not a percentage change over anything. The action body rejects it too; catching it here keeps +// the failure at the same stage as every other attestation argument error. +func validateIndexChangeInRangeArgs(rawArgs any) error { + argsBytes, ok := rawArgs.([]byte) + if !ok { + return fmt.Errorf("args_bytes must be []byte, got %T", rawArgs) + } + + args, err := DecodeActionArgs(argsBytes) + if err != nil { + return fmt.Errorf("failed to decode action args: %w", err) + } + + if len(args) < 5 { + return fmt.Errorf("index_change_in_range requires at least 5 args, got %d", len(args)) + } + + intervalVal := derefIntPtr(args[4]) + if intervalVal == nil { + return fmt.Errorf("index_change_in_range requires a 'time_interval' parameter") + } + + interval, err := toInt64(*intervalVal) + if err != nil { + return fmt.Errorf("failed to parse 'time_interval' parameter: %w", err) + } + + if interval <= 0 { + return fmt.Errorf("index_change_in_range 'time_interval' must be positive, got %d", interval) + } + + return nil +} + // derefIntPtr dereferences a pointer to any integer type, returning nil if the // input is nil or a nil pointer. DecodeActionArgs may return pointer variants // (*int64, *int32, *int, *uint64, etc.) for nullable parameters. @@ -1167,6 +1235,8 @@ func getActionIDNumber(actionName string) (uint16, error) { // Single-row range actions (return TABLE(event_time INT8, value NUMERIC) LIMIT 1) "get_high_value": 10, "get_low_value": 11, + // Index-change action (returns TABLE(result BOOLEAN)) — see migration 055 + "index_change_in_range": IndexChangeInRangeActionID, } id, ok := actionMap[actionName] diff --git a/internal/migrations/055-index-change-attestation-action.sql b/internal/migrations/055-index-change-attestation-action.sql new file mode 100644 index 00000000..8f312709 --- /dev/null +++ b/internal/migrations/055-index-change-attestation-action.sql @@ -0,0 +1,199 @@ +/* + * INDEX-CHANGE ATTESTATION ACTION + * + * Adds index_change_in_range, a single-row binary action that resolves on how + * far a stream's index moved over an interval rather than on the value the + * stream publishes. + * + * Streams that publish an index level (BLS CPI at ~335, PCE at ~131) cannot + * back an inflation-rate market through the value actions in migration 040, + * because those compare a level against a rate. get_index_change computes the + * rate but returns a series, and multi-row actions are blocked from + * attestation. This action computes the same percentage and returns one + * boolean, so it is attestable. + * + * Nullable bounds cover every outcome of a bucketed market with one action id: + * + * min NULL, max 1.335 -> "Below 1.335%" + * min 1.335, max 1.605 -> "1.335% - 1.605%" + * min 2.246, max NULL -> "Above 2.246%" + * min 0, max NULL -> "did the rate rise at all?" + * + * Bounds are half-open, [min, max), so the buckets of a market tile the number + * line exactly once. The value actions in 040 are inclusive on both ends of + * value_in_range, which lets a value landing exactly on an interior boundary + * satisfy two adjacent buckets. + * + * Action ID: + * 12 = index_change_in_range + */ + +-- Register the action in the attestation allowlist. Must stay in step with +-- getActionIDNumber in extensions/tn_utils/precompiles.go; nothing checks that +-- the two agree. +INSERT INTO attestation_actions (action_name, action_id) VALUES ('index_change_in_range', 12) +ON CONFLICT (action_name) DO NOTHING; + +-- ============================================================================= +-- get_indexed_value_at: the indexed value in force at $at, refusing stale data +-- ============================================================================= +-- +-- Reads through get_index rather than primitive_events directly, so the number +-- this action settles on is the number get_index_change reports for the same +-- base_time. For a primitive stream the base value cancels out of the ratio and +-- the choice makes no difference; for a composed stream it does, because +-- get_index_composed weights indexed children and the ratio of composed raw +-- records is not the ratio of the composed index. +-- +-- The window is one second wide on purpose. get_record carries an LOCF anchor, +-- so a read of ($at - 1, $at] also returns the last record at or before +-- $at - 1 when the window itself holds nothing. That makes an empty result +-- impossible to tell apart from a value that is years old, which is why the +-- staleness check below is an explicit comparison on event_time rather than a +-- narrower range. +-- +-- Parameters: +-- $max_staleness: how far before $at the value may sit, in seconds +-- +CREATE OR REPLACE ACTION get_indexed_value_at( + $data_provider TEXT, + $stream_id TEXT, + $at INT8, + $max_staleness INT8, + $base_time INT8, + $frozen_at INT8 +) PRIVATE VIEW RETURNS (value NUMERIC(36,18)) { + $found_time INT8 := NULL; + $found_value NUMERIC(36,18) := NULL; + + -- get_index returns ascending event_time, so the last row of the loop is + -- the value in force at $at. + for $row in get_index($data_provider, $stream_id, $at - 1, $at, $frozen_at, $base_time, false) { + $found_time := $row.event_time; + $found_value := $row.value; + } + + if $found_time IS NULL { + ERROR('No data at or before ' || $at::TEXT || ' for stream ' || $stream_id); + } + + -- Checked separately from the time, and separately from each other, because + -- kwil's OR does not follow SQL three-valued logic. A NULL value reaching + -- the caller would make every comparison NULL, and the bucket would resolve + -- TRUE without anything having been compared. + if $found_value IS NULL { + ERROR('Null value at ' || $found_time::TEXT || ' for stream ' || $stream_id); + } + + $oldest_allowed INT8 := $at - $max_staleness; + if $found_time < $oldest_allowed { + ERROR('Stream ' || $stream_id || ' has no value within ' || $max_staleness::TEXT || + ' seconds of ' || $at::TEXT || '. Most recent is ' || $found_time::TEXT); + } + + RETURN $found_value; +}; + +-- ============================================================================= +-- index_change_in_range: TRUE if the index moved by a percentage in [min, max) +-- ============================================================================= +-- +-- Use case: "Will US CPI inflation come in between 1.335% and 1.605%?" +-- +-- Parameters: +-- $data_provider: The data provider address (0x-prefixed hex) +-- $stream_id: The stream ID (32 characters) +-- $timestamp: Unix timestamp the market settles at +-- $base_time: Base time for the index, passed through to get_index +-- $time_interval: Seconds to look back for the comparison point +-- $min_change: Lower bound in percent, inclusive; NULL for an open tail +-- $max_change: Upper bound in percent, exclusive; NULL for an open tail +-- $frozen_at: Optional frozen_at timestamp for historical queries +-- +-- Returns: Single row with boolean result column +-- +-- use_cache is never exposed and always passed as false, so every validator +-- computes the same result regardless of cache state. That is also why this +-- action needs no entry in the force_last_arg_false branch of migration 024. +-- +CREATE OR REPLACE ACTION index_change_in_range( + $data_provider TEXT, + $stream_id TEXT, + $timestamp INT8, + $base_time INT8, + $time_interval INT, + $min_change NUMERIC(36, 18), + $max_change NUMERIC(36, 18), + $frozen_at INT8 +) PUBLIC VIEW RETURNS TABLE ( + result BOOLEAN +) { + $data_provider := LOWER($data_provider); + + -- A market cannot resolve before its settlement time. + validate_not_before_timestamp($timestamp); + + if $time_interval IS NULL { + ERROR('time_interval is required'); + } + if $time_interval <= 0 { + ERROR('time_interval must be positive, got ' || $time_interval::TEXT); + } + + -- Split rather than combined with OR: kwil's OR does not follow SQL + -- three-valued logic, and `NULL OR NULL` here would not behave as written. + if $min_change IS NULL { + if $max_change IS NULL { + ERROR('at least one of min_change or max_change is required'); + } + } + if $min_change IS NOT NULL { + if $max_change IS NOT NULL { + if $min_change >= $max_change { + ERROR('min_change must be less than max_change'); + } + } + } + + $interval_seconds INT8 := ($time_interval)::INT8; + $prior_at INT8 := $timestamp - $interval_seconds; + + -- The current anchor keeps the one-day staleness rule the 040 actions use: + -- it is a freshness check, refusing to settle today's market on last week's + -- value. + $current_value NUMERIC(36,18) := get_indexed_value_at( + $data_provider, $stream_id, $timestamp, 86400, $base_time, $frozen_at); + + -- The prior anchor is a historical lookup, where staleness means nothing. + -- Its only job is to refuse when the stream has a hole where the comparison + -- point belongs, so it scales with the interval asked for: a year-over-year + -- market accepts a prior print up to a year old, a month-over-month market + -- accepts a month. A stream too young to have a comparison point is + -- refused rather than settled on the wrong number. + $prior_value NUMERIC(36,18) := get_indexed_value_at( + $data_provider, $stream_id, $prior_at, $interval_seconds, $base_time, $frozen_at); + + -- get_index_change skips a zero prior value and moves to the next row. A + -- single-row action has no next row, so it refuses. + if $prior_value = 0::NUMERIC(36,18) { + ERROR('Prior value is 0 at ' || $prior_at::TEXT || '; percentage change is undefined'); + } + + -- Same arithmetic as get_index_change, so the two agree for the same + -- (base_time, time_interval). + $change NUMERIC(36,18) := (($current_value - $prior_value) * 100::NUMERIC(36,18)) / $prior_value; + + $in_range BOOL := true; + if $min_change IS NOT NULL { + if $change < $min_change { + $in_range := false; + } + } + if $max_change IS NOT NULL { + if $change >= $max_change { + $in_range := false; + } + } + + RETURN NEXT $in_range; +}; diff --git a/tests/streams/attestation/attestation_date_range_test.go b/tests/streams/attestation/attestation_date_range_test.go index b1b93690..fdd77cab 100644 --- a/tests/streams/attestation/attestation_date_range_test.go +++ b/tests/streams/attestation/attestation_date_range_test.go @@ -138,7 +138,9 @@ func testAllDateRangeValidations(t *testing.T) func(ctx context.Context, platfor } argsBytes, err = tn_utils.EncodeActionArgs(binaryArgs) require.NoError(t, err) - err = requestAttestationWithArgsBytes(ctx, platform, &systemAdmin, systemAdmin.Address(), streamID, "price_above_threshold", argsBytes) + // The binary actions refuse to resolve before their timestamp, so the block clock has to + // have reached it. While call_dispatch discarded action errors this call looked like a pass. + err = requestAttestationAtBlockTime(ctx, platform, &systemAdmin, systemAdmin.Address(), streamID, "price_above_threshold", argsBytes, 1000000) require.NoError(t, err, "binary action should pass") // ===================================================================== @@ -220,6 +222,77 @@ func testAllDateRangeValidations(t *testing.T) func(ctx context.Context, platfor err = requestAttestationWithArgsBytes(ctx, platform, &systemAdmin, systemAdmin.Address(), streamID, "get_high_value", argsBytes) require.NoError(t, err, "exactly 90-day range should succeed") + // ===================================================================== + // Group 5: Action 12 (index_change_in_range) interval validation + // ===================================================================== + // + // The seeded series is 75 at t=1000000, 120 at t=1000100, 30 at + // t=1000200, so an interval of 100 at t=1000200 compares 30 against 120. + // The block clock has to have reached the settlement time, which is why + // these calls set one. + + indexChangeArgs := func(interval any) []byte { + minVal, parseErr := kwilTypes.ParseDecimal("-100.000000000000000000") + require.NoError(t, parseErr) + minVal.SetPrecisionAndScale(36, 18) + maxVal, parseErr := kwilTypes.ParseDecimal("0.000000000000000000") + require.NoError(t, parseErr) + maxVal.SetPrecisionAndScale(36, 18) + + encoded, encodeErr := tn_utils.EncodeActionArgs([]any{ + systemAdmin.Address(), streamID, + int64(1000200), // timestamp + nil, // base_time + interval, // time_interval + minVal, // min_change + maxVal, // max_change + nil, // frozen_at + }) + require.NoError(t, encodeErr) + return encoded + } + + // === Test 13: index_change_in_range with a valid interval succeeds === + // Proves the action is registered in attestation_actions and that + // getActionIDNumber agrees, since a mismatch fails one or the other. + t.Log("Test 13: index_change_in_range with a valid interval should succeed") + err = requestAttestationAtBlockTime(ctx, platform, &systemAdmin, systemAdmin.Address(), streamID, + "index_change_in_range", indexChangeArgs(int64(100)), 1000200) + require.NoError(t, err, "index_change_in_range with a valid interval should succeed") + + // === Test 14: zero time_interval fails === + t.Log("Test 14: index_change_in_range with a zero interval should fail") + err = requestAttestationAtBlockTime(ctx, platform, &systemAdmin, systemAdmin.Address(), streamID, + "index_change_in_range", indexChangeArgs(int64(0)), 1000200) + require.Error(t, err, "a zero interval is not a change over anything") + require.Contains(t, err.Error(), "time_interval' must be positive") + + // === Test 15: negative time_interval fails === + t.Log("Test 15: index_change_in_range with a negative interval should fail") + err = requestAttestationAtBlockTime(ctx, platform, &systemAdmin, systemAdmin.Address(), streamID, + "index_change_in_range", indexChangeArgs(int64(-100)), 1000200) + require.Error(t, err, "a negative interval would place the anchor in the future") + require.Contains(t, err.Error(), "time_interval' must be positive") + + // === Test 16: nil time_interval fails === + t.Log("Test 16: index_change_in_range with no interval should fail") + err = requestAttestationAtBlockTime(ctx, platform, &systemAdmin, systemAdmin.Address(), streamID, + "index_change_in_range", indexChangeArgs(nil), 1000200) + require.Error(t, err, "the interval is what places the comparison anchor") + require.Contains(t, err.Error(), "requires a 'time_interval' parameter") + + // === Test 17: the 90-day cap does not apply to action 12 === + // An interval well past 90 days reaches back before the series starts, + // so the action itself refuses. What matters is that the refusal comes + // from the action rather than from the range validator. + t.Log("Test 17: index_change_in_range is not subject to the 90-day range cap") + err = requestAttestationAtBlockTime(ctx, platform, &systemAdmin, systemAdmin.Address(), streamID, + "index_change_in_range", indexChangeArgs(int64(180*24*60*60)), 1000200) + require.Error(t, err, "there is no record that far back in the fixture") + require.NotContains(t, err.Error(), "exceeds maximum", + "the 90-day rule belongs to the range actions, not to index_change_in_range") + require.Contains(t, err.Error(), "No data at or before") + t.Log("All attestation restriction tests passed") return nil } diff --git a/tests/streams/attestation/request_attestation_fee_test.go b/tests/streams/attestation/request_attestation_fee_test.go index 081a948a..c81d4677 100644 --- a/tests/streams/attestation/request_attestation_fee_test.go +++ b/tests/streams/attestation/request_attestation_fee_test.go @@ -56,7 +56,6 @@ func TestRequestAttestationFees(t *testing.T) { Name: "ATTESTATION_FEE01_RequestAttestationFees", SeedStatements: migrations.GetSeedScriptStatements(), FunctionTests: []kwilTesting.TestFunc{ - setupAttestationTestEnvironment(t), testAttestationNetworkWriterPaysFee(t), testAttestationInsufficientBalance(t), testAttestationMultipleRequestsChargeFees(t), @@ -66,7 +65,13 @@ func TestRequestAttestationFees(t *testing.T) { }, testutils.GetTestOptionsWithCache()) } -// setupAttestationTestEnvironment creates system admin, registers test action, and creates test stream +// setupAttestationTestEnvironment creates system admin, registers test action, and creates test stream. +// +// Every function test in a suite runs against its own fresh container (see +// tests/streams/utils/runner.go), so this cannot be listed as a FunctionTest of its own: the stream +// it creates would be gone by the time the next function ran. Each test calls it directly instead. +// While it was a standalone entry, every dispatched get_last_record here failed with "Stream not +// found", and call_dispatch discarded that error, so the suite attested nothing and still passed. func setupAttestationTestEnvironment(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { return func(ctx context.Context, platform *kwilTesting.Platform) error { // Use the system admin address (derived from private key 0x00...01) @@ -109,6 +114,10 @@ func setupAttestationTestEnvironment(t *testing.T) func(ctx context.Context, pla // Test 1: Non-exempt user pays 40 TRUF fee per attestation request func testAttestationNetworkWriterPaysFee(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { return func(ctx context.Context, platform *kwilTesting.Platform) error { + // Each function test gets its own container, so the provider and stream are created + // here rather than once for the suite. + require.NoError(t, setupAttestationTestEnvironment(t)(ctx, platform), "environment setup") + requesterAddrVal := util.Unsafe_NewEthereumAddressFromString("0xa111111111111111111111111111111111111111") requesterAddr := &requesterAddrVal @@ -142,6 +151,10 @@ func testAttestationNetworkWriterPaysFee(t *testing.T) func(ctx context.Context, // Test 2: Insufficient balance causes error func testAttestationInsufficientBalance(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { return func(ctx context.Context, platform *kwilTesting.Platform) error { + // Each function test gets its own container, so the provider and stream are created + // here rather than once for the suite. + require.NoError(t, setupAttestationTestEnvironment(t)(ctx, platform), "environment setup") + requesterAddrVal := util.Unsafe_NewEthereumAddressFromString("0xa222222222222222222222222222222222222222") requesterAddr := &requesterAddrVal @@ -164,6 +177,10 @@ func testAttestationInsufficientBalance(t *testing.T) func(ctx context.Context, // Test 3: Multiple attestation requests charge 40 TRUF each func testAttestationMultipleRequestsChargeFees(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { return func(ctx context.Context, platform *kwilTesting.Platform) error { + // Each function test gets its own container, so the provider and stream are created + // here rather than once for the suite. + require.NoError(t, setupAttestationTestEnvironment(t)(ctx, platform), "environment setup") + requesterAddrVal := util.Unsafe_NewEthereumAddressFromString("0xa333333333333333333333333333333333333333") requesterAddr := &requesterAddrVal @@ -202,6 +219,10 @@ func testAttestationMultipleRequestsChargeFees(t *testing.T) func(ctx context.Co // Test 4: Leader receives attestation fees correctly func testAttestationLeaderReceivesFees(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { return func(ctx context.Context, platform *kwilTesting.Platform) error { + // Each function test gets its own container, so the provider and stream are created + // here rather than once for the suite. + require.NoError(t, setupAttestationTestEnvironment(t)(ctx, platform), "environment setup") + requesterAddrVal := util.Unsafe_NewEthereumAddressFromString("0xa444444444444444444444444444444444444444") requesterAddr := &requesterAddrVal @@ -247,6 +268,10 @@ func testAttestationLeaderReceivesFees(t *testing.T) func(ctx context.Context, p // Test 5: Balance is correctly deducted after fee payment func testAttestationBalanceCorrectlyDeducted(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { return func(ctx context.Context, platform *kwilTesting.Platform) error { + // Each function test gets its own container, so the provider and stream are created + // here rather than once for the suite. + require.NoError(t, setupAttestationTestEnvironment(t)(ctx, platform), "environment setup") + requesterAddrVal := util.Unsafe_NewEthereumAddressFromString("0xa555555555555555555555555555555555555555") requesterAddr := &requesterAddrVal @@ -458,6 +483,12 @@ func requestAttestationWithLeader(ctx context.Context, platform *kwilTesting.Pla // requestAttestationWithArgsBytes requests attestation with pre-encoded args bytes func requestAttestationWithArgsBytes(ctx context.Context, platform *kwilTesting.Platform, signer *util.EthereumAddress, dataProvider string, streamID string, actionName string, argsBytes []byte) error { + return requestAttestationAtBlockTime(ctx, platform, signer, dataProvider, streamID, actionName, argsBytes, 0) +} + +// requestAttestationAtBlockTime is requestAttestationWithArgsBytes with a settable block clock. +// Actions that refuse to resolve before a settlement time need a clock that has reached it. +func requestAttestationAtBlockTime(ctx context.Context, platform *kwilTesting.Platform, signer *util.EthereumAddress, dataProvider string, streamID string, actionName string, argsBytes []byte, blockTimestamp int64) error { _, pubGeneric, err := crypto.GenerateSecp256k1Key(nil) if err != nil { return err @@ -467,8 +498,9 @@ func requestAttestationWithArgsBytes(ctx context.Context, platform *kwilTesting. tx := &common.TxContext{ Ctx: ctx, BlockContext: &common.BlockContext{ - Height: 1, - Proposer: pub, + Height: 1, + Proposer: pub, + Timestamp: blockTimestamp, }, Signer: signer.Bytes(), Caller: signer.Address(), diff --git a/tests/streams/index_change_in_range_test.go b/tests/streams/index_change_in_range_test.go new file mode 100644 index 00000000..918f110e --- /dev/null +++ b/tests/streams/index_change_in_range_test.go @@ -0,0 +1,574 @@ +package tests + +import ( + "context" + "math/big" + "strings" + "testing" + + "github.com/pkg/errors" + "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/procedure" + "github.com/trufnetwork/node/tests/streams/utils/setup" + "github.com/trufnetwork/node/tests/streams/utils/testctx" + "github.com/trufnetwork/sdk-go/core/types" + "github.com/trufnetwork/sdk-go/core/util" +) + +// The series every test in this file runs against. It is the fixture from +// TestIndexChange, reused on purpose: the percentages get_index_change produces +// for it are already asserted there, so agreement with those numbers is what +// this file is really testing. +// +// | event_time | value | change vs t-1 +// | 1 | 100.00 | +// | 2 | 102.00 | 2.000000000000000000 +// | 3 | 103.00 | 0.980392156862745098 +// | 4 | 101.00 | -1.941747572815533981 +// | 6 | 106.00 | 4.950495049504950495 (compares against t=4) +// | 7 | 105.00 | -0.943396226415094340 +// | 8 | 108.00 | 2.857142857142857143 +const indexChangeFixture = ` + | event_time | value | + |------------|--------| + | 1 | 100.00 | + | 2 | 102.00 | + | 3 | 103.00 | + | 4 | 101.00 | + # gap at 5, so t=6 compares against t=4 + | 6 | 106.00 | + | 7 | 105.00 | + | 8 | 108.00 | + ` + +func TestIndexChangeInRange(t *testing.T) { + testutils.RunSchemaTest(t, kwilTesting.SchemaTest{ + Name: "index_change_in_range_test", + SeedStatements: migrations.GetSeedScriptStatements(), + FunctionTests: []kwilTesting.TestFunc{ + withTestIndexChangeSetup(testIndexChangeInRangeBuckets(t)), + withTestIndexChangeSetup(testIndexChangeInRangeTails(t)), + withTestIndexChangeSetup(testIndexChangeInRangeHalfOpenBoundary(t)), + withTestIndexChangeSetup(testIndexChangeInRangeBucketsTileOnce(t)), + withTestIndexChangeSetup(testIndexChangeInRangeMatchesIndexChange(t)), + withTestIndexChangeSetup(testIndexChangeInRangeMatchesIndexChangeComposed(t)), + withTestIndexChangeSetup(testIndexChangeInRangeRefusesStaleData(t)), + withTestIndexChangeSetup(testIndexChangeInRangeArgumentErrors(t)), + }, + }, testutils.GetTestOptionsWithCache()) +} + +// ============================================================================= +// Bucket behaviour +// ============================================================================= + +func testIndexChangeInRangeBuckets(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + streamID, err := setupIndexChangeStream(ctx, platform, "icr_buckets") + if err != nil { + return err + } + + // change at t=8 is 2.857142857142857143 + call := indexChangeCall{streamID: streamID, at: 8, interval: 1} + + result, err := callIndexChangeInRange(t, ctx, platform, call.withBounds("2", "3")) + require.NoError(t, err) + require.True(t, result, "2.857 sits inside [2, 3)") + + result, err = callIndexChangeInRange(t, ctx, platform, call.withBounds("3", "4")) + require.NoError(t, err) + require.False(t, result, "2.857 sits below [3, 4)") + + result, err = callIndexChangeInRange(t, ctx, platform, call.withBounds("1", "2")) + require.NoError(t, err) + require.False(t, result, "2.857 sits above [1, 2)") + + // A negative change still resolves. At t=4 the index fell 1.94%. + negative := indexChangeCall{streamID: streamID, at: 4, interval: 1} + + result, err = callIndexChangeInRange(t, ctx, platform, negative.withBounds("-2", "-1")) + require.NoError(t, err) + require.True(t, result, "-1.941 sits inside [-2, -1)") + + result, err = callIndexChangeInRange(t, ctx, platform, negative.withBounds("0", "1")) + require.NoError(t, err) + require.False(t, result, "a fall is not a rise") + + return nil + } +} + +func testIndexChangeInRangeTails(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + streamID, err := setupIndexChangeStream(ctx, platform, "icr_tails") + if err != nil { + return err + } + + // change at t=8 is 2.857142857142857143 + call := indexChangeCall{streamID: streamID, at: 8, interval: 1} + + result, err := callIndexChangeInRange(t, ctx, platform, call.withMax("3")) + require.NoError(t, err) + require.True(t, result, "open lower tail: 2.857 is below 3") + + result, err = callIndexChangeInRange(t, ctx, platform, call.withMax("2")) + require.NoError(t, err) + require.False(t, result, "open lower tail: 2.857 is not below 2") + + result, err = callIndexChangeInRange(t, ctx, platform, call.withMin("2")) + require.NoError(t, err) + require.True(t, result, "open upper tail: 2.857 is at or above 2") + + result, err = callIndexChangeInRange(t, ctx, platform, call.withMin("3")) + require.NoError(t, err) + require.False(t, result, "open upper tail: 2.857 is not at or above 3") + + // min 0 with an open upper tail is the goal's own headline market, + // "did the rate rise at all?". + result, err = callIndexChangeInRange(t, ctx, platform, call.withMin("0")) + require.NoError(t, err) + require.True(t, result, "the index rose between t=7 and t=8") + + fell := indexChangeCall{streamID: streamID, at: 7, interval: 1} + result, err = callIndexChangeInRange(t, ctx, platform, fell.withMin("0")) + require.NoError(t, err) + require.False(t, result, "the index fell between t=6 and t=7") + + return nil + } +} + +// testIndexChangeInRangeHalfOpenBoundary pins the [min, max) convention. The +// change at t=2 is exactly 2, which is the only reason this assertion can be +// made without relying on a rounding accident. +func testIndexChangeInRangeHalfOpenBoundary(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + streamID, err := setupIndexChangeStream(ctx, platform, "icr_boundary") + if err != nil { + return err + } + + // change at t=2 is 2.000000000000000000, exactly + call := indexChangeCall{streamID: streamID, at: 2, interval: 1} + + result, err := callIndexChangeInRange(t, ctx, platform, call.withBounds("2", "3")) + require.NoError(t, err) + require.True(t, result, "a value on the lower bound belongs to that bucket") + + result, err = callIndexChangeInRange(t, ctx, platform, call.withBounds("1", "2")) + require.NoError(t, err) + require.False(t, result, "a value on the upper bound belongs to the next bucket up") + + return nil + } +} + +// testIndexChangeInRangeBucketsTileOnce is the property the half-open +// convention exists for: across the five buckets of a real market, exactly one +// resolves TRUE, including when the change lands exactly on an interior +// boundary. The 040 family fails this, because value_in_range is inclusive on +// both ends. +func testIndexChangeInRangeBucketsTileOnce(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + streamID, err := setupIndexChangeStream(ctx, platform, "icr_tiling") + if err != nil { + return err + } + + // Boundaries chosen so that t=2's change of exactly 2 lands on an + // interior boundary, the case that double-resolves under inclusive + // bounds. + boundaries := []string{"0", "1", "2", "3"} + + for _, at := range []int64{2, 3, 4, 6, 7, 8} { + call := indexChangeCall{streamID: streamID, at: at, interval: 1} + trueCount := 0 + + // Below the lowest boundary. + result, err := callIndexChangeInRange(t, ctx, platform, call.withMax(boundaries[0])) + require.NoError(t, err) + if result { + trueCount++ + } + + // The three interior buckets. + for i := 0; i < len(boundaries)-1; i++ { + result, err = callIndexChangeInRange(t, ctx, platform, call.withBounds(boundaries[i], boundaries[i+1])) + require.NoError(t, err) + if result { + trueCount++ + } + } + + // At or above the highest boundary. + result, err = callIndexChangeInRange(t, ctx, platform, call.withMin(boundaries[len(boundaries)-1])) + require.NoError(t, err) + if result { + trueCount++ + } + + require.Equal(t, 1, trueCount, "exactly one bucket must resolve TRUE at t=%d", at) + } + + return nil + } +} + +// ============================================================================= +// Agreement with get_index_change +// ============================================================================= + +// testIndexChangeInRangeMatchesIndexChange is the assertion the whole action +// exists to satisfy: the number it settles on has to be the number the rest of +// the product displays. +func testIndexChangeInRangeMatchesIndexChange(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + streamID, err := setupIndexChangeStream(ctx, platform, "icr_agrees") + if err != nil { + return err + } + + locator := types.StreamLocator{ + StreamId: util.GenerateStreamId("icr_agrees"), + DataProvider: defaultDeployer, + } + + // t=6 is the interesting one: the series has no record at t=5, so both + // this action and get_index_change have to fall back to t=4. + for _, at := range []int64{2, 3, 4, 6, 7, 8} { + assertAgreesWithIndexChange(t, ctx, platform, locator, streamID, at, 1) + } + + return nil + } +} + +// testIndexChangeInRangeMatchesIndexChangeComposed runs the same agreement +// check against a composed stream with unequal children. +// +// This is the case that decides how the action reads its values. A composed +// index weights its children after indexing them, so the ratio of composed raw +// records is not the ratio of the composed index. Reading through get_index is +// what keeps the two in step; reading primitive_events directly, the way the +// 040 actions do, would not. +func testIndexChangeInRangeMatchesIndexChangeComposed(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + streamName := "icr_composed" + streamID := util.GenerateStreamId(streamName) + + if err := setup.SetupComposedFromMarkdown(ctx, setup.MarkdownComposedSetupInput{ + Platform: platform, + StreamId: streamID, + Height: 0, + // Children move by different amounts, so the weighted index and the + // weighted raw value diverge. + MarkdownData: ` + | event_time | value_1 | value_2 | + |------------|---------|---------| + | 1 | 100 | 50 | + | 2 | 102 | 60 | + | 3 | 103 | 55 | + | 4 | 101 | 70 | + `, + Weights: []string{"1", "3"}, + }); err != nil { + return errors.Wrap(err, "error setting up composed stream") + } + + locator := types.StreamLocator{ + StreamId: streamID, + DataProvider: defaultDeployer, + } + + for _, at := range []int64{2, 3, 4} { + assertAgreesWithIndexChange(t, ctx, platform, locator, streamID.String(), at, 1) + } + + return nil + } +} + +// assertAgreesWithIndexChange proves the action computed exactly the value +// get_index_change reports, by squeezing it between two probes. +// +// The action returns a boolean, so the value cannot be read out directly. But +// `[V, ∞)` resolving TRUE means change >= V, and `[V + 1ulp, ∞)` resolving +// FALSE means change < V + 1ulp. Together, at 18 decimal places, change == V. +func assertAgreesWithIndexChange( + t *testing.T, + ctx context.Context, + platform *kwilTesting.Platform, + locator types.StreamLocator, + streamID string, + at int64, + interval int, +) { + t.Helper() + + from, to := at, at + rows, err := procedure.GetIndexChange(ctx, procedure.GetIndexChangeInput{ + Platform: platform, + StreamLocator: locator, + FromTime: &from, + ToTime: &to, + Interval: &interval, + Height: 0, + }) + require.NoError(t, err, "get_index_change at t=%d", at) + require.Len(t, rows, 1, "get_index_change should return one row at t=%d", at) + + expected := rows[0][1] + call := indexChangeCall{streamID: streamID, at: at, interval: interval} + + atOrAbove, err := callIndexChangeInRange(t, ctx, platform, call.withMin(expected)) + require.NoError(t, err) + require.True(t, atOrAbove, "change at t=%d should be at or above get_index_change's %s", at, expected) + + oneUlpHigher := nextAfterFixedPoint(t, expected) + belowNext, err := callIndexChangeInRange(t, ctx, platform, call.withMin(oneUlpHigher)) + require.NoError(t, err) + require.False(t, belowNext, "change at t=%d should be below %s", at, oneUlpHigher) + + t.Logf("t=%d: index_change_in_range agrees with get_index_change at %s", at, expected) +} + +// ============================================================================= +// Refusals +// ============================================================================= + +func testIndexChangeInRangeRefusesStaleData(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + streamID, err := setupIndexChangeStream(ctx, platform, "icr_stale") + if err != nil { + return err + } + + // The current anchor keeps a one-day freshness rule. The series ends at + // t=8, so a market settling two days later has nothing fresh to settle + // on, even though get_index would happily hand back t=8 as its LOCF + // anchor. + _, err = callIndexChangeInRange(t, ctx, platform, indexChangeCall{ + streamID: streamID, + at: 2 * 86400, + interval: 1, + }.withMin("0")) + require.Error(t, err, "a two-day-old value should not settle a market") + require.Contains(t, err.Error(), "no value within") + + // The prior anchor scales with the interval asked for. Looking back one + // second from t=8 wants a comparison point no older than t=6; the record + // at t=7 satisfies that. + result, err := callIndexChangeInRange(t, ctx, platform, indexChangeCall{ + streamID: streamID, + at: 8, + interval: 1, + }.withMin("0")) + require.NoError(t, err) + require.True(t, result) + + // Reaching back further than the series exists refuses rather than + // comparing against nothing. + _, err = callIndexChangeInRange(t, ctx, platform, indexChangeCall{ + streamID: streamID, + at: 8, + interval: 100, + }.withMin("0")) + require.Error(t, err, "there is no record at or before t=-92") + require.Contains(t, err.Error(), "No data at or before") + + return nil + } +} + +func testIndexChangeInRangeArgumentErrors(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + streamID, err := setupIndexChangeStream(ctx, platform, "icr_args") + if err != nil { + return err + } + + base := indexChangeCall{streamID: streamID, at: 8, interval: 1} + + _, err = callIndexChangeInRange(t, ctx, platform, base) + require.Error(t, err, "a market with no bounds would always resolve TRUE") + require.Contains(t, err.Error(), "at least one of min_change or max_change") + + zeroInterval := base.withMin("0") + zeroInterval.interval = 0 + _, err = callIndexChangeInRange(t, ctx, platform, zeroInterval) + require.Error(t, err, "a zero interval is not a change over anything") + require.Contains(t, err.Error(), "time_interval must be positive") + + negativeInterval := base.withMin("0") + negativeInterval.interval = -1 + _, err = callIndexChangeInRange(t, ctx, platform, negativeInterval) + require.Error(t, err, "a negative interval would place the anchor in the future") + require.Contains(t, err.Error(), "time_interval must be positive") + + _, err = callIndexChangeInRange(t, ctx, platform, base.withBounds("3", "2")) + require.Error(t, err, "an inverted bucket can never resolve TRUE") + require.Contains(t, err.Error(), "min_change must be less than max_change") + + // A market cannot resolve before its settlement time. + future := base.withMin("0") + future.now = 1 + _, err = callIndexChangeInRange(t, ctx, platform, future) + require.Error(t, err, "settling before the settlement time should be refused") + require.Contains(t, err.Error(), "Cannot resolve market before target timestamp") + + return nil + } +} + +// ============================================================================= +// Helpers +// ============================================================================= + +// indexChangeCall is one invocation of index_change_in_range. Bounds are held +// as strings so a test can say "2" and let the helper decide whether that means +// a decimal or a NULL. +type indexChangeCall struct { + streamID string + at int64 + interval int + minChange *string + maxChange *string + // now overrides the block timestamp. Zero means "the settlement time", + // which is the normal case: a market settles at or after its settle_time. + now int64 +} + +func (c indexChangeCall) withBounds(min, max string) indexChangeCall { + c.minChange, c.maxChange = &min, &max + return c +} + +func (c indexChangeCall) withMin(min string) indexChangeCall { + c.minChange, c.maxChange = &min, nil + return c +} + +func (c indexChangeCall) withMax(max string) indexChangeCall { + c.minChange, c.maxChange = nil, &max + return c +} + +func setupIndexChangeStream(ctx context.Context, platform *kwilTesting.Platform, name string) (string, error) { + streamID := util.GenerateStreamId(name) + if err := setup.SetupPrimitiveFromMarkdown(ctx, setup.MarkdownPrimitiveSetupInput{ + Platform: platform, + StreamId: streamID, + Height: 0, + MarkdownData: indexChangeFixture, + }); err != nil { + return "", errors.Wrap(err, "error setting up primitive stream") + } + return streamID.String(), nil +} + +// callIndexChangeInRange returns the action's boolean, or the error it raised. +// Action errors surface in res.Error rather than in err, so both are checked. +func callIndexChangeInRange( + t *testing.T, + ctx context.Context, + platform *kwilTesting.Platform, + call indexChangeCall, +) (bool, error) { + t.Helper() + + engineCtx := testctx.NewEngineContext(ctx, platform, defaultDeployer, 0) + blockTimestamp := call.now + if blockTimestamp == 0 { + blockTimestamp = call.at + } + engineCtx.TxContext.BlockContext.Timestamp = blockTimestamp + + // Left as an untyped nil when absent. A typed (*Decimal)(nil) does not read + // back as SQL NULL, which would turn an open tail into a silent zero bound. + toArg := func(v *string) (any, error) { + if v == nil { + return nil, nil + } + d, err := kwilTypes.ParseDecimalExplicit(*v, 36, 18) + if err != nil { + return nil, err + } + return d, nil + } + + minArg, err := toArg(call.minChange) + if err != nil { + return false, errors.Wrap(err, "parse min_change") + } + maxArg, err := toArg(call.maxChange) + if err != nil { + return false, errors.Wrap(err, "parse max_change") + } + + var result bool + var gotRow bool + res, err := platform.Engine.Call(engineCtx, platform.DB, "", "index_change_in_range", + []any{ + defaultDeployer.Address(), + call.streamID, + call.at, + nil, // base_time + call.interval, + minArg, + maxArg, + nil, // frozen_at + }, + func(row *common.Row) error { + result = row.Values[0].(bool) + gotRow = true + return nil + }) + if err != nil { + return false, err + } + if res.Error != nil { + return false, res.Error + } + require.True(t, gotRow, "index_change_in_range returned no row") + return result, nil +} + +// nextAfterFixedPoint adds one unit in the last place of an 18-decimal +// fixed-point string, so "2.857142857142857143" becomes +// "2.857142857142857144" and "-1.941747572815533981" becomes +// "-1.941747572815533980". +func nextAfterFixedPoint(t *testing.T, value string) string { + t.Helper() + + const scale = 18 + + negative := strings.HasPrefix(value, "-") + digits := strings.Replace(strings.TrimPrefix(value, "-"), ".", "", 1) + + scaled, ok := new(big.Int).SetString(digits, 10) + require.True(t, ok, "parse %q as fixed point", value) + if negative { + scaled.Neg(scaled) + } + scaled.Add(scaled, big.NewInt(1)) + + sign := "" + if scaled.Sign() < 0 { + sign = "-" + scaled.Neg(scaled) + } + + padded := scaled.String() + if len(padded) <= scale { + padded = strings.Repeat("0", scale-len(padded)+1) + padded + } + split := len(padded) - scale + return sign + padded[:split] + "." + padded[split:] +}