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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions extensions/tn_settlement/settlement_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -603,15 +603,16 @@ func createStreamWithoutSigningAttestation(
}, nil)
require.NoError(t, err)

// Encode action args for get_record
// Encode action args for get_last_record
// Signature: ($data_provider, $stream_id, $before, $frozen_at, $use_cache)
argsBytes, err := tn_utils.EncodeActionArgs([]any{
dataProvider, streamID, int64(500), int64(1500), nil, false,
dataProvider, streamID, int64(1500), nil, false,
})
require.NoError(t, err)

// Request attestation (but don't sign)
res, err := platform.Engine.Call(engineCtx, platform.DB, "", "request_attestation",
[]any{dataProvider, streamID, "get_record", argsBytes, false, nil},
[]any{dataProvider, streamID, "get_last_record", argsBytes, false, nil},
func(row *common.Row) error {
return nil
})
Expand All @@ -623,7 +624,7 @@ func createStreamWithoutSigningAttestation(
// NOTE: Intentionally NOT calling helper.SignAttestation() to test unsigned attestation

// Return ABI-encoded query_components for create_market
queryComponents, err := encodeQueryComponents(dataProvider, streamID, "get_record", argsBytes)
queryComponents, err := encodeQueryComponents(dataProvider, streamID, "get_last_record", argsBytes)
require.NoError(t, err)

return queryComponents
Expand Down Expand Up @@ -663,17 +664,18 @@ func createStreamAndAttestation(
}, nil)
require.NoError(t, err)

// Encode action args for get_record - MUST match what we use in request_attestation
// Encode action args for get_last_record - MUST match what we use in request_attestation
// Signature: ($data_provider, $stream_id, $before, $frozen_at, $use_cache)
argsBytes, err := tn_utils.EncodeActionArgs([]any{
dataProvider, streamID, int64(500), int64(1500), nil, false,
dataProvider, streamID, int64(1500), nil, false,
})
require.NoError(t, err)

// Request attestation using the SAME engineCtx so it sees the inserted data
var requestTxID string
var attestationHash []byte
res, err := platform.Engine.Call(engineCtx, platform.DB, "", "request_attestation",
[]any{dataProvider, streamID, "get_record", argsBytes, false, nil},
[]any{dataProvider, streamID, "get_last_record", argsBytes, false, nil},
func(row *common.Row) error {
requestTxID = row.Values[0].(string)
attestationHash = append([]byte(nil), row.Values[1].([]byte)...)
Expand All @@ -691,7 +693,7 @@ func createStreamAndAttestation(

// Return ABI-encoded query_components for create_market
// The hash is computed by create_market from these query_components
queryComponents, err := encodeQueryComponents(dataProvider, streamID, "get_record", argsBytes)
queryComponents, err := encodeQueryComponents(dataProvider, streamID, "get_last_record", argsBytes)
require.NoError(t, err)

return queryComponents
Expand Down
49 changes: 26 additions & 23 deletions extensions/tn_utils/precompiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -732,14 +732,13 @@ 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

// validateAttestationDateRangeMethod checks that the date range in attestation
// query args does not exceed 90 days. Only applies to range-based actions
// (action_id 1-3: get_record, get_index, get_change_over_time) where args
// contain $from at index 2 and $to at index 3.
//
// Actions 4-5 (get_last_record, get_first_record) are single-point queries
// with LIMIT 1 and do not need date range validation.
// Actions 6-9 (binary) return a single boolean and are inherently safe.
// 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.
// - Actions 4-5 (get_last_record, get_first_record) are single-point (LIMIT 1) — no validation needed.
// - 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).
func validateAttestationDateRangeMethod() precompiles.Method {
return precompiles.Method{
Name: "validate_attestation_date_range",
Expand All @@ -759,10 +758,16 @@ func validateAttestationDateRangeHandler(ctx *common.EngineContext, app *common.
return fmt.Errorf("action_id: %w", err)
}

// Only validate range-based actions (1-3: get_record, get_index, get_change_over_time).
// Actions 4-5 are single-point (LIMIT 1), actions 6-9 are binary (single bool).
if actionID < 1 || actionID > 3 {
return nil // no validation needed
// Block multi-row actions entirely (IDs 1-3: get_record, get_index, get_change_over_time).
// These return arrays and are not allowed for attestation.
if actionID >= 1 && actionID <= 3 {
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)
}

// 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 {
return nil
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

argsBytes, ok := inputs[1].([]byte)
Expand All @@ -775,23 +780,18 @@ func validateAttestationDateRangeHandler(ctx *common.EngineContext, app *common.
return fmt.Errorf("failed to decode action args: %w", err)
}

// Range-based actions have signature: ($data_provider, $stream_id, $from, $to, ...)
// $from is at index 2, $to is at index 3
// get_high_value/get_low_value signature: ($data_provider, $stream_id, $from, $to, $frozen_at)
// $from at index 2, $to at index 3
if len(args) < 4 {
return fmt.Errorf("range-based attestation action requires at least 4 args, got %d", len(args))
return fmt.Errorf("attestation action requires at least 4 args, got %d", len(args))
}

// If both from and to are nil, the action returns the latest record (LIMIT 1) — safe
fromVal := derefIntPtr(args[2])
toVal := derefIntPtr(args[3])

if fromVal == nil && toVal == nil {
return nil
}

// If only one is provided, the range is effectively unbounded — reject
// Both from and to are required for get_high_value/get_low_value (no "latest record" shortcut)
if fromVal == nil || toVal == nil {
return fmt.Errorf("attestation queries with range-based actions (get_record, get_index, get_change_over_time) must specify both 'from' and 'to' parameters")
return fmt.Errorf("get_high_value/get_low_value attestation actions require both 'from' and 'to' parameters")
}

fromTS, err := toInt64(*fromVal)
Expand Down Expand Up @@ -1161,11 +1161,14 @@ func getActionIDNumber(actionName string) (uint16, error) {
"price_below_threshold": 7,
"value_in_range": 8,
"value_equals": 9,
// Single-row range actions (return TABLE(event_time INT8, value NUMERIC) LIMIT 1)
"get_high_value": 10,
"get_low_value": 11,
}

id, ok := actionMap[actionName]
if !ok {
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 0, fmt.Errorf("unknown action: %s", actionName)
}
return id, nil
}
Expand Down
188 changes: 188 additions & 0 deletions internal/migrations/045-high-low-attestation-actions.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/*
* HIGH/LOW ATTESTATION ACTIONS
*
* Adds get_high_value and get_low_value actions that return a single row
* (max or min value within a bounded date range). These are designed for
* attestation use cases where escrows need high/low price data.
*
* Both actions return exactly 1 row (LIMIT 1), making them safe for the
* attestation path. The precompile validates a max 90-day date range.
*
* Action IDs:
* 10 = get_high_value (max value in [from, to])
* 11 = get_low_value (min value in [from, to])
*/

-- Register new actions in the attestation allowlist
INSERT INTO attestation_actions (action_name, action_id) VALUES ('get_high_value', 10)
ON CONFLICT (action_name) DO NOTHING;
INSERT INTO attestation_actions (action_name, action_id) VALUES ('get_low_value', 11)
ON CONFLICT (action_name) DO NOTHING;

-- =============================================================================
-- get_high_value_primitive: Returns the row with the highest value in [from, to]
-- =============================================================================
CREATE OR REPLACE ACTION get_high_value_primitive(
$data_provider TEXT,
$stream_id TEXT,
$from INT8,
$to INT8,
$frozen_at INT8
) PRIVATE VIEW RETURNS TABLE(
event_time INT8,
value NUMERIC(36,18)
) {
$data_provider := LOWER($data_provider);
$lower_caller TEXT := LOWER(@caller);
$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);
}

if is_allowed_to_read_core($stream_ref, $lower_caller, $from, $to) == false {
ERROR('wallet not allowed to read');
}

$max_int8 INT8 := 9223372036854775000;
$effective_from INT8 := COALESCE($from, 0);
$effective_to INT8 := COALESCE($to, $max_int8);
$effective_frozen_at INT8 := COALESCE($frozen_at, $max_int8);

RETURN WITH
deduped AS (
SELECT pe.event_time, pe.value,
ROW_NUMBER() OVER (
PARTITION BY pe.event_time
ORDER BY pe.created_at DESC
) AS rn
FROM primitive_events pe
WHERE pe.stream_ref = $stream_ref
AND pe.event_time >= $effective_from
AND pe.event_time <= $effective_to
AND pe.created_at <= $effective_frozen_at
)
SELECT event_time, value FROM deduped WHERE rn = 1
ORDER BY value DESC, event_time ASC
LIMIT 1;
};

-- =============================================================================
-- get_low_value_primitive: Returns the row with the lowest value in [from, to]
-- =============================================================================
CREATE OR REPLACE ACTION get_low_value_primitive(
$data_provider TEXT,
$stream_id TEXT,
$from INT8,
$to INT8,
$frozen_at INT8
) PRIVATE VIEW RETURNS TABLE(
event_time INT8,
value NUMERIC(36,18)
) {
$data_provider := LOWER($data_provider);
$lower_caller TEXT := LOWER(@caller);
$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);
}

if is_allowed_to_read_core($stream_ref, $lower_caller, $from, $to) == false {
ERROR('wallet not allowed to read');
}

$max_int8 INT8 := 9223372036854775000;
$effective_from INT8 := COALESCE($from, 0);
$effective_to INT8 := COALESCE($to, $max_int8);
$effective_frozen_at INT8 := COALESCE($frozen_at, $max_int8);

RETURN WITH
deduped AS (
SELECT pe.event_time, pe.value,
ROW_NUMBER() OVER (
PARTITION BY pe.event_time
ORDER BY pe.created_at DESC
) AS rn
FROM primitive_events pe
WHERE pe.stream_ref = $stream_ref
AND pe.event_time >= $effective_from
AND pe.event_time <= $effective_to
AND pe.created_at <= $effective_frozen_at
)
SELECT event_time, value FROM deduped WHERE rn = 1
ORDER BY value ASC, event_time ASC
LIMIT 1;
};

-- =============================================================================
-- get_high_value: Public facade — routes to primitive or composed
-- =============================================================================
CREATE OR REPLACE ACTION get_high_value(
$data_provider TEXT,
$stream_id TEXT,
$from INT8,
$to INT8,
$frozen_at INT8
) PUBLIC VIEW RETURNS TABLE(
event_time INT8,
value NUMERIC(36,18)
) {
$data_provider := LOWER($data_provider);
$is_primitive BOOL := is_primitive_stream($data_provider, $stream_id);

if $is_primitive {
for $row in get_high_value_primitive($data_provider, $stream_id, $from, $to, $frozen_at) {
RETURN NEXT $row.event_time, $row.value;
}
} else {
-- For composed streams, iterate get_record results to find max value
$max_value NUMERIC(36,18) := NULL;
$max_event_time INT8 := NULL;
for $row in get_record($data_provider, $stream_id, $from, $to, $frozen_at, FALSE) {
if $max_value IS NULL OR $row.value > $max_value {
$max_value := $row.value;
$max_event_time := $row.event_time;
}
}
if $max_value IS NOT NULL {
RETURN NEXT $max_event_time, $max_value;
}
}
};

-- =============================================================================
-- get_low_value: Public facade — routes to primitive or composed
-- =============================================================================
CREATE OR REPLACE ACTION get_low_value(
$data_provider TEXT,
$stream_id TEXT,
$from INT8,
$to INT8,
$frozen_at INT8
) PUBLIC VIEW RETURNS TABLE(
event_time INT8,
value NUMERIC(36,18)
) {
$data_provider := LOWER($data_provider);
$is_primitive BOOL := is_primitive_stream($data_provider, $stream_id);

if $is_primitive {
for $row in get_low_value_primitive($data_provider, $stream_id, $from, $to, $frozen_at) {
RETURN NEXT $row.event_time, $row.value;
}
} else {
-- For composed streams, iterate get_record results to find min value
$min_value NUMERIC(36,18) := NULL;
$min_event_time INT8 := NULL;
for $row in get_record($data_provider, $stream_id, $from, $to, $frozen_at, FALSE) {
if $min_value IS NULL OR $row.value < $min_value {
$min_value := $row.value;
$min_event_time := $row.event_time;
}
}
if $min_value IS NOT NULL {
RETURN NEXT $min_event_time, $min_value;
}
}
};
Loading
Loading