diff --git a/pallets/indexing/Cargo.toml b/pallets/indexing/Cargo.toml index 5135f293..ceef8887 100644 --- a/pallets/indexing/Cargo.toml +++ b/pallets/indexing/Cargo.toml @@ -21,7 +21,7 @@ scale-info = { features = [ "derive", ], workspace = true } -polkadot-sdk = { workspace = true, features = ["frame-benchmarking", "frame-support", "frame-system", "pallet-session", "sp-runtime", "sp-core"]} +polkadot-sdk = { workspace = true, features = ["frame-benchmarking", "frame-support", "frame-system", "pallet-session", "pallet-balances", "pallet-transaction-payment", "sp-runtime", "sp-core"]} pallet-commitments.workspace = true pallet-permissions.workspace = true @@ -40,7 +40,7 @@ arrow-ipc-no-std.workspace = true snafu.workspace = true [dev-dependencies] -polkadot-sdk = { workspace = true, features = ["sp-staking", "frame-election-provider-support", "pallet-staking-reward-curve", "pallet-timestamp", "pallet-balances", "pallet-staking", "sp-core", "sp-io", "sp-runtime"]} +polkadot-sdk = { workspace = true, features = ["sp-staking", "frame-election-provider-support", "pallet-staking-reward-curve", "pallet-timestamp", "pallet-balances", "pallet-staking", "pallet-transaction-payment", "pallet-utility", "sp-core", "sp-io", "sp-runtime"]} proof-of-sql-static-setups = { workspace = true, features = ["io"] } native.workspace = true diff --git a/pallets/indexing/src/benchmarking.rs b/pallets/indexing/src/benchmarking.rs index 8062411c..7ca87bc7 100644 --- a/pallets/indexing/src/benchmarking.rs +++ b/pallets/indexing/src/benchmarking.rs @@ -2,9 +2,12 @@ use alloc::vec; use polkadot_sdk::frame_benchmarking::v2::*; +use polkadot_sdk::frame_support::dispatch::DispatchInfo; use polkadot_sdk::frame_system; use polkadot_sdk::frame_system::RawOrigin; +use polkadot_sdk::pallet_transaction_payment::{self, OnChargeTransaction}; use polkadot_sdk::sp_core::crypto::Ss58Codec; +use polkadot_sdk::sp_runtime::traits::Dispatchable; use super::*; #[cfg(test)] @@ -17,25 +20,37 @@ use crate::Pallet as Indexing; where ::AccountId: Ss58Codec, I: NativeApi, + T::RuntimeCall: Dispatchable, + ::OnChargeTransaction: + OnChargeTransaction, )] mod benchmarks { + use codec::Encode; use native_api::NativeApi; use on_chain_table::{OnChainColumn, OnChainTable}; use pallet_tables::benchmarking::schema_bytes_and_ddl_and_source; use pallet_tables::pallet::BlockEnforcementMode; use pallet_tables::{BlockEnforcement, CommitmentCreationCmd, UpdateTable}; + use polkadot_sdk::frame_support::traits::fungible::Mutate; + use polkadot_sdk::frame_support::traits::Get; + use polkadot_sdk::pallet_balances; + use polkadot_sdk::sp_runtime::traits::Bounded; use proof_of_sql_commitment_map::CommitmentSchemeFlags; use sqlparser::ast::Ident; + use sxt_core::indexing::REFUND_PERCENTAGE_DOMAIN; use sxt_core::permissions::{IndexingPalletPermission, PermissionLevel, PermissionList}; use sxt_core::tables::{ InsertQuorumSize, Source, TableIdentifier, + TableMetadataBytes, TableName, TableNamespace, TableType, MAX_COLS_PER_TABLE, }; + use sxt_core::utils::table_treasury_account; + use sxt_core::ByteString; use super::*; @@ -139,9 +154,16 @@ mod benchmarks { T: Config, ::AccountId: Ss58Codec, I: NativeApi, + T::RuntimeCall: Dispatchable, + ::OnChargeTransaction: + OnChargeTransaction, { - let (update_table, batch_id, row_data) = + let (mut update_table, batch_id, row_data) = benchmark_expensive_table_and_data::(num_rows, num_cols, commitment_schemes); + update_table.table_type = TableType::Testing(InsertQuorumSize { + public: Some(MAX_SUBMITTERS as u8 - 1), + privileged: None, + }); let (namespace, namespace_ddl, source) = schema_bytes_and_ddl_and_source("BENCHMARK"); pallet_tables::Pallet::::create_namespace( @@ -165,37 +187,39 @@ mod benchmarks { )]) .unwrap(); - let caller: T::AccountId = account("alice", 0, 0); - pallet_permissions::Permissions::::insert(&caller, &permissions); - Indexing::::submit_data( - RawOrigin::Signed(caller).into(), - update_table.ident.clone(), - batch_id.clone(), - row_data.clone(), - ) - .unwrap(); + for i in 0..MAX_SUBMITTERS - 1 { + let submitter: T::AccountId = account("submitter", i, 0); + pallet_permissions::Permissions::::insert(&submitter, &permissions); + pallet_balances::Pallet::::mint_into( + &submitter, + ::ExistentialDeposit::get(), + ) + .unwrap(); + Indexing::::submit_data( + RawOrigin::Signed(submitter).into(), + update_table.ident.clone(), + batch_id.clone(), + row_data.clone(), + ) + .unwrap(); + } - let caller: T::AccountId = account("bob", 0, 0); - pallet_permissions::Permissions::::insert(&caller, &permissions); - Indexing::::submit_data( - RawOrigin::Signed(caller).into(), - update_table.ident.clone(), - batch_id.clone(), - row_data.clone(), - ) - .unwrap(); - let caller: T::AccountId = account("carol", 0, 0); + let caller: T::AccountId = account("caller", 0, 0); pallet_permissions::Permissions::::insert(&caller, &permissions); - Indexing::::submit_data( - RawOrigin::Signed(caller).into(), - update_table.ident.clone(), - batch_id.clone(), - row_data.clone(), + pallet_balances::Pallet::::mint_into( + &caller, + ::ExistentialDeposit::get(), ) .unwrap(); - let caller: T::AccountId = account("dave", 0, 0); - pallet_permissions::Permissions::::insert(&caller, &permissions); + pallet_tables::TableMetadata::::insert( + ByteString::try_from(REFUND_PERCENTAGE_DOMAIN.to_vec()).unwrap(), + &update_table.ident, + TableMetadataBytes::try_from(100u16.encode()).unwrap(), + ); + let treasury = table_treasury_account::(&update_table.ident).unwrap(); + pallet_balances::Pallet::::mint_into(&treasury, T::Balance::max_value() / 2u32.into()) + .unwrap(); (caller, update_table.ident, batch_id, row_data) } diff --git a/pallets/indexing/src/lib.rs b/pallets/indexing/src/lib.rs index 3ea126d6..56ddcf0c 100644 --- a/pallets/indexing/src/lib.rs +++ b/pallets/indexing/src/lib.rs @@ -39,16 +39,22 @@ pub mod pallet { use on_chain_table::OnChainTable; use pallet_tables::pallet::BlockEnforcementMode; use pallet_tables::BlockEnforcement; + use polkadot_sdk::frame_support::dispatch::DispatchInfo; use polkadot_sdk::frame_support::pallet_prelude::*; + use polkadot_sdk::frame_support::traits::fungible::Mutate; + use polkadot_sdk::frame_support::traits::tokens::Preservation; use polkadot_sdk::frame_support::Blake2_128Concat; - use polkadot_sdk::frame_system; use polkadot_sdk::frame_system::pallet_prelude::*; - use polkadot_sdk::sp_runtime::traits::Hash; - use polkadot_sdk::sp_runtime::BoundedVec; + use polkadot_sdk::pallet_transaction_payment::{FeeDetails, InclusionFee, OnChargeTransaction}; + use polkadot_sdk::sp_runtime::traits::{Dispatchable, Hash, Zero}; + use polkadot_sdk::sp_runtime::{BoundedVec, Saturating}; + use polkadot_sdk::{frame_system, pallet_balances, pallet_transaction_payment}; use proof_of_sql_commitment_map::CommitmentScheme; use sxt_core::permissions::{IndexingPalletPermission, PermissionLevel}; use sxt_core::record_batch::record_batch_bytes_dimensions; use sxt_core::tables::{InsertQuorumSize, QuorumScope, TableIdentifier}; + use sxt_core::utils::table_treasury_account; + use sxt_core::ByteString; use super::*; @@ -67,6 +73,8 @@ pub mod pallet { + pallet_commitments::Config + pallet_tables::Config + pallet_system_tables::Config + + pallet_balances::Config + + pallet_transaction_payment::Config { /// Binding for the runtime event, typically provided by an implementation /// in runtime/lib.rs @@ -166,6 +174,28 @@ pub mod pallet { /// Voters against this quorum dissents: BoundedBTreeSet>, }, + + /// The submission fee for a finalized quorum has been refunded to its submitters. + RefundProcessed { + /// The table identifier + table: TableIdentifier, + /// The batch that was refunded + batch_id: BatchId, + /// The amount refunded to each submitter + refund: T::Balance, + }, + + /// Emitted when a refund transfer to a submitter fails. + RefundError { + /// The table identifier + table: TableIdentifier, + /// The batch whose refund failed + batch_id: BatchId, + /// The submitter who was to receive the refund + recipient: T::AccountId, + /// The error received while processing the transfer + error: DispatchError, + }, } #[pallet::error] @@ -230,6 +260,8 @@ pub mod pallet { ArrowRowCountOutOfBounds, /// Arrow schema contains no fields. ArrowSchemaMissingFields, + /// The call is not a data submission. + UnsupportedCall, } #[pallet::call] @@ -237,6 +269,9 @@ pub mod pallet { where T: pallet_tables::Config, I: NativeApi, + T::RuntimeCall: Dispatchable, + ::OnChargeTransaction: + OnChargeTransaction, { /// Submit an IPC-formatted record batch for a given table. /// @@ -271,7 +306,14 @@ pub mod pallet { batch_id: BatchId, data: RowData, ) -> DispatchResult { - submit_data_inner::(origin, table, batch_id, data, None) + submit_data_inner::( + origin, + Call::submit_data { + table, + batch_id, + data, + }, + ) } /// Submit an IPC-formatted record batch for a given table with block number metadata. @@ -310,7 +352,15 @@ pub mod pallet { data: RowData, block_number: u64, ) -> DispatchResult { - submit_data_inner::(origin, table, batch_id, data, Some(block_number)) + submit_data_inner::( + origin, + Call::submit_blockchain_data { + table, + batch_id, + data, + block_number, + }, + ) } /// Set the block number for a table. @@ -481,19 +531,32 @@ pub mod pallet { Ok((quorum_scope, table_insert_quorum)) } - fn submit_data_inner( - origin: OriginFor, - table: TableIdentifier, - outer_batch_id: BatchId, - data: RowData, - block_number: Option, - ) -> DispatchResult + fn submit_data_inner(origin: OriginFor, call: Call) -> DispatchResult where T: Config, I: NativeApi, + T::RuntimeCall: Dispatchable, + ::OnChargeTransaction: + OnChargeTransaction, { let who = ensure_signed(origin.clone())?; + let len = ( as PalletInfoAccess>::index() as u8, &call).encoded_size() as u32; + let (table, outer_batch_id, data, block_number) = match call { + Call::submit_data { + table, + batch_id, + data, + } => (table, batch_id, data, None), + Call::submit_blockchain_data { + table, + batch_id, + data, + block_number, + } => (table, batch_id, data, Some(block_number)), + _ => Err(Error::::UnsupportedCall)?, + }; + let (quorum_scope, table_insert_quorum) = get_submission_permissions::(origin, &table)?; @@ -511,12 +574,79 @@ pub mod pallet { &table_insert_quorum, &quorum_scope, )? { - finalize_quorum::(data_quorum, data, block_number, who)?; + let weight = submit_data_weight::(&data_quorum.table, &data); + finalize_quorum::(&data_quorum, data, block_number, who)?; + refund_quorum::(data_quorum, weight, len); } Ok(()) } + /// Refunds each quorum submitter the weight and length fee of their call, scaled by [`REFUND_PERCENTAGE_DOMAIN`]. + pub(crate) fn refund_quorum( + quorum: DataQuorum, + weight: Weight, + len: u32, + ) where + T: Config, + I: NativeApi, + T::RuntimeCall: Dispatchable, + ::OnChargeTransaction: + OnChargeTransaction, + { + let Some(percentage) = ByteString::try_from(REFUND_PERCENTAGE_DOMAIN.to_vec()) + .ok() + .and_then(|domain| pallet_tables::TableMetadata::::get(&domain, &quorum.table)) + .and_then(|bytes| u16::decode(&mut bytes.as_slice()).ok()) + else { + return; + }; + + let Some(treasury) = table_treasury_account::(&quorum.table) else { + // Unreachable unless the runtime's `AccountId` stops decoding from `AccountId32`. + return; + }; + + let info = DispatchInfo { + weight, + class: DispatchClass::Normal, + pays_fee: Pays::Yes, + }; + let details = + pallet_transaction_payment::Pallet::::compute_fee_details(len, &info, Zero::zero()); + let cost = FeeDetails { + inclusion_fee: details.inclusion_fee.map(|fee| InclusionFee { + base_fee: Zero::zero(), + ..fee + }), + ..details + } + .final_fee(); + let refund = cost.saturating_mul(percentage.into()) / 100u16.into(); + + for recipient in quorum.agreements { + if let Err(error) = pallet_balances::Pallet::::transfer( + &treasury, + &recipient, + refund, + Preservation::Expendable, + ) { + Pallet::::deposit_event(Event::RefundError { + table: quorum.table.clone(), + batch_id: quorum.batch_id.clone(), + recipient, + error, + }); + } + } + + Pallet::::deposit_event(Event::RefundProcessed { + table: quorum.table, + batch_id: quorum.batch_id, + refund, + }); + } + /// Submit data and check if we have a quorum. /// /// If quorum is reached, the associated [`DataQuorum`] is returned, otherwise returns `None`. @@ -643,7 +773,7 @@ pub mod pallet { /// - emitting `QuorumReached` event /// - cleaning up submissions fn finalize_quorum( - quorum: DataQuorum, + quorum: &DataQuorum, row_data: RowData, block_number: Option, submitter: T::AccountId, @@ -652,7 +782,7 @@ pub mod pallet { T: Config, I: NativeApi, { - clean_up_and_record_quorum::(&quorum); + clean_up_and_record_quorum::(quorum); // Deserialize into Arrow-compatible OnChainTable let table_bytes = I::record_batch_to_onchain(sxt_core::native::RowData { row_data }) diff --git a/pallets/indexing/src/mock.rs b/pallets/indexing/src/mock.rs index 49f5138e..5fd0f004 100644 --- a/pallets/indexing/src/mock.rs +++ b/pallets/indexing/src/mock.rs @@ -6,7 +6,10 @@ use polkadot_sdk::frame_election_provider_support::bounds::{ use polkadot_sdk::frame_election_provider_support::{onchain, SequentialPhragmen}; use polkadot_sdk::frame_support::pallet_prelude::ConstU32; use polkadot_sdk::frame_support::traits::ConstU128; +use polkadot_sdk::frame_support::weights::ConstantMultiplier; use polkadot_sdk::frame_support::{derive_impl, parameter_types}; +#[allow(deprecated)] +use polkadot_sdk::pallet_transaction_payment::CurrencyAdapter; use polkadot_sdk::sp_core::{ConstU64, H256}; use polkadot_sdk::sp_runtime::traits::{IdentityLookup, OpaqueKeys}; use polkadot_sdk::sp_runtime::{BuildStorage, KeyTypeId}; @@ -18,6 +21,8 @@ use polkadot_sdk::{ pallet_staking, pallet_staking_reward_curve, pallet_timestamp, + pallet_transaction_payment, + pallet_utility, sp_core, sp_io, sp_runtime, @@ -44,6 +49,8 @@ frame_support::construct_runtime!( SystemTables: pallet_system_tables, Balances: pallet_balances, Staking: pallet_staking, + TransactionPayment: pallet_transaction_payment, + Utility: pallet_utility, } ); @@ -77,6 +84,29 @@ impl pallet_balances::Config for Test { type MaxFreezes = (); } +parameter_types! { + pub const WeightFeePerRefTime: Balance = 1; + pub const TransactionByteFee: Balance = 1; + pub const OperationalFeeMultiplier: u8 = 5; +} + +impl pallet_transaction_payment::Config for Test { + type RuntimeEvent = RuntimeEvent; + #[allow(deprecated)] + type OnChargeTransaction = CurrencyAdapter; + type WeightToFee = ConstantMultiplier; + type LengthToFee = ConstantMultiplier; + type FeeMultiplierUpdate = (); + type OperationalFeeMultiplier = OperationalFeeMultiplier; +} + +impl pallet_utility::Config for Test { + type RuntimeEvent = RuntimeEvent; + type RuntimeCall = RuntimeCall; + type PalletsOrigin = OriginCaller; + type WeightInfo = (); +} + impl pallet_timestamp::Config for Test { type Moment = u64; type OnTimestampSet = (); diff --git a/pallets/indexing/src/tests.rs b/pallets/indexing/src/tests.rs index a31dfb27..8f2b3567 100644 --- a/pallets/indexing/src/tests.rs +++ b/pallets/indexing/src/tests.rs @@ -12,11 +12,14 @@ use codec::{Decode, Encode, MaxEncodedLen}; use native_api::Api; use pallet_tables::{CommitmentCreationCmd, UpdateTable}; use polkadot_sdk::frame_support::__private::RuntimeDebug; -use polkadot_sdk::frame_support::dispatch::DispatchResult; +use polkadot_sdk::frame_support::dispatch::{DispatchResult, GetDispatchInfo}; use polkadot_sdk::frame_support::pallet_prelude::TypeInfo; +use polkadot_sdk::frame_support::traits::fungible::Mutate; +use polkadot_sdk::frame_support::weights::Weight; use polkadot_sdk::frame_support::{assert_err, assert_ok}; use polkadot_sdk::frame_system::ensure_signed; use polkadot_sdk::sp_core::Hasher; +use polkadot_sdk::sp_runtime::traits::Dispatchable; use polkadot_sdk::sp_runtime::BoundedVec; use polkadot_sdk::{frame_system, sp_runtime}; use proof_of_sql_commitment_map::CommitmentSchemeFlags; @@ -28,10 +31,13 @@ use sxt_core::tables::{ InsertQuorumSize, QuorumScope, TableIdentifier, + TableMetadataBytes, TableName, TableNamespace, TableType, }; +use sxt_core::utils::table_treasury_account; +use sxt_core::ByteString; use crate::mock::*; use crate::{build_inner_batch_id, BatchId, Event, RowData}; @@ -101,6 +107,17 @@ fn record_batch_to_row_data(batch: RecordBatch, schema: Arc) -> RowData RowData::try_from(data).unwrap() } +fn set_refund_percentage(table: &TableIdentifier, percentage: u16) { + let domain = ByteString::try_from(crate::REFUND_PERCENTAGE_DOMAIN.to_vec()).unwrap(); + let bytes = TableMetadataBytes::try_from(percentage.encode()).unwrap(); + assert_ok!(Tables::set_table_metadata( + RuntimeOrigin::root(), + domain, + table.clone(), + Some(bytes), + )); +} + fn sample_table_definition() -> (TableIdentifier, CreateStatement) { let table_id = TableIdentifier { namespace: TableNamespace::try_from(b"TEST_NAMESPACE".to_owned().to_vec()).unwrap(), @@ -2517,3 +2534,282 @@ fn submit_empty_blocks_respects_quorum() { ); }); } + +#[test] +fn refund_is_paid_to_submitter_when_quorum_is_reached() { + for block_number in [None, Some(12345)] { + new_test_ext().execute_with(|| { + System::set_block_number(1); + let (table, create_statement) = sample_table_definition(); + Tables::create_tables( + RuntimeOrigin::root(), + vec![UpdateTable { + ident: table.clone(), + create_statement, + table_type: TableType::Testing(InsertQuorumSize { + public: Some(0), + privileged: None, + }), + commitment: CommitmentCreationCmd::Empty(CommitmentSchemeFlags { + hyper_kzg: true, + dynamic_dory: true, + }), + source: sxt_core::tables::Source::Ethereum, + }] + .try_into() + .unwrap(), + ) + .unwrap(); + set_refund_percentage(&table, 100); + let treasury = table_treasury_account::(&table).unwrap(); + assert_ok!(Balances::mint_into(&treasury, 1_000_000_000_000_000_000)); + let submitter = sp_runtime::AccountId32::new([1; 32]); + pallet_permissions::Permissions::::insert( + submitter.clone(), + PermissionList::try_from(vec![PermissionLevel::IndexingPallet( + IndexingPalletPermission::SubmitDataForPublicQuorum, + )]) + .unwrap(), + ); + let batch_id = BatchId::try_from(b"test_batch".to_vec()).unwrap(); + let origin = RuntimeOrigin::signed(submitter.clone()); + + assert_ok!(match block_number { + None => Indexing::submit_data(origin, table.clone(), batch_id.clone(), row_data()), + Some(n) => Indexing::submit_blockchain_data( + origin, + table.clone(), + batch_id.clone(), + row_data(), + n, + ), + }); + + let refund = Balances::free_balance(&submitter); + assert!(refund > 0, "submitter should have been refunded"); + System::assert_has_event( + Event::::RefundProcessed { + batch_id: build_inner_batch_id::(&batch_id, &table), + table, + refund, + } + .into(), + ); + }); + } +} + +#[test] +fn unrefunded_fee_is_constant_across_batch_sizes() { + let mut expected_unrefunded_fee = None; + let mut per_call_refund = None; + for size in [1, 2, 4, 8] { + let (fee, refund) = new_test_ext().execute_with(|| { + System::set_block_number(1); + let namespace = sample_table_definition().0.namespace; + let tables: Vec<_> = (0..size) + .map(|i| TableIdentifier { + namespace: namespace.clone(), + name: TableName::try_from(format!("TEST_TABLE_{i}").into_bytes()).unwrap(), + }) + .collect(); + Tables::create_tables( + RuntimeOrigin::root(), + (0..size) + .map(|i| UpdateTable { + ident: tables[i].clone(), + create_statement: CreateStatement::try_from( + format!("CREATE TABLE TEST_NAMESPACE.TEST_TABLE_{i} (int_column INT NOT NULL)") + .into_bytes(), + ) + .unwrap(), + table_type: TableType::Testing(InsertQuorumSize { + public: Some(0), + privileged: None, + }), + commitment: CommitmentCreationCmd::Empty(CommitmentSchemeFlags { + hyper_kzg: true, + dynamic_dory: true, + }), + source: sxt_core::tables::Source::Ethereum, + }) + .collect::>() + .try_into() + .unwrap(), + ) + .unwrap(); + for table in &tables { + set_refund_percentage(table, 100); + assert_ok!(Balances::mint_into( + &table_treasury_account::(table).unwrap(), + 1_000_000_000_000_000_000, + )); + } + let submitter = sp_runtime::AccountId32::new([1; 32]); + pallet_permissions::Permissions::::insert( + submitter.clone(), + PermissionList::try_from(vec![PermissionLevel::IndexingPallet( + IndexingPalletPermission::SubmitDataForPublicQuorum, + )]) + .unwrap(), + ); + + let batch = RuntimeCall::Utility(polkadot_sdk::pallet_utility::Call::batch_all { + calls: tables + .into_iter() + .map(|table| { + crate::Call::::submit_data { + table, + batch_id: BatchId::try_from(b"test_batch".to_vec()).unwrap(), + data: row_data(), + } + .into() + }) + .collect(), + }); + let info = batch.get_dispatch_info(); + let post_info = batch + .clone() + .dispatch(RuntimeOrigin::signed(submitter.clone())) + .unwrap(); + let fee = TransactionPayment::compute_actual_fee( + batch.encoded_size() as u32, + &info, + &post_info, + 0, + ) - TransactionPayment::weight_to_fee( + <() as polkadot_sdk::pallet_utility::WeightInfo>::batch_all(size as u32), + ); + + (fee, Balances::free_balance(&submitter)) + }); + assert_eq!( + refund, + *per_call_refund.get_or_insert(refund) * size as u128, + "batch of {size}" + ); + assert_eq!( + *expected_unrefunded_fee.get_or_insert(fee - refund), + fee - refund, + "batch of {size}" + ); + } +} + +#[test] +fn refund_quorum_pays_each_agreement_its_fee_scaled_by_percentage() { + for (percentage, expected_refund) in [(100, 1_001_000), (150, 1_501_500), (200, 2_002_000)] { + new_test_ext().execute_with(|| { + System::set_block_number(1); + let submitters = [ + sp_runtime::AccountId32::new([1; 32]), + sp_runtime::AccountId32::new([2; 32]), + ]; + let quorum = crate::DataQuorum { + table: sample_table_definition().0, + batch_id: BatchId::try_from(b"test_batch".to_vec()).unwrap(), + data_hash: Default::default(), + block_number: Default::default(), + agreements: std::collections::BTreeSet::from(submitters.clone()) + .try_into() + .unwrap(), + dissents: Default::default(), + quorum_scope: QuorumScope::Public, + }; + set_refund_percentage(&quorum.table, percentage); + let treasury = table_treasury_account::(&quorum.table).unwrap(); + assert_ok!(Balances::mint_into(&treasury, 1_000_000_000)); + + crate::refund_quorum::( + quorum.clone(), + Weight::from_parts(1_000_000, 0), + 1_000, + ); + + for submitter in &submitters { + assert_eq!(Balances::free_balance(submitter), expected_refund); + } + assert_eq!( + Balances::free_balance(&treasury), + 1_000_000_000 - 2 * expected_refund + ); + assert_eq!( + System::read_events_for_pallet::>(), + vec![Event::RefundProcessed { + table: quorum.table, + batch_id: quorum.batch_id, + refund: expected_refund, + }] + ); + }); + } +} + +#[test] +fn refund_quorum_skips_when_percentage_is_missing_or_invalid() { + for metadata in [None, Some(vec![0u8])] { + new_test_ext().execute_with(|| { + System::set_block_number(1); + let submitter = sp_runtime::AccountId32::new([1; 32]); + let quorum = crate::DataQuorum { + table: sample_table_definition().0, + batch_id: BatchId::try_from(b"test_batch".to_vec()).unwrap(), + data_hash: Default::default(), + block_number: Default::default(), + agreements: std::collections::BTreeSet::from([submitter.clone()]) + .try_into() + .unwrap(), + dissents: Default::default(), + quorum_scope: QuorumScope::Public, + }; + if let Some(bytes) = metadata { + assert_ok!(Tables::set_table_metadata( + RuntimeOrigin::root(), + ByteString::try_from(crate::REFUND_PERCENTAGE_DOMAIN.to_vec()).unwrap(), + quorum.table.clone(), + Some(TableMetadataBytes::try_from(bytes).unwrap()), + )); + } + assert_ok!(Balances::mint_into( + &table_treasury_account::(&quorum.table).unwrap(), + 1_000_000_000 + )); + + crate::refund_quorum::(quorum, Weight::from_parts(1_000_000, 0), 1_000); + + assert_eq!(Balances::free_balance(&submitter), 0); + assert!(System::read_events_for_pallet::>().is_empty()); + }); + } +} + +#[test] +fn refund_quorum_emits_refund_error_when_treasury_is_unfunded() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + let submitter = sp_runtime::AccountId32::new([1; 32]); + let quorum = crate::DataQuorum { + table: sample_table_definition().0, + batch_id: BatchId::try_from(b"test_batch".to_vec()).unwrap(), + data_hash: Default::default(), + block_number: Default::default(), + agreements: std::collections::BTreeSet::from([submitter.clone()]) + .try_into() + .unwrap(), + dissents: Default::default(), + quorum_scope: QuorumScope::Public, + }; + set_refund_percentage(&quorum.table, 100); + + crate::refund_quorum::(quorum.clone(), Weight::from_parts(1_000_000, 0), 1_000); + + assert_eq!(Balances::free_balance(&submitter), 0); + assert!(matches!( + System::read_events_for_pallet::>().as_slice(), + [ + Event::RefundError { table, batch_id, recipient, .. }, + Event::RefundProcessed { .. }, + ] if recipient == &submitter && table == &quorum.table && batch_id == &quorum.batch_id + )); + }); +} diff --git a/pallets/indexing/src/weights.rs b/pallets/indexing/src/weights.rs index bce551a6..e34302d8 100644 --- a/pallets/indexing/src/weights.rs +++ b/pallets/indexing/src/weights.rs @@ -2,7 +2,7 @@ //! Autogenerated weights for `pallet_indexing` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 43.0.0 -//! DATE: 2026-03-18, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-10-02, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` //! HOSTNAME: `trevor-benchmark`, CPU: `AMD EPYC 7763 64-Core Processor` //! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: `1024` @@ -59,20 +59,26 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Commitments::CommitmentStorageMap` (`max_values`: None, `max_size`: Some(45497), added: 47972, mode: `MaxEncodedLen`) /// Storage: `Tables::BlockEnforcement` (r:1 w:0) /// Proof: `Tables::BlockEnforcement` (`max_values`: None, `max_size`: Some(149), added: 2624, mode: `MaxEncodedLen`) + /// Storage: `Tables::TableMetadata` (r:1 w:0) + /// Proof: `Tables::TableMetadata` (`max_values`: None, `max_size`: Some(8424), added: 10899, mode: `MaxEncodedLen`) + /// Storage: `TransactionPayment::NextFeeMultiplier` (r:1 w:0) + /// Proof: `TransactionPayment::NextFeeMultiplier` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:33 w:33) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) /// The range of component `c` is `[1, 64]`. /// The range of component `n` is `[0, 16384]`. fn submit_data_quorum_reached_dynamic_dory(c: u32, n: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `1188 + c * (607 ±0)` + // Measured: `6821 + c * (607 ±0)` // Estimated: `276827554 + c * (607 ±0)` - // Minimum execution time: 2_206_770_000 picoseconds. - Weight::from_parts(2_232_157_000, 276827554) - // Standard Error: 25_460_189 - .saturating_add(Weight::from_parts(629_572_780, 0).saturating_mul(c.into())) - // Standard Error: 99_400 - .saturating_add(Weight::from_parts(33_396_949, 0).saturating_mul(n.into())) - .saturating_add(T::DbWeight::get().reads(11_u64)) - .saturating_add(T::DbWeight::get().writes(3_u64)) + // Minimum execution time: 3_706_688_000 picoseconds. + Weight::from_parts(3_802_046_000, 276827554) + // Standard Error: 26_725_519 + .saturating_add(Weight::from_parts(670_991_317, 0).saturating_mul(c.into())) + // Standard Error: 104_340 + .saturating_add(Weight::from_parts(34_006_074, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(46_u64)) + .saturating_add(T::DbWeight::get().writes(36_u64)) .saturating_add(Weight::from_parts(0, 607).saturating_mul(c.into())) } /// Storage: `Tables::TableInsertQuorums` (r:1 w:0) @@ -91,18 +97,24 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Commitments::CommitmentStorageMap` (`max_values`: None, `max_size`: Some(45497), added: 47972, mode: `MaxEncodedLen`) /// Storage: `Tables::BlockEnforcement` (r:1 w:0) /// Proof: `Tables::BlockEnforcement` (`max_values`: None, `max_size`: Some(149), added: 2624, mode: `MaxEncodedLen`) + /// Storage: `Tables::TableMetadata` (r:1 w:0) + /// Proof: `Tables::TableMetadata` (`max_values`: None, `max_size`: Some(8424), added: 10899, mode: `MaxEncodedLen`) + /// Storage: `TransactionPayment::NextFeeMultiplier` (r:1 w:0) + /// Proof: `TransactionPayment::NextFeeMultiplier` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:33 w:33) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) /// The range of component `c` is `[1, 64]`. /// The range of component `n` is `[0, 16384]`. fn submit_data_quorum_reached_hyper_kzg(c: u32, n: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `1184 + c * (87 ±0)` + // Measured: `6817 + c * (87 ±0)` // Estimated: `276827554 + c * (87 ±0)` - // Minimum execution time: 913_610_000 picoseconds. - Weight::from_parts(922_727_000, 276827554) - // Standard Error: 64_618 - .saturating_add(Weight::from_parts(33_482_779, 0).saturating_mul(n.into())) - .saturating_add(T::DbWeight::get().reads(11_u64)) - .saturating_add(T::DbWeight::get().writes(3_u64)) + // Minimum execution time: 2_450_433_000 picoseconds. + Weight::from_parts(2_601_418_000, 276827554) + // Standard Error: 65_884 + .saturating_add(Weight::from_parts(34_072_128, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(46_u64)) + .saturating_add(T::DbWeight::get().writes(36_u64)) .saturating_add(Weight::from_parts(0, 87).saturating_mul(c.into())) } /// Storage: `Indexing::BlockNumbers` (r:0 w:1) @@ -111,8 +123,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 6_432_000 picoseconds. - Weight::from_parts(6_693_000, 0) + // Minimum execution time: 7_253_000 picoseconds. + Weight::from_parts(7_604_000, 0) .saturating_add(T::DbWeight::get().writes(1_u64)) } /// Storage: `Tables::TableInsertQuorums` (r:1 w:0) @@ -135,8 +147,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `1110` // Estimated: `276827554` - // Minimum execution time: 113_191_000 picoseconds. - Weight::from_parts(115_686_000, 276827554) + // Minimum execution time: 119_884_000 picoseconds. + Weight::from_parts(122_800_000, 276827554) .saturating_add(T::DbWeight::get().reads(10_u64)) .saturating_add(T::DbWeight::get().writes(3_u64)) } @@ -160,20 +172,26 @@ impl WeightInfo for () { /// Proof: `Commitments::CommitmentStorageMap` (`max_values`: None, `max_size`: Some(45497), added: 47972, mode: `MaxEncodedLen`) /// Storage: `Tables::BlockEnforcement` (r:1 w:0) /// Proof: `Tables::BlockEnforcement` (`max_values`: None, `max_size`: Some(149), added: 2624, mode: `MaxEncodedLen`) + /// Storage: `Tables::TableMetadata` (r:1 w:0) + /// Proof: `Tables::TableMetadata` (`max_values`: None, `max_size`: Some(8424), added: 10899, mode: `MaxEncodedLen`) + /// Storage: `TransactionPayment::NextFeeMultiplier` (r:1 w:0) + /// Proof: `TransactionPayment::NextFeeMultiplier` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:33 w:33) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) /// The range of component `c` is `[1, 64]`. /// The range of component `n` is `[0, 16384]`. fn submit_data_quorum_reached_dynamic_dory(c: u32, n: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `1188 + c * (607 ±0)` + // Measured: `6821 + c * (607 ±0)` // Estimated: `276827554 + c * (607 ±0)` - // Minimum execution time: 2_206_770_000 picoseconds. - Weight::from_parts(2_232_157_000, 276827554) - // Standard Error: 25_460_189 - .saturating_add(Weight::from_parts(629_572_780, 0).saturating_mul(c.into())) - // Standard Error: 99_400 - .saturating_add(Weight::from_parts(33_396_949, 0).saturating_mul(n.into())) - .saturating_add(RocksDbWeight::get().reads(11_u64)) - .saturating_add(RocksDbWeight::get().writes(3_u64)) + // Minimum execution time: 3_706_688_000 picoseconds. + Weight::from_parts(3_802_046_000, 276827554) + // Standard Error: 26_725_519 + .saturating_add(Weight::from_parts(670_991_317, 0).saturating_mul(c.into())) + // Standard Error: 104_340 + .saturating_add(Weight::from_parts(34_006_074, 0).saturating_mul(n.into())) + .saturating_add(RocksDbWeight::get().reads(46_u64)) + .saturating_add(RocksDbWeight::get().writes(36_u64)) .saturating_add(Weight::from_parts(0, 607).saturating_mul(c.into())) } /// Storage: `Tables::TableInsertQuorums` (r:1 w:0) @@ -192,18 +210,24 @@ impl WeightInfo for () { /// Proof: `Commitments::CommitmentStorageMap` (`max_values`: None, `max_size`: Some(45497), added: 47972, mode: `MaxEncodedLen`) /// Storage: `Tables::BlockEnforcement` (r:1 w:0) /// Proof: `Tables::BlockEnforcement` (`max_values`: None, `max_size`: Some(149), added: 2624, mode: `MaxEncodedLen`) + /// Storage: `Tables::TableMetadata` (r:1 w:0) + /// Proof: `Tables::TableMetadata` (`max_values`: None, `max_size`: Some(8424), added: 10899, mode: `MaxEncodedLen`) + /// Storage: `TransactionPayment::NextFeeMultiplier` (r:1 w:0) + /// Proof: `TransactionPayment::NextFeeMultiplier` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:33 w:33) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) /// The range of component `c` is `[1, 64]`. /// The range of component `n` is `[0, 16384]`. fn submit_data_quorum_reached_hyper_kzg(c: u32, n: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `1184 + c * (87 ±0)` + // Measured: `6817 + c * (87 ±0)` // Estimated: `276827554 + c * (87 ±0)` - // Minimum execution time: 913_610_000 picoseconds. - Weight::from_parts(922_727_000, 276827554) - // Standard Error: 64_618 - .saturating_add(Weight::from_parts(33_482_779, 0).saturating_mul(n.into())) - .saturating_add(RocksDbWeight::get().reads(11_u64)) - .saturating_add(RocksDbWeight::get().writes(3_u64)) + // Minimum execution time: 2_450_433_000 picoseconds. + Weight::from_parts(2_601_418_000, 276827554) + // Standard Error: 65_884 + .saturating_add(Weight::from_parts(34_072_128, 0).saturating_mul(n.into())) + .saturating_add(RocksDbWeight::get().reads(46_u64)) + .saturating_add(RocksDbWeight::get().writes(36_u64)) .saturating_add(Weight::from_parts(0, 87).saturating_mul(c.into())) } /// Storage: `Indexing::BlockNumbers` (r:0 w:1) @@ -212,8 +236,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 6_432_000 picoseconds. - Weight::from_parts(6_693_000, 0) + // Minimum execution time: 7_253_000 picoseconds. + Weight::from_parts(7_604_000, 0) .saturating_add(RocksDbWeight::get().writes(1_u64)) } /// Storage: `Tables::TableInsertQuorums` (r:1 w:0) @@ -236,8 +260,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `1110` // Estimated: `276827554` - // Minimum execution time: 113_191_000 picoseconds. - Weight::from_parts(115_686_000, 276827554) + // Minimum execution time: 119_884_000 picoseconds. + Weight::from_parts(122_800_000, 276827554) .saturating_add(RocksDbWeight::get().reads(10_u64)) .saturating_add(RocksDbWeight::get().writes(3_u64)) } diff --git a/pallets/smartcontracts/src/mock.rs b/pallets/smartcontracts/src/mock.rs index 67e169bc..ab148ee5 100644 --- a/pallets/smartcontracts/src/mock.rs +++ b/pallets/smartcontracts/src/mock.rs @@ -4,7 +4,10 @@ use polkadot_sdk::frame_election_provider_support::bounds::{ ElectionBoundsBuilder, }; use polkadot_sdk::frame_election_provider_support::{onchain, SequentialPhragmen}; +use polkadot_sdk::frame_support::weights::ConstantMultiplier; use polkadot_sdk::frame_support::{derive_impl, parameter_types}; +#[allow(deprecated)] +use polkadot_sdk::pallet_transaction_payment::CurrencyAdapter; use polkadot_sdk::sp_core::{ConstU32, ConstU64, H256}; use polkadot_sdk::sp_runtime::traits::{IdentityLookup, OpaqueKeys}; use polkadot_sdk::sp_runtime::{BuildStorage, KeyTypeId}; @@ -16,6 +19,7 @@ use polkadot_sdk::{ pallet_staking, pallet_staking_reward_curve, pallet_timestamp, + pallet_transaction_payment, sp_core, sp_io, sp_runtime, @@ -43,6 +47,7 @@ frame_support::construct_runtime!( Balances: pallet_balances, ZkPay: pallet_zkpay, Staking: pallet_staking, + TransactionPayment: pallet_transaction_payment, } ); @@ -76,6 +81,22 @@ impl pallet_balances::Config for Test { type MaxFreezes = (); } +parameter_types! { + pub const WeightFeePerRefTime: Balance = 1; + pub const TransactionByteFee: Balance = 1; + pub const OperationalFeeMultiplier: u8 = 5; +} + +impl pallet_transaction_payment::Config for Test { + type RuntimeEvent = RuntimeEvent; + #[allow(deprecated)] + type OnChargeTransaction = CurrencyAdapter; + type WeightToFee = ConstantMultiplier; + type LengthToFee = ConstantMultiplier; + type FeeMultiplierUpdate = (); + type OperationalFeeMultiplier = OperationalFeeMultiplier; +} + pallet_staking_reward_curve::build! { const I_NPOS: sp_runtime::curve::PiecewiseLinear<'static> = curve!( min_inflation: 0_025_000, diff --git a/sxt-core/src/indexing.rs b/sxt-core/src/indexing.rs index 1d087c4f..dd585afe 100644 --- a/sxt-core/src/indexing.rs +++ b/sxt-core/src/indexing.rs @@ -21,6 +21,10 @@ pub type BatchId = BoundedVec>; /// The maximum number of submitters for a particular batch id pub const MAX_SUBMITTERS: u32 = 32; + +/// `TableMetadata` domain for a table's fee refund percentage as a SCALE `u16` (`100` = fee paid, above `100` pays more); absent or invalid means no refund. +pub const REFUND_PERCENTAGE_DOMAIN: &[u8] = b"REFUND_PERCENTAGE"; + /// A list of submitter account IDs, We use the generic to allow us to use the runtime's /// accountId, regardless of the underlying implementation of that Id pub type SubmitterList = BoundedBTreeSet>; diff --git a/sxt-core/src/utils.rs b/sxt-core/src/utils.rs index 43be2823..573852fb 100644 --- a/sxt-core/src/utils.rs +++ b/sxt-core/src/utils.rs @@ -1,7 +1,8 @@ use alloc::vec::Vec; -use codec::Decode; +use codec::{Decode, Encode}; use polkadot_sdk::frame_system::Config as SystemConfig; +use polkadot_sdk::sp_core::blake2_256; use polkadot_sdk::sp_core::crypto::AccountId32; use polkadot_sdk::sp_runtime::traits::StaticLookup; use polkadot_sdk::sp_runtime::DispatchError; @@ -71,6 +72,19 @@ pub fn eth_address_to_substrate_account_id( try_get_account_from_20_byte_vec::(raw_bytes) } +/// Takes a table identifier and returns the deterministic `AccountId` of its treasury. +pub fn table_treasury_account( + table: &crate::tables::TableIdentifier, +) -> Option +where + T::AccountId: Decode, +{ + convert_account_id::(AccountId32::new( + (b"sxt/table", &table.namespace, &table.name).using_encoded(blake2_256), + )) + .ok() +} + /// Convert the supplied AccountId32 to the runtime's AccountId type pub fn convert_account_id( account_id32: AccountId32, @@ -91,3 +105,52 @@ pub fn proof_of_sql_bincode_config() -> impl binc .with_big_endian() .with_limit::() } + +#[cfg(test)] +mod tests { + use polkadot_sdk::frame_support::derive_impl; + use polkadot_sdk::sp_core::crypto::Ss58Codec; + + use super::*; + use crate::tables::{TableIdentifier, TableName, TableNamespace}; + + polkadot_sdk::frame_support::construct_runtime!( + pub enum Test { + System: frame_system, + } + ); + + #[derive_impl(frame_system::config_preludes::TestDefaultConfig as frame_system::DefaultConfig)] + impl frame_system::Config for Test { + type Block = frame_system::mocking::MockBlock; + type AccountId = AccountId32; + type Lookup = sp_runtime::traits::IdentityLookup; + } + + #[test] + fn convert_account_id_round_trips_through_accountid32() { + let account = AccountId32::new([7u8; 32]); + + let converted = convert_account_id::(account.clone()).unwrap(); + + assert_eq!(converted, account); + } + + #[test] + fn table_treasury_account_matches_known_derivation() { + let table = TableIdentifier { + namespace: TableNamespace::try_from( + b"EACAGGREGATORPROXY_V1_FA74C8D117B4BD0F3F9C6B10F6C47CE66DC499A0".to_vec(), + ) + .unwrap(), + name: TableName::try_from(b"OWNERSHIP_TRANSFERRED".to_vec()).unwrap(), + }; + + let treasury = table_treasury_account::(&table).unwrap(); + + assert_eq!( + treasury.to_ss58check(), + "5FgaiRaTgngHbkoLipwLVHXSUyHuyUvBHXW1HwTTDNfp5HTs" + ); + } +}