From a83f411cefa088df3cb1d1b2a8b098cf811ea86a Mon Sep 17 00:00:00 2001 From: Michael Buntarman Date: Sat, 23 May 2026 03:26:51 +0700 Subject: [PATCH] chore: charge 100 TRUF per stream on create_streams (#3971) --- .../settlement_integration_test.go | 34 ++-- .../migrations/001-common-actions.prod.sql | 38 ++-- internal/migrations/001-common-actions.sql | 45 +++-- tests/streams/allow_zeros_test.go | 5 +- .../order_book/settlement_payout_test.go | 6 +- tests/streams/stream_creation_fee_test.go | 166 ++++++++---------- .../streams/transaction_events_ledger_test.go | 42 +++-- tests/streams/transaction_input_data_test.go | 7 +- .../streams/utils/feefund/feefund_default.go | 5 +- .../streams/utils/feefund/feefund_kwiltest.go | 13 +- tests/streams/utils/setup/common.go | 16 +- 11 files changed, 183 insertions(+), 194 deletions(-) diff --git a/extensions/tn_settlement/settlement_integration_test.go b/extensions/tn_settlement/settlement_integration_test.go index ec6a0e31..224ddd29 100644 --- a/extensions/tn_settlement/settlement_integration_test.go +++ b/extensions/tn_settlement/settlement_integration_test.go @@ -87,9 +87,10 @@ func testFindUnsettledMarkets(t *testing.T) func(context.Context, *kwilTesting.P require.NoError(t, err) // Give TRUF balance for market creation fee - // Cover one market's worth of fees: create_stream (1) + insert_records - // (1) + request_attestation (40) = 42 TRUF. Fund 100 TRUF for headroom. - err = giveTrufBalance(ctx, platform, deployer.Address(), "100000000000000000000") // 100 TRUF + // Cover one market's worth of fees: create_stream (100, #3971) + + // insert_records (1) + request_attestation (40) = 141 TRUF. + // Fund 200 TRUF for headroom. + err = giveTrufBalance(ctx, platform, deployer.Address(), "200000000000000000000") // 200 TRUF require.NoError(t, err) // Create stream and attestation - returns query_components (ABI-encoded) @@ -165,9 +166,10 @@ func testAttestationExists(t *testing.T) func(context.Context, *kwilTesting.Plat require.NoError(t, err) // Give TRUF balance for market creation fee - // Cover one market's worth of fees: create_stream (1) + insert_records - // (1) + request_attestation (40) = 42 TRUF. Fund 100 TRUF for headroom. - err = giveTrufBalance(ctx, platform, deployer.Address(), "100000000000000000000") // 100 TRUF + // Cover one market's worth of fees: create_stream (100, #3971) + + // insert_records (1) + request_attestation (40) = 141 TRUF. + // Fund 200 TRUF for headroom. + err = giveTrufBalance(ctx, platform, deployer.Address(), "200000000000000000000") // 200 TRUF require.NoError(t, err) streamID := "stattexists000000000000000000000" @@ -242,9 +244,10 @@ func testSettleMarketViaAction(t *testing.T) func(context.Context, *kwilTesting. require.NoError(t, err) // Give TRUF balance for market creation fee - // Cover one market's worth of fees: create_stream (1) + insert_records - // (1) + request_attestation (40) = 42 TRUF. Fund 100 TRUF for headroom. - err = giveTrufBalance(ctx, platform, deployer.Address(), "100000000000000000000") // 100 TRUF + // Cover one market's worth of fees: create_stream (100, #3971) + + // insert_records (1) + request_attestation (40) = 141 TRUF. + // Fund 200 TRUF for headroom. + err = giveTrufBalance(ctx, platform, deployer.Address(), "200000000000000000000") // 200 TRUF require.NoError(t, err) streamID := "stsettleaction000000000000000000" @@ -356,9 +359,10 @@ func testSkipMarketWithoutAttestation(t *testing.T) func(context.Context, *kwilT require.NoError(t, err) // Give TRUF balance for market creation fee - // Cover one market's worth of fees: create_stream (1) + insert_records - // (1) + request_attestation (40) = 42 TRUF. Fund 100 TRUF for headroom. - err = giveTrufBalance(ctx, platform, deployer.Address(), "100000000000000000000") // 100 TRUF + // Cover one market's worth of fees: create_stream (100, #3971) + + // insert_records (1) + request_attestation (40) = 141 TRUF. + // Fund 200 TRUF for headroom. + err = giveTrufBalance(ctx, platform, deployer.Address(), "200000000000000000000") // 200 TRUF require.NoError(t, err) // Create stream and attestation WITHOUT signing (skip the SignAttestation step) @@ -443,10 +447,10 @@ func testMultipleMarketsProcessing(t *testing.T) func(context.Context, *kwilTest err = erc20bridge.ForTestingInitializeExtension(ctx, platform) require.NoError(t, err) - // Cover three markets' worth of fees: 3 × (create_stream 1 + - // insert_records 1 + request_attestation 40) = 126 TRUF. Fund 500 TRUF + // Cover three markets' worth of fees: 3 × (create_stream 100, #3971 + + // insert_records 1 + request_attestation 40) = 423 TRUF. Fund 600 TRUF // for headroom. - err = giveTrufBalance(ctx, platform, deployer.Address(), "500000000000000000000") // 500 TRUF + err = giveTrufBalance(ctx, platform, deployer.Address(), "600000000000000000000") // 600 TRUF require.NoError(t, err) // Create 3 markets (settleTime in future relative to BlockContext) diff --git a/internal/migrations/001-common-actions.prod.sql b/internal/migrations/001-common-actions.prod.sql index e6a5410a..f132c119 100644 --- a/internal/migrations/001-common-actions.prod.sql +++ b/internal/migrations/001-common-actions.prod.sql @@ -40,34 +40,26 @@ CREATE OR REPLACE ACTION create_streams( } -- ===== 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. - $total_fee := 1000000000000000000::NUMERIC(78, 0); -- 1 TRUF with 18 decimals + -- Per-stream write fee per issue #3971. Charged universally — no role gate. + -- Streams aren't truncated by the daily digest, so per-stream pricing + -- ensures storage cost scales with what the caller actually creates. + $per_stream_fee NUMERIC(78, 0) := '100000000000000000000'::NUMERIC(78, 0); -- 100 TRUF (10^20) + $total_fee NUMERIC(78, 0) := $per_stream_fee * array_length($stream_ids)::NUMERIC(78, 0); - $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 stream 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 stream creation. Required: 100 TRUF per stream'); } + + eth_truf.transfer($leader_hex, $total_fee); + $fee_total := $total_fee; + $fee_recipient := '0x' || $leader_hex; -- ===== END FEE COLLECTION ===== -- ===== STREAM CREATION LOGIC ===== diff --git a/internal/migrations/001-common-actions.sql b/internal/migrations/001-common-actions.sql index cdb1fb1d..cb807395 100644 --- a/internal/migrations/001-common-actions.sql +++ b/internal/migrations/001-common-actions.sql @@ -54,7 +54,10 @@ CREATE OR REPLACE ACTION create_stream( /** * create_streams: Creates multiple streams at once. - * Fee: 1 TRUF flat per transaction (charged to every caller, no exemptions) + * Fee: 100 TRUF per stream (issue #3971). Charged on every caller — + * no role exemption. The total transferred is + * 100 TRUF × array_length($stream_ids), bounded only by the caller's + * TRUF balance. * Validates stream_id format, data provider address, and stream type. * Sets default metadata including type, owner, visibility, and readonly keys. * @@ -90,34 +93,26 @@ CREATE OR REPLACE ACTION create_streams( } -- ===== 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. - $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; + -- Per-stream write fee per issue #3971. Charged universally — no role gate. + -- Streams aren't truncated by the daily digest, so per-stream pricing + -- ensures storage cost scales with what the caller actually creates. + $per_stream_fee NUMERIC(78, 0) := '100000000000000000000'::NUMERIC(78, 0); -- 100 TRUF (10^20) + $total_fee NUMERIC(78, 0) := $per_stream_fee * array_length($stream_ids)::NUMERIC(78, 0); + + 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 stream 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 stream creation. Required: 100 TRUF per stream'); } + + hoodi_tt.transfer($leader_hex, $total_fee); + $fee_total := $total_fee; + $fee_recipient := '0x' || $leader_hex; -- ===== END FEE COLLECTION ===== -- ===== STREAM CREATION LOGIC ===== diff --git a/tests/streams/allow_zeros_test.go b/tests/streams/allow_zeros_test.go index cb0d5f2e..6be7bce0 100644 --- a/tests/streams/allow_zeros_test.go +++ b/tests/streams/allow_zeros_test.go @@ -324,8 +324,9 @@ func createStreamWithAllowZeros(ctx context.Context, platform *kwilTesting.Platf return errors.Wrap(err, "invalid data provider address") } - // Fund for the universal create_stream fee — mirror setup.UntypedCreateStream. - if err := feefund.EnsureWalletFunded(ctx, platform, addr.Address(), feefund.WriteFeeWei); err != nil { + // Fund for the universal create_stream per-stream fee (issue #3971) — + // mirror setup.UntypedCreateStream. + if err := feefund.EnsureWalletFunded(ctx, platform, addr.Address(), feefund.StreamCreationFeeWei); err != nil { return errors.Wrap(err, "fund wallet for create_stream fee") } diff --git a/tests/streams/order_book/settlement_payout_test.go b/tests/streams/order_book/settlement_payout_test.go index 4131ea0d..f7522abe 100644 --- a/tests/streams/order_book/settlement_payout_test.go +++ b/tests/streams/order_book/settlement_payout_test.go @@ -70,9 +70,9 @@ func testWinnerReceivesFullPayout(t *testing.T) func(context.Context, *kwilTesti require.NoError(t, err) // Fund DP on the sepolia_bridge (the dev TRUF substitute) for - // create_stream (6 TRUF), insert_records (6 TRUF), request_attestation - // (40 TRUF). Give 100 TRUF to leave headroom. - err = feefund.EnsureWalletFunded(ctx, platform, dpAddr.Address(), "100000000000000000000") + // create_stream (100 TRUF, #3971), insert_records (1 TRUF), and + // request_attestation (40 TRUF). 200 TRUF leaves headroom. + err = feefund.EnsureWalletFunded(ctx, platform, dpAddr.Address(), "200000000000000000000") require.NoError(t, err) // Get USDC balance before market operations diff --git a/tests/streams/stream_creation_fee_test.go b/tests/streams/stream_creation_fee_test.go index 9214cfc8..5e9488dc 100644 --- a/tests/streams/stream_creation_fee_test.go +++ b/tests/streams/stream_creation_fee_test.go @@ -33,12 +33,11 @@ const ( ) var ( - // oneTRUF is parsed from feefund.WriteFeeWei — the same constant the - // migration uses, so a fee-schedule change in one place can't drift - // from test assertions silently. Per issue #3805 the write fee is now a - // flat 1 TRUF per transaction, regardless of how many streams the tx - // creates. - oneTRUF = mustParseBigInt(feefund.WriteFeeWei) + // perStreamFee is parsed from feefund.StreamCreationFeeWei — the same + // constant the migration uses, so a fee-schedule change in one place + // can't drift from test assertions silently. Per issue #3971 create_streams + // charges 100 TRUF per stream, universally (no role exemption). + perStreamFee = mustParseBigInt(feefund.StreamCreationFeeWei) pointCounter int64 = 10 // Start from 10, increment for each balance injection ) @@ -61,9 +60,9 @@ func TestStreamCreationFees(t *testing.T) { testNonExemptWalletPaysFee(t), testInsufficientBalance(t), testFeeIndependentOfRole(t), - testBatchCreationChargesFlatFee(t), + testBatchChargesPerStreamFee(t), testLeaderReceivesFees(t), - testUnenrolledWalletCreatesStreamFree(t), + testUnenrolledWalletStillCharged(t), }, }, testutils.GetTestOptionsWithCache()) } @@ -99,8 +98,8 @@ func setupTestEnvironment(t *testing.T) func(ctx context.Context, platform *kwil } } -// Test 1: Wallet with network_writer role still pays the create_streams fee. -// The role no longer carries an exemption — funded callers always pay 1 TRUF per tx. +// Test 1: Wallet with network_writer role pays the create_streams per-stream fee. +// The role carries no exemption — funded callers always pay 100 TRUF per stream. func testWriterRolePaysFee(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { return func(ctx context.Context, platform *kwilTesting.Platform) error { writerAddrVal := util.Unsafe_NewEthereumAddressFromString("0x1111111111111111111111111111111111111111") @@ -110,13 +109,8 @@ func testWriterRolePaysFee(t *testing.T) func(ctx context.Context, platform *kwi 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") - - // Fund wallet so the create fee can be paid. - err = giveBalance(ctx, platform, writerAddr.Address(), "100000000000000000000") + // Fund wallet with 200 TRUF so the single-stream create fee can be paid. + err = giveBalance(ctx, platform, writerAddr.Address(), "200000000000000000000") require.NoError(t, err, "failed to give balance") initialBalance, err := getBalance(ctx, platform, writerAddr.Address()) @@ -128,30 +122,27 @@ func testWriterRolePaysFee(t *testing.T) func(ctx context.Context, platform *kwi finalBalance, err := getBalance(ctx, platform, writerAddr.Address()) require.NoError(t, err, "failed to get final balance") - expectedBalance := new(big.Int).Sub(initialBalance, oneTRUF) + expectedBalance := new(big.Int).Sub(initialBalance, perStreamFee) require.Equal(t, 0, expectedBalance.Cmp(finalBalance), - "network_writer should pay 1 TRUF, expected %s but got %s", expectedBalance, finalBalance) + "network_writer should pay 100 TRUF per stream, expected %s but got %s", expectedBalance, finalBalance) return nil } } -// Test 2: Non-exempt wallet pays 1 TRUF fee +// Test 2: Non-whitelisted wallet pays the per-stream fee. +// No role gating exists for create_streams — every caller pays 100 TRUF/stream. func testNonExemptWalletPaysFee(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { return func(ctx context.Context, platform *kwilTesting.Platform) error { userAddrVal := util.Unsafe_NewEthereumAddressFromString("0x3333333333333333333333333333333333333333") userAddr := &userAddrVal - // Register data provider WITHOUT role (non-whitelisted, will pay fees) + // Register data provider WITHOUT role (non-whitelisted). err := setup.CreateDataProviderWithoutRole(ctx, platform, userAddr.Address()) require.NoError(t, err, "failed to register data provider") - // Enroll wallet in fee_required so the create fee is charged. - err = setup.AddMemberToRoleBypass(ctx, platform, "system", "fee_required", userAddr.Address()) - require.NoError(t, err, "failed to enroll in fee_required role") - - // Give user 100 TRUF - err = giveBalance(ctx, platform, userAddr.Address(), "100000000000000000000") + // Give user 200 TRUF (enough for a single-stream create). + err = giveBalance(ctx, platform, userAddr.Address(), "200000000000000000000") require.NoError(t, err, "failed to give balance") // Get initial balance @@ -162,35 +153,30 @@ func testNonExemptWalletPaysFee(t *testing.T) func(ctx context.Context, platform err = createStream(ctx, platform, userAddr, "st000000000000000000000000000002", "primitive") require.NoError(t, err, "stream creation should succeed") - // Verify balance decreased by 1 TRUF + // Verify balance decreased by 100 TRUF finalBalance, err := getBalance(ctx, platform, userAddr.Address()) require.NoError(t, err, "failed to get final balance") - expectedBalance := new(big.Int).Sub(initialBalance, oneTRUF) + expectedBalance := new(big.Int).Sub(initialBalance, perStreamFee) require.Equal(t, 0, expectedBalance.Cmp(finalBalance), - "Balance should decrease by 1 TRUF, expected %s but got %s", expectedBalance, finalBalance) + "Balance should decrease by 100 TRUF, expected %s but got %s", expectedBalance, finalBalance) return nil } } -// Test 3: Insufficient balance fails +// Test 3: Insufficient balance fails (caller has < 100 TRUF, can't pay per-stream fee). func testInsufficientBalance(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { return func(ctx context.Context, platform *kwilTesting.Platform) error { userAddrVal := util.Unsafe_NewEthereumAddressFromString("0x4444444444444444444444444444444444444444") userAddr := &userAddrVal - // Register data provider WITHOUT role (non-whitelisted, will pay fees) + // Register data provider WITHOUT role (non-whitelisted). err := setup.CreateDataProviderWithoutRole(ctx, platform, userAddr.Address()) require.NoError(t, err, "failed to register data provider") - // Enroll wallet in fee_required so the create fee is charged - // (test expects insufficient-balance failure, not the free path). - err = setup.AddMemberToRoleBypass(ctx, platform, "system", "fee_required", userAddr.Address()) - require.NoError(t, err, "failed to enroll in fee_required role") - - // Give user 0.5 TRUF (insufficient for the flat 1 TRUF fee). - err = giveBalance(ctx, platform, userAddr.Address(), "500000000000000000") + // Give user 50 TRUF (insufficient for the 100 TRUF per-stream fee). + err = giveBalance(ctx, platform, userAddr.Address(), "50000000000000000000") require.NoError(t, err, "failed to give balance") // Try to create stream (should fail) @@ -204,7 +190,7 @@ func testInsufficientBalance(t *testing.T) func(ctx context.Context, platform *k } // Test 4: network_writer role grant/revoke does NOT change the create_streams fee. -// Every call charges 1 TRUF regardless of role membership. +// Every call charges 100 TRUF per stream regardless of role membership. func testFeeIndependentOfRole(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { return func(ctx context.Context, platform *kwilTesting.Platform) error { userAddrVal := util.Unsafe_NewEthereumAddressFromString("0x5555555555555555555555555555555555555555") @@ -213,27 +199,22 @@ func testFeeIndependentOfRole(t *testing.T) func(ctx context.Context, platform * err := setup.CreateDataProviderWithoutRole(ctx, platform, userAddr.Address()) require.NoError(t, err, "failed to register data provider") - // Enroll wallet in fee_required so create_streams charges across - // network_writer grant/revoke (test is about NW orthogonality, not - // about 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 = giveBalance(ctx, platform, userAddr.Address(), "100000000000000000000") + // Give 500 TRUF — three single-stream creates @ 100 TRUF each. + err = giveBalance(ctx, platform, userAddr.Address(), "500000000000000000000") require.NoError(t, err, "failed to give balance") initialBalance, err := getBalance(ctx, platform, userAddr.Address()) require.NoError(t, err) - // Without role: charges 1 TRUF. + // Without role: charges 100 TRUF. err = createStream(ctx, platform, userAddr, "st000000000000000000000000000004", "primitive") require.NoError(t, err, "first stream creation should succeed") balanceAfterFirst, err := getBalance(ctx, platform, userAddr.Address()) require.NoError(t, err) - expectedAfterFirst := new(big.Int).Sub(initialBalance, oneTRUF) - require.Equal(t, 0, expectedAfterFirst.Cmp(balanceAfterFirst), "first create should charge 1 TRUF") + expectedAfterFirst := new(big.Int).Sub(initialBalance, perStreamFee) + require.Equal(t, 0, expectedAfterFirst.Cmp(balanceAfterFirst), "first create should charge 100 TRUF") // Grant role — must NOT exempt. err = setup.AddMemberToRoleBypass(ctx, platform, "system", "network_writer", userAddr.Address()) @@ -244,9 +225,9 @@ func testFeeIndependentOfRole(t *testing.T) func(ctx context.Context, platform * balanceAfterSecond, err := getBalance(ctx, platform, userAddr.Address()) require.NoError(t, err) - expectedAfterSecond := new(big.Int).Sub(balanceAfterFirst, oneTRUF) + expectedAfterSecond := new(big.Int).Sub(balanceAfterFirst, perStreamFee) require.Equal(t, 0, expectedAfterSecond.Cmp(balanceAfterSecond), - "network_writer must still pay the 1 TRUF fee — exemption removed") + "network_writer must still pay the 100 TRUF fee — no exemption") // Revoke role — fee unchanged. err = revokeRoleBypass(ctx, platform, "system", "network_writer", userAddr.Address()) @@ -258,31 +239,27 @@ func testFeeIndependentOfRole(t *testing.T) func(ctx context.Context, platform * balanceAfterThird, err := getBalance(ctx, platform, userAddr.Address()) require.NoError(t, err) - expectedAfterThird := new(big.Int).Sub(balanceAfterSecond, oneTRUF) + expectedAfterThird := new(big.Int).Sub(balanceAfterSecond, perStreamFee) require.Equal(t, 0, expectedAfterThird.Cmp(balanceAfterThird), - "third create should charge 1 TRUF (role revoked, fee unchanged)") + "third create should charge 100 TRUF (role revoked, fee unchanged)") return nil } } -// Test 5: Batched create_streams charges a flat 1 TRUF (not 1 TRUF × N). -// This is the key invariant of issue #3805 — pricing is per-tx, not per-stream. -func testBatchCreationChargesFlatFee(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { +// Test 5: Batched create_streams charges N × perStreamFee (issue #3971). +// Pricing is per-stream, not per-tx — three streams = 300 TRUF. +func testBatchChargesPerStreamFee(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { return func(ctx context.Context, platform *kwilTesting.Platform) error { userAddrVal := util.Unsafe_NewEthereumAddressFromString("0x6666666666666666666666666666666666666666") userAddr := &userAddrVal - // Register data provider WITHOUT role (non-whitelisted, will pay fees) + // Register data provider WITHOUT role (non-whitelisted). 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") - - // Give user 100 TRUF - err = giveBalance(ctx, platform, userAddr.Address(), "100000000000000000000") + // Give user 500 TRUF (more than the 300 TRUF needed for 3 streams). + err = giveBalance(ctx, platform, userAddr.Address(), "500000000000000000000") require.NoError(t, err, "failed to give balance") // Get initial balance @@ -300,33 +277,30 @@ func testBatchCreationChargesFlatFee(t *testing.T) func(ctx context.Context, pla err = createStreams(ctx, platform, userAddr, streamIds, streamTypes) require.NoError(t, err, "batch stream creation should succeed") - // Verify balance decreased by exactly 1 TRUF for the whole batch. + // Verify balance decreased by N × perStreamFee for the whole batch. finalBalance, err := getBalance(ctx, platform, userAddr.Address()) require.NoError(t, err) - expectedBalance := new(big.Int).Sub(initialBalance, oneTRUF) + totalFee := new(big.Int).Mul(perStreamFee, big.NewInt(int64(len(streamIds)))) + expectedBalance := new(big.Int).Sub(initialBalance, totalFee) require.Equal(t, 0, expectedBalance.Cmp(finalBalance), - "Batch of %d streams must still charge 1 TRUF flat (per-tx, not per-stream); expected %s but got %s", + "Batch of %d streams charges N × 100 TRUF (per-stream, not per-tx); expected %s but got %s", len(streamIds), expectedBalance, finalBalance) return nil } } -// Test 6: Leader receives fees +// Test 6: Leader receives the per-stream fee. func testLeaderReceivesFees(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { return func(ctx context.Context, platform *kwilTesting.Platform) error { userAddrVal := util.Unsafe_NewEthereumAddressFromString("0x7777777777777777777777777777777777777777") userAddr := &userAddrVal - // Register data provider WITHOUT role (non-whitelisted, will pay fees) + // Register data provider WITHOUT role (non-whitelisted). 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") - // Setup leader _, pubGeneric, err := crypto.GenerateSecp256k1Key(nil) require.NoError(t, err, "failed to generate leader key") @@ -334,8 +308,8 @@ func testLeaderReceivesFees(t *testing.T) func(ctx context.Context, platform *kw leaderSigner := crypto.EthereumAddressFromPubKey(pub) leaderAddr := fmt.Sprintf("0x%x", leaderSigner) - // Give user 100 TRUF - err = giveBalance(ctx, platform, userAddr.Address(), "100000000000000000000") + // Give user 200 TRUF (covers one create at 100 TRUF). + err = giveBalance(ctx, platform, userAddr.Address(), "200000000000000000000") require.NoError(t, err, "failed to give user balance") // Give leader initial balance (so we can track the increase) @@ -350,43 +324,47 @@ func testLeaderReceivesFees(t *testing.T) func(ctx context.Context, platform *kw err = createStreamWithLeader(ctx, platform, userAddr, pub, "st00000000000000000000000000000a", "primitive") require.NoError(t, err, "stream creation with leader should succeed") - // Verify leader balance increased by 1 TRUF + // Verify leader balance increased by 100 TRUF finalLeaderBalance, err := getBalance(ctx, platform, leaderAddr) require.NoError(t, err, "failed to get final leader balance") - expectedLeaderBalance := new(big.Int).Add(initialLeaderBalance, oneTRUF) + expectedLeaderBalance := new(big.Int).Add(initialLeaderBalance, perStreamFee) require.Equal(t, 0, expectedLeaderBalance.Cmp(finalLeaderBalance), - "Leader should receive 1 TRUF fee, expected %s but got %s", expectedLeaderBalance, finalLeaderBalance) + "Leader should receive 100 TRUF fee, expected %s but got %s", expectedLeaderBalance, finalLeaderBalance) return nil } } -// Test 7: A wallet not enrolled in system:fee_required writes 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 create succeeds even when it has zero TRUF. -func testUnenrolledWalletCreatesStreamFree(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { +// Test 7: A wallet not enrolled in system:fee_required is still charged. +// Regression check that the phased-rollout exemption has been removed +// from create_streams (issue #3971 universal charging). +func testUnenrolledWalletStillCharged(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { return func(ctx context.Context, platform *kwilTesting.Platform) error { - freeAddrVal := util.Unsafe_NewEthereumAddressFromString("0x8888888888888888888888888888888888888888") - freeAddr := &freeAddrVal + userAddrVal := util.Unsafe_NewEthereumAddressFromString("0x8888888888888888888888888888888888888888") + userAddr := &userAddrVal // Register data provider WITHOUT role. NOT enrolled in fee_required. - err := setup.CreateDataProviderWithoutRole(ctx, platform, freeAddr.Address()) + err := setup.CreateDataProviderWithoutRole(ctx, platform, userAddr.Address()) require.NoError(t, err, "failed to register data provider") - // Deliberately do NOT fund the wallet — a free-write path should - // not require any TRUF balance. - initialBalance, err := getBalance(ctx, platform, freeAddr.Address()) + // Fund with 200 TRUF — enough to cover one 100-TRUF create. + err = giveBalance(ctx, platform, userAddr.Address(), "200000000000000000000") + require.NoError(t, err, "failed to give balance") + + initialBalance, err := getBalance(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") - err = createStream(ctx, platform, freeAddr, "st00000000000000000000000000000b", "primitive") - require.NoError(t, err, "un-enrolled wallet should be able to create streams for free") + err = createStream(ctx, platform, userAddr, "st00000000000000000000000000000b", "primitive") + require.NoError(t, err, "wallet should be able to create stream when funded") - 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") + + expectedBalance := new(big.Int).Sub(initialBalance, perStreamFee) + require.Equal(t, 0, expectedBalance.Cmp(finalBalance), + "Un-enrolled wallet must still be charged 100 TRUF — exemption removed; expected %s but got %s", + expectedBalance, finalBalance) return nil } diff --git a/tests/streams/transaction_events_ledger_test.go b/tests/streams/transaction_events_ledger_test.go index bc6a87d5..a828e848 100644 --- a/tests/streams/transaction_events_ledger_test.go +++ b/tests/streams/transaction_events_ledger_test.go @@ -38,12 +38,15 @@ const ( ledgerERC20 = "0x2222222222222222222222222222222222222222" ledgerExtensionAlias = "sepolia_bridge" - feeHalfTRUF = "500000000000000000" - feeOneTRUF = "1000000000000000000" - feeFortyTRUF = "40000000000000000000" - transferAmount = "5000000000000000000" - withdrawAmount = "10000000000000000000" - initialUserFunds = "200000000000000000000" + feeHalfTRUF = "500000000000000000" + feeOneTRUF = "1000000000000000000" + feeFortyTRUF = "40000000000000000000" + // feeTwoHundredTRUF mirrors create_streams charging 100 TRUF × N streams + // (issue #3971); this test creates 2 streams in one tx → 200 TRUF. + feeTwoHundredTRUF = "200000000000000000000" + transferAmount = "5000000000000000000" + withdrawAmount = "10000000000000000000" + initialUserFunds = "200000000000000000000" ) var ledgerPointCounter int64 = 20000 @@ -91,14 +94,17 @@ func runTransactionEventsLedgerScenario(t *testing.T) func(ctx context.Context, require.NoError(t, ledgerGiveBalance(ctx, platform, actor.Address(), initialUserFunds)) // The write-fee actions (create_streams / insert_records / // insert_taxonomy / request_attestation) charge against `hoodi_tt`, - // not `sepolia_bridge`. Fund 100 TRUF there — covers 1+1+1 write - // fees plus the 40 TRUF attestation fee with headroom. - require.NoError(t, feefund.EnsureWalletFunded(ctx, platform, actor.Address(), "100000000000000000000"), + // not `sepolia_bridge`. create_streams is now 100 TRUF per stream + // (#3971); this test creates 2 streams, inserts 1 record, sets a + // taxonomy, and requests an attestation. Fund 500 TRUF to cover + // 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: create_streams / insert_records / insert_taxonomy - // only charge wallets in `system:fee_required`. This test asserts the full - // fee ledger (deployStream + insertRecords + setTaxonomies rows), so enroll - // the actor before any write fires. + // 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())) receiverVal := util.Unsafe_NewEthereumAddressFromString("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") @@ -213,7 +219,7 @@ func runTransactionEventsLedgerScenario(t *testing.T) func(ctx context.Context, argsBytes, err := tn_utils.EncodeActionArgs([]any{ strings.ToLower(systemAdmin.Address()), attestationStream.String(), - int64(999), // before + int64(999), // before int64(99999), // frozen_at false, // use_cache }) @@ -237,10 +243,10 @@ func runTransactionEventsLedgerScenario(t *testing.T) func(ctx context.Context, expected := map[string]ledgerExpectation{ createTx: { method: "deployStream", - fee: feeOneTRUF, // flat 1 TRUF regardless of stream count + fee: feeTwoHundredTRUF, // 100 TRUF × 2 streams (#3971) feeRecipient: createLeaderAddr, feeDistributions: []string{ - buildDistribution(createLeaderAddr, feeOneTRUF), + buildDistribution(createLeaderAddr, feeTwoHundredTRUF), }, assertMetadata: assertNoMetadata, }, @@ -744,7 +750,9 @@ func runTransactionIDTrackingScenario(t *testing.T) func(ctx context.Context, pl require.NoError(t, setup.CreateDataProviderWithoutRole(ctx, platform, actor.Address())) require.NoError(t, ledgerGiveBalance(ctx, platform, actor.Address(), initialUserFunds)) // Write-fee actions charge against `hoodi_tt`; fund that bridge too. - require.NoError(t, feefund.EnsureWalletFunded(ctx, platform, actor.Address(), "100000000000000000000"), + // create_streams is now 100 TRUF per stream (#3971) — this test + // creates 2 streams + inserts records. 300 TRUF gives headroom. + require.NoError(t, feefund.EnsureWalletFunded(ctx, platform, actor.Address(), "300000000000000000000"), "fund actor on hoodi_tt for write fees") userLower := strings.ToLower(actor.Address()) diff --git a/tests/streams/transaction_input_data_test.go b/tests/streams/transaction_input_data_test.go index 74aac005..b9138e75 100644 --- a/tests/streams/transaction_input_data_test.go +++ b/tests/streams/transaction_input_data_test.go @@ -42,11 +42,12 @@ func runTransactionInputActionsTest(t *testing.T) func(ctx context.Context, plat require.NoError(t, setup.CreateDataProvider(ctx, platform, systemAdmin.Address())) // Universal write-fee enforcement requires a leader sender on the block - // context AND a funded caller wallet. Cover both direct Engine.Call - // invocations below (2 streams + 1 record = 18 TRUF). + // context AND a funded caller wallet. The direct Engine.Call below + // creates 2 streams (200 TRUF @ 100/stream per #3971) and inserts 1 + // record (1 TRUF). Fund with headroom. _, leaderPub, err := kcrypto.GenerateSecp256k1Key(nil) require.NoError(t, err, "generate leader key") - require.NoError(t, feefund.EnsureWalletFunded(ctx, platform, systemAdmin.Address(), "18000000000000000000")) + require.NoError(t, feefund.EnsureWalletFunded(ctx, platform, systemAdmin.Address(), "300000000000000000000")) // Test 1: get_transaction_streams t.Log("Test 1: Create streams and verify get_transaction_streams") diff --git a/tests/streams/utils/feefund/feefund_default.go b/tests/streams/utils/feefund/feefund_default.go index fa4a4e77..d204894f 100644 --- a/tests/streams/utils/feefund/feefund_default.go +++ b/tests/streams/utils/feefund/feefund_default.go @@ -14,8 +14,9 @@ import ( // Constants kept identical to feefund_kwiltest.go so callers compile uniformly. const ( - WriteFeeWei = "1000000000000000000" // 1 TRUF (flat per-tx write fee) - AttestationFeeWei = "40000000000000000000" // 40 TRUF + WriteFeeWei = "1000000000000000000" // 1 TRUF (flat per-tx write fee on insert_records / insert_taxonomy) + StreamCreationFeeWei = "100000000000000000000" // 100 TRUF per stream (create_streams; issue #3971) + AttestationFeeWei = "40000000000000000000" // 40 TRUF ) // EnsureWalletFunded is a no-op outside the kwiltest build. Non-kwiltest callers diff --git a/tests/streams/utils/feefund/feefund_kwiltest.go b/tests/streams/utils/feefund/feefund_kwiltest.go index fef00278..6a3af10a 100644 --- a/tests/streams/utils/feefund/feefund_kwiltest.go +++ b/tests/streams/utils/feefund/feefund_kwiltest.go @@ -24,12 +24,17 @@ import ( // in sync with on-chain charges. If a migration changes its fee, update here. const ( // WriteFeeWei mirrors the flat per-transaction write fee charged by - // 001-common-actions.sql `create_streams`, 003-primitive-insertion.sql - // `insert_records`, and 004-composed-taxonomy.sql `insert_taxonomy`. - // Per issue #3805 the fee is independent of batch size — one tx charges - // 1 TRUF regardless of how many streams/records/children it touches. + // 003-primitive-insertion.sql `insert_records` and + // 004-composed-taxonomy.sql `insert_taxonomy` — independent of batch + // size (1 TRUF regardless of records/children). WriteFeeWei = "1000000000000000000" // 1 TRUF + // StreamCreationFeeWei mirrors the per-stream fee charged by + // 001-common-actions.sql `create_streams`. Per issue #3971 the fee + // scales with array_length($stream_ids): a batch of N streams costs + // N × StreamCreationFeeWei. No role exemption — every caller pays. + StreamCreationFeeWei = "100000000000000000000" // 100 TRUF per stream + // AttestationFeeWei mirrors the flat fee in 024-attestation-actions.sql. AttestationFeeWei = "40000000000000000000" // 40 TRUF ) diff --git a/tests/streams/utils/setup/common.go b/tests/streams/utils/setup/common.go index fb553d2c..17625b0d 100644 --- a/tests/streams/utils/setup/common.go +++ b/tests/streams/utils/setup/common.go @@ -2,6 +2,7 @@ package setup import ( "context" + "math/big" "strings" "github.com/pkg/errors" @@ -74,9 +75,9 @@ func UntypedCreateStream(ctx context.Context, platform *kwilTesting.Platform, st return errors.Wrap(err, "invalid data provider address") } - // Universal write-fee enforcement (001-common-actions.sql) charges a flat - // 1 TRUF per transaction — top the wallet up so setup helpers stay neutral. - if err := feefund.EnsureWalletFunded(ctx, platform, addr.Address(), feefund.WriteFeeWei); err != nil { + // create_streams charges 100 TRUF per stream (001-common-actions.sql, + // issue #3971). Single-stream helper → fund a single per-stream fee. + if err := feefund.EnsureWalletFunded(ctx, platform, addr.Address(), feefund.StreamCreationFeeWei); err != nil { return errors.Wrap(err, "fund wallet for stream creation fee") } @@ -131,9 +132,12 @@ func CreateStreamsWithOptions(ctx context.Context, platform *kwilTesting.Platfor return errors.Wrap(err, "error creating composed dataset") } - // Fund the deployer with a flat 1 TRUF for the single create_streams call. - // The write fee is per-tx, not per-stream, so batch size doesn't multiply it. - if err := feefund.EnsureWalletFunded(ctx, platform, deployer.Address(), feefund.WriteFeeWei); err != nil { + // create_streams charges 100 TRUF per stream (001-common-actions.sql, + // issue #3971). Batch call → fund N × per-stream fee. + totalCreationFee := new(big.Int) + totalCreationFee.SetString(feefund.StreamCreationFeeWei, 10) + totalCreationFee.Mul(totalCreationFee, big.NewInt(int64(len(streamInfos)))) + if err := feefund.EnsureWalletFunded(ctx, platform, deployer.Address(), totalCreationFee.String()); err != nil { return errors.Wrap(err, "fund deployer for batch stream creation fee") }