From 47f0848ffcc3464e7d272408a5f48ca8f3508a46 Mon Sep 17 00:00:00 2001 From: Michael Buntarman Date: Thu, 2 Jul 2026 22:33:58 +0700 Subject: [PATCH] Charge the write fee on every wallet without exception --- docs/modular-agent-addresses.md | 9 +- .../003-primitive-insertion.prod.sql | 33 ++-- .../migrations/003-primitive-insertion.sql | 33 ++-- .../migrations/004-composed-taxonomy.prod.sql | 33 ++-- internal/migrations/004-composed-taxonomy.sql | 33 ++-- .../migrations/009-truflation-query.prod.sql | 129 ++++++++++++++++ internal/migrations/009-truflation-query.sql | 32 ++++ internal/migrations/047-fee-required-role.sql | 25 --- scripts/generate_prod_migrations.py | 4 +- tests/streams/insert_records_fee_test.go | 87 +++++------ tests/streams/maa/data_agent_test.go | 44 +++--- tests/streams/taxonomy_fee_test.go | 70 ++++----- .../streams/transaction_events_ledger_test.go | 10 +- tests/streams/truflation_insert_fee_test.go | 144 ++++++++++++++++++ tests/streams/utils/setup/primitive.go | 6 + 15 files changed, 456 insertions(+), 236 deletions(-) create mode 100644 internal/migrations/009-truflation-query.prod.sql delete mode 100644 internal/migrations/047-fee-required-role.sql create mode 100644 tests/streams/truflation_insert_fee_test.go diff --git a/docs/modular-agent-addresses.md b/docs/modular-agent-addresses.md index 984b428c..1215df15 100644 --- a/docs/modular-agent-addresses.md +++ b/docs/modular-agent-addresses.md @@ -135,11 +135,10 @@ secure that the agent can spend it only on the network's write fees, never take | `main` | `insert_records` | Provide data to those indexes | Both actions charge **caller-keyed** bridge fees — `create_streams` 100 TRUF per stream, -`insert_records` a flat 1 TRUF per transaction once the wallet is enrolled in -`system:fee_required` — so with `@caller` rewritten to the MAA they debit the **agent -wallet's own escrow**, with zero fee code specific to MAA. The fee `transfer` targets -`@leader_sender`, which is exactly the token boundary's write-fee carve-out; any other -`transfer` recipient remains blocked for the restricted agent. +`insert_records` a flat 1 TRUF per transaction — so with `@caller` rewritten to the MAA they +debit the **agent wallet's own escrow**, with zero fee code specific to MAA. The fee +`transfer` targets `@leader_sender`, which is exactly the token boundary's write-fee +carve-out; any other `transfer` recipient remains blocked for the restricted agent. The streams the agent creates belong to the **MAA**, not to the agent's own key: the agent wallet is the data provider of record, so replacing the agent (a new rule + wallet) never diff --git a/internal/migrations/003-primitive-insertion.prod.sql b/internal/migrations/003-primitive-insertion.prod.sql index a186745c..2573f931 100644 --- a/internal/migrations/003-primitive-insertion.prod.sql +++ b/internal/migrations/003-primitive-insertion.prod.sql @@ -62,33 +62,24 @@ CREATE OR REPLACE ACTION insert_records( -- ===== FEE COLLECTION ===== -- Flat 1 TRUF per transaction (write-fee policy per issue #3805). - -- Phased rollout: only wallets enrolled in `system:fee_required` - -- are charged. Empty role => no caller is charged. Once every - -- active write wallet is enrolled, drop this gate in a follow-up - -- migration so universal charging resumes. + -- Charged universally — no role gate. Every caller pays the flat + -- per-tx fee regardless of role membership. $total_fee := 1000000000000000000::NUMERIC(78, 0); -- 1 TRUF with 18 decimals - $fee_required BOOL := FALSE; - for $r in are_members_of('system', 'fee_required', ARRAY[$lower_caller]) { - $fee_required := $r.is_member; + IF @leader_sender IS NULL { + ERROR('Leader address not available for fee transfer'); } + $leader_hex := encode(@leader_sender, 'hex')::TEXT; - IF $fee_required { - 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); + $caller_balance := eth_truf.balance(@caller); - IF $caller_balance < $total_fee { - ERROR('Insufficient balance for write fee. Required: 1 TRUF'); - } - - eth_truf.transfer($leader_hex, $total_fee); - $fee_total := $total_fee; - $fee_recipient := '0x' || $leader_hex; + IF $caller_balance < $total_fee { + ERROR('Insufficient balance for write fee. Required: 1 TRUF'); } + + eth_truf.transfer($leader_hex, $total_fee); + $fee_total := $total_fee; + $fee_recipient := '0x' || $leader_hex; -- ===== END FEE COLLECTION ===== $current_block INT := @height; diff --git a/internal/migrations/003-primitive-insertion.sql b/internal/migrations/003-primitive-insertion.sql index 783ea118..8344d7d6 100644 --- a/internal/migrations/003-primitive-insertion.sql +++ b/internal/migrations/003-primitive-insertion.sql @@ -69,33 +69,24 @@ CREATE OR REPLACE ACTION insert_records( -- ===== FEE COLLECTION ===== -- Flat 1 TRUF per transaction (write-fee policy per issue #3805). - -- Phased rollout: only wallets enrolled in `system:fee_required` - -- are charged. Empty role => no caller is charged. Once every - -- active write wallet is enrolled, drop this gate in a follow-up - -- migration so universal charging resumes. + -- Charged universally — no role gate. Every caller pays the flat + -- per-tx fee regardless of role membership. $total_fee := 1000000000000000000::NUMERIC(78, 0); -- 1 TRUF with 18 decimals - $fee_required BOOL := FALSE; - for $r in are_members_of('system', 'fee_required', ARRAY[$lower_caller]) { - $fee_required := $r.is_member; + IF @leader_sender IS NULL { + ERROR('Leader address not available for fee transfer'); } + $leader_hex := encode(@leader_sender, 'hex')::TEXT; - IF $fee_required { - IF @leader_sender IS NULL { - ERROR('Leader address not available for fee transfer'); - } - $leader_hex := encode(@leader_sender, 'hex')::TEXT; - - $caller_balance := hoodi_tt.balance(@caller); + $caller_balance := hoodi_tt.balance(@caller); - IF $caller_balance < $total_fee { - ERROR('Insufficient balance for write fee. Required: 1 TRUF'); - } - - hoodi_tt.transfer($leader_hex, $total_fee); - $fee_total := $total_fee; - $fee_recipient := '0x' || $leader_hex; + IF $caller_balance < $total_fee { + ERROR('Insufficient balance for write fee. Required: 1 TRUF'); } + + hoodi_tt.transfer($leader_hex, $total_fee); + $fee_total := $total_fee; + $fee_recipient := '0x' || $leader_hex; -- ===== END FEE COLLECTION ===== $current_block INT := @height; diff --git a/internal/migrations/004-composed-taxonomy.prod.sql b/internal/migrations/004-composed-taxonomy.prod.sql index 8f44a67c..daaa09f0 100644 --- a/internal/migrations/004-composed-taxonomy.prod.sql +++ b/internal/migrations/004-composed-taxonomy.prod.sql @@ -57,33 +57,24 @@ CREATE OR REPLACE ACTION insert_taxonomy( -- ===== FEE COLLECTION ===== -- Flat 1 TRUF per transaction (write-fee policy per issue #3805). - -- Phased rollout: only wallets enrolled in `system:fee_required` - -- are charged. Empty role => no caller is charged. Once every - -- active write wallet is enrolled, drop this gate in a follow-up - -- migration so universal charging resumes. + -- Charged universally — no role gate. Every caller pays the flat + -- per-tx fee regardless of role membership. $total_fee := 1000000000000000000::NUMERIC(78, 0); -- 1 TRUF with 18 decimals - $fee_required BOOL := FALSE; - for $r in are_members_of('system', 'fee_required', ARRAY[$lower_caller]) { - $fee_required := $r.is_member; + IF @leader_sender IS NULL { + ERROR('Leader address not available for fee transfer'); } + $leader_hex := encode(@leader_sender, 'hex')::TEXT; - IF $fee_required { - 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); + $caller_balance := eth_truf.balance(@caller); - IF $caller_balance < $total_fee { - ERROR('Insufficient balance for taxonomies creation. Required: 1 TRUF'); - } - - eth_truf.transfer($leader_hex, $total_fee); - $fee_total := $total_fee; - $fee_recipient := '0x' || $leader_hex; + IF $caller_balance < $total_fee { + ERROR('Insufficient balance for taxonomies creation. Required: 1 TRUF'); } + + 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 diff --git a/internal/migrations/004-composed-taxonomy.sql b/internal/migrations/004-composed-taxonomy.sql index 89cd4477..4a4a76dc 100644 --- a/internal/migrations/004-composed-taxonomy.sql +++ b/internal/migrations/004-composed-taxonomy.sql @@ -45,33 +45,24 @@ CREATE OR REPLACE ACTION insert_taxonomy( -- ===== FEE COLLECTION ===== -- Flat 1 TRUF per transaction (write-fee policy per issue #3805). - -- Phased rollout: only wallets enrolled in `system:fee_required` - -- are charged. Empty role => no caller is charged. Once every - -- active write wallet is enrolled, drop this gate in a follow-up - -- migration so universal charging resumes. + -- Charged universally — no role gate. Every caller pays the flat + -- per-tx fee regardless of role membership. $total_fee := 1000000000000000000::NUMERIC(78, 0); -- 1 TRUF with 18 decimals - $fee_required BOOL := FALSE; - for $r in are_members_of('system', 'fee_required', ARRAY[$lower_caller]) { - $fee_required := $r.is_member; + IF @leader_sender IS NULL { + ERROR('Leader address not available for fee transfer'); } + $leader_hex := encode(@leader_sender, 'hex')::TEXT; - IF $fee_required { - IF @leader_sender IS NULL { - ERROR('Leader address not available for fee transfer'); - } - $leader_hex := encode(@leader_sender, 'hex')::TEXT; - - $caller_balance := hoodi_tt.balance(@caller); + $caller_balance := hoodi_tt.balance(@caller); - IF $caller_balance < $total_fee { - ERROR('Insufficient balance for taxonomies creation. Required: 1 TRUF'); - } - - hoodi_tt.transfer($leader_hex, $total_fee); - $fee_total := $total_fee; - $fee_recipient := '0x' || $leader_hex; + IF $caller_balance < $total_fee { + ERROR('Insufficient balance for taxonomies creation. Required: 1 TRUF'); } + + hoodi_tt.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 diff --git a/internal/migrations/009-truflation-query.prod.sql b/internal/migrations/009-truflation-query.prod.sql new file mode 100644 index 00000000..76f14e7f --- /dev/null +++ b/internal/migrations/009-truflation-query.prod.sql @@ -0,0 +1,129 @@ +-- ============================================================================= +-- GENERATED FILE — DO NOT EDIT BY HAND +-- ============================================================================= +-- Source : internal/migrations/009-truflation-query.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 truflation_insert_records( + $data_provider TEXT[], + $stream_id TEXT[], + $event_time INT8[], + $value NUMERIC(36,18)[], + $truflation_created_at TEXT[] +) PUBLIC { + -- Use helper function to avoid expensive for-loop roundtrips + $data_providers := 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; + $num_records INT := array_length($data_provider); + if $num_records != array_length($stream_id) or $num_records != array_length($event_time) or $num_records != array_length($value) or $num_records != array_length($truflation_created_at) { + ERROR('array lengths mismatch'); + } + + -- ===== FEE COLLECTION ===== + -- Flat 1 TRUF per transaction (write-fee policy per issue #3805). + -- Charged universally — no role gate. Every caller pays the flat + -- per-tx fee regardless of role membership. + $total_fee := 1000000000000000000::NUMERIC(78, 0); -- 1 TRUF with 18 decimals + + 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 { + ERROR('Insufficient balance for write fee. Required: 1 TRUF'); + } + + eth_truf.transfer($leader_hex, $total_fee); + $fee_total := $total_fee; + $fee_recipient := '0x' || $leader_hex; + -- ===== END FEE COLLECTION ===== + + $current_block INT := @height; + + -- Get stream reference for all streams (get_stream_ids returns NULL for non-existent streams) + $stream_refs := get_stream_ids($data_providers, $stream_id); + + -- Check stream existence using stream refs (NULL values indicate non-existent streams) + for $i in 1..array_length($stream_refs) { + if $stream_refs[$i] IS NULL { + ERROR('stream does not exist: data_provider=' || $data_provider[$i] || ', stream_id=' || $stream_id[$i]); + } + } + + -- Check if streams are primitive in batch + for $row in is_primitive_stream_batch($data_providers, $stream_id) { + if !$row.is_primitive { + ERROR('stream is not a primitive stream: data_provider=' || $row.data_provider || ', stream_id=' || $row.stream_id); + } + } + + -- Validate that the wallet is allowed to write to each stream + for $row in is_wallet_allowed_to_write_batch($data_providers, $stream_id, $lower_caller) { + if !$row.is_allowed { + ERROR('wallet not allowed to write to stream: data_provider=' || $row.data_provider || ', stream_id=' || $row.stream_id); + } + } + + -- Insert all records using WITH RECURSIVE pattern to avoid round trips + WITH RECURSIVE + indexes AS ( + SELECT 1 AS idx + UNION ALL + SELECT idx + 1 FROM indexes + WHERE idx < $num_records + ), + record_arrays AS ( + SELECT + $event_time AS event_times, + $value AS values_array, + $truflation_created_at AS truflation_created_at_array, + $stream_refs AS stream_refs_array + ), + arguments AS ( + SELECT + record_arrays.event_times[idx] AS event_time, + record_arrays.values_array[idx] AS value, + record_arrays.truflation_created_at_array[idx] AS truflation_created_at, + record_arrays.stream_refs_array[idx] AS stream_ref + FROM indexes + JOIN record_arrays ON 1=1 + ) + INSERT INTO primitive_events (event_time, value, created_at, truflation_created_at, stream_ref) + SELECT + event_time, + value, + $current_block, + truflation_created_at, + stream_ref + FROM arguments; + + -- Enqueue days for pruning (idempotent, distinct), filtering out zero values + helper_enqueue_prune_days( + $stream_refs, + $event_time, + $value + ); + + record_transaction_event( + 2, + $fee_total, + $fee_recipient, + NULL + ); +}; diff --git a/internal/migrations/009-truflation-query.sql b/internal/migrations/009-truflation-query.sql index 50541c0b..04c3e0e5 100644 --- a/internal/migrations/009-truflation-query.sql +++ b/internal/migrations/009-truflation-query.sql @@ -49,11 +49,36 @@ CREATE OR REPLACE ACTION truflation_insert_records( -- Use helper function to avoid expensive for-loop roundtrips $data_providers := 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; $num_records INT := array_length($data_provider); if $num_records != array_length($stream_id) or $num_records != array_length($event_time) or $num_records != array_length($value) or $num_records != array_length($truflation_created_at) { ERROR('array lengths mismatch'); } + -- ===== FEE COLLECTION ===== + -- Flat 1 TRUF per transaction (write-fee policy per issue #3805). + -- Charged universally — no role gate. Every caller pays the flat + -- per-tx fee regardless of role membership. + $total_fee := 1000000000000000000::NUMERIC(78, 0); -- 1 TRUF with 18 decimals + + IF @leader_sender IS NULL { + ERROR('Leader address not available for fee transfer'); + } + $leader_hex := encode(@leader_sender, 'hex')::TEXT; + + $caller_balance := hoodi_tt.balance(@caller); + + IF $caller_balance < $total_fee { + ERROR('Insufficient balance for write fee. Required: 1 TRUF'); + } + + hoodi_tt.transfer($leader_hex, $total_fee); + $fee_total := $total_fee; + $fee_recipient := '0x' || $leader_hex; + -- ===== END FEE COLLECTION ===== + $current_block INT := @height; -- Get stream reference for all streams (get_stream_ids returns NULL for non-existent streams) @@ -119,6 +144,13 @@ CREATE OR REPLACE ACTION truflation_insert_records( $event_time, $value ); + + record_transaction_event( + 2, + $fee_total, + $fee_recipient, + NULL + ); }; diff --git a/internal/migrations/047-fee-required-role.sql b/internal/migrations/047-fee-required-role.sql deleted file mode 100644 index ba57cc79..00000000 --- a/internal/migrations/047-fee-required-role.sql +++ /dev/null @@ -1,25 +0,0 @@ -/* - FEE-REQUIRED ROLE BOOTSTRAP - - Creates the `system:fee_required` role used to gate per-tx write-fee - collection in create_streams / insert_records / insert_taxonomy. - - Phased rollout per truflation/website#3805: only wallets enrolled in - this role are charged the flat 1 TRUF/tx. Empty role = no caller is - charged. Wallets are enrolled one at a time as their TRUF funding - lands. Once every active write wallet is enrolled, the gating - `IF $fee_required` blocks in 001/003/004 can be deleted in a - follow-up migration so universal charging resumes. - - Manager: network_writers_manager (same admin who already grants - write capability — keeps fee-enrollment ops on a privilege the - network already provisions). -*/ - -INSERT INTO roles (owner, role_name, display_name, role_type, created_at) VALUES - ('system', 'fee_required', 'Fee Required', 'system', 0) -ON CONFLICT (owner, role_name) DO NOTHING; - -UPDATE roles -SET manager_owner = 'system', manager_role_name = 'network_writers_manager' -WHERE owner = 'system' AND role_name = 'fee_required'; diff --git a/scripts/generate_prod_migrations.py b/scripts/generate_prod_migrations.py index 7365e5b1..0b5eec0f 100644 --- a/scripts/generate_prod_migrations.py +++ b/scripts/generate_prod_migrations.py @@ -27,7 +27,8 @@ `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). + 003 insert_records, 004 insert_taxonomy, 009 truflation_insert_records, + 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 @@ -85,6 +86,7 @@ ("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"), + ("009-truflation-query.sql", "009-truflation-query.prod.sql", "fee"), ("024-attestation-actions.sql", "024-attestation-actions.prod.sql", "fee"), ] diff --git a/tests/streams/insert_records_fee_test.go b/tests/streams/insert_records_fee_test.go index b09ea1e8..83ac9a53 100644 --- a/tests/streams/insert_records_fee_test.go +++ b/tests/streams/insert_records_fee_test.go @@ -61,7 +61,7 @@ func TestInsertRecordsFees(t *testing.T) { testInsertFeeIndependentOfRole(t), testInsertBatchChargesFlatFee(t), testInsertLeaderReceivesFees(t), - testInsertUnenrolledWalletWritesFree(t), + testInsertUnenrolledWalletStillPaysFee(t), }, }, testutils.GetTestOptionsWithCache()) } @@ -85,7 +85,7 @@ func setupInsertTestEnvironment(t *testing.T) func(ctx context.Context, platform return fmt.Errorf("failed to register system admin as data provider: %w", err) } - // Create all test streams using setup utility (systemAdmin is exempt from fees) + // Create all test streams using setup utility testStreams := []string{ "st111111111111111111111111111111", // Test 1 "st333333333333333333333333333333", // Test 2 @@ -156,11 +156,6 @@ func testInsertWriterRolePaysFee(t *testing.T) func(ctx context.Context, platfor err := setup.CreateDataProvider(ctx, platform, writerAddr.Address()) require.NoError(t, err, "failed to register data provider") - // Enroll wallet in fee_required (phased rollout per #3805 — only - // enrolled wallets pay; the test asserts fees are charged). - err = setup.AddMemberToRoleBypass(ctx, platform, "system", "fee_required", writerAddr.Address()) - require.NoError(t, err, "failed to enroll in fee_required role") - streamID := "st111111111111111111111111111111" streamLocator := types.StreamLocator{ StreamId: util.GenerateStreamId(streamID), @@ -199,7 +194,7 @@ func testInsertNonExemptWalletPaysFee(t *testing.T) func(ctx context.Context, pl // Get systemAdmin who owns the stream systemAdmin := util.Unsafe_NewEthereumAddressFromString("0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf") - // Register systemAdmin as data provider (with network_writer role, so exempt from fees) + // Register systemAdmin as data provider (owns the test stream) err := setup.CreateDataProvider(ctx, platform, systemAdmin.Address()) require.NoError(t, err, "failed to register systemAdmin as data provider") @@ -221,10 +216,6 @@ func testInsertNonExemptWalletPaysFee(t *testing.T) func(ctx context.Context, pl err = setup.CreateDataProviderWithoutRole(ctx, platform, userAddr.Address()) require.NoError(t, err, "failed to register data provider") - // Enroll wallet in fee_required so the insert fee is charged. - err = setup.AddMemberToRoleBypass(ctx, platform, "system", "fee_required", userAddr.Address()) - require.NoError(t, err, "failed to enroll in fee_required role") - // Grant write access to user err = grantStreamWriteAccess(ctx, platform, systemAdmin.Address(), streamID, userAddr.Address()) require.NoError(t, err, "failed to grant write access") @@ -281,11 +272,6 @@ func testInsertInsufficientBalance(t *testing.T) func(ctx context.Context, platf err = setup.CreateDataProviderWithoutRole(ctx, platform, userAddr.Address()) require.NoError(t, err, "failed to register data provider") - // Enroll wallet so the insufficient-balance ERROR fires (un-enrolled - // wallets bypass the balance check entirely). - err = setup.AddMemberToRoleBypass(ctx, platform, "system", "fee_required", userAddr.Address()) - require.NoError(t, err, "failed to enroll in fee_required role") - // Grant write access to user err = grantStreamWriteAccess(ctx, platform, systemAdmin.Address(), streamID, userAddr.Address()) require.NoError(t, err, "failed to grant write access") @@ -333,11 +319,6 @@ func testInsertFeeIndependentOfRole(t *testing.T) func(ctx context.Context, plat err = setup.CreateDataProviderWithoutRole(ctx, platform, userAddr.Address()) require.NoError(t, err, "failed to register data provider") - // Enroll wallet so insert charges across NW grant/revoke (test is - // about NW orthogonality, not phased-rollout free-write path). - err = setup.AddMemberToRoleBypass(ctx, platform, "system", "fee_required", userAddr.Address()) - require.NoError(t, err, "failed to enroll in fee_required role") - err = grantStreamWriteAccess(ctx, platform, systemAdmin.Address(), streamID, userAddr.Address()) require.NoError(t, err, "failed to grant write access") @@ -418,10 +399,6 @@ func testInsertBatchChargesFlatFee(t *testing.T) func(ctx context.Context, platf err = setup.CreateDataProviderWithoutRole(ctx, platform, userAddr.Address()) require.NoError(t, err, "failed to register data provider") - // Enroll wallet in fee_required so the batch is charged. - err = setup.AddMemberToRoleBypass(ctx, platform, "system", "fee_required", userAddr.Address()) - require.NoError(t, err, "failed to enroll in fee_required role") - // Grant write access to user err = grantStreamWriteAccess(ctx, platform, systemAdmin.Address(), streamID, userAddr.Address()) require.NoError(t, err, "failed to grant write access") @@ -480,10 +457,6 @@ func testInsertLeaderReceivesFees(t *testing.T) func(ctx context.Context, platfo err = setup.CreateDataProviderWithoutRole(ctx, platform, userAddr.Address()) require.NoError(t, err, "failed to register data provider") - // Enroll wallet in fee_required so the leader receives the fee. - err = setup.AddMemberToRoleBypass(ctx, platform, "system", "fee_required", userAddr.Address()) - require.NoError(t, err, "failed to enroll in fee_required role") - // Grant write access to user err = grantStreamWriteAccess(ctx, platform, systemAdmin.Address(), streamID, userAddr.Address()) require.NoError(t, err, "failed to grant write access") @@ -525,11 +498,12 @@ func testInsertLeaderReceivesFees(t *testing.T) func(ctx context.Context, platfo } } -// Test 7: A wallet not enrolled in system:fee_required inserts for free. -// Phased rollout of #3805 — until enrolled, the action runs without -// touching the ERC20 bridge. The wallet's TRUF balance is unchanged -// and the insert succeeds even when it has zero TRUF. -func testInsertUnenrolledWalletWritesFree(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { +// Test 7: A wallet with no fee-related role membership is still charged. +// Regression check that the phased-rollout exemption has been removed from +// insert_records (issue #3805 universal charging) — every caller pays the +// flat 1 TRUF, so an unfunded wallet now fails on the balance check and a +// funded one is debited like everybody else. +func testInsertUnenrolledWalletStillPaysFee(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { return func(ctx context.Context, platform *kwilTesting.Platform) error { systemAdmin := util.Unsafe_NewEthereumAddressFromString("0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf") @@ -547,27 +521,44 @@ func testInsertUnenrolledWalletWritesFree(t *testing.T) func(ctx context.Context }) require.NoError(t, err, "failed to create stream") - freeAddrVal := util.Unsafe_NewEthereumAddressFromString("0xa888888888888888888888888888888888888888") - freeAddr := &freeAddrVal - err = setup.CreateDataProviderWithoutRole(ctx, platform, freeAddr.Address()) + userAddrVal := util.Unsafe_NewEthereumAddressFromString("0xa888888888888888888888888888888888888888") + userAddr := &userAddrVal + err = setup.CreateDataProviderWithoutRole(ctx, platform, userAddr.Address()) require.NoError(t, err, "failed to register data provider") - // NOT enrolled in fee_required. Grant per-stream write access only. - err = grantStreamWriteAccess(ctx, platform, systemAdmin.Address(), streamID, freeAddr.Address()) + // No fee-related role membership. Grant per-stream write access only. + err = grantStreamWriteAccess(ctx, platform, systemAdmin.Address(), streamID, userAddr.Address()) require.NoError(t, err, "failed to grant write access") - // Deliberately do not fund the wallet — free path should not need - // any TRUF balance. - initialBalance, err := getInsertBalance(ctx, platform, freeAddr.Address()) + // Unfunded wallet: the universal fee now applies, so the insert must + // fail on the balance check — there is no free path anymore. + unfundedBalance, err := getInsertBalance(ctx, platform, userAddr.Address()) require.NoError(t, err, "failed to get initial balance") - require.Equal(t, big.NewInt(0), initialBalance, "free-write wallet should start with zero TRUF") + require.Equal(t, big.NewInt(0), unfundedBalance, "wallet should start with zero TRUF") - err = insertRecord(ctx, platform, freeAddr, systemAdmin.Address(), streamID, 5000, "20.5") - require.NoError(t, err, "un-enrolled wallet should insert for free") + err = insertRecord(ctx, platform, userAddr, systemAdmin.Address(), streamID, 5000, "20.5") + require.Error(t, err, "insert must fail — un-enrolled wallet is no longer exempt") + require.Contains(t, err.Error(), "Insufficient balance for write fee", + "error should mention insufficient balance") - finalBalance, err := getInsertBalance(ctx, platform, freeAddr.Address()) + // Fund the wallet and retry: the insert now succeeds and charges the + // flat 1 TRUF like every other caller. + err = giveInsertBalance(ctx, platform, userAddr.Address(), "100000000000000000000") + require.NoError(t, err, "failed to give balance") + + initialBalance, err := getInsertBalance(ctx, platform, userAddr.Address()) + require.NoError(t, err, "failed to get funded balance") + + err = insertRecord(ctx, platform, userAddr, systemAdmin.Address(), streamID, 5001, "20.5") + require.NoError(t, err, "funded wallet should insert and pay the universal fee") + + finalBalance, err := getInsertBalance(ctx, platform, userAddr.Address()) require.NoError(t, err, "failed to get final balance") - require.Equal(t, big.NewInt(0), finalBalance, "un-enrolled wallet must not be charged") + + expectedBalance := new(big.Int).Sub(initialBalance, oneTRUFInsert) + require.Equal(t, 0, expectedBalance.Cmp(finalBalance), + "un-enrolled wallet must pay 1 TRUF — exemption removed; expected %s but got %s", + expectedBalance, finalBalance) return nil } diff --git a/tests/streams/maa/data_agent_test.go b/tests/streams/maa/data_agent_test.go index de925746..8601429e 100644 --- a/tests/streams/maa/data_agent_test.go +++ b/tests/streams/maa/data_agent_test.go @@ -19,7 +19,6 @@ import ( testutils "github.com/trufnetwork/node/tests/streams/utils" testerc20 "github.com/trufnetwork/node/tests/streams/utils/erc20" "github.com/trufnetwork/node/tests/streams/utils/feefund" - "github.com/trufnetwork/node/tests/streams/utils/setup" "github.com/trufnetwork/sdk-go/core/util" ) @@ -28,8 +27,8 @@ import ( // An AI agent (the restricted key) creates new indexes and provides regular data to them, // running entirely AS its agent wallet (the MAA). The owner (the unrestricted key) "fills up" // the MAA with TRUF; the agent's job costs TRUF — create_streams charges 100 TRUF per stream -// and insert_records a flat 1 TRUF once the wallet is enrolled in system:fee_required — and -// both fees are caller-keyed bridge transfers, so with @caller rewritten to the MAA they +// and insert_records a flat 1 TRUF per transaction — both fees are caller-keyed bridge +// transfers, so with @caller rewritten to the MAA they // debit the MAA's OWN escrow with zero new fee code. The owner stays secure that the agent // can never exfiltrate the funds: the erc20 MAARestricted boundary blocks every fund exit // for the restricted role, carving out ONLY the protocol write-fee — a transfer whose @@ -54,7 +53,7 @@ var ( ) const ( - daInsertFee = "1000000000000000000" // flat 1 TRUF per insert_records tx (003, fee_required-gated) + daInsertFee = "1000000000000000000" // flat 1 TRUF per insert_records tx (003) ) func TestMAADataAgent(t *testing.T) { @@ -196,9 +195,8 @@ func streamOwner(t *testing.T, ctx context.Context, platform *kwilTesting.Platfo // testDataAgentLifecycle: the full happy path. The agent creates an index and provides data // to it AS the MAA, every fee coming out of the MAA's TRUF escrow (100 TRUF per stream; -// 1 TRUF per insert once the wallet is enrolled in system:fee_required) and landing with the -// block leader; the owner reads the provided data back and withdraws the remaining escrow at -// any time, paying the agent its commission. +// 1 TRUF per insert) and landing with the block leader; the owner reads the provided data +// back and withdraws the remaining escrow at any time, paying the agent its commission. func testDataAgentLifecycle(t *testing.T) func(context.Context, *kwilTesting.Platform) error { return func(ctx context.Context, platform *kwilTesting.Platform) error { restricted := util.Unsafe_NewEthereumAddressFromString(restrictedHex) @@ -232,28 +230,28 @@ func testDataAgentLifecycle(t *testing.T) func(context.Context, *kwilTesting.Pla require.Equal(t, maaAddr.Address(), streamOwner(t, ctx, platform, streamID), "the stream belongs to the agent wallet, not the agent's own key") - // 2) THE AGENT PROVIDES DATA. Before fee_required enrollment the insert is free — - // today's phased-rollout default. + // 2) THE AGENT PROVIDES DATA. insert_records charges the flat 1-TRUF write fee on + // every call — it too comes out of the MAA escrow and lands with the leader. require.NoError(t, daCallAsMAA(ctx, platform, maaAddr, true /* agent */, leader, "insert_records", []any{ []string{maaAddr.Address()}, []string{streamID}, []int64{100}, []*kwilTypes.Decimal{dec36(t, "42.5")}, }), "the agent must be able to insert records into its wallet's stream") - require.Equal(t, "150000000000000000000", balanceTRUF(t, ctx, platform, maaAddr.Address()), - "un-enrolled wallets pay no insert fee (phased rollout)") + require.Equal(t, "149000000000000000000", balanceTRUF(t, ctx, platform, maaAddr.Address()), + "the 1-TRUF write fee must come out of the MAA escrow") + require.Equal(t, "101000000000000000000", balanceTRUF(t, ctx, platform, leaderAddr), + "the write fee must land with the block leader: 100 + 1") - // 3) Once the wallet is enrolled in system:fee_required, the flat 1-TRUF write fee - // applies — and it too comes out of the MAA escrow. - require.NoError(t, setup.AddMemberToRoleBypass(ctx, platform, "system", "fee_required", maaAddr.Address())) + // 3) A SECOND DATA POINT — each insert is charged the flat 1 TRUF from escrow. require.NoError(t, daCallAsMAA(ctx, platform, maaAddr, true /* agent */, leader, "insert_records", []any{ []string{maaAddr.Address()}, []string{streamID}, []int64{200}, []*kwilTypes.Decimal{dec36(t, "43.75")}, }), - "the enrolled agent must still be able to insert, paying the write fee from escrow") - require.Equal(t, "149000000000000000000", balanceTRUF(t, ctx, platform, maaAddr.Address()), - "the 1-TRUF write fee must come out of the MAA escrow") - require.Equal(t, "101000000000000000000", balanceTRUF(t, ctx, platform, leaderAddr), - "the write fee must land with the block leader: 100 + 1") + "the agent must be able to insert a second record, paying the write fee from escrow") + require.Equal(t, "148000000000000000000", balanceTRUF(t, ctx, platform, maaAddr.Address()), + "a second 1-TRUF write fee comes out of the MAA escrow") + require.Equal(t, "102000000000000000000", balanceTRUF(t, ctx, platform, leaderAddr), + "the write fee must land with the block leader: 100 + 1 + 1") // 4) THE DATA IS PROVIDED: anyone (here the owner) reads the records back. records := readRecords(t, ctx, platform, unrestricted, maaAddr.Address(), streamID) @@ -263,13 +261,13 @@ func testDataAgentLifecycle(t *testing.T) func(context.Context, *kwilTesting.Pla }, records, "the owner sees the data the agent provided") // 5) THE OWNER WITHDRAWS the un-spent escrow at any time, paying the 2.5% - // commission: 149 × 2.5% = 3.725 TRUF to the agent, the rest to the owner. + // commission: 148 × 2.5% = 3.7 TRUF to the agent, the rest to the owner. require.NoError(t, callAsMAA(ctx, platform, maaAddr, false /* unrestricted owner */, "maa_withdraw", - []any{wdTRUFBridge, dec(t, "149000000000000000000")}), + []any{wdTRUFBridge, dec(t, "148000000000000000000")}), "the owner must be able to withdraw the remaining escrow any time") require.Equal(t, "0", balanceTRUF(t, ctx, platform, maaAddr.Address()), "the agent wallet is drained") - require.Equal(t, "3725000000000000000", balanceTRUF(t, ctx, platform, restrictedHex), "agent earns 2.5% of 149 = 3.725 TRUF") - require.Equal(t, "145275000000000000000", balanceTRUF(t, ctx, platform, unrestrictedHex), "owner recovers the 145.275 TRUF remainder") + require.Equal(t, "3700000000000000000", balanceTRUF(t, ctx, platform, restrictedHex), "agent earns 2.5% of 148 = 3.7 TRUF") + require.Equal(t, "144300000000000000000", balanceTRUF(t, ctx, platform, unrestrictedHex), "owner recovers the 144.3 TRUF remainder") require.Equal(t, 1, countWithdrawEvents(t, ctx, platform, unrestricted, ruleID), "the exit is audited") return nil } diff --git a/tests/streams/taxonomy_fee_test.go b/tests/streams/taxonomy_fee_test.go index 5b346349..c922d3b1 100644 --- a/tests/streams/taxonomy_fee_test.go +++ b/tests/streams/taxonomy_fee_test.go @@ -42,7 +42,7 @@ func TestTaxonomyFees(t *testing.T) { testTaxonomyNonExemptWalletPaysFee(t), testTaxonomyInsufficientBalance(t), testTaxonomyMultipleChildrenChargesFlatFee(t), - testTaxonomyUnenrolledWalletWritesFree(t), + testTaxonomyUnenrolledWalletStillPaysFee(t), }, }, testutils.GetTestOptionsWithCache()) } @@ -81,11 +81,6 @@ func testTaxonomyWriterRolePaysFee(t *testing.T) func(ctx context.Context, platf err := setup.CreateDataProvider(ctx, platform, writerAddr.Address()) require.NoError(t, err, "failed to create data provider") - // Enroll wallet in fee_required (phased rollout per #3805 — only - // enrolled wallets pay; the test asserts fees are charged). - err = setup.AddMemberToRoleBypass(ctx, platform, "system", "fee_required", writerAddr.Address()) - require.NoError(t, err, "failed to enroll in fee_required role") - err = giveBalance(ctx, platform, writerAddr.Address(), "300000000000000000000") // 300 TRUF require.NoError(t, err, "failed to give balance") @@ -133,10 +128,6 @@ func testTaxonomyNonExemptWalletPaysFee(t *testing.T) func(ctx context.Context, err := setup.CreateDataProviderWithoutRole(ctx, platform, nonExemptAddr.Address()) require.NoError(t, err, "failed to create data provider without role") - // Enroll wallet in fee_required so taxonomy writes are charged. - err = setup.AddMemberToRoleBypass(ctx, platform, "system", "fee_required", nonExemptAddr.Address()) - require.NoError(t, err, "failed to enroll in fee_required role") - // Give exactly 201 TRUF: 100 (composed) + 100 (child) + 1 (taxonomy) exactFund := mustParseBigInt("201000000000000000000") // 201 TRUF err = giveBalance(ctx, platform, nonExemptAddr.Address(), exactFund.String()) @@ -191,11 +182,6 @@ func testTaxonomyInsufficientBalance(t *testing.T) func(ctx context.Context, pla err := setup.CreateDataProviderWithoutRole(ctx, platform, insufficientAddr.Address()) require.NoError(t, err, "failed to create data provider without role") - // Enroll wallet so the insufficient-balance ERROR fires on the - // taxonomy step (un-enrolled wallets bypass the balance check). - err = setup.AddMemberToRoleBypass(ctx, platform, "system", "fee_required", insufficientAddr.Address()) - require.NoError(t, err, "failed to enroll in fee_required role") - // Give exactly 200 TRUF: enough for two create_stream calls (100 + 100) // but nothing left over for the 1 TRUF taxonomy fee. twoHundredTRUF := mustParseBigInt("200000000000000000000") @@ -244,11 +230,6 @@ func testTaxonomyMultipleChildrenChargesFlatFee(t *testing.T) func(ctx context.C err := setup.CreateDataProviderWithoutRole(ctx, platform, multiAddr.Address()) require.NoError(t, err, "failed to create data provider without role") - // Enroll wallet in fee_required so the multi-child taxonomy is - // charged at the flat 1 TRUF. - err = setup.AddMemberToRoleBypass(ctx, platform, "system", "fee_required", multiAddr.Address()) - require.NoError(t, err, "failed to enroll in fee_required role") - // Give exactly 401 TRUF: 100 (composed) + 300 (3 children) + 1 (taxonomy, flat). // If the migration were still per-child, the 3-child taxonomy would // cost 3 TRUF and this test would fail with insufficient balance. @@ -300,48 +281,49 @@ func testTaxonomyMultipleChildrenChargesFlatFee(t *testing.T) func(ctx context.C } } -// Test 5: A wallet not enrolled in system:fee_required pays the universal -// stream creation fee (100 TRUF/stream) but the taxonomy insertion is free. -// Phased rollout of #3805 — insert_taxonomy fee only fires for enrolled wallets. -func testTaxonomyUnenrolledWalletWritesFree(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { +// Test 5: A wallet with no fee-related role membership still pays the +// insert_taxonomy fee. Regression check that the phased-rollout exemption +// has been removed (issue #3805 universal charging) — after two 100-TRUF +// stream creates, the 1-TRUF taxonomy fee is charged like everybody else. +func testTaxonomyUnenrolledWalletStillPaysFee(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { return func(ctx context.Context, platform *kwilTesting.Platform) error { - freeAddrVal := util.Unsafe_NewEthereumAddressFromString("0x6555555555555555555555555555555555555555") - freeAddr := &freeAddrVal + userAddrVal := util.Unsafe_NewEthereumAddressFromString("0x6555555555555555555555555555555555555555") + userAddr := &userAddrVal - err := setup.CreateDataProviderWithoutRole(ctx, platform, freeAddr.Address()) + err := setup.CreateDataProviderWithoutRole(ctx, platform, userAddr.Address()) require.NoError(t, err, "failed to create data provider without role") - // Fund with exactly 200 TRUF for two stream creations (100 each, universal). - // NOT enrolled in fee_required — taxonomy itself should be free. - err = giveBalance(ctx, platform, freeAddr.Address(), "200000000000000000000") + // Fund exactly 201 TRUF: 100 (composed) + 100 (child) + 1 (taxonomy). + // No fee-related role membership — the taxonomy fee applies anyway. + err = giveBalance(ctx, platform, userAddr.Address(), "201000000000000000000") require.NoError(t, err, "failed to give balance") - composedStreamId := util.GenerateStreamId("taxonomy_free_composed") - childStreamId := util.GenerateStreamId("taxonomy_free_child") + composedStreamId := util.GenerateStreamId("taxonomy_unenrolled_composed") + childStreamId := util.GenerateStreamId("taxonomy_unenrolled_child") - err = createStream(ctx, platform, freeAddr, composedStreamId.String(), "composed") + err = createStream(ctx, platform, userAddr, composedStreamId.String(), "composed") require.NoError(t, err, "failed to create composed stream") - err = createStream(ctx, platform, freeAddr, childStreamId.String(), "primitive") + err = createStream(ctx, platform, userAddr, childStreamId.String(), "primitive") require.NoError(t, err, "failed to create child stream") - // After 2 stream creations (200 TRUF spent), balance should be 0 - balanceAfterStreams, err := getBalance(ctx, platform, freeAddr.Address()) + // After 2 stream creations (200 TRUF spent), balance should be 1 TRUF. + balanceAfterStreams, err := getBalance(ctx, platform, userAddr.Address()) require.NoError(t, err, "failed to get balance after stream creation") - require.Equal(t, big.NewInt(0), balanceAfterStreams, "Balance should be 0 after creating streams") + require.Equal(t, oneTRUFTaxonomy, balanceAfterStreams, "Balance should be 1 TRUF after creating streams") - // Taxonomy insertion should succeed for free (not enrolled in fee_required) - err = insertTaxonomy(ctx, platform, freeAddr, - freeAddr.Address(), composedStreamId.String(), - []string{freeAddr.Address()}, + // Taxonomy insertion is charged the universal 1 TRUF — no exemption. + err = insertTaxonomy(ctx, platform, userAddr, + userAddr.Address(), composedStreamId.String(), + []string{userAddr.Address()}, []string{childStreamId.String()}, []string{"1.0"}, nil) - require.NoError(t, err, "un-enrolled wallet should insert taxonomy for free") + require.NoError(t, err, "un-enrolled wallet should insert taxonomy, paying the universal fee") - finalBalance, err := getBalance(ctx, platform, freeAddr.Address()) + finalBalance, err := getBalance(ctx, platform, userAddr.Address()) require.NoError(t, err, "failed to get final balance") - require.Equal(t, big.NewInt(0), finalBalance, "un-enrolled wallet must not be charged for taxonomy insertion") + require.Equal(t, big.NewInt(0), finalBalance, "un-enrolled wallet must pay the 1 TRUF taxonomy fee — exemption removed") return nil } diff --git a/tests/streams/transaction_events_ledger_test.go b/tests/streams/transaction_events_ledger_test.go index a828e848..f02a2389 100644 --- a/tests/streams/transaction_events_ledger_test.go +++ b/tests/streams/transaction_events_ledger_test.go @@ -100,12 +100,10 @@ func runTransactionEventsLedgerScenario(t *testing.T) func(ctx context.Context, // 200 + 1 + 1 + 40 with headroom for future ledger assertions. require.NoError(t, feefund.EnsureWalletFunded(ctx, platform, actor.Address(), "500000000000000000000"), "fund actor on hoodi_tt for write fees") - // Phased rollout: insert_records / insert_taxonomy only charge wallets - // in `system:fee_required`. (create_streams charges universally now — - // issue #3971 — so it does not need the enrollment, but the ledger - // test asserts the full fee ledger including insertRecords + setTaxonomies - // rows, so enroll the actor before those writes fire.) - require.NoError(t, setup.AddMemberToRoleBypass(ctx, platform, "system", "fee_required", actor.Address())) + // create_streams (100 TRUF/stream, #3971), insert_records and + // insert_taxonomy (flat 1 TRUF/tx, #3805) all charge every caller + // universally now — no role enrollment needed for the ledger test's + // fee rows to fire. receiverVal := util.Unsafe_NewEthereumAddressFromString("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") receiver := &receiverVal diff --git a/tests/streams/truflation_insert_fee_test.go b/tests/streams/truflation_insert_fee_test.go new file mode 100644 index 00000000..d6054a19 --- /dev/null +++ b/tests/streams/truflation_insert_fee_test.go @@ -0,0 +1,144 @@ +//go:build kwiltest + +package tests + +import ( + "context" + "fmt" + "math/big" + "testing" + + "github.com/stretchr/testify/require" + "github.com/trufnetwork/kwil-db/common" + "github.com/trufnetwork/kwil-db/core/crypto" + coreauth "github.com/trufnetwork/kwil-db/core/crypto/auth" + kwilTypes "github.com/trufnetwork/kwil-db/core/types" + kwilTesting "github.com/trufnetwork/kwil-db/testing" + "github.com/trufnetwork/node/internal/migrations" + testutils "github.com/trufnetwork/node/tests/streams/utils" + "github.com/trufnetwork/node/tests/streams/utils/setup" + "github.com/trufnetwork/sdk-go/core/types" + "github.com/trufnetwork/sdk-go/core/util" +) + +// TestTruflationInsertRecordsFees verifies truflation_insert_records charges the flat +// 1 TRUF per-transaction write fee for every caller (issue #3805 universal charging), +// mirroring insert_records. The fee is per-tx (not per-record) and comes straight out of +// the caller's TRUF balance; an unfunded caller's insert reverts on the balance check. +// (Reuses helpers giveInsertBalance / getInsertBalance / oneTRUFInsert from +// insert_records_fee_test.go — same package.) +func TestTruflationInsertRecordsFees(t *testing.T) { + testutils.RunSchemaTest(t, kwilTesting.SchemaTest{ + Name: "TRUFLATION_INSERT_FEE01_Fees", + SeedStatements: migrations.GetSeedScriptStatements(), + FunctionTests: []kwilTesting.TestFunc{ + testTruflationInsertBatchChargesFlatFee(t), + testTruflationInsertInsufficientBalance(t), + }, + }, testutils.GetTestOptionsWithCache()) +} + +// Test 1: A batch of N truflation records in one call charges a flat 1 TRUF (per-tx, +// not per-record) and the fee comes out of the caller's balance. +func testTruflationInsertBatchChargesFlatFee(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + writerVal := util.Unsafe_NewEthereumAddressFromString("0xb111111111111111111111111111111111111111") + writer := &writerVal + require.NoError(t, setup.CreateDataProvider(ctx, platform, writer.Address()), "register data provider") + + streamID := util.GenerateStreamId("truflation_fee_flat") + require.NoError(t, setup.CreateStream(ctx, platform, setup.StreamInfo{ + Type: setup.ContractTypePrimitive, + Locator: types.StreamLocator{StreamId: streamID, DataProvider: *writer}, + }), "create stream") + + require.NoError(t, giveInsertBalance(ctx, platform, writer.Address(), "100000000000000000000"), "fund writer") + initial, err := getInsertBalance(ctx, platform, writer.Address()) + require.NoError(t, err, "get initial balance") + + // Insert 5 records in one tx — must still charge exactly 1 TRUF (flat). + require.NoError(t, callTruflationInsert(ctx, platform, writer, writer.Address(), streamID.String(), 1000, 5), + "truflation insert should succeed for a funded caller") + + final, err := getInsertBalance(ctx, platform, writer.Address()) + require.NoError(t, err, "get final balance") + + expected := new(big.Int).Sub(initial, oneTRUFInsert) + require.Equal(t, 0, expected.Cmp(final), + "batch of 5 truflation records must charge a flat 1 TRUF (per-tx, not per-record); expected %s but got %s", + expected, final) + return nil + } +} + +// Test 2: An unfunded caller's truflation insert reverts on the balance check — there is +// no exemption; every caller must be able to pay the fee. +func testTruflationInsertInsufficientBalance(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + writerVal := util.Unsafe_NewEthereumAddressFromString("0xb222222222222222222222222222222222222222") + writer := &writerVal + require.NoError(t, setup.CreateDataProvider(ctx, platform, writer.Address()), "register data provider") + + streamID := util.GenerateStreamId("truflation_fee_insufficient") + require.NoError(t, setup.CreateStream(ctx, platform, setup.StreamInfo{ + Type: setup.ContractTypePrimitive, + Locator: types.StreamLocator{StreamId: streamID, DataProvider: *writer}, + }), "create stream") + + // Give only 0.5 TRUF — below the flat 1 TRUF fee. + require.NoError(t, giveInsertBalance(ctx, platform, writer.Address(), "500000000000000000"), "fund writer") + + err := callTruflationInsert(ctx, platform, writer, writer.Address(), streamID.String(), 1000, 1) + require.Error(t, err, "truflation insert must fail with insufficient balance") + require.Contains(t, err.Error(), "Insufficient balance for write fee", + "error should mention insufficient balance") + return nil + } +} + +// callTruflationInsert calls truflation_insert_records with `count` records for one stream, +// signed by `signer`, with a deterministic block proposer so @leader_sender (the fee +// recipient) resolves. +func callTruflationInsert(ctx context.Context, platform *kwilTesting.Platform, signer *util.EthereumAddress, dataProvider, streamID string, startEventTime int64, count int) error { + _, pubGeneric, err := crypto.GenerateSecp256k1Key(nil) + if err != nil { + return err + } + pub := pubGeneric.(*crypto.Secp256k1PublicKey) + + dataProviders := make([]string, count) + streamIds := make([]string, count) + eventTimes := make([]int64, count) + values := make([]*kwilTypes.Decimal, count) + truflationCreatedAts := make([]string, count) + for i := 0; i < count; i++ { + dataProviders[i] = dataProvider + streamIds[i] = streamID + eventTimes[i] = startEventTime + int64(i) + dec, err := kwilTypes.ParseDecimalExplicit(fmt.Sprintf("%d.5", 10+i), 36, 18) + if err != nil { + return err + } + values[i] = dec + truflationCreatedAts[i] = "2024-01-01T00:00:00Z" + } + + tx := &common.TxContext{ + Ctx: ctx, + BlockContext: &common.BlockContext{Height: 1, Proposer: pub}, + Signer: signer.Bytes(), + Caller: signer.Address(), + TxID: platform.Txid(), + Authenticator: coreauth.EthPersonalSignAuth, + } + res, err := platform.Engine.Call(&common.EngineContext{TxContext: tx}, platform.DB, "", "truflation_insert_records", + []any{dataProviders, streamIds, eventTimes, values, truflationCreatedAts}, + func(row *common.Row) error { return nil }) + if err != nil { + return err + } + if res != nil && res.Error != nil { + return res.Error + } + return nil +} diff --git a/tests/streams/utils/setup/primitive.go b/tests/streams/utils/setup/primitive.go index 0fb52b02..dc95839c 100644 --- a/tests/streams/utils/setup/primitive.go +++ b/tests/streams/utils/setup/primitive.go @@ -441,6 +441,12 @@ func InsertTruflationDataBatch(ctx context.Context, input InsertTruflationDataIn return errors.Wrap(err, "error in InsertTruflationDataBatch") } + // truflation_insert_records now charges the flat per-tx write fee (issue #3805); + // this helper makes a single engine call, so fund the provider for one call. + if err := fundForInsertCalls(ctx, input.Platform, deployer.Address(), 1); err != nil { + return errors.Wrap(err, "fund provider for truflation_insert_records") + } + engineContext := newEthEngineContext(ctx, input.Platform, deployer, input.Height) engineContext.TxContext.TxID = txid