diff --git a/internal/migrations/001-common-actions.prod.sql b/internal/migrations/001-common-actions.prod.sql index 3169eb91..e6a5410a 100644 --- a/internal/migrations/001-common-actions.prod.sql +++ b/internal/migrations/001-common-actions.prod.sql @@ -41,24 +41,33 @@ CREATE OR REPLACE ACTION create_streams( -- ===== FEE COLLECTION ===== -- Flat 1 TRUF per transaction (write-fee policy per issue #3805). - -- Cost is independent of $num_streams: batching N streams in one call - -- charges the same 1 TRUF as creating a single stream. + -- 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 - IF @leader_sender IS NULL { - ERROR('Leader address not available for fee transfer'); + $fee_required BOOL := FALSE; + for $r in are_members_of('system', 'fee_required', ARRAY[$lower_caller]) { + $fee_required := $r.is_member; } - $leader_hex := encode(@leader_sender, 'hex')::TEXT; - $caller_balance := eth_truf.balance(@caller); + IF $fee_required { + IF @leader_sender IS NULL { + ERROR('Leader address not available for fee transfer'); + } + $leader_hex := encode(@leader_sender, 'hex')::TEXT; - IF $caller_balance < $total_fee { - ERROR('Insufficient balance for stream creation. Required: 1 TRUF'); - } + $caller_balance := eth_truf.balance(@caller); - 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: 1 TRUF'); + } + + 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 5eaf2ec8..cdb1fb1d 100644 --- a/internal/migrations/001-common-actions.sql +++ b/internal/migrations/001-common-actions.sql @@ -91,24 +91,33 @@ CREATE OR REPLACE ACTION create_streams( -- ===== FEE COLLECTION ===== -- Flat 1 TRUF per transaction (write-fee policy per issue #3805). - -- Cost is independent of $num_streams: batching N streams in one call - -- charges the same 1 TRUF as creating a single stream. + -- 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 - IF @leader_sender IS NULL { - ERROR('Leader address not available for fee transfer'); + $fee_required BOOL := FALSE; + for $r in are_members_of('system', 'fee_required', ARRAY[$lower_caller]) { + $fee_required := $r.is_member; } - $leader_hex := encode(@leader_sender, 'hex')::TEXT; - $caller_balance := hoodi_tt.balance(@caller); + IF $fee_required { + IF @leader_sender IS NULL { + ERROR('Leader address not available for fee transfer'); + } + $leader_hex := encode(@leader_sender, 'hex')::TEXT; - IF $caller_balance < $total_fee { - ERROR('Insufficient balance for stream creation. Required: 1 TRUF'); - } + $caller_balance := hoodi_tt.balance(@caller); - 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: 1 TRUF'); + } + + 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/internal/migrations/003-primitive-insertion.prod.sql b/internal/migrations/003-primitive-insertion.prod.sql index cbfc01e6..a186745c 100644 --- a/internal/migrations/003-primitive-insertion.prod.sql +++ b/internal/migrations/003-primitive-insertion.prod.sql @@ -62,24 +62,33 @@ CREATE OR REPLACE ACTION insert_records( -- ===== FEE COLLECTION ===== -- Flat 1 TRUF per transaction (write-fee policy per issue #3805). - -- Cost is independent of $num_records: batching N records in one call - -- charges the same 1 TRUF as inserting a single record. + -- 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 - IF @leader_sender IS NULL { - ERROR('Leader address not available for fee transfer'); + $fee_required BOOL := FALSE; + for $r in are_members_of('system', 'fee_required', ARRAY[$lower_caller]) { + $fee_required := $r.is_member; } - $leader_hex := encode(@leader_sender, 'hex')::TEXT; - $caller_balance := eth_truf.balance(@caller); + IF $fee_required { + IF @leader_sender IS NULL { + ERROR('Leader address not available for fee transfer'); + } + $leader_hex := encode(@leader_sender, 'hex')::TEXT; - IF $caller_balance < $total_fee { - ERROR('Insufficient balance for write fee. Required: 1 TRUF'); - } + $caller_balance := eth_truf.balance(@caller); - 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 c58b9de7..783ea118 100644 --- a/internal/migrations/003-primitive-insertion.sql +++ b/internal/migrations/003-primitive-insertion.sql @@ -69,24 +69,33 @@ CREATE OR REPLACE ACTION insert_records( -- ===== FEE COLLECTION ===== -- Flat 1 TRUF per transaction (write-fee policy per issue #3805). - -- Cost is independent of $num_records: batching N records in one call - -- charges the same 1 TRUF as inserting a single record. + -- 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 - IF @leader_sender IS NULL { - ERROR('Leader address not available for fee transfer'); + $fee_required BOOL := FALSE; + for $r in are_members_of('system', 'fee_required', ARRAY[$lower_caller]) { + $fee_required := $r.is_member; } - $leader_hex := encode(@leader_sender, 'hex')::TEXT; - $caller_balance := hoodi_tt.balance(@caller); + IF $fee_required { + IF @leader_sender IS NULL { + ERROR('Leader address not available for fee transfer'); + } + $leader_hex := encode(@leader_sender, 'hex')::TEXT; - IF $caller_balance < $total_fee { - ERROR('Insufficient balance for write fee. Required: 1 TRUF'); - } + $caller_balance := hoodi_tt.balance(@caller); - 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 c4fde0a3..8f44a67c 100644 --- a/internal/migrations/004-composed-taxonomy.prod.sql +++ b/internal/migrations/004-composed-taxonomy.prod.sql @@ -57,24 +57,33 @@ CREATE OR REPLACE ACTION insert_taxonomy( -- ===== FEE COLLECTION ===== -- Flat 1 TRUF per transaction (write-fee policy per issue #3805). - -- Cost is independent of $num_children: a taxonomy with N children - -- charges the same 1 TRUF as one with a single child. + -- 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 - IF @leader_sender IS NULL { - ERROR('Leader address not available for fee transfer'); + $fee_required BOOL := FALSE; + for $r in are_members_of('system', 'fee_required', ARRAY[$lower_caller]) { + $fee_required := $r.is_member; } - $leader_hex := encode(@leader_sender, 'hex')::TEXT; - $caller_balance := eth_truf.balance(@caller); + IF $fee_required { + IF @leader_sender IS NULL { + ERROR('Leader address not available for fee transfer'); + } + $leader_hex := encode(@leader_sender, 'hex')::TEXT; - IF $caller_balance < $total_fee { - ERROR('Insufficient balance for taxonomies creation. Required: 1 TRUF'); - } + $caller_balance := eth_truf.balance(@caller); - 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 d3ccc48f..89cd4477 100644 --- a/internal/migrations/004-composed-taxonomy.sql +++ b/internal/migrations/004-composed-taxonomy.sql @@ -45,24 +45,33 @@ CREATE OR REPLACE ACTION insert_taxonomy( -- ===== FEE COLLECTION ===== -- Flat 1 TRUF per transaction (write-fee policy per issue #3805). - -- Cost is independent of $num_children: a taxonomy with N children - -- charges the same 1 TRUF as one with a single child. + -- 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 - IF @leader_sender IS NULL { - ERROR('Leader address not available for fee transfer'); + $fee_required BOOL := FALSE; + for $r in are_members_of('system', 'fee_required', ARRAY[$lower_caller]) { + $fee_required := $r.is_member; } - $leader_hex := encode(@leader_sender, 'hex')::TEXT; - $caller_balance := hoodi_tt.balance(@caller); + IF $fee_required { + IF @leader_sender IS NULL { + ERROR('Leader address not available for fee transfer'); + } + $leader_hex := encode(@leader_sender, 'hex')::TEXT; - IF $caller_balance < $total_fee { - ERROR('Insufficient balance for taxonomies creation. Required: 1 TRUF'); - } + $caller_balance := hoodi_tt.balance(@caller); - 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/tests/streams/insert_records_fee_test.go b/tests/streams/insert_records_fee_test.go index 9907eb43..b09ea1e8 100644 --- a/tests/streams/insert_records_fee_test.go +++ b/tests/streams/insert_records_fee_test.go @@ -61,6 +61,7 @@ func TestInsertRecordsFees(t *testing.T) { testInsertFeeIndependentOfRole(t), testInsertBatchChargesFlatFee(t), testInsertLeaderReceivesFees(t), + testInsertUnenrolledWalletWritesFree(t), }, }, testutils.GetTestOptionsWithCache()) } @@ -155,6 +156,11 @@ 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), @@ -215,6 +221,10 @@ 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") @@ -271,6 +281,11 @@ 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") @@ -318,6 +333,11 @@ 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") @@ -398,6 +418,10 @@ 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") @@ -456,6 +480,10 @@ 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") @@ -497,6 +525,54 @@ 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 { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + systemAdmin := util.Unsafe_NewEthereumAddressFromString("0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf") + + err := setup.CreateDataProvider(ctx, platform, systemAdmin.Address()) + require.NoError(t, err, "failed to register systemAdmin as data provider") + + streamID := "st888888888888888888888888888888" + streamLocator := types.StreamLocator{ + StreamId: util.GenerateStreamId(streamID), + DataProvider: systemAdmin, + } + err = setup.CreateStream(ctx, platform, setup.StreamInfo{ + Type: setup.ContractTypePrimitive, + Locator: streamLocator, + }) + require.NoError(t, err, "failed to create stream") + + freeAddrVal := util.Unsafe_NewEthereumAddressFromString("0xa888888888888888888888888888888888888888") + freeAddr := &freeAddrVal + err = setup.CreateDataProviderWithoutRole(ctx, platform, freeAddr.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()) + 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()) + 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 = insertRecord(ctx, platform, freeAddr, systemAdmin.Address(), streamID, 5000, "20.5") + require.NoError(t, err, "un-enrolled wallet should insert for free") + + finalBalance, err := getInsertBalance(ctx, platform, freeAddr.Address()) + require.NoError(t, err, "failed to get final balance") + require.Equal(t, big.NewInt(0), finalBalance, "un-enrolled wallet must not be charged") + + return nil + } +} + // ===== HELPER FUNCTIONS ===== // revokeInsertRoleBypass revokes a role using direct SQL with OverrideAuthz diff --git a/tests/streams/stream_creation_fee_test.go b/tests/streams/stream_creation_fee_test.go index d4ec5f73..9214cfc8 100644 --- a/tests/streams/stream_creation_fee_test.go +++ b/tests/streams/stream_creation_fee_test.go @@ -63,6 +63,7 @@ func TestStreamCreationFees(t *testing.T) { testFeeIndependentOfRole(t), testBatchCreationChargesFlatFee(t), testLeaderReceivesFees(t), + testUnenrolledWalletCreatesStreamFree(t), }, }, testutils.GetTestOptionsWithCache()) } @@ -109,6 +110,11 @@ 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") require.NoError(t, err, "failed to give balance") @@ -140,6 +146,10 @@ func testNonExemptWalletPaysFee(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 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") require.NoError(t, err, "failed to give balance") @@ -174,6 +184,11 @@ func testInsufficientBalance(t *testing.T) func(ctx context.Context, platform *k 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") require.NoError(t, err, "failed to give balance") @@ -198,6 +213,12 @@ 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") require.NoError(t, err, "failed to give balance") @@ -256,6 +277,10 @@ func testBatchCreationChargesFlatFee(t *testing.T) func(ctx context.Context, pla 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") require.NoError(t, err, "failed to give balance") @@ -298,6 +323,10 @@ func testLeaderReceivesFees(t *testing.T) func(ctx context.Context, platform *kw 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") @@ -333,6 +362,36 @@ func testLeaderReceivesFees(t *testing.T) func(ctx context.Context, platform *kw } } +// 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 { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + freeAddrVal := util.Unsafe_NewEthereumAddressFromString("0x8888888888888888888888888888888888888888") + freeAddr := &freeAddrVal + + // Register data provider WITHOUT role. NOT enrolled in fee_required. + err := setup.CreateDataProviderWithoutRole(ctx, platform, freeAddr.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()) + 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") + + finalBalance, err := getBalance(ctx, platform, freeAddr.Address()) + require.NoError(t, err, "failed to get final balance") + require.Equal(t, big.NewInt(0), finalBalance, "un-enrolled wallet must not be charged") + + return nil + } +} + // ===== HELPER FUNCTIONS ===== // revokeRoleBypass revokes a role using direct SQL with OverrideAuthz diff --git a/tests/streams/taxonomy_fee_test.go b/tests/streams/taxonomy_fee_test.go index e14b5a4e..2f20bfcd 100644 --- a/tests/streams/taxonomy_fee_test.go +++ b/tests/streams/taxonomy_fee_test.go @@ -42,6 +42,7 @@ func TestTaxonomyFees(t *testing.T) { testTaxonomyNonExemptWalletPaysFee(t), testTaxonomyInsufficientBalance(t), testTaxonomyMultipleChildrenChargesFlatFee(t), + testTaxonomyUnenrolledWalletWritesFree(t), }, }, testutils.GetTestOptionsWithCache()) } @@ -80,6 +81,11 @@ 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(), "100000000000000000000") // 100 TRUF require.NoError(t, err, "failed to give balance") @@ -128,6 +134,11 @@ 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 all three writes (composed, + // child, taxonomy) 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 3 TRUF: 1 (composed stream fee) + 1 (child stream fee) + 1 (taxonomy fee) threeTRUF := mustParseBigInt("3000000000000000000") // 3 TRUF err = giveBalance(ctx, platform, nonExemptAddr.Address(), threeTRUF.String()) @@ -184,6 +195,11 @@ 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 2 TRUF: enough for the two create_stream calls (1 + 1) // but nothing left over for the 1 TRUF taxonomy fee. twoTRUF := mustParseBigInt("2000000000000000000") @@ -232,6 +248,11 @@ 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 5 TRUF: 1 (composed) + 3 (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. @@ -284,6 +305,47 @@ func testTaxonomyMultipleChildrenChargesFlatFee(t *testing.T) func(ctx context.C } } +// Test 5: A wallet not enrolled in system:fee_required runs the whole +// composed/child/taxonomy sequence for free. Phased rollout of #3805 — +// until enrolled, none of the three write actions touches the bridge. +func testTaxonomyUnenrolledWalletWritesFree(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 + + err := setup.CreateDataProviderWithoutRole(ctx, platform, freeAddr.Address()) + require.NoError(t, err, "failed to create data provider without role") + + // NOT enrolled in fee_required. Wallet has zero TRUF. + initialBalance, err := getBalance(ctx, platform, freeAddr.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") + + composedStreamId := util.GenerateStreamId("taxonomy_free_composed") + childStreamId := util.GenerateStreamId("taxonomy_free_child") + + err = createStream(ctx, platform, freeAddr, composedStreamId.String(), "composed") + require.NoError(t, err, "un-enrolled wallet should create composed stream for free") + + err = createStream(ctx, platform, freeAddr, childStreamId.String(), "primitive") + require.NoError(t, err, "un-enrolled wallet should create child stream for free") + + err = insertTaxonomy(ctx, platform, freeAddr, + freeAddr.Address(), composedStreamId.String(), + []string{freeAddr.Address()}, + []string{childStreamId.String()}, + []string{"1.0"}, + nil) + require.NoError(t, err, "un-enrolled wallet should insert taxonomy for free") + + finalBalance, err := getBalance(ctx, platform, freeAddr.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 any of the three writes") + + return nil + } +} + // insertTaxonomy directly calls the insert_taxonomy action with proper context func insertTaxonomy(ctx context.Context, platform *kwilTesting.Platform, signer *util.EthereumAddress, dataProvider string, streamId string, diff --git a/tests/streams/transaction_events_ledger_test.go b/tests/streams/transaction_events_ledger_test.go index 5355c8d6..bc6a87d5 100644 --- a/tests/streams/transaction_events_ledger_test.go +++ b/tests/streams/transaction_events_ledger_test.go @@ -95,6 +95,11 @@ func runTransactionEventsLedgerScenario(t *testing.T) func(ctx context.Context, // fees plus the 40 TRUF attestation fee with headroom. require.NoError(t, feefund.EnsureWalletFunded(ctx, platform, actor.Address(), "100000000000000000000"), "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. + require.NoError(t, setup.AddMemberToRoleBypass(ctx, platform, "system", "fee_required", actor.Address())) receiverVal := util.Unsafe_NewEthereumAddressFromString("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") receiver := &receiverVal