diff --git a/internal/migrations/001-common-actions.prod.sql b/internal/migrations/001-common-actions.prod.sql new file mode 100644 index 00000000..e4bdbbec --- /dev/null +++ b/internal/migrations/001-common-actions.prod.sql @@ -0,0 +1,278 @@ +-- ============================================================================= +-- GENERATED FILE — DO NOT EDIT BY HAND +-- ============================================================================= +-- Source : internal/migrations/001-common-actions.sql +-- Script : scripts/generate_prod_migrations.py +-- +-- Manual-apply mainnet override. The embedded migration loader skips +-- *.prod.sql, so apply via: +-- +-- kwil-cli exec-sql --file --sync \ +-- --private-key $PRIVATE_KEY --provider $PROVIDER +-- +-- Prerequisite: erc20-bridge/000-extension.prod.sql must be applied +-- FIRST so the eth_truf and eth_usdc bridge instances exist. +-- ============================================================================= + +CREATE OR REPLACE ACTION create_streams( + $stream_ids TEXT[], + $stream_types TEXT[] +) PUBLIC { + -- ===== FEE COLLECTION WITH ROLE EXEMPTION ===== + $lower_caller TEXT := LOWER(@caller); + $fee_total NUMERIC(78, 0) := 0::NUMERIC(78, 0); + $fee_recipient TEXT := NULL; + $leader_hex TEXT := NULL; + + -- Get stream count (used for both fee calculation and validation) + $num_streams INT := array_length($stream_ids); + + -- Check if caller is exempt (has system:network_writer role) + $is_exempt BOOL := FALSE; + FOR $row IN are_members_of('system', 'network_writer', ARRAY[$lower_caller]) { + IF $row.wallet = $lower_caller AND $row.is_member { + $is_exempt := TRUE; + BREAK; + } + } + + -- Collect fee only from non-exempt wallets (2 TRUF per stream) + IF NOT $is_exempt { + $fee_per_stream := 2000000000000000000::NUMERIC(78, 0); -- 2 TRUF with 18 decimals + $total_fee := $fee_per_stream * $num_streams::NUMERIC(78, 0); + + IF @leader_sender IS NULL { + ERROR('Leader address not available for fee transfer'); + } + $leader_hex := encode(@leader_sender, 'hex')::TEXT; + + $caller_balance := eth_truf.balance(@caller); + + IF $caller_balance < $total_fee { + -- Derive human-readable fee from $total_fee + ERROR('Insufficient balance for stream creation. Required: ' || ($total_fee / 1000000000000000000::NUMERIC(78, 0))::TEXT || ' TRUF for ' || $num_streams::TEXT || ' stream(s)'); + } + + eth_truf.transfer($leader_hex, $total_fee); + $fee_total := $total_fee; + $fee_recipient := '0x' || $leader_hex; + } + -- ===== END FEE COLLECTION ===== + + -- ===== STREAM CREATION LOGIC ===== + -- Get caller's address (data provider) + $data_provider TEXT := $lower_caller; + + -- Check if caller is a valid ethereum address + if NOT check_ethereum_address($data_provider) { + ERROR('Invalid data provider address. Must be a valid Ethereum address: ' || $data_provider); + } + + -- Check if stream_ids and stream_types arrays have the same length + if array_length($stream_ids) != array_length($stream_types) { + ERROR('Stream IDs and stream types arrays must have the same length'); + } + + -- Validate stream IDs + for $validation_result in validate_stream_ids_format_batch($stream_ids) { + if NOT $validation_result.is_valid { + ERROR('Invalid stream_id format: ' || $validation_result.stream_id || ' - ' || + $validation_result.error_reason); + } + } + + -- Validate stream types using dedicated private function + for $validation_result in validate_stream_types_batch($stream_types) { + IF $validation_result.error_reason != '' { + ERROR('Invalid stream type at position ' || $validation_result.position || ': ' || + $validation_result.stream_type || ' - ' || $validation_result.error_reason); + } + } + + $base_uuid := uuid_generate_kwil('create_streams_' || @txid); + + -- Get the data provider id + $data_provider_id INT; + $dp_found BOOL := false; + for $data_provider_row in SELECT id + FROM data_providers + WHERE address = $data_provider + LIMIT 1 { + $dp_found := true; + $data_provider_id := $data_provider_row.id; + } + + if $dp_found = false { + ERROR('Data provider not found: ' || $data_provider); + } + + -- Create the streams using UNNEST for optimal performance + INSERT INTO streams (id, data_provider_id, data_provider, stream_id, stream_type, created_at, tx_id) + SELECT + ROW_NUMBER() OVER (ORDER BY t.stream_id) + COALESCE((SELECT MAX(id) FROM streams), 0) AS id, + $data_provider_id, + $data_provider, + t.stream_id, + t.stream_type, + @height, + @txid + FROM UNNEST($stream_ids, $stream_types) AS t(stream_id, stream_type); + + -- Create metadata for the streams using UNNEST for optimal performance + -- Insert stream_owner metadata + INSERT INTO metadata ( + row_id, + metadata_key, + value_i, + value_f, + value_b, + value_s, + value_ref, + created_at, + disabled_at, + stream_ref, + tx_id + ) + SELECT + uuid_generate_v5($base_uuid, 'metadata' || $data_provider || t.stream_id || 'stream_owner' || '1')::UUID, + 'stream_owner'::TEXT, + NULL::INT, + NULL::NUMERIC(36,18), + NULL::BOOL, + NULL::TEXT, + LOWER($data_provider)::TEXT, + @height, + NULL::INT, + s.id, + @txid + FROM UNNEST($stream_ids, $stream_types) AS t(stream_id, stream_type) + JOIN data_providers dp ON dp.address = $data_provider + JOIN streams s ON s.data_provider_id = dp.id AND s.stream_id = t.stream_id; + + -- Insert read_visibility metadata + INSERT INTO metadata ( + row_id, + metadata_key, + value_i, + value_f, + value_b, + value_s, + value_ref, + created_at, + disabled_at, + stream_ref, + tx_id + ) + SELECT + uuid_generate_v5($base_uuid, 'metadata' || $data_provider || t.stream_id || 'read_visibility' || '2')::UUID, + 'read_visibility'::TEXT, + 0::INT, + NULL::NUMERIC(36,18), + NULL::BOOL, + NULL::TEXT, + NULL::TEXT, + @height, + NULL::INT, + s.id, + @txid + FROM UNNEST($stream_ids, $stream_types) AS t(stream_id, stream_type) + JOIN data_providers dp ON dp.address = $data_provider + JOIN streams s ON s.data_provider_id = dp.id AND s.stream_id = t.stream_id; + + -- Insert readonly_key metadata (stream_owner) + INSERT INTO metadata ( + row_id, + metadata_key, + value_i, + value_f, + value_b, + value_s, + value_ref, + created_at, + disabled_at, + stream_ref, + tx_id + ) + SELECT + uuid_generate_v5($base_uuid, 'metadata' || $data_provider || t.stream_id || 'readonly_key' || '3')::UUID, + 'readonly_key'::TEXT, + NULL::INT, + NULL::NUMERIC(36,18), + NULL::BOOL, + 'stream_owner'::TEXT, + NULL::TEXT, + @height, + NULL::INT, + s.id, + @txid + FROM UNNEST($stream_ids, $stream_types) AS t(stream_id, stream_type) + JOIN data_providers dp ON dp.address = $data_provider + JOIN streams s ON s.data_provider_id = dp.id AND s.stream_id = t.stream_id; + + -- Insert readonly_key metadata (readonly_key) + INSERT INTO metadata ( + row_id, + metadata_key, + value_i, + value_f, + value_b, + value_s, + value_ref, + created_at, + disabled_at, + stream_ref, + tx_id + ) + SELECT + uuid_generate_v5($base_uuid, 'metadata' || $data_provider || t.stream_id || 'readonly_key' || '4')::UUID, + 'readonly_key'::TEXT, + NULL::INT, + NULL::NUMERIC(36,18), + NULL::BOOL, + 'readonly_key'::TEXT, + NULL::TEXT, + @height, + NULL::INT, + s.id, + @txid + FROM UNNEST($stream_ids, $stream_types) AS t(stream_id, stream_type) + JOIN data_providers dp ON dp.address = $data_provider + JOIN streams s ON s.data_provider_id = dp.id AND s.stream_id = t.stream_id; + + -- Insert type metadata + INSERT INTO metadata ( + row_id, + metadata_key, + value_i, + value_f, + value_b, + value_s, + value_ref, + created_at, + disabled_at, + stream_ref, + tx_id + ) + SELECT + uuid_generate_v5($base_uuid, 'metadata' || $data_provider || t.stream_id || 'type' || '5')::UUID, + 'type'::TEXT, + NULL::INT, + NULL::NUMERIC(36,18), + NULL::BOOL, + t.stream_type, + NULL::TEXT, + @height, + NULL::INT, + s.id, + @txid + FROM UNNEST($stream_ids, $stream_types) AS t(stream_id, stream_type) + JOIN data_providers dp ON dp.address = $data_provider + JOIN streams s ON s.data_provider_id = dp.id AND s.stream_id = t.stream_id; + + record_transaction_event( + 1, + $fee_total, + $fee_recipient, + NULL + ); +}; diff --git a/internal/migrations/003-primitive-insertion.prod.sql b/internal/migrations/003-primitive-insertion.prod.sql new file mode 100644 index 00000000..66d730da --- /dev/null +++ b/internal/migrations/003-primitive-insertion.prod.sql @@ -0,0 +1,122 @@ +-- ============================================================================= +-- GENERATED FILE — DO NOT EDIT BY HAND +-- ============================================================================= +-- Source : internal/migrations/003-primitive-insertion.sql +-- Script : scripts/generate_prod_migrations.py +-- +-- Manual-apply mainnet override. The embedded migration loader skips +-- *.prod.sql, so apply via: +-- +-- kwil-cli exec-sql --file --sync \ +-- --private-key $PRIVATE_KEY --provider $PROVIDER +-- +-- Prerequisite: erc20-bridge/000-extension.prod.sql must be applied +-- FIRST so the eth_truf and eth_usdc bridge instances exist. +-- ============================================================================= + +CREATE OR REPLACE ACTION insert_records( + $data_provider TEXT[], + $stream_id TEXT[], + $event_time INT8[], + $value NUMERIC(36,18)[] +) PUBLIC { + -- Use helper function to avoid expensive for-loop roundtrips + $data_provider := helper_lowercase_array($data_provider); + $lower_caller TEXT := LOWER(@caller); + $fee_total NUMERIC(78, 0) := 0::NUMERIC(78, 0); + $fee_recipient TEXT := NULL; + $leader_hex TEXT := NULL; + + -- Get record count (used for both fee calculation and validation) + $num_records INT := array_length($data_provider); + + -- Cap batch size to prevent superlinear block execution time. + -- With per-tx PG isolation, blocks handle thousands of small txns efficiently. + -- TODO: Commented out for now to allow larger batch sizes, MUST be re-enabled once ingestors are updated!!! + -- if $num_records > 10 { + -- ERROR('insert_records: batch size exceeds maximum of 10 records'); + -- } + + -- ===== FEE COLLECTION WITH ROLE EXEMPTION ===== + -- Check if caller is exempt (has system:network_writer role) + $is_exempt BOOL := FALSE; + FOR $row IN are_members_of('system', 'network_writer', ARRAY[$lower_caller]) { + IF $row.wallet = $lower_caller AND $row.is_member { + $is_exempt := TRUE; + BREAK; + } + } + + -- Collect fee only from non-exempt wallets (2 TRUF per record) + IF NOT $is_exempt { + $fee_per_record := 2000000000000000000::NUMERIC(78, 0); -- 2 TRUF with 18 decimals + $total_fee := $fee_per_record * $num_records::NUMERIC(78, 0); + + IF @leader_sender IS NULL { + ERROR('Leader address not available for fee transfer'); + } + $leader_hex := encode(@leader_sender, 'hex')::TEXT; + + $caller_balance := eth_truf.balance(@caller); + + IF $caller_balance < $total_fee { + -- Derive human-readable fee from $total_fee + ERROR('Insufficient balance for write fee. Required: ' || ($total_fee / 1000000000000000000::NUMERIC(78, 0))::TEXT || ' TRUF for ' || $num_records::TEXT || ' record(s)'); + } + + eth_truf.transfer($leader_hex, $total_fee); + $fee_total := $total_fee; + $fee_recipient := '0x' || $leader_hex; + } + -- ===== END FEE COLLECTION ===== + if $num_records != array_length($stream_id) or $num_records != array_length($event_time) or $num_records != array_length($value) { + ERROR('array lengths mismatch'); + } + + $current_block INT := @height; + + -- Get stream reference for all streams early for better performance + $stream_refs := get_stream_ids($data_provider, $stream_id); + + -- Check stream existence using stream refs (handles nulls for non-existent streams) + if !stream_exists_batch_core($stream_refs) { + ERROR('one or more streams do not exist'); + } + + -- Check if streams are primitive using stream refs + if !is_primitive_stream_batch_core($stream_refs) { + ERROR('one or more streams are not primitive streams'); + } + + -- Validate that the wallet is allowed to write to each stream using stream refs + if !wallet_write_batch_core($stream_refs, $lower_caller) { + ERROR('wallet not allowed to write to one or more streams'); + } + + -- Insert all records using UNNEST to expand arrays efficiently + INSERT INTO primitive_events (event_time, value, created_at, truflation_created_at, stream_ref, tx_id) + SELECT + unnested.event_time, + unnested.value, + $current_block, + NULL, + unnested.stream_ref, + @txid + FROM UNNEST($event_time, $value, $stream_refs) AS unnested(event_time, value, stream_ref) + WHERE unnested.value != 0::NUMERIC(36,18) + ORDER BY unnested.stream_ref, unnested.event_time, $current_block; -- matches (stream_ref, event_time, created_at) + + -- Enqueue days for pruning using helper (idempotent, distinct per day) + helper_enqueue_prune_days( + $stream_refs, + $event_time, + $value + ); + + record_transaction_event( + 2, + $fee_total, + $fee_recipient, + NULL + ); +}; diff --git a/internal/migrations/003-primitive-insertion.sql b/internal/migrations/003-primitive-insertion.sql index d4d30a6c..04dd53e1 100644 --- a/internal/migrations/003-primitive-insertion.sql +++ b/internal/migrations/003-primitive-insertion.sql @@ -39,9 +39,10 @@ CREATE OR REPLACE ACTION insert_records( -- Cap batch size to prevent superlinear block execution time. -- With per-tx PG isolation, blocks handle thousands of small txns efficiently. - if $num_records > 10 { - ERROR('insert_records: batch size exceeds maximum of 10 records'); - } + -- TODO: Commented out for now to allow larger batch sizes, MUST be re-enabled once ingestors are updated!!! + -- if $num_records > 10 { + -- ERROR('insert_records: batch size exceeds maximum of 10 records'); + -- } -- ===== FEE COLLECTION WITH ROLE EXEMPTION ===== -- Check if caller is exempt (has system:network_writer role) diff --git a/internal/migrations/004-composed-taxonomy.prod.sql b/internal/migrations/004-composed-taxonomy.prod.sql new file mode 100644 index 00000000..04156b7c --- /dev/null +++ b/internal/migrations/004-composed-taxonomy.prod.sql @@ -0,0 +1,146 @@ +-- ============================================================================= +-- GENERATED FILE — DO NOT EDIT BY HAND +-- ============================================================================= +-- Source : internal/migrations/004-composed-taxonomy.sql +-- Script : scripts/generate_prod_migrations.py +-- +-- Manual-apply mainnet override. The embedded migration loader skips +-- *.prod.sql, so apply via: +-- +-- kwil-cli exec-sql --file --sync \ +-- --private-key $PRIVATE_KEY --provider $PROVIDER +-- +-- Prerequisite: erc20-bridge/000-extension.prod.sql must be applied +-- FIRST so the eth_truf and eth_usdc bridge instances exist. +-- ============================================================================= + +CREATE OR REPLACE ACTION insert_taxonomy( + $data_provider TEXT, -- The data provider of the parent stream. + $stream_id TEXT, -- The stream ID of the parent stream. + $child_data_providers TEXT[], -- The data providers of the child streams. + $child_stream_ids TEXT[], -- The stream IDs of the child streams. + $weights NUMERIC(36,18)[], -- The weights of the child streams. + $start_date INT -- The start date of the taxonomy. +) PUBLIC { + $data_provider := LOWER($data_provider); + for $i in 1..array_length($child_data_providers) { + $child_data_providers[$i] := LOWER($child_data_providers[$i]); + } + $lower_caller := LOWER(@caller); + $fee_total NUMERIC(78, 0) := 0::NUMERIC(78, 0); + $fee_recipient TEXT := NULL; + $leader_hex TEXT := NULL; + + -- ensure it's a composed stream + if is_primitive_stream($data_provider, $stream_id) == true { + ERROR('stream is not a composed stream'); + } + + -- Ensure the wallet is allowed to write + if is_wallet_allowed_to_write($data_provider, $stream_id, $lower_caller) == false { + ERROR('wallet not allowed to write'); + } + + -- Determine the number of child records provided. + $num_children := array_length($child_stream_ids); + + if $num_children IS NULL { + $num_children := 0; + } + if $num_children != array_length($child_data_providers) OR $num_children != array_length($weights) { + ERROR('All child arrays must be of the same length'); + } + -- ensure there is at least 1 child, otherwise we might have silent bugs + if $num_children == 0 { + ERROR('There must be at least 1 child'); + } + + -- ===== FEE COLLECTION WITH ROLE EXEMPTION ===== + + -- Check if caller is exempt (has system:network_writer role) + $is_exempt BOOL := FALSE; + FOR $row IN are_members_of('system', 'network_writer', ARRAY[$lower_caller]) { + IF $row.wallet = $lower_caller AND $row.is_member { + $is_exempt := TRUE; + BREAK; + } + } + + -- Collect fee only from non-exempt wallets (2 TRUF per stream) + IF NOT $is_exempt { + $fee_per_stream := 2000000000000000000::NUMERIC(78, 0); -- 2 TRUF with 18 decimals + $total_fee := $fee_per_stream * $num_children::NUMERIC(78, 0); + + IF @leader_sender IS NULL { + ERROR('Leader address not available for fee transfer'); + } + $leader_hex := encode(@leader_sender, 'hex')::TEXT; + + $caller_balance := eth_truf.balance(@caller); + + IF $caller_balance < $total_fee { + -- Derive human-readable fee from $total_fee + ERROR('Insufficient balance for taxonomies creation. Required: ' || ($total_fee / 1000000000000000000::NUMERIC(78, 0))::TEXT || ' TRUF for ' || $num_children::TEXT || ' child stream(s)'); + } + + eth_truf.transfer($leader_hex, $total_fee); + $fee_total := $total_fee; + $fee_recipient := '0x' || $leader_hex; + } + -- ===== END FEE COLLECTION ===== + + -- Default start time to 0 if not provided + if $start_date IS NULL { + $start_date := 0; + } + + -- Retrieve the current group_sequence for this parent and increment it by 1. + $new_group_sequence := get_current_group_sequence($data_provider, $stream_id, true) + 1; + + $stream_ref := get_stream_id($data_provider, $stream_id); + if $stream_ref IS NULL { + ERROR('parent stream does not exist: ' || $data_provider || ':' || $stream_id); + } + + FOR $i IN 1..$num_children { + $child_data_provider_value := $child_data_providers[$i]; + $child_stream_id_value := $child_stream_ids[$i]; + $child_stream_ref := get_stream_id($child_data_provider_value, $child_stream_id_value); + $weight_value := $weights[$i]; + + if $child_stream_ref IS NULL { + ERROR('child stream does not exist: ' || $child_data_provider_value || ':' || $child_stream_id_value); + } + + $taxonomy_id := uuid_generate_kwil(@txid||$data_provider||$stream_id||$child_data_provider_value||$child_stream_id_value||$i::TEXT); + + INSERT INTO taxonomies ( + taxonomy_id, + weight, + created_at, + disabled_at, + group_sequence, + start_time, + stream_ref, + child_stream_ref, + tx_id + ) VALUES ( + $taxonomy_id, + $weight_value, + @height, -- Use the current block height for created_at. + NULL, -- New record is active. + $new_group_sequence, -- Use the new group_sequence for all child records. + $start_date, -- Start date of the taxonomy. + $stream_ref, + $child_stream_ref, + @txid + ); + } + + record_transaction_event( + 3, + $fee_total, + $fee_recipient, + NULL + ); +}; diff --git a/internal/migrations/024-attestation-actions.prod.sql b/internal/migrations/024-attestation-actions.prod.sql new file mode 100644 index 00000000..3d605e83 --- /dev/null +++ b/internal/migrations/024-attestation-actions.prod.sql @@ -0,0 +1,181 @@ +-- ============================================================================= +-- GENERATED FILE — DO NOT EDIT BY HAND +-- ============================================================================= +-- Source : internal/migrations/024-attestation-actions.sql +-- Script : scripts/generate_prod_migrations.py +-- +-- Manual-apply mainnet override. The embedded migration loader skips +-- *.prod.sql, so apply via: +-- +-- kwil-cli exec-sql --file --sync \ +-- --private-key $PRIVATE_KEY --provider $PROVIDER +-- +-- Prerequisite: erc20-bridge/000-extension.prod.sql must be applied +-- FIRST so the eth_truf and eth_usdc bridge instances exist. +-- ============================================================================= + +CREATE OR REPLACE ACTION request_attestation( + $data_provider TEXT, + $stream_id TEXT, + $action_name TEXT, + $args_bytes BYTEA, + $encrypt_sig BOOLEAN, + $max_fee NUMERIC(78, 0) +) PUBLIC RETURNS (request_tx_id TEXT, attestation_hash BYTEA) { + -- Capture transaction ID for primary key + $request_tx_id := @txid; + + -- Validate encryption flag (must be false in MVP) + if $encrypt_sig = true { + ERROR('Encryption not implemented'); + } + + -- Validate action is in allowlist + $action_id := 0; + for $row in SELECT action_id FROM attestation_actions WHERE action_name = $action_name { + $action_id := $row.action_id; + } + if $action_id = 0 { + ERROR('Action not allowed for attestation: ' || $action_name); + } + + -- ===== FEE COLLECTION WITH ROLE EXEMPTION ===== + -- Declare variables in outer scope + $attestation_fee NUMERIC(78, 0); + $caller_balance NUMERIC(78, 0); + $leader_addr TEXT; + + -- Normalizing caller and leader safely using precompiles + $caller_bytes BYTEA := tn_utils.get_caller_bytes(); + $lower_caller TEXT := tn_utils.get_caller_hex(); + + -- Check if caller is exempt (has system:network_writer role) + $is_exempt BOOL := FALSE; + FOR $row IN are_members_of('system', 'network_writer', ARRAY[$lower_caller]) { + IF $row.wallet = $lower_caller AND $row.is_member { + $is_exempt := TRUE; + BREAK; + } + } + + -- Collect fee only from non-exempt wallets (40 TRUF flat fee) + IF NOT $is_exempt { + $attestation_fee := '40000000000000000000'::NUMERIC(78, 0); -- 40 TRUF with 18 decimals + + -- Validate max_fee if provided + IF $max_fee IS NOT NULL AND $max_fee > 0::NUMERIC(78, 0) { + IF $attestation_fee > $max_fee { + ERROR('Attestation fee (40 TRUF) exceeds caller max_fee limit: ' || ($max_fee / 1000000000000000000::NUMERIC(78, 0))::TEXT || ' TRUF'); + } + } + + $caller_balance := eth_truf.balance(@caller); + + IF $caller_balance < $attestation_fee { + ERROR('Insufficient balance for attestation. Required: 40 TRUF'); + } + + -- Safe leader address conversion + $leader_addr := tn_utils.get_leader_hex(); + IF $leader_addr = '' { + ERROR('Leader address not available for fee transfer'); + } + + eth_truf.transfer($leader_addr, $attestation_fee); + } + -- ===== END FEE COLLECTION ===== + + -- Get current block height + $created_height := @height; + + -- Normalize caller address to bytes for storage (re-use safe normalization) + $caller_bytes := $caller_bytes; -- Already normalized above + + -- Normalize provider input and enforce length + $provider_lower := LOWER($data_provider); + if char_length($provider_lower) != 42 { + ERROR('data_provider must be 0x-prefixed 40 hex characters'); + } + if substring($provider_lower, 1, 2) != '0x' { + ERROR('data_provider must be 0x-prefixed 40 hex characters'); + } + $data_provider_bytes := decode(substring($provider_lower, 3, 40), 'hex'); + + if length($stream_id) != 32 { + ERROR('stream_id must be 32 characters'); + } + $stream_bytes := $stream_id::BYTEA; + + -- Validate date range for range-based attestation actions (IDs 1-3) BEFORE + -- executing the query. This prevents unbounded queries from scanning the entire + -- primitive_events table during block execution. Max range: 90 days. + -- This check runs before call_dispatch to reject expensive queries early, + -- before kwil-db buffers all result rows into memory. + tn_utils.validate_attestation_date_range($action_id, $args_bytes); + + -- Force deterministic execution by overriding non-deterministic parameters. + -- Query actions (IDs 1-5) all have use_cache as their last parameter. + -- Force use_cache=false to ensure all validators compute identical results + -- regardless of cache state. + if $action_id >= 1 AND $action_id <= 5 { + $args_bytes := tn_utils.force_last_arg_false($args_bytes); + } + + -- Execute target query deterministically using tn_utils.call_dispatch precompile + $query_result := tn_utils.call_dispatch($action_name, $args_bytes); + $result_payload := tn_utils.canonical_to_datapoints_abi($query_result); + + $version := 1; + $algo := 0; -- secp256k1 + -- Serialize canonical payload (version through result) using tn_utils helpers + $version_bytes := tn_utils.encode_uint8($version::INT); + $algo_bytes := tn_utils.encode_uint8($algo::INT); + $height_bytes := tn_utils.encode_uint64($created_height::INT); + $action_id_bytes := tn_utils.encode_uint16($action_id::INT); + + -- Canonical payload mirrors Go helpers: each field length-prefixed so the + -- validator can recover every component without ambiguity. + $result_canonical := tn_utils.bytea_join(ARRAY[ + $version_bytes, + $algo_bytes, + $height_bytes, + tn_utils.bytea_length_prefix($data_provider_bytes), + tn_utils.bytea_length_prefix($stream_bytes), + $action_id_bytes, + tn_utils.bytea_length_prefix($args_bytes), + tn_utils.bytea_length_prefix($result_payload) + ], NULL); + + -- Build hash material in canonical order using caller-provided inputs only. + -- This keeps the hash deterministic for clients (excludes block height and result). + $hash_input := tn_utils.bytea_join(ARRAY[ + $version_bytes, + $algo_bytes, + tn_utils.bytea_length_prefix($data_provider_bytes), + tn_utils.bytea_length_prefix($stream_bytes), + $action_id_bytes, + tn_utils.bytea_length_prefix($args_bytes) + ], NULL); + $attestation_hash := digest($hash_input, 'sha256'); + + -- Store unsigned attestation + INSERT INTO attestations ( + request_tx_id, attestation_hash, requester, data_provider, stream_id, + result_canonical, encrypt_sig, created_height, signature, validator_pubkey, signed_height + ) VALUES ( + $request_tx_id, $attestation_hash, $caller_bytes, $data_provider, $stream_id, + $result_canonical, $encrypt_sig, $created_height, NULL, NULL, NULL + ); + + -- Queue for signing (no-op on non-leader validators; handled by precompile) + tn_attestation.queue_for_signing(encode($attestation_hash, 'hex')); + + record_transaction_event( + 6, + $attestation_fee, + $leader_addr, + NULL + ); + +RETURN $request_tx_id, $attestation_hash; +}; diff --git a/internal/migrations/031-order-book-vault.prod.sql b/internal/migrations/031-order-book-vault.prod.sql new file mode 100644 index 00000000..ae864d3d --- /dev/null +++ b/internal/migrations/031-order-book-vault.prod.sql @@ -0,0 +1,51 @@ +-- ============================================================================= +-- GENERATED FILE — DO NOT EDIT BY HAND +-- ============================================================================= +-- Source : internal/migrations/031-order-book-vault.sql +-- Script : scripts/generate_prod_migrations.py +-- +-- Manual-apply mainnet override. The embedded migration loader skips +-- *.prod.sql, so apply via: +-- +-- kwil-cli exec-sql --file --sync \ +-- --private-key $PRIVATE_KEY --provider $PROVIDER +-- +-- Prerequisite: erc20-bridge/000-extension.prod.sql must be applied +-- FIRST so the eth_truf and eth_usdc bridge instances exist. +-- ============================================================================= + +CREATE OR REPLACE ACTION ob_lock_collateral($bridge TEXT, $amount NUMERIC(78, 0)) +PRIVATE { + -- Validate bridge (will ERROR if invalid) + -- Note: validate_bridge procedure defined in 032-order-book-actions.sql + -- Since migrations run in order, that procedure will exist when this is called + + -- Validate amount + if $amount IS NULL OR $amount <= 0::NUMERIC(78, 0) { + ERROR('Lock amount must be positive'); + } + + -- Lock collateral using bridge (user -> network ownedBalance) + if $bridge != 'eth_usdc' { + ERROR('Invalid bridge. Supported: eth_usdc'); + } + eth_usdc.lock($amount); +}; + +CREATE OR REPLACE ACTION ob_unlock_collateral($bridge TEXT, $user_address TEXT, $amount NUMERIC(78, 0)) +PRIVATE { + -- Validate inputs (must be 0x-prefixed 40 hex character address) + if $user_address IS NULL OR length($user_address) != 42 OR substring(LOWER($user_address), 1, 2) != '0x' { + ERROR('Invalid user address format (expected 0x-prefixed hex, 42 chars)'); + } + + if $amount IS NULL OR $amount <= 0::NUMERIC(78, 0) { + ERROR('Unlock amount must be positive'); + } + + -- Unlock collateral using bridge (network ownedBalance -> user) + if $bridge != 'eth_usdc' { + ERROR('Invalid bridge. Supported: eth_usdc'); + } + eth_usdc.unlock($user_address, $amount); +}; diff --git a/internal/migrations/032-order-book-actions.prod.sql b/internal/migrations/032-order-book-actions.prod.sql new file mode 100644 index 00000000..1b7608a6 --- /dev/null +++ b/internal/migrations/032-order-book-actions.prod.sql @@ -0,0 +1,735 @@ +-- ============================================================================= +-- GENERATED FILE — DO NOT EDIT BY HAND +-- ============================================================================= +-- Source : internal/migrations/032-order-book-actions.sql +-- Script : scripts/generate_prod_migrations.py +-- +-- Manual-apply mainnet override. The embedded migration loader skips +-- *.prod.sql, so apply via: +-- +-- kwil-cli exec-sql --file --sync \ +-- --private-key $PRIVATE_KEY --provider $PROVIDER +-- +-- Prerequisite: erc20-bridge/000-extension.prod.sql must be applied +-- FIRST so the eth_truf and eth_usdc bridge instances exist. +-- ============================================================================= + +CREATE OR REPLACE ACTION validate_bridge($bridge TEXT) PRIVATE { + if $bridge IS NULL { + ERROR('bridge parameter is required'); + } + + if $bridge != 'eth_usdc' { + ERROR('Invalid bridge. Supported: eth_usdc'); + } + + RETURN; +}; + +CREATE OR REPLACE ACTION create_market( + $bridge TEXT, + $query_components BYTEA, + $settle_time INT8, + $max_spread INT, + $min_order_size INT8 +) PUBLIC RETURNS (query_id INT) { + -- ========================================================================== + -- VALIDATION + -- ========================================================================== + + -- Validate bridge parameter + validate_bridge($bridge); + + -- Validate query components (must be ABI-encoded) + if $query_components IS NULL OR length(encode($query_components, 'hex')) = 0 { + ERROR('query_components is required (ABI-encoded (address,bytes32,string,bytes))'); + } + + -- Compute hash from query components using attestation format + -- This ensures market hash matches attestation hash for automatic settlement + $query_hash BYTEA; + for $row in tn_utils.compute_attestation_hash($query_components) { + $query_hash := $row.hash; + } + + -- Validate hash is exactly 32 bytes + if length(encode($query_hash, 'hex')) != 64 { -- 32 bytes = 64 hex chars + ERROR('Invalid query_components: computed hash must be 32 bytes'); + } + + -- Check for duplicate market (hash must be unique) + $existing_id INT; + for $row in SELECT id FROM ob_queries WHERE hash = $query_hash { + $existing_id := $row.id; + } + if $existing_id IS NOT NULL { + ERROR('Market already exists with this query hash (query_id: ' || $existing_id::TEXT || ')'); + } + + -- Validate settlement time (must be in the future) + -- Use @block_timestamp (unix epoch seconds of current block) + if $settle_time IS NULL OR $settle_time <= @block_timestamp { + ERROR('Settlement time must be in the future'); + } + + -- Validate max_spread (1-50 cents) + if $max_spread IS NULL OR $max_spread < 1 OR $max_spread > 50 { + ERROR('Max spread must be between 1 and 50 cents'); + } + + -- Validate min_order_size (must be positive) + if $min_order_size IS NULL OR $min_order_size < 1 { + ERROR('Minimum order size must be positive'); + } + + -- ========================================================================== + -- FEE COLLECTION + -- ========================================================================== + -- Fee: 2 TRUF (2 * 10^18 wei) + -- Market creation fee is ALWAYS paid in TRUF (eth_truf on testnet) + -- regardless of which bridge the market uses for collateral + $market_creation_fee NUMERIC(78, 0) := '2000000000000000000'::NUMERIC(78, 0); + + -- Check caller has sufficient TRUF balance + -- IMPORTANT: Fee is collected from eth_truf (TRUF), not from market's bridge + $caller_balance NUMERIC(78, 0) := COALESCE(eth_truf.balance(@caller), 0::NUMERIC(78, 0)); + + if $caller_balance < $market_creation_fee { + ERROR('Insufficient TRUF balance for market creation fee. Required: 2 TRUF (eth_truf balance)'); + } + + -- Verify leader address is available for fee transfer + if @leader_sender IS NULL { + ERROR('Leader address not available for fee transfer'); + } + + -- Safe leader address conversion (handles both TEXT and BYTEA leader_sender) + $leader_hex TEXT := tn_utils.get_leader_hex(); + if $leader_hex = '' { + ERROR('Leader address not available for fee transfer'); + } + + -- Transfer fee to leader from TRUF bridge (eth_truf) + eth_truf.transfer($leader_hex, $market_creation_fee); + + -- ========================================================================== + -- CREATE MARKET + -- ========================================================================== + + -- Safe caller normalization (handles both TEXT and BYTEA @caller) + $caller_bytes BYTEA := tn_utils.get_caller_bytes(); + + -- Insert market record with MAX(id) + 1 pattern + -- Note: This is safe in Kwil because transactions within a block are processed + -- sequentially by the consensus engine, not concurrently. + INSERT INTO ob_queries ( + id, + hash, + query_components, + settle_time, + max_spread, + min_order_size, + created_at, + creator, + bridge + ) + SELECT + COALESCE(MAX(id), 0) + 1, + $query_hash, + $query_components, + $settle_time, + $max_spread, + $min_order_size, + @height, + $caller_bytes, + $bridge + FROM ob_queries; + + -- Get the ID we just inserted + $query_id INT; + for $row in SELECT id FROM ob_queries WHERE hash = $query_hash { + $query_id := $row.id; + } + + -- ========================================================================== + -- RECORD TRANSACTION EVENT + -- ========================================================================== + record_transaction_event( + 8, + $market_creation_fee, + $leader_hex, + NULL + ); + + RETURN $query_id; +}; + +CREATE OR REPLACE ACTION place_buy_order( + $query_id INT, + $outcome BOOL, + $price INT, + $amount INT8 +) PUBLIC { + -- Constants + $collateral_decimals INT := 18; + + -- ========================================================================== + -- SECTION 1: VALIDATION + -- ========================================================================== + + -- 1.1 Get market bridge (will ERROR if market doesn't exist) + $bridge TEXT := get_market_bridge($query_id); + + -- 1.2 Validate @caller format and normalize to bytes + -- Safe caller normalization (handles both TEXT and BYTEA @caller) + $caller_bytes BYTEA := tn_utils.get_caller_bytes(); + + -- 1.3 Validate parameters + if $query_id IS NULL { + ERROR('query_id is required'); + } + + if $outcome IS NULL { + ERROR('outcome is required (TRUE for YES, FALSE for NO)'); + } + + if $price IS NULL OR $price < 1 OR $price > 99 { + ERROR('price must be between 1 and 99 ($0.01 to $0.99)'); + } + + if $amount IS NULL OR $amount <= 0 { + ERROR('amount must be positive'); + } + + if $amount > 1000000000 { + ERROR('amount exceeds maximum allowed of 1,000,000,000'); + } + + -- 1.4 Validate market exists and is not settled + $settled BOOL; + $settle_time INT8; + $market_found BOOL := false; + + for $row in SELECT settled, settle_time FROM ob_queries WHERE id = $query_id { + $settled := $row.settled; + $settle_time := $row.settle_time; + $market_found := true; + } + + if NOT $market_found { + ERROR('Market does not exist (query_id: ' || $query_id::TEXT || ')'); + } + + -- Note: Markets remain tradable until settlement time is reached or explicitly settled (settled=true). + -- The settle_time is metadata indicating when settlement CAN occur, and now serves as a hard cutoff for trading. + -- Users cannot continue trading past settle_time. + -- This two-phase design allows flexibility in settlement timing while ensuring a fixed trading window. + if $settled { + ERROR('Market has already settled (no trading allowed)'); + } + + -- Trading Cutoff: Prevent new orders after settlement time + if @block_timestamp >= $settle_time { + ERROR('Trading is closed. Market has passed its settlement time.'); + } + + -- ========================================================================== + -- SECTION 2: CALCULATE COLLATERAL NEEDED + -- ========================================================================== + + -- For buy order: collateral = amount × price × 10^16 + -- Example: 10 shares at $0.56 = 10 × 56 × 10^16 = 5.6 × 10^18 wei + -- + -- Why 10^16? + -- - Prices are in cents (1-99) + -- - Token has 18 decimals + -- - Formula: 10^(18-2) = 10^16 + -- Note: Kuneiform doesn't have POWER(), so we use hardcoded constant + -- Cast INT8 and INT to NUMERIC for multiplication + $collateral_needed NUMERIC(78, 0) := ($amount::NUMERIC(78, 0) * $price::NUMERIC(78, 0) * '10000000000000000'::NUMERIC(78, 0)); + + -- ========================================================================== + -- SECTION 3: CHECK BALANCE (bridge-specific) + -- ========================================================================== + + $caller_balance NUMERIC(78, 0); + $caller_balance := COALESCE(eth_usdc.balance(@caller), 0::NUMERIC(78, 0)); + + if $caller_balance < $collateral_needed { + -- Note: Division by 10^18 for display purposes (convert wei to TRUF) + ERROR('Insufficient balance. Required: ' || $collateral_needed::TEXT || ' wei (' || + ($collateral_needed / '1000000000000000000'::NUMERIC(78, 0))::TEXT || ' TRUF)'); + } + + -- ========================================================================== + -- SECTION 4: GET OR CREATE PARTICIPANT + -- ========================================================================== + + -- Safe caller normalization (already done in Section 1.2) + -- $caller_bytes is already available + + -- Try to get existing participant + $participant_id INT; + for $row in SELECT id FROM ob_participants WHERE wallet_address = $caller_bytes { + $participant_id := $row.id; + } + + -- Create if not found (MAX(id) + 1 pattern) + -- Note: This is safe in Kwil because transactions within a block are processed + -- sequentially by the consensus engine, not concurrently. + if $participant_id IS NULL { + INSERT INTO ob_participants (id, wallet_address) + SELECT COALESCE(MAX(id), 0) + 1, $caller_bytes + FROM ob_participants; + + -- Retrieve the newly created ID + for $row in SELECT id FROM ob_participants WHERE wallet_address = $caller_bytes { + $participant_id := $row.id; + } + } + + + + -- ========================================================================== + -- SECTION 5: LOCK COLLATERAL (bridge-specific) + -- ========================================================================== + + -- Lock tokens from user to vault (network-owned balance) + -- Note: Bridge lock() throws ERROR on failure (insufficient balance, etc.) + eth_usdc.lock($collateral_needed); + + -- Record initial impact (collateral spent) + ob_record_tx_impact($participant_id, $outcome, 0::INT8, $collateral_needed, TRUE); + + -- ========================================================================== + -- SECTION 6: INSERT BUY ORDER (UPSERT) + -- ========================================================================== + + -- Buy orders use NEGATIVE price to distinguish from sell orders + -- Example: Buy at $0.56 stored as price = -56 + -- + -- If multiple orders at same (query_id, participant_id, outcome, price): + -- - Accumulate amounts + -- - Update timestamp to latest (FIFO within price level) + INSERT INTO ob_positions + (query_id, participant_id, outcome, price, amount, last_updated) + VALUES ($query_id, $participant_id, $outcome, -$price, $amount, @block_timestamp) + ON CONFLICT (query_id, participant_id, outcome, price) DO UPDATE + SET amount = ob_positions.amount + EXCLUDED.amount, + last_updated = EXCLUDED.last_updated; + + -- Record order event + ob_record_order_event($query_id, $participant_id, 'buy_placed', $outcome, $price, $amount, NULL); + + -- ========================================================================== + -- SECTION 7: TRIGGER MATCHING ENGINE + -- ========================================================================== + + -- Attempt to match this buy order with existing sell orders + match_orders($query_id, $outcome, $price, $bridge); + + -- ========================================================================== + -- SECTION 8: CLEANUP & MATERIALIZE IMPACTS + -- ========================================================================== + + ob_cleanup_tx_payouts($query_id); + + -- Success: Order placed (may be partially or fully matched by future matching engine) +}; + +CREATE OR REPLACE ACTION place_split_limit_order( + $query_id INT, + $true_price INT, + $amount INT8 +) PUBLIC { + -- ========================================================================== + -- SECTION 1: VALIDATION + -- ========================================================================== + + -- 1.1 Get market bridge (will ERROR if market doesn't exist) + $bridge TEXT := get_market_bridge($query_id); + + -- 1.2 Validate @caller format and normalize to bytes + -- Safe caller normalization (handles both TEXT and BYTEA @caller) + $caller_bytes BYTEA := tn_utils.get_caller_bytes(); + + -- 1.3 Validate parameters + if $query_id IS NULL { + ERROR('query_id is required'); + } + + if $true_price IS NULL OR $true_price < 1 OR $true_price > 99 { + ERROR('true_price must be between 1 and 99 ($0.01 to $0.99)'); + } + + if $amount IS NULL OR $amount <= 0 { + ERROR('amount must be positive'); + } + + if $amount > 1000000000 { + ERROR('amount exceeds maximum allowed of 1,000,000,000'); + } + + -- 1.4 Validate market exists and is not settled + $settled BOOL; + $settle_time INT8; + $market_found BOOL := false; + + for $row in SELECT settled, settle_time FROM ob_queries WHERE id = $query_id { + $settled := $row.settled; + $settle_time := $row.settle_time; + $market_found := true; + } + + if NOT $market_found { + ERROR('Market does not exist (query_id: ' || $query_id::TEXT || ')'); + } + + -- Note: Markets remain tradable until settlement time is reached or explicitly settled (settled=true). + -- The settle_time is metadata indicating when settlement CAN occur, and now serves as a hard cutoff for trading. + -- Users cannot continue trading past settle_time. + -- This two-phase design allows flexibility in settlement timing while ensuring a fixed trading window. + if $settled { + ERROR('Market has already settled (no trading allowed)'); + } + + -- Trading Cutoff: Prevent new orders after settlement time + if @block_timestamp >= $settle_time { + ERROR('Trading is closed. Market has passed its settlement time.'); + } + + -- ========================================================================== + -- SECTION 2: CALCULATE COLLATERAL NEEDED + -- ========================================================================== + + -- For split order: collateral = amount × $1.00 = amount × 10^18 + -- Example: 100 shares × 10^18 = 100,000,000,000,000,000,000 wei = 100 TRUF + -- + -- Why 10^18? + -- - Minting a share pair (YES + NO) requires $1.00 total collateral + -- - Token has 18 decimals + -- - Formula: $1.00 × 10^18 + -- Note: Kuneiform doesn't have POWER(), so we use hardcoded constant + -- Cast INT8 to NUMERIC for multiplication + $collateral_needed NUMERIC(78, 0) := ($amount::NUMERIC(78, 0) * '1000000000000000000'::NUMERIC(78, 0)); + + -- ========================================================================== + -- SECTION 3: CHECK BALANCE (bridge-specific) + -- ========================================================================== + + $caller_balance NUMERIC(78, 0); + $caller_balance := COALESCE(eth_usdc.balance(@caller), 0::NUMERIC(78, 0)); + + if $caller_balance < $collateral_needed { + -- Note: Division by 10^18 for display purposes (convert wei to TRUF) + ERROR('Insufficient balance. Required: ' || $collateral_needed::TEXT || ' wei (' || + ($collateral_needed / '1000000000000000000'::NUMERIC(78, 0))::TEXT || ' TRUF)'); + } + + -- ========================================================================== + -- SECTION 4: GET OR CREATE PARTICIPANT + -- ========================================================================== + + -- Safe caller normalization (already done in Section 1.2) + -- $caller_bytes is already available + + -- Try to get existing participant + $participant_id INT; + for $row in SELECT id FROM ob_participants WHERE wallet_address = $caller_bytes { + $participant_id := $row.id; + } + + -- Create if not found (MAX(id) + 1 pattern) + -- Note: This is safe in Kwil because transactions within a block are processed + -- sequentially by the consensus engine, not concurrently. + if $participant_id IS NULL { + INSERT INTO ob_participants (id, wallet_address) + SELECT COALESCE(MAX(id), 0) + 1, $caller_bytes + FROM ob_participants; + + -- Retrieve the newly created ID + for $row in SELECT id FROM ob_participants WHERE wallet_address = $caller_bytes { + $participant_id := $row.id; + } + } + + + + -- ========================================================================== + -- SECTION 5: LOCK COLLATERAL (bridge-specific) + -- ========================================================================== + + -- Lock tokens from user to vault (network-owned balance) + -- Note: Bridge lock() throws ERROR on failure (insufficient balance, etc.) + eth_usdc.lock($collateral_needed); + + -- Record initial impacts: + -- Calculate split collateral (50/50 split for YES/NO legs) + $collateral_per_leg NUMERIC(78, 0) := $collateral_needed / 2::NUMERIC(78, 0); + -- Handle dust: add remainder to YES leg if odd amount + $collateral_yes NUMERIC(78, 0) := $collateral_per_leg + ($collateral_needed - (2::NUMERIC(78, 0) * $collateral_per_leg)); + + -- 1. Collateral lock (split between outcomes) + ob_record_tx_impact($participant_id, TRUE, 0::INT8, $collateral_yes, TRUE); + ob_record_tx_impact($participant_id, FALSE, 0::INT8, $collateral_per_leg, TRUE); + + -- 2. Mint YES shares + ob_record_tx_impact($participant_id, TRUE, $amount, 0::NUMERIC(78,0), FALSE); + -- 3. Mint NO shares + ob_record_tx_impact($participant_id, FALSE, $amount, 0::NUMERIC(78,0), FALSE); + + -- ========================================================================== + -- SECTION 7: CREATE POSITIONS + -- ========================================================================== + + + -- Mint YES shares and hold them (not for sale) + -- These are stored with price = 0 to indicate holding (not listed) + -- + -- If user already has YES holdings, amounts accumulate (UPSERT) + INSERT INTO ob_positions + (query_id, participant_id, outcome, price, amount, last_updated) + VALUES ($query_id, $participant_id, TRUE, 0, $amount, @block_timestamp) + ON CONFLICT (query_id, participant_id, outcome, price) DO UPDATE + SET amount = ob_positions.amount + EXCLUDED.amount, + last_updated = EXCLUDED.last_updated; + + -- ========================================================================== + -- SECTION 7: MINT NO SHARES (SELL ORDER) + -- ========================================================================== + + -- Calculate complementary price for NO shares + -- If user wants YES @ $0.56, they sell NO @ $0.44 (100 - 56 = 44) + $false_price INT := 100 - $true_price; + + -- Create NO sell order directly (v2 optimization) + -- Note: We skip the intermediate holding step - go straight to sell order + -- This is more efficient than: mint at price=0, then call place_sell_order() + -- + -- If user already has a NO sell order at this price, amounts accumulate (UPSERT) + INSERT INTO ob_positions + (query_id, participant_id, outcome, price, amount, last_updated) + VALUES ($query_id, $participant_id, FALSE, $false_price, $amount, @block_timestamp) + ON CONFLICT (query_id, participant_id, outcome, price) DO UPDATE + SET amount = ob_positions.amount + EXCLUDED.amount, + last_updated = EXCLUDED.last_updated; + + -- Record order events: YES holding + NO sell + ob_record_order_event($query_id, $participant_id, 'split_placed', TRUE, $true_price, $amount, NULL); + ob_record_order_event($query_id, $participant_id, 'split_placed', FALSE, $false_price, $amount, NULL); + + -- ========================================================================== + -- SECTION 8: TRIGGER MATCHING ENGINE + -- ========================================================================== + + -- Attempt to match the NO sell order with existing buy orders + -- Match is attempted on the FALSE (NO) outcome at the false_price + match_orders($query_id, FALSE, $false_price, $bridge); + + -- ========================================================================== + -- SECTION 9: CLEANUP & MATERIALIZE IMPACTS + -- ========================================================================== + + ob_cleanup_tx_payouts($query_id); + + -- Success: Split order placed + -- - YES shares held at price=0 (not for sale) + -- - NO shares listed for sale at price=false_price + -- - May be partially or fully matched by future matching engine +}; + +CREATE OR REPLACE ACTION change_bid( + $query_id INT, + $outcome BOOL, + $old_price INT, + $new_price INT, + $new_amount INT8 +) PUBLIC { + -- ========================================================================== + -- SECTION 0: GET MARKET BRIDGE + -- ========================================================================== + + -- Get market bridge (will ERROR if market doesn't exist) + $bridge TEXT := get_market_bridge($query_id); + + -- Safe caller normalization using precompiles + $caller_bytes BYTEA := tn_utils.get_caller_bytes(); + + -- ========================================================================== + -- SECTION 2: VALIDATE PARAMETERS + -- ========================================================================== + + if $query_id IS NULL OR $query_id < 1 { + ERROR('Invalid query_id'); + } + + if $outcome IS NULL { + ERROR('Outcome must be specified (TRUE for YES, FALSE for NO)'); + } + + -- Validate old_price (must be negative for buy orders) + if $old_price IS NULL OR $old_price >= 0 OR $old_price < -99 { + ERROR('Old price must be negative (buy order) between -99 and -1'); + } + + -- Validate new_price (must be negative for buy orders) + if $new_price IS NULL OR $new_price >= 0 OR $new_price < -99 { + ERROR('New price must be negative (buy order) between -99 and -1'); + } + + -- Validate prices are different + if $old_price = $new_price { + ERROR('New price must differ from old price. Use cancel_order() to remove an order.'); + } + + -- Validate amount + if $new_amount IS NULL OR $new_amount <= 0 { + ERROR('New amount must be positive'); + } + + if $new_amount > 1000000000 { + ERROR('amount exceeds maximum allowed of 1,000,000,000'); + } + + -- ========================================================================== + -- SECTION 3: VALIDATE MARKET + -- ========================================================================== + + $settled BOOL; + $settle_time INT8; + for $row in SELECT settled, settle_time FROM ob_queries WHERE id = $query_id { + $settled := $row.settled; + $settle_time := $row.settle_time; + } + + if $settled IS NULL { + ERROR('Market does not exist'); + } + + if $settled { + ERROR('Cannot modify orders on settled market'); + } + + -- Trading Cutoff: Prevent modifying orders after settlement time + if @block_timestamp >= $settle_time { + ERROR('Trading is closed. Market has passed its settlement time.'); + } + + -- ========================================================================== + -- SECTION 4: GET OLD ORDER DETAILS + -- ========================================================================== + + $participant_id INT := ob_get_participant_id(tn_utils.get_caller_hex()); + if $participant_id IS NULL { + ERROR('No participant record found for this wallet'); + } + + -- Get old order amount and timestamp + $old_amount INT8; + $old_timestamp INT8; + for $row in SELECT amount, last_updated FROM ob_positions + WHERE query_id = $query_id + AND participant_id = $participant_id + AND outcome = $outcome + AND price = $old_price { + $old_amount := $row.amount; + $old_timestamp := $row.last_updated; + } + + if $old_amount IS NULL { + ERROR('Old order not found at specified price'); + } + + -- ========================================================================== + -- SECTION 5: CALCULATE COLLATERAL CHANGE + -- ========================================================================== + + -- Buy order collateral formula: amount × |price| × 10^16 wei + -- Example: 100 shares @ $0.54 = 100 × 54 × 10^16 = 5.4 × 10^19 wei (54 TRUF) + $multiplier NUMERIC(78, 0) := '10000000000000000'::NUMERIC(78, 0); -- 10^16 + + $old_abs_price INT := -$old_price; -- Make positive + $new_abs_price INT := -$new_price; -- Make positive + + $old_collateral NUMERIC(78, 0) := $old_amount::NUMERIC(78, 0) * + $old_abs_price::NUMERIC(78, 0) * + $multiplier; + + $new_collateral NUMERIC(78, 0) := $new_amount::NUMERIC(78, 0) * + $new_abs_price::NUMERIC(78, 0) * + $multiplier; + + $collateral_delta NUMERIC(78, 0) := $new_collateral - $old_collateral; + $zero NUMERIC(78, 0) := '0'::NUMERIC(78, 0); + + -- ========================================================================== + -- SECTION 6: ADJUST COLLATERAL (NET CHANGE ONLY) + -- ========================================================================== + + if $collateral_delta > $zero { + -- New order needs MORE collateral + -- Lock additional amount (will ERROR if insufficient balance) + eth_usdc.lock($collateral_delta); + + -- Record initial impact (lock) + ob_record_tx_impact($participant_id, $outcome, 0::INT8, $collateral_delta, TRUE); + } else if $collateral_delta < $zero { + -- New order needs LESS collateral + -- Unlock excess amount + $unlock_amount NUMERIC(78, 0) := $zero - $collateral_delta; -- Make positive + ob_unlock_collateral($bridge, @caller, $unlock_amount); + + -- Record initial impact (refund) + ob_record_tx_impact($participant_id, $outcome, 0::INT8, $unlock_amount, FALSE); + } + -- If $collateral_delta = 0, no collateral adjustment needed + + -- ========================================================================== + -- SECTION 7: DELETE OLD ORDER + -- ========================================================================== + + DELETE FROM ob_positions + WHERE query_id = $query_id + AND participant_id = $participant_id + AND outcome = $outcome + AND price = $old_price; + + -- ========================================================================== + -- SECTION 8: INSERT NEW ORDER (PRESERVING TIMESTAMP) + -- ========================================================================== + + -- CRITICAL: Use old_timestamp to preserve FIFO priority + -- If accumulating with existing order at new price, keep EARLIEST timestamp + INSERT INTO ob_positions + (query_id, participant_id, outcome, price, amount, last_updated) + VALUES ($query_id, $participant_id, $outcome, $new_price, $new_amount, $old_timestamp) + ON CONFLICT (query_id, participant_id, outcome, price) DO UPDATE + SET amount = ob_positions.amount + EXCLUDED.amount, + last_updated = CASE + WHEN ob_positions.last_updated < EXCLUDED.last_updated + THEN ob_positions.last_updated -- Keep earlier timestamp (existing order was first) + ELSE EXCLUDED.last_updated -- Keep earlier timestamp (moved order was first) + END; + + -- Record bid change event (positive price for display) + ob_record_order_event($query_id, $participant_id, 'bid_changed', $outcome, $new_abs_price, $new_amount, NULL); + + -- ========================================================================== + -- SECTION 9: TRIGGER MATCHING ENGINE + -- ========================================================================== + + -- Try to match new order immediately + -- Note: match_orders expects positive price (1-99), so use $new_abs_price not $new_price + match_orders($query_id, $outcome, $new_abs_price, $bridge); + + -- ========================================================================== + -- SECTION 10: CLEANUP & MATERIALIZE IMPACTS + -- ========================================================================== + + ob_cleanup_tx_payouts($query_id); + + -- Success: Buy order price modified atomically + -- - Old order deleted, new order placed with preserved timestamp + -- - Collateral adjusted (net change only) + -- - FIFO priority maintained +}; diff --git a/internal/migrations/032-order-book-actions.sql b/internal/migrations/032-order-book-actions.sql index d99d988c..15f2c88a 100644 --- a/internal/migrations/032-order-book-actions.sql +++ b/internal/migrations/032-order-book-actions.sql @@ -148,7 +148,6 @@ CREATE OR REPLACE ACTION create_market( -- Check caller has sufficient TRUF balance -- IMPORTANT: Fee is collected from hoodi_tt (TRUF), not from market's bridge - -- TODO: Replace hoodi_tt with actual TRUF bridge on mainnet when available! hoodi_tt is testnet only. $caller_balance NUMERIC(78, 0) := COALESCE(hoodi_tt.balance(@caller), 0::NUMERIC(78, 0)); if $caller_balance < $market_creation_fee { diff --git a/internal/migrations/033-order-book-settlement.prod.sql b/internal/migrations/033-order-book-settlement.prod.sql new file mode 100644 index 00000000..048ba66b --- /dev/null +++ b/internal/migrations/033-order-book-settlement.prod.sql @@ -0,0 +1,255 @@ +-- ============================================================================= +-- GENERATED FILE — DO NOT EDIT BY HAND +-- ============================================================================= +-- Source : internal/migrations/033-order-book-settlement.sql +-- Script : scripts/generate_prod_migrations.py +-- +-- Manual-apply mainnet override. The embedded migration loader skips +-- *.prod.sql, so apply via: +-- +-- kwil-cli exec-sql --file --sync \ +-- --private-key $PRIVATE_KEY --provider $PROVIDER +-- +-- Prerequisite: erc20-bridge/000-extension.prod.sql must be applied +-- FIRST so the eth_truf and eth_usdc bridge instances exist. +-- ============================================================================= + +CREATE OR REPLACE ACTION ob_batch_unlock_collateral( + $query_id INT, + $bridge TEXT, + $pids INT[], + $wallet_hexes TEXT[], + $amounts NUMERIC(78, 0)[], + $outcomes BOOL[] +) PRIVATE { + -- 1. Call precompile for each unlock (Safe in loops) + for $payout in + SELECT u.wallet_hex, u.amount + FROM UNNEST($wallet_hexes, $amounts) AS u(wallet_hex, amount) + { + eth_usdc.unlock($payout.wallet_hex, $payout.amount); + } + + -- 2. Record all impacts using a loop to avoid "unknown variable" in INSERT...SELECT + $next_id INT; + for $row in SELECT COALESCE(MAX(id), 0::INT) + 1 as val FROM ob_net_impacts { + $next_id := $row.val; + } + + for $imp in + SELECT pid, outcome, amount + FROM UNNEST($pids, $outcomes, $amounts) AS u(pid, outcome, amount) + { + $cur_pid INT := $imp.pid; + $cur_outcome BOOL := $imp.outcome; + $cur_amount NUMERIC(78,0) := $imp.amount; + + if $cur_pid IS NOT NULL { + ob_record_net_impact($next_id, $query_id, $cur_pid, $cur_outcome, 0::INT8, $cur_amount, FALSE); + $next_id := $next_id + 1; + } + } +}; + +CREATE OR REPLACE ACTION distribute_fees( + $query_id INT, + $total_fees NUMERIC(78, 0), + $winning_outcome BOOL +) PRIVATE { + $bridge TEXT; + $query_components BYTEA; + + $min_pid INT; + $remainder NUMERIC(78, 0) := '0'::NUMERIC(78, 0); + $pids INT[]; + $wallet_hexes TEXT[]; + $amounts NUMERIC(78, 0)[]; + $outcomes BOOL[]; + + for $row in SELECT bridge, query_components FROM ob_queries WHERE id = $query_id { + $bridge := $row.bridge; + $query_components := $row.query_components; + } + + -- 75/12.5/12.5 split + $lp_share NUMERIC(78, 0) := ($total_fees * 75::NUMERIC(78, 0)) / 100::NUMERIC(78, 0); + $infra_share NUMERIC(78, 0) := ($total_fees * 125::NUMERIC(78, 0)) / 1000::NUMERIC(78, 0); + $lp_share := $lp_share + ($total_fees - $lp_share - (2::NUMERIC(78, 0) * $infra_share)); + + -- Pre-compute block_count and create parent distribution record early + -- so DP and validator detail rows can reference it via FK + $block_count INT := 0; + for $row in SELECT COUNT(DISTINCT block) as cnt FROM ob_rewards WHERE query_id = $query_id { $block_count := $row.cnt; } + + $lp_count INT := 0; + $actual_fees_distributed NUMERIC(78, 0) := '0'::NUMERIC(78, 0); + $distribution_id INT; + for $row in SELECT COALESCE(MAX(id), 0) + 1 as val FROM ob_fee_distributions { $distribution_id := $row.val; } + + -- PRE-INSERT PARENT RECORD with placeholders (updated at end with final values) + INSERT INTO ob_fee_distributions ( + id, query_id, total_fees_distributed, total_dp_fees, total_validator_fees, total_lp_count, block_count, distributed_at + ) VALUES ( + $distribution_id, $query_id, '0'::NUMERIC(78, 0), '0'::NUMERIC(78, 0), '0'::NUMERIC(78, 0), 0, $block_count, @block_timestamp + ); + + -- Payout Data Provider + $dp_addr BYTEA; + for $row in tn_utils.unpack_query_components($query_components) { $dp_addr := $row.data_provider; } + + $actual_dp_fees NUMERIC(78, 0) := '0'::NUMERIC(78, 0); + if $dp_addr IS NOT NULL AND $infra_share > '0'::NUMERIC(78, 0) { + $dp_wallet_hex TEXT := '0x' || encode($dp_addr, 'hex'); + eth_usdc.unlock($dp_wallet_hex, $infra_share); + + $actual_dp_fees := $infra_share; + + -- Ensure DP has a participant record so the fee is tracked in ob_net_impacts + $dp_pid INT; + for $p in SELECT id FROM ob_participants WHERE wallet_address = $dp_addr { $dp_pid := $p.id; } + if $dp_pid IS NULL { + INSERT INTO ob_participants (id, wallet_address) + SELECT COALESCE(MAX(id), 0) + 1, $dp_addr + FROM ob_participants; + for $p in SELECT id FROM ob_participants WHERE wallet_address = $dp_addr { $dp_pid := $p.id; } + } + if $dp_pid IS NOT NULL { + $next_id_dp INT; + for $row in SELECT COALESCE(MAX(id), 0::INT) + 1 as val FROM ob_net_impacts { $next_id_dp := $row.val; } + ob_record_net_impact($next_id_dp, $query_id, $dp_pid, $winning_outcome, 0::INT8, $infra_share, FALSE); + + -- Record DP in distribution details so indexer picks it up + INSERT INTO ob_fee_distribution_details (distribution_id, participant_id, wallet_address, reward_amount, total_reward_percent) + VALUES ($distribution_id, $dp_pid, $dp_addr, $infra_share, 12.50::NUMERIC(10, 2)); + } + } + + -- Payout Validators (split evenly among all active validators) + $actual_validator_fees NUMERIC(78, 0) := '0'::NUMERIC(78, 0); + + if $infra_share > '0'::NUMERIC(78, 0) { + $validator_count INT := tn_utils.get_validator_count(); + + if $validator_count > 0 { + $per_validator NUMERIC(78, 0) := $infra_share / $validator_count::NUMERIC(78, 0); + $val_remainder NUMERIC(78, 0) := $infra_share - ($per_validator * $validator_count::NUMERIC(78, 0)); + $first_validator BOOL := TRUE; + $val_pct NUMERIC(10, 2) := 12.50::NUMERIC(10, 2) / $validator_count::NUMERIC(10, 2); + $val_pct_remainder NUMERIC(10, 2) := 12.50::NUMERIC(10, 2) - ($val_pct * $validator_count::NUMERIC(10, 2)); + + for $v in tn_utils.get_validators() { + -- Extract row fields to local variables (Kuneiform SQL generator limitation) + $v_wallet_hex TEXT := $v.wallet_hex; + $v_wallet_bytes BYTEA := $v.wallet_bytes; + $v_payout NUMERIC(78, 0) := $per_validator; + $v_pct NUMERIC(10, 2) := $val_pct; + + -- Give remainder to first validator (deterministic: sorted by pubkey) + if $first_validator { + $v_payout := $v_payout + $val_remainder; + $v_pct := $v_pct + $val_pct_remainder; + $first_validator := FALSE; + } + + -- Skip validators with zero payout (e.g., infra_share < validator_count) + if $v_payout > '0'::NUMERIC(78, 0) { + -- Unlock funds via bridge + eth_usdc.unlock($v_wallet_hex, $v_payout); + + -- Ensure validator has a participant record + $v_pid INT; + for $p in SELECT id FROM ob_participants WHERE wallet_address = $v_wallet_bytes { $v_pid := $p.id; } + if $v_pid IS NULL { + INSERT INTO ob_participants (id, wallet_address) + SELECT COALESCE(MAX(id), 0) + 1, $v_wallet_bytes + FROM ob_participants; + for $p in SELECT id FROM ob_participants WHERE wallet_address = $v_wallet_bytes { $v_pid := $p.id; } + } + if $v_pid IS NOT NULL { + $next_id_val INT; + for $row in SELECT COALESCE(MAX(id), 0::INT) + 1 as val FROM ob_net_impacts { $next_id_val := $row.val; } + ob_record_net_impact($next_id_val, $query_id, $v_pid, $winning_outcome, 0::INT8, $v_payout, FALSE); + + -- Record validator in distribution details so indexer picks it up + INSERT INTO ob_fee_distribution_details (distribution_id, participant_id, wallet_address, reward_amount, total_reward_percent) + VALUES ($distribution_id, $v_pid, $v_wallet_bytes, $v_payout, $v_pct) + ON CONFLICT (distribution_id, participant_id) DO UPDATE + SET reward_amount = ob_fee_distribution_details.reward_amount + $v_payout, + total_reward_percent = ob_fee_distribution_details.total_reward_percent + $v_pct; + } + + $actual_validator_fees := $actual_validator_fees + $v_payout; + } + } + } + } + + -- LP Distribution + -- NOTE: sample_lp_rewards() is called in process_settlement() BEFORE ob_positions are deleted, + -- so the final sample reads the live order book. Do NOT call it here. + + if $block_count > 0 { + for $row in SELECT MIN(participant_id) as val FROM ob_rewards WHERE query_id = $query_id { $min_pid := $row.val; } + + -- Calculate remainder + $total_calc NUMERIC(78, 0) := '0'::NUMERIC(78, 0); + for $res in + SELECT participant_id, SUM(reward_percent) as reward_percent + FROM ob_rewards WHERE query_id = $query_id GROUP BY participant_id + { + $reward_tmp NUMERIC(78, 0) := (($lp_share::NUMERIC(78, 20) * $res.reward_percent::NUMERIC(78, 20)) / (100::NUMERIC(78, 20) * $block_count::NUMERIC(78, 20)))::NUMERIC(78, 0); + $total_calc := $total_calc + $reward_tmp; + } + $remainder := $lp_share - $total_calc; + + for $res in + WITH reward_summary AS ( + SELECT r.participant_id, p.wallet_address, SUM(r.reward_percent) as reward_percent + FROM ob_rewards r JOIN ob_participants p ON r.participant_id = p.id + WHERE r.query_id = $query_id GROUP BY r.participant_id, p.wallet_address + ) + SELECT participant_id, wallet_address, '0x' || encode(wallet_address, 'hex') as wallet_hex, reward_percent + FROM reward_summary ORDER BY wallet_address + { + $reward_final NUMERIC(78, 0) := (($lp_share::NUMERIC(78, 20) * $res.reward_percent::NUMERIC(78, 20)) / (100::NUMERIC(78, 20) * $block_count::NUMERIC(78, 20)))::NUMERIC(78, 0); + if $res.participant_id = $min_pid { $reward_final := $reward_final + $remainder; } + + if $reward_final > '0'::NUMERIC(78, 0) { + $pids := array_append($pids, $res.participant_id); + $wallet_hexes := array_append($wallet_hexes, $res.wallet_hex); + $amounts := array_append($amounts, $reward_final); + $outcomes := array_append($outcomes, $winning_outcome); + + -- Record LP audit detail (skip if percent rounds to 0 at NUMERIC(10,2) precision) + $curr_pid INT := $res.participant_id; + $curr_wallet BYTEA := $res.wallet_address; + $curr_reward NUMERIC(78,0) := $reward_final; + $curr_pct NUMERIC(10,2) := $res.reward_percent::NUMERIC(10, 2); + + if $curr_pct > 0.00::NUMERIC(10, 2) { + INSERT INTO ob_fee_distribution_details (distribution_id, participant_id, wallet_address, reward_amount, total_reward_percent) + VALUES ($distribution_id, $curr_pid, $curr_wallet, $curr_reward, $curr_pct) + ON CONFLICT (distribution_id, participant_id) DO UPDATE + SET reward_amount = ob_fee_distribution_details.reward_amount + $curr_reward, + total_reward_percent = ob_fee_distribution_details.total_reward_percent + $curr_pct; + } + } + } + + if COALESCE(array_length($pids), 0) > 0 { + $lp_count := array_length($pids); + $actual_fees_distributed := $lp_share; + ob_batch_unlock_collateral($query_id, $bridge, $pids, $wallet_hexes, $amounts, $outcomes); + } + } + + -- UPDATE SUMMARY with final values + UPDATE ob_fee_distributions + SET total_fees_distributed = $actual_fees_distributed, + total_dp_fees = $actual_dp_fees, + total_validator_fees = $actual_validator_fees, + total_lp_count = $lp_count + WHERE id = $distribution_id; + + if $lp_count > 0 { DELETE FROM ob_rewards WHERE query_id = $query_id; } +}; diff --git a/internal/migrations/037-order-book-validation.prod.sql b/internal/migrations/037-order-book-validation.prod.sql new file mode 100644 index 00000000..fb63f0d7 --- /dev/null +++ b/internal/migrations/037-order-book-validation.prod.sql @@ -0,0 +1,155 @@ +-- ============================================================================= +-- GENERATED FILE — DO NOT EDIT BY HAND +-- ============================================================================= +-- Source : internal/migrations/037-order-book-validation.sql +-- Script : scripts/generate_prod_migrations.py +-- +-- Manual-apply mainnet override. The embedded migration loader skips +-- *.prod.sql, so apply via: +-- +-- kwil-cli exec-sql --file --sync \ +-- --private-key $PRIVATE_KEY --provider $PROVIDER +-- +-- Prerequisite: erc20-bridge/000-extension.prod.sql must be applied +-- FIRST so the eth_truf and eth_usdc bridge instances exist. +-- ============================================================================= + +CREATE OR REPLACE ACTION validate_market_collateral($query_id INT) +PUBLIC VIEW RETURNS ( + valid_token_binaries BOOL, + valid_collateral BOOL, + total_true BIGINT, + total_false BIGINT, + vault_balance NUMERIC(78, 0), + expected_collateral NUMERIC(78, 0), + open_buys_value BIGINT +) { + -- Step 1: Count TRUE shares in circulation for THIS market (holdings + open sells) + $total_true BIGINT := 0; + for $row in + SELECT COALESCE(SUM(amount)::BIGINT, 0::BIGINT) as total + FROM ob_positions + WHERE query_id = $query_id + AND outcome = TRUE + AND price >= 0 -- Holdings (price=0) + open sells (price>0) + { + $total_true := $row.total; + } + + -- Step 2: Count FALSE shares in circulation for THIS market (holdings + open sells) + $total_false BIGINT := 0; + for $row in + SELECT COALESCE(SUM(amount)::BIGINT, 0::BIGINT) as total + FROM ob_positions + WHERE query_id = $query_id + AND outcome = FALSE + AND price >= 0 -- Holdings (price=0) + open sells (price>0) + { + $total_false := $row.total; + } + + -- Step 3: Calculate open buy collateral obligations for THIS market (in cents) + -- Buy orders: price is negative (stored in cents: -1 to -99) + -- Collateral per buy order = |price| * amount / 100 (converted to dollars) + -- We return the value in cents for precision + $open_buys_value BIGINT := 0; + for $row in + SELECT COALESCE(SUM(ABS(price) * amount)::BIGINT, 0::BIGINT) as total_value + FROM ob_positions + WHERE query_id = $query_id + AND price < 0 -- Only buy orders (negative price) + { + $open_buys_value := $row.total_value; + } + + -- Step 4: Get market's bridge + $bridge TEXT; + for $row in SELECT bridge FROM ob_queries WHERE id = $query_id { + $bridge := $row.bridge; + } + if $bridge IS NULL { + ERROR('Market not found for query_id: ' || $query_id::TEXT); + } + + -- Step 5: Calculate TOTAL expected collateral across ALL unsettled markets using same bridge + -- This fixes the multi-market validation issue where vault holds collateral for all markets + $total_shares_all_markets BIGINT := 0; + $total_buys_cents_all_markets BIGINT := 0; + + -- Sum TRUE shares (holdings + sells) across all unsettled markets with same bridge + for $row in + SELECT COALESCE(SUM(p.amount)::BIGINT, 0::BIGINT) as total + FROM ob_positions p + JOIN ob_queries q ON p.query_id = q.id + WHERE q.settled = FALSE + AND q.bridge = $bridge + AND p.outcome = TRUE + AND p.price >= 0 + { + $total_shares_all_markets := $row.total; + } + + -- Sum open buy orders (in cents) across all unsettled markets with same bridge + for $row in + SELECT COALESCE(SUM(ABS(p.price) * p.amount)::BIGINT, 0::BIGINT) as total_value + FROM ob_positions p + JOIN ob_queries q ON p.query_id = q.id + WHERE q.settled = FALSE + AND q.bridge = $bridge + AND p.price < 0 + { + $total_buys_cents_all_markets := $row.total_value; + } + + -- Calculate total expected collateral in wei (18 decimals) + $expected_collateral NUMERIC(78, 0); + $shares_collateral NUMERIC(78, 0) := $total_shares_all_markets::NUMERIC(78, 0) * '1000000000000000000'::NUMERIC(78, 0); + $buys_collateral NUMERIC(78, 0) := ($total_buys_cents_all_markets::NUMERIC(78, 0) * '1000000000000000000'::NUMERIC(78, 0)) / 100::NUMERIC(78, 0); + $expected_collateral := ($shares_collateral + $buys_collateral)::NUMERIC(78, 0); + + -- Step 6: Get actual vault balance from bridge + $vault_balance NUMERIC(78, 0) := 0::NUMERIC(78, 0); + $row_count INT := 0; + + if $bridge != 'eth_usdc' { + ERROR('Invalid bridge. Supported: eth_usdc'); + } + for $info in eth_usdc.info() { + $vault_balance := $info.balance; + $row_count := $row_count + 1; + } + + -- Validate that bridge returned data (distinguish unavailable from empty vault) + if $row_count = 0 { + ERROR('Cannot validate collateral: bridge.info() returned no data. Bridge may be unavailable or not initialized.'); + } + + -- Step 7: Validate binary token parity for THIS market + $valid_token_binaries BOOL; + if $total_true = $total_false { + $valid_token_binaries := TRUE; + } else { + $valid_token_binaries := FALSE; + } + + -- Step 8: Validate collateral balance + -- Now compares vault balance against TOTAL expected collateral from ALL unsettled markets + -- Using >= because having MORE collateral than expected is safe (extra margin), + -- while having LESS would indicate missing funds (which would fail this check) + $valid_collateral BOOL; + if $vault_balance >= $expected_collateral { + $valid_collateral := TRUE; + } else { + $valid_collateral := FALSE; + } + + -- Step 9: Return diagnostics + RETURN + $valid_token_binaries, + $valid_collateral, + $total_true, + $total_false, + $vault_balance, + $expected_collateral, + $open_buys_value; +}; diff --git a/internal/migrations/erc20-bridge/000-extension.prod.sql b/internal/migrations/erc20-bridge/000-extension.prod.sql new file mode 100644 index 00000000..000bf0df --- /dev/null +++ b/internal/migrations/erc20-bridge/000-extension.prod.sql @@ -0,0 +1,37 @@ +-- ============================================================================= +-- MAINNET BRIDGE DECLARATIONS — apply BEFORE any other *.prod.sql override +-- ============================================================================= +-- Manual-apply mainnet override. The embedded migration loader skips +-- *.prod.sql, so apply via: +-- +-- kwil-cli exec-sql --file --sync \ +-- --private-key $PRIVATE_KEY --provider $PROVIDER +-- +-- Registers the two production ERC20 bridges that replace the testnet +-- hoodi_tt (TRUF) and hoodi_tt2 (USDC) instances. Once these aliases +-- exist, apply the script-generated *.prod.sql files (031, 032, 033, +-- 037, erc20-bridge/{001,004,005}) which redefine the order-book +-- actions and per-bridge wrappers to target eth_truf / eth_usdc. +-- +-- Cleanup of the testnet aliases (UNUSE hoodi_tt; UNUSE hoodi_tt2; +-- UNUSE sepolia_bridge;) is intentionally not bundled here — apply +-- those manually after verifying the new bridges are healthy. +-- ============================================================================= + +-- TRUF token bridge (proxy: 0x5E4d32D5072A2cF94E4A20CD715E1aa76Cd52e43) +-- Implementation: 0xc6E82D96D2B0204e6467B67752817939Ec6162e9 +-- Replaces testnet hoodi_tt — used for the market-creation fee. +USE erc20 { + chain: 'ethereum', + escrow: '0x5E4d32D5072A2cF94E4A20CD715E1aa76Cd52e43', + distribution_period: '10m' +} AS eth_truf; + +-- USDC bridge (proxy: 0x67f087bC88E36721919A4531b83C62C5022Ca810) +-- Implementation: 0x618AdFd0bc1802De96c758628Ea5eA6C32F48Bf0 +-- Replaces testnet hoodi_tt2 — used for order-book collateral. +USE erc20 { + chain: 'ethereum', + escrow: '0x67f087bC88E36721919A4531b83C62C5022Ca810', + distribution_period: '10m' +} AS eth_usdc; diff --git a/internal/migrations/erc20-bridge/001-actions.prod.sql b/internal/migrations/erc20-bridge/001-actions.prod.sql new file mode 100644 index 00000000..1bde9307 --- /dev/null +++ b/internal/migrations/erc20-bridge/001-actions.prod.sql @@ -0,0 +1,89 @@ +-- ============================================================================= +-- GENERATED FILE — DO NOT EDIT BY HAND +-- ============================================================================= +-- Source : internal/migrations/erc20-bridge/001-actions.sql +-- Script : scripts/generate_prod_migrations.py +-- +-- Manual-apply mainnet override. The embedded migration loader skips +-- *.prod.sql, so apply via: +-- +-- kwil-cli exec-sql --file --sync \ +-- --private-key $PRIVATE_KEY --provider $PROVIDER +-- +-- Prerequisite: erc20-bridge/000-extension.prod.sql must be applied +-- FIRST so the eth_truf and eth_usdc bridge instances exist. +-- ============================================================================= + +CREATE OR REPLACE ACTION eth_truf_get_erc20_bridge_info() +PUBLIC VIEW RETURNS ( + chain TEXT, + escrow TEXT, + epoch_period TEXT, + erc20 TEXT, + decimals INT, + balance NUMERIC(78, 0), + synced BOOLEAN, + synced_at INT8, + enabled BOOLEAN +) { + FOR $row IN eth_truf.info() { + RETURN $row.chain, $row.escrow, $row.epoch_period, $row.erc20, $row.decimals, $row.balance, $row.synced, $row.synced_at, $row.enabled; + } +}; + +CREATE OR REPLACE ACTION eth_truf_wallet_balance($wallet_address TEXT) PUBLIC VIEW RETURNS (balance NUMERIC(78, 0)) { + $balance := eth_truf.balance($wallet_address); + RETURN $balance; +}; + +CREATE OR REPLACE ACTION eth_truf_bridge_tokens($recipient TEXT DEFAULT NULL, $amount TEXT) PUBLIC { + $withdrawal_amount := $amount::NUMERIC(78, 0); + $caller_balance := COALESCE(eth_truf.balance(@caller), 0::NUMERIC(78, 0)); + + IF $caller_balance < $withdrawal_amount { + ERROR('Insufficient balance for withdrawal. Required: ' || + ($withdrawal_amount / '1000000000000000000'::NUMERIC(78, 0))::TEXT || ' tokens'); + } + + $bridge_recipient TEXT := LOWER(COALESCE($recipient, @caller)); + + -- Execute withdrawal using the bridge extension + eth_truf.bridge($bridge_recipient, $withdrawal_amount); +}; + +CREATE OR REPLACE ACTION eth_usdc_get_erc20_bridge_info() +PUBLIC VIEW RETURNS ( + chain TEXT, + escrow TEXT, + epoch_period TEXT, + erc20 TEXT, + decimals INT, + balance NUMERIC(78, 0), + synced BOOLEAN, + synced_at INT8, + enabled BOOLEAN +) { + FOR $row IN eth_usdc.info() { + RETURN $row.chain, $row.escrow, $row.epoch_period, $row.erc20, $row.decimals, $row.balance, $row.synced, $row.synced_at, $row.enabled; + } +}; + +CREATE OR REPLACE ACTION eth_usdc_wallet_balance($wallet_address TEXT) PUBLIC VIEW RETURNS (balance NUMERIC(78, 0)) { + $balance := eth_usdc.balance($wallet_address); + RETURN $balance; +}; + +CREATE OR REPLACE ACTION eth_usdc_bridge_tokens($recipient TEXT DEFAULT NULL, $amount TEXT) PUBLIC { + $withdrawal_amount := $amount::NUMERIC(78, 0); + $caller_balance := COALESCE(eth_usdc.balance(@caller), 0::NUMERIC(78, 0)); + + IF $caller_balance < $withdrawal_amount { + ERROR('Insufficient balance for withdrawal. Required: ' || + ($withdrawal_amount / '1000000000000000000'::NUMERIC(78, 0))::TEXT || ' tokens'); + } + + $bridge_recipient TEXT := LOWER(COALESCE($recipient, @caller)); + + -- Execute withdrawal using the bridge extension + eth_usdc.bridge($bridge_recipient, $withdrawal_amount); +}; diff --git a/internal/migrations/erc20-bridge/002-public-transfer-actions.prod.sql b/internal/migrations/erc20-bridge/002-public-transfer-actions.prod.sql new file mode 100644 index 00000000..165942f4 --- /dev/null +++ b/internal/migrations/erc20-bridge/002-public-transfer-actions.prod.sql @@ -0,0 +1,109 @@ +-- ============================================================================= +-- HAND-WRITTEN MAINNET OVERRIDE — TRUF + USDC peer-to-peer transfers +-- ============================================================================= +-- Source pattern: internal/migrations/erc20-bridge/002-public-transfer-actions.sql +-- +-- The original file's `sepolia_transfer` and `ethereum_transfer` actions +-- target bridge instances that do not exist on mainnet (sepolia_bridge +-- and ethereum_bridge). This override adds two new actions targeting the +-- production bridges: +-- +-- eth_truf_transfer — TRUF token p2p transfer + 1 TRUF fee (18 decimals) +-- eth_usdc_transfer — USDC token p2p transfer + 1 USDC fee (6 decimals) +-- +-- Both follow the same pattern as the original ethereum_transfer: +-- fee is paid in the SAME bridge as the transfer (not always in TRUF). +-- This avoids forcing USDC senders to also hold TRUF. +-- +-- Manual-apply mainnet override. The embedded migration loader skips +-- *.prod.sql, so apply via: +-- +-- kwil-cli exec-sql --file --sync \ +-- --private-key $PRIVATE_KEY --provider $PROVIDER +-- +-- Prerequisite: erc20-bridge/000-extension.prod.sql must be applied +-- FIRST so the eth_truf and eth_usdc bridge instances exist. +-- ============================================================================= + +-- TRUF p2p transfer (replaces ethereum_transfer) +-- Fee: 1 TRUF (10^18 wei, since TRUF has 18 decimals) +CREATE OR REPLACE ACTION eth_truf_transfer($to_address TEXT, $amount TEXT) PUBLIC { + $recipient_lower TEXT := LOWER($to_address); + + -- Validate Ethereum address format + if NOT check_ethereum_address($recipient_lower) { + ERROR('Invalid Ethereum address format. Must be a valid Ethereum address: ' || $to_address); + } + + -- Validate amount is positive + if $amount::NUMERIC(78, 0) <= 0::NUMERIC(78, 0) { + ERROR('Transfer amount must be positive'); + } + + -- Fee + $fee := 1000000000000000000::NUMERIC(78, 0); -- 1 TRUF with 18 decimals + $caller_balance := COALESCE(eth_truf.balance(@caller), 0::NUMERIC(78, 0)); + + IF @leader_sender IS NULL { + ERROR('Leader address not available for fee transfer'); + } + $leader_hex TEXT := encode(@leader_sender, 'hex')::TEXT; + + IF ($caller_balance < ($amount::NUMERIC(78, 0) + $fee)) { + ERROR('Insufficient balance for transfer. Requires an extra 1 TRUF fee on top of the transfer amount'); + } + + eth_truf.transfer($leader_hex, $fee); + + -- Execute transfer using the bridge extension + eth_truf.transfer($to_address, $amount::NUMERIC(78, 0)); + + record_transaction_event( + 4, + $fee, + '0x' || $leader_hex, + NULL + ); +}; + + +-- USDC p2p transfer +-- Fee: 1 USDC (10^6 wei, since USDC has 6 decimals) +CREATE OR REPLACE ACTION eth_usdc_transfer($to_address TEXT, $amount TEXT) PUBLIC { + $recipient_lower TEXT := LOWER($to_address); + + -- Validate Ethereum address format + if NOT check_ethereum_address($recipient_lower) { + ERROR('Invalid Ethereum address format. Must be a valid Ethereum address: ' || $to_address); + } + + -- Validate amount is positive + if $amount::NUMERIC(78, 0) <= 0::NUMERIC(78, 0) { + ERROR('Transfer amount must be positive'); + } + + -- Fee + $fee := 1000000::NUMERIC(78, 0); -- 1 USDC with 6 decimals + $caller_balance := COALESCE(eth_usdc.balance(@caller), 0::NUMERIC(78, 0)); + + IF @leader_sender IS NULL { + ERROR('Leader address not available for fee transfer'); + } + $leader_hex TEXT := encode(@leader_sender, 'hex')::TEXT; + + IF ($caller_balance < ($amount::NUMERIC(78, 0) + $fee)) { + ERROR('Insufficient balance for transfer. Requires an extra 1 USDC fee on top of the transfer amount'); + } + + eth_usdc.transfer($leader_hex, $fee); + + -- Execute transfer using the bridge extension + eth_usdc.transfer($to_address, $amount::NUMERIC(78, 0)); + + record_transaction_event( + 4, + $fee, + '0x' || $leader_hex, + NULL + ); +}; diff --git a/internal/migrations/erc20-bridge/004-withdrawal-proof-action.prod.sql b/internal/migrations/erc20-bridge/004-withdrawal-proof-action.prod.sql new file mode 100644 index 00000000..0d190d75 --- /dev/null +++ b/internal/migrations/erc20-bridge/004-withdrawal-proof-action.prod.sql @@ -0,0 +1,61 @@ +-- ============================================================================= +-- GENERATED FILE — DO NOT EDIT BY HAND +-- ============================================================================= +-- Source : internal/migrations/erc20-bridge/004-withdrawal-proof-action.sql +-- Script : scripts/generate_prod_migrations.py +-- +-- Manual-apply mainnet override. The embedded migration loader skips +-- *.prod.sql, so apply via: +-- +-- kwil-cli exec-sql --file --sync \ +-- --private-key $PRIVATE_KEY --provider $PROVIDER +-- +-- Prerequisite: erc20-bridge/000-extension.prod.sql must be applied +-- FIRST so the eth_truf and eth_usdc bridge instances exist. +-- ============================================================================= + +CREATE OR REPLACE ACTION eth_truf_get_withdrawal_proof($wallet_address TEXT) +PUBLIC VIEW RETURNS TABLE ( + chain TEXT, + chain_id TEXT, + contract TEXT, + created_at INT8, + recipient TEXT, + amount NUMERIC(78, 0), + block_hash BYTEA, + root BYTEA, + proofs BYTEA[], + signatures BYTEA[] +) { + -- with_pending = false means only return confirmed epochs (ready for withdrawal) + -- Returns ALL confirmed epochs ordered by height DESC (newest first) + FOR $row IN eth_truf.list_wallet_rewards($wallet_address, false) { + -- Return each row (don't exit loop!) + RETURN NEXT $row.chain, $row.chain_id, $row.contract, $row.created_at, + $row.param_recipient, $row.param_amount, $row.param_block_hash, + $row.param_root, $row.param_proofs, $row.param_signatures; + } +}; + +CREATE OR REPLACE ACTION eth_usdc_get_withdrawal_proof($wallet_address TEXT) +PUBLIC VIEW RETURNS TABLE ( + chain TEXT, + chain_id TEXT, + contract TEXT, + created_at INT8, + recipient TEXT, + amount NUMERIC(78, 0), + block_hash BYTEA, + root BYTEA, + proofs BYTEA[], + signatures BYTEA[] +) { + -- with_pending = false means only return confirmed epochs (ready for withdrawal) + -- Returns ALL confirmed epochs ordered by height DESC (newest first) + FOR $row IN eth_usdc.list_wallet_rewards($wallet_address, false) { + -- Return each row (don't exit loop!) + RETURN NEXT $row.chain, $row.chain_id, $row.contract, $row.created_at, + $row.param_recipient, $row.param_amount, $row.param_block_hash, + $row.param_root, $row.param_proofs, $row.param_signatures; + } +}; diff --git a/internal/migrations/erc20-bridge/005-history-actions.prod.sql b/internal/migrations/erc20-bridge/005-history-actions.prod.sql new file mode 100644 index 00000000..197d641c --- /dev/null +++ b/internal/migrations/erc20-bridge/005-history-actions.prod.sql @@ -0,0 +1,55 @@ +-- ============================================================================= +-- GENERATED FILE — DO NOT EDIT BY HAND +-- ============================================================================= +-- Source : internal/migrations/erc20-bridge/005-history-actions.sql +-- Script : scripts/generate_prod_migrations.py +-- +-- Manual-apply mainnet override. The embedded migration loader skips +-- *.prod.sql, so apply via: +-- +-- kwil-cli exec-sql --file --sync \ +-- --private-key $PRIVATE_KEY --provider $PROVIDER +-- +-- Prerequisite: erc20-bridge/000-extension.prod.sql must be applied +-- FIRST so the eth_truf and eth_usdc bridge instances exist. +-- ============================================================================= + +CREATE OR REPLACE ACTION eth_truf_get_history($wallet_address TEXT, $limit INT, $offset INT) +PUBLIC VIEW RETURNS TABLE ( + type TEXT, + amount NUMERIC(78, 0), + from_address BYTEA, + to_address BYTEA, + internal_tx_hash BYTEA, + external_tx_hash BYTEA, + status TEXT, + block_height INT8, + block_timestamp INT8, + external_block_height INT8 +) { + FOR $row IN eth_truf.get_history($wallet_address, $limit, $offset) { + RETURN NEXT $row.type, $row.amount, $row.from_address, $row.to_address, + $row.internal_tx_hash, $row.external_tx_hash, $row.status, + $row.block_height, $row.block_timestamp, $row.external_block_height; + } +}; + +CREATE OR REPLACE ACTION eth_usdc_get_history($wallet_address TEXT, $limit INT, $offset INT) +PUBLIC VIEW RETURNS TABLE ( + type TEXT, + amount NUMERIC(78, 0), + from_address BYTEA, + to_address BYTEA, + internal_tx_hash BYTEA, + external_tx_hash BYTEA, + status TEXT, + block_height INT8, + block_timestamp INT8, + external_block_height INT8 +) { + FOR $row IN eth_usdc.get_history($wallet_address, $limit, $offset) { + RETURN NEXT $row.type, $row.amount, $row.from_address, $row.to_address, + $row.internal_tx_hash, $row.external_tx_hash, $row.status, + $row.block_height, $row.block_timestamp, $row.external_block_height; + } +}; diff --git a/scripts/generate_prod_migrations.py b/scripts/generate_prod_migrations.py new file mode 100644 index 00000000..6859c48f --- /dev/null +++ b/scripts/generate_prod_migrations.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +"""Generate .prod.sql override migrations for the mainnet bridge swap. + +This script reads the test-flavored embedded migrations (which reference +the Hoodi testnet bridges `hoodi_tt` for TRUF and `hoodi_tt2` for USDC) +and produces sibling `.prod.sql` files containing CREATE OR REPLACE +overrides that target the mainnet bridges `eth_truf` and `eth_usdc`. + +The embedded migration loader in `internal/migrations/migration.go` +explicitly skips files matching `*.prod.sql`, so the generated outputs +are NOT loaded automatically — they must be applied manually after the +embedded migrations run, e.g.:: + + kwil-cli exec-sql --file --sync \\ + --private-key $PRIVATE_KEY --provider $PROVIDER + +Three transformation modes: + +* `core` — the order-book SQL files (031, 032, 033, 037). For each + action that mentions `hoodi_tt*`, substitute the bridge names and + collapse `if $bridge = ''` dispatch chains so only the `eth_usdc` + branch remains. The `validate_bridge` AND-chain is also collapsed. + +* `wrap` — the per-bridge ERC20 wrapper actions (erc20-bridge/001, + 004, 005). These define standalone `hoodi_tt_*` and `hoodi_tt2_*` + actions; emit them with substituted names (`eth_truf_*`, + `eth_usdc_*`) and skip the sepolia/ethereum siblings entirely. + +* `fee` — the write-fee collection actions (001 create_streams, + 003 insert_records, 004 insert_taxonomy, 024 request_attestation). + These hardcode `ethereum_bridge.balance/.transfer` for the TRUF + fee. On mainnet `ethereum_bridge` is declared-but-empty, so emit a + CREATE OR REPLACE override with `ethereum_bridge` → `eth_truf` to + route fees through the production TRUF bridge instead. + +Re-running the script regenerates the outputs deterministically. To +remove an override, delete the corresponding `.prod.sql`. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path +from typing import Iterable + +REPO_ROOT = Path(__file__).resolve().parent.parent +MIGRATIONS_DIR = REPO_ROOT / "internal" / "migrations" + +# (source_relpath, output_relpath, mode) +TARGETS: list[tuple[str, str, str]] = [ + ("031-order-book-vault.sql", "031-order-book-vault.prod.sql", "core"), + ("032-order-book-actions.sql", "032-order-book-actions.prod.sql", "core"), + ("033-order-book-settlement.sql", "033-order-book-settlement.prod.sql", "core"), + ("037-order-book-validation.sql", "037-order-book-validation.prod.sql", "core"), + ( + "erc20-bridge/001-actions.sql", + "erc20-bridge/001-actions.prod.sql", + "wrap", + ), + ( + "erc20-bridge/004-withdrawal-proof-action.sql", + "erc20-bridge/004-withdrawal-proof-action.prod.sql", + "wrap", + ), + ( + "erc20-bridge/005-history-actions.sql", + "erc20-bridge/005-history-actions.prod.sql", + "wrap", + ), + ("001-common-actions.sql", "001-common-actions.prod.sql", "fee"), + ("003-primitive-insertion.sql", "003-primitive-insertion.prod.sql", "fee"), + ("004-composed-taxonomy.sql", "004-composed-taxonomy.prod.sql", "fee"), + ("024-attestation-actions.sql", "024-attestation-actions.prod.sql", "fee"), +] + +HEADER_TEMPLATE = """\ +-- ============================================================================= +-- GENERATED FILE — DO NOT EDIT BY HAND +-- ============================================================================= +-- Source : internal/migrations/{source} +-- Script : scripts/generate_prod_migrations.py +-- +-- Manual-apply mainnet override. The embedded migration loader skips +-- *.prod.sql, so apply via: +-- +-- kwil-cli exec-sql --file --sync \\ +-- --private-key $PRIVATE_KEY --provider $PROVIDER +-- +-- Prerequisite: erc20-bridge/000-extension.prod.sql must be applied +-- FIRST so the eth_truf and eth_usdc bridge instances exist. +-- ============================================================================= + +""" + +ACTION_HEADER_RE = re.compile( + r"^CREATE\s+OR\s+REPLACE\s+ACTION\s+(?P\w+)", + re.MULTILINE, +) + + +def _find_action_end(sql: str, header_start: int) -> int: + """Return the index just past the closing `};` of the action that + starts at `header_start`. + + Brace depth is tracked from the first `{` after the header. The + action ends at the matching `}`; we then advance past the trailing + `;` if present. + """ + n = len(sql) + i = header_start + # Find the first `{` that opens the action body. + while i < n and sql[i] != "{": + i += 1 + if i >= n: + raise ValueError(f"no opening brace after header at offset {header_start}") + depth = 0 + while i < n: + c = sql[i] + if c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + # Past closing brace; consume optional `;` and trailing newline. + i += 1 + if i < n and sql[i] == ";": + i += 1 + if i < n and sql[i] == "\n": + i += 1 + return i + i += 1 + raise ValueError(f"unterminated action starting at offset {header_start}") + + +def split_actions(sql: str) -> list[tuple[str, str]]: + """Return [(action_name, full_text)] for each top-level CREATE OR REPLACE ACTION. + + Non-action chunks (file-level comments, blank lines) are not + returned — only callable actions, in source order. + """ + actions: list[tuple[str, str]] = [] + for match in ACTION_HEADER_RE.finditer(sql): + name = match.group("name") + end = _find_action_end(sql, match.start()) + actions.append((name, sql[match.start():end])) + return actions + + +def substitute_tokens(text: str) -> str: + """Apply the bridge-name substitutions. + + Order matters: `hoodi_tt2` must be replaced before `hoodi_tt`, + otherwise the second pass would corrupt the suffix. + """ + text = text.replace("hoodi_tt2", "eth_usdc") + text = text.replace("hoodi_tt", "eth_truf") + return text + + +_DISPATCH_BRANCH_RE = re.compile( + r"if\s+\$bridge\s*=\s*'eth_usdc'\s*\{", +) +_ELSEIF_HEADER_RE = re.compile( + r"\s*else\s+if\s+\$bridge\s*=\s*'(?:sepolia_bridge|ethereum_bridge)'\s*\{", +) +_ELSE_HEADER_RE = re.compile(r"\s*else\s*\{") + + +def _scan_balanced_block(text: str, start: int) -> int: + """Given an index pointing at an opening `{`, return index just past matching `}`.""" + if text[start] != "{": + raise ValueError(f"expected '{{' at offset {start}, got {text[start]!r}") + depth = 0 + i = start + n = len(text) + while i < n: + c = text[i] + if c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + return i + 1 + i += 1 + raise ValueError(f"unbalanced braces from offset {start}") + + +def collapse_dispatch(action_text: str) -> str: + """Collapse `if $bridge = '' { ... } else if ... else if ... [else { ERROR }]` + chains in `action_text` so only the `eth_usdc` branch remains. + + If the original chain ended in an `else { ERROR(...) }` clause, the + eth_usdc body is prefixed with a guard `if $bridge != 'eth_usdc' { + ERROR(...) }` to preserve the original "reject unknown bridge" + semantics. Otherwise the body is inlined naked, mirroring the + original silent fall-through behavior. + + Multiple dispatch chains in the same action are handled iteratively. + """ + out = [] + i = 0 + n = len(action_text) + while i < n: + m = _DISPATCH_BRANCH_RE.search(action_text, i) + if not m: + out.append(action_text[i:]) + break + # Emit everything before the dispatch verbatim. + out.append(action_text[i:m.start()]) + + # eth_usdc branch: scan its body. + body_open = m.end() - 1 # index of `{` + body_end = _scan_balanced_block(action_text, body_open) + usdc_body_inner = action_text[body_open + 1:body_end - 1] + + # Determine the leading whitespace of the original `if` line so we + # can re-indent the inlined body to match. + line_start = action_text.rfind("\n", 0, m.start()) + 1 + indent = action_text[line_start:m.start()] + + cursor = body_end + # Consume `else if $bridge = 'sepolia_bridge' { ... }` and + # `else if $bridge = 'ethereum_bridge' { ... }` (in either order, + # zero or more times). + while True: + em = _ELSEIF_HEADER_RE.match(action_text, cursor) + if not em: + break + cursor = _scan_balanced_block(action_text, em.end() - 1) + + # Optional final `else { ... }` — typically an ERROR. + had_final_else = False + em = _ELSE_HEADER_RE.match(action_text, cursor) + if em: + had_final_else = True + cursor = _scan_balanced_block(action_text, em.end() - 1) + + # Build replacement. + usdc_body = _redent_body(usdc_body_inner, indent) + if had_final_else: + replacement = ( + f"if $bridge != 'eth_usdc' {{\n" + f"{indent} ERROR('Invalid bridge. Supported: eth_usdc');\n" + f"{indent}}}\n" + f"{indent}{usdc_body}" + ) + else: + replacement = usdc_body + out.append(replacement) + i = cursor + return "".join(out) + + +def _redent_body(body: str, target_indent: str) -> str: + """Strip the body's outer indentation and re-indent every line to + `target_indent` (the indentation of the `if` keyword we're + replacing). Leading and trailing blank lines are removed. + """ + lines = body.splitlines() + # Drop leading blank lines. + while lines and not lines[0].strip(): + lines.pop(0) + # Drop trailing blank lines. + while lines and not lines[-1].strip(): + lines.pop() + if not lines: + return "" + # Compute the smallest non-empty indentation in the body. + min_indent = None + for line in lines: + if not line.strip(): + continue + stripped_len = len(line) - len(line.lstrip(" ")) + if min_indent is None or stripped_len < min_indent: + min_indent = stripped_len + if min_indent is None: + min_indent = 0 + out_lines = [] + for idx, line in enumerate(lines): + if not line.strip(): + out_lines.append("") + continue + dedented = line[min_indent:] + if idx == 0: + # First line is appended after the existing target_indent. + out_lines.append(dedented) + else: + out_lines.append(target_indent + dedented) + return "\n".join(out_lines) + + +_VALIDATE_AND_CHAIN_RE = re.compile( + r"\$bridge\s*!=\s*'eth_usdc'\s+AND\s*\n?\s*" + r"\$bridge\s*!=\s*'sepolia_bridge'\s+AND\s*\n?\s*" + r"\$bridge\s*!=\s*'ethereum_bridge'", +) +_SUPPORTED_LIST_RE = re.compile( + r"Supported:\s+eth_usdc,\s+sepolia_bridge,\s+ethereum_bridge", +) + + +def collapse_validate_and_chain(text: str) -> str: + """Collapse the `$bridge != 'eth_usdc' AND $bridge != 'sepolia_bridge' + AND $bridge != 'ethereum_bridge'` predicate (used in + `validate_bridge`) to a single inequality, and shrink the matching + "Supported:" ERROR list. Idempotent. + """ + text = _VALIDATE_AND_CHAIN_RE.sub("$bridge != 'eth_usdc'", text) + text = _SUPPORTED_LIST_RE.sub("Supported: eth_usdc", text) + return text + + +def transform_core(source_sql: str) -> tuple[str, list[str]]: + """Mode A. Emit each action whose body references `eth_truf`/`eth_usdc` + after substitution, with dispatch chains collapsed. + """ + parts: list[str] = [] + names: list[str] = [] + for name, raw in split_actions(source_sql): + if "hoodi_tt" not in raw: + continue + substituted = substitute_tokens(raw) + substituted = collapse_validate_and_chain(substituted) + substituted = collapse_dispatch(substituted) + parts.append(substituted.rstrip() + "\n") + names.append(name) + return "\n".join(parts), names + + +def transform_wrap(source_sql: str) -> tuple[str, list[str]]: + """Mode B. Emit only `hoodi_tt_*` and `hoodi_tt2_*` actions, renamed.""" + parts: list[str] = [] + names: list[str] = [] + for name, raw in split_actions(source_sql): + if not (name.startswith("hoodi_tt_") or name.startswith("hoodi_tt2_")): + continue + substituted = substitute_tokens(raw) + # Rename in the action header only (substitute_tokens already did + # this since the header contains the prefix). + parts.append(substituted.rstrip() + "\n") + names.append(substitute_tokens(name)) + return "\n".join(parts), names + + +def transform_fee(source_sql: str) -> tuple[str, list[str]]: + """Mode C. Emit each action that calls `ethereum_bridge` directly + (TRUF fee-collection pattern), with `ethereum_bridge` → `eth_truf` + substitution. + + No if/else dispatch collapse — `ethereum_bridge` here appears as + unconditional method calls inside fee-collection blocks, not as a + branch in a bridge-dispatch chain. Order-book files where + `ethereum_bridge` was a dispatch branch are handled by mode `core` + instead, which drops those branches entirely. + """ + parts: list[str] = [] + names: list[str] = [] + for name, raw in split_actions(source_sql): + if "ethereum_bridge" not in raw: + continue + substituted = raw.replace("ethereum_bridge", "eth_truf") + parts.append(substituted.rstrip() + "\n") + names.append(name) + return "\n".join(parts), names + + +def main(argv: Iterable[str] | None = None) -> int: + print(f"reading from: {MIGRATIONS_DIR}") + for source_rel, output_rel, mode in TARGETS: + source_path = MIGRATIONS_DIR / source_rel + output_path = MIGRATIONS_DIR / output_rel + sql = source_path.read_text() + if mode == "core": + body, names = transform_core(sql) + elif mode == "wrap": + body, names = transform_wrap(sql) + elif mode == "fee": + body, names = transform_fee(sql) + else: + raise ValueError(f"unknown mode {mode!r}") + if not body.strip(): + print(f" SKIP {output_rel} (no hoodi references found)") + continue + output = HEADER_TEMPLATE.format(source=source_rel) + body + output_path.write_text(output) + print(f" wrote {output_rel:<55} ({len(names)} action(s): {', '.join(names)})") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/migrate.sh b/scripts/migrate.sh index 534ad450..e7925d2b 100755 --- a/scripts/migrate.sh +++ b/scripts/migrate.sh @@ -44,8 +44,16 @@ run_with_retry() { done } -# Get all .sql files in ./internal/migrations folder -files=(./internal/migrations/*.sql) +# Get all .sql files in ./internal/migrations folder, skipping *.prod.sql. +# *.prod.sql files are manual-apply mainnet overrides — the embedded +# migration loader (internal/migrations/migration.go) skips them, and so +# does this script to keep its behavior aligned. Apply them by hand AFTER +# this script via `kwil-cli exec-sql --file --sync`. +files=() +for f in ./internal/migrations/*.sql; do + [[ "$f" == *.prod.sql ]] && continue + files+=("$f") +done num_files=${#files[@]} # Run them with kwil-cli exec-sql