From e44f575878e6332dd17a6c1ff29812b338dc02be Mon Sep 17 00:00:00 2001 From: Jay White Date: Mon, 20 Jul 2026 09:26:47 -0400 Subject: [PATCH 1/5] refactor: move `TARGET_BYTE_FEE` and `WEIGHT_FEE` into `sxt_core` We will use these when computing refunds. So, they need to be accessible by the pallets. --- runtime/src/lib.rs | 23 ++--------------------- sxt-core/src/fees.rs | 20 ++++++++++++++++++++ sxt-core/src/lib.rs | 3 +++ 3 files changed, 25 insertions(+), 21 deletions(-) create mode 100644 sxt-core/src/fees.rs diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 039c8f0e..2207ea5b 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -400,28 +400,9 @@ impl pallet_utility::Config for Runtime { type WeightInfo = pallet_utility::weights::SubstrateWeight; } -/// We want to base our pricing on the cost of the data insertion transaction since this is the -/// most common action on the network. The values below are intended to represent an 'Average' -/// Insert of 256 bytes of data per row. -pub const AVERAGE_INSERT_SIZE_BYTES_PER_ROW: u128 = 256; -pub const AVERAGE_INSERT_TARGET_COST_PER_ROW: u128 = MILLICENTS.saturating_mul(20); -pub const TARGET_BYTE_FEE: u128 = - AVERAGE_INSERT_TARGET_COST_PER_ROW.saturating_div(AVERAGE_INSERT_SIZE_BYTES_PER_ROW); - -/// This value should be the coefficient of a 1-element-insert, as measured in pallet-indexing's weights.rs -pub const INSERT_CALL_WEIGHT_PER_ELEMENT: u128 = 33_837_719; -/// The number of elements per row that the target cost should apply to -pub const INSERT_FEE_TARGET_ROW_LENGTH: u128 = 16; - -/// Insert weight for an insert with INSERT_FEE_TARGET_ROW_COUNT rows. -pub const INSERT_FEE_TARGET_CALL_WEIGHT: u128 = - INSERT_CALL_WEIGHT_PER_ELEMENT.saturating_mul(INSERT_FEE_TARGET_ROW_LENGTH); -pub const WEIGHT_FEE: u128 = - AVERAGE_INSERT_TARGET_COST_PER_ROW.saturating_div(INSERT_FEE_TARGET_CALL_WEIGHT); - parameter_types! { - pub const TransactionByteFee: Balance = TARGET_BYTE_FEE; - pub const WeightFeePerRefTime: Balance = WEIGHT_FEE; + pub const TransactionByteFee: Balance = sxt_core::fees::TARGET_BYTE_FEE; + pub const WeightFeePerRefTime: Balance = sxt_core::fees::WEIGHT_FEE; pub const OperationalFeeMultiplier: u8 = 5; pub const TargetBlockFullness: Perquintill = Perquintill::from_percent(80); pub AdjustmentVariable: Multiplier = Multiplier::saturating_from_rational(1, 100_000); diff --git a/sxt-core/src/fees.rs b/sxt-core/src/fees.rs new file mode 100644 index 00000000..428cd9c0 --- /dev/null +++ b/sxt-core/src/fees.rs @@ -0,0 +1,20 @@ +//! Module containing `WEIGHT_FEE` and `TARGET_BYTE_FEE`. + +/// We want to base our pricing on the cost of the data insertion transaction since this is the +/// most common action on the network. The values below are intended to represent an 'Average' +/// Insert of 256 bytes of data per row. +const AVERAGE_INSERT_SIZE_BYTES_PER_ROW: u128 = 256; +const AVERAGE_INSERT_TARGET_COST_PER_ROW: u128 = 10_000_000_000_000u128.saturating_mul(20); +pub const TARGET_BYTE_FEE: u128 = + AVERAGE_INSERT_TARGET_COST_PER_ROW.saturating_div(AVERAGE_INSERT_SIZE_BYTES_PER_ROW); + +/// This value should be the coefficient of a 1-element-insert, as measured in pallet-indexing's weights.rs +const INSERT_CALL_WEIGHT_PER_ELEMENT: u128 = 33_837_719; +/// The number of elements per row that the target cost should apply to +const INSERT_FEE_TARGET_ROW_LENGTH: u128 = 16; + +/// Insert weight for an insert with INSERT_FEE_TARGET_ROW_COUNT rows. +const INSERT_FEE_TARGET_CALL_WEIGHT: u128 = + INSERT_CALL_WEIGHT_PER_ELEMENT.saturating_mul(INSERT_FEE_TARGET_ROW_LENGTH); +pub const WEIGHT_FEE: u128 = + AVERAGE_INSERT_TARGET_COST_PER_ROW.saturating_div(INSERT_FEE_TARGET_CALL_WEIGHT); diff --git a/sxt-core/src/lib.rs b/sxt-core/src/lib.rs index 272c5cb3..8df7cdee 100644 --- a/sxt-core/src/lib.rs +++ b/sxt-core/src/lib.rs @@ -26,6 +26,9 @@ pub mod indexing; /// Types consumed by the native code interface pub mod native; +/// Constants and calculations related to on-chain fees and weights +pub mod fees; + pub mod attestation; /// Shared items for the prover-db-indexer pallet, its producer call From bf5f9ab7f86964e0f5a2ab896c1b1a0bc8c2a1de Mon Sep 17 00:00:00 2001 From: Jay White Date: Mon, 20 Jul 2026 09:30:40 -0400 Subject: [PATCH 2/5] feat: add `account_id_from_table_id` helper This defined the account id associated with a TableIdentifier. This is a domain key: `sxt/table` that ensures this account id will not collide with any other account ids --- sxt-core/src/utils.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/sxt-core/src/utils.rs b/sxt-core/src/utils.rs index 43be2823..04e5c0b6 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 account_id_from_table_id( + 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, From b7c057d2fa761412a52da445baeb85b47fba5cd6 Mon Sep 17 00:00:00 2001 From: Jay White Date: Mon, 20 Jul 2026 09:44:43 -0400 Subject: [PATCH 3/5] refactor: have `finalize_quorum` take `data_quorum` by reference This doesn't change anything since `finalize_quorum` already only uses it by reference. --- pallets/indexing/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pallets/indexing/src/lib.rs b/pallets/indexing/src/lib.rs index cc40565d..93b3f521 100644 --- a/pallets/indexing/src/lib.rs +++ b/pallets/indexing/src/lib.rs @@ -515,7 +515,7 @@ pub mod pallet { &table_insert_quorum, &quorum_scope, )? { - finalize_quorum::(data_quorum, data, block_number, who)?; + finalize_quorum::(&data_quorum, data, block_number, who)?; } Ok(()) @@ -647,7 +647,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, @@ -656,7 +656,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 }) From a9cab73113a73ac178196c49549dccad3dd69f2f Mon Sep 17 00:00:00 2001 From: Jay White Date: Mon, 20 Jul 2026 10:52:02 -0400 Subject: [PATCH 4/5] feat: refund submission fee to quorum submitters In this implementation, only agreements are refunded. Late submissions and dissents are not refunded. That change is left for later --- pallets/indexing/Cargo.toml | 2 +- pallets/indexing/src/lib.rs | 93 ++++++++++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/pallets/indexing/Cargo.toml b/pallets/indexing/Cargo.toml index 5135f293..ea85b27d 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", "sp-runtime", "sp-core"]} pallet-commitments.workspace = true pallet-permissions.workspace = true diff --git a/pallets/indexing/src/lib.rs b/pallets/indexing/src/lib.rs index 93b3f521..0b375ace 100644 --- a/pallets/indexing/src/lib.rs +++ b/pallets/indexing/src/lib.rs @@ -20,6 +20,7 @@ pub mod weights; // Do not remove this or the same attribute for the pallet // The cargo doc command will fail because of a bug even though the code is working properly pub use pallet::*; +use sxt_core::fees::{TARGET_BYTE_FEE, WEIGHT_FEE}; pub use sxt_core::indexing::*; pub use weights::*; @@ -40,11 +41,13 @@ pub mod pallet { use pallet_tables::pallet::BlockEnforcementMode; use pallet_tables::BlockEnforcement; 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::sp_runtime::{BoundedVec, SaturatedConversion}; use proof_of_sql_commitment_map::CommitmentScheme; use sxt_core::permissions::{IndexingPalletPermission, PermissionLevel}; use sxt_core::prover_db_indexer::EventCapture; @@ -68,6 +71,7 @@ pub mod pallet { + pallet_commitments::Config + pallet_tables::Config + pallet_system_tables::Config + + polkadot_sdk::pallet_balances::Config { /// Binding for the runtime event, typically provided by an implementation /// in runtime/lib.rs @@ -170,6 +174,24 @@ 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 submitter who was to receive the refund + recipient: T::AccountId, + /// The error received while processing the transfer + error: DispatchError, + }, } #[pallet::error] @@ -515,12 +537,81 @@ pub mod pallet { &table_insert_quorum, &quorum_scope, )? { + let cost = submission_extrinsic_fee::( + &data_quorum.table, + &data_quorum.batch_id, + &data, + block_number, + ); finalize_quorum::(&data_quorum, data, block_number, who)?; + refund_quorum::(data_quorum, cost); } Ok(()) } + /// Estimates the fee paid to submit the data for a quorum, combining weight-based and + /// byte-length-based fee components, so it can be refunded to the quorum's submitters. + fn submission_extrinsic_fee( + table: &TableIdentifier, + batch_id: &BatchId, + data: &RowData, + block_number: Option, + ) -> T::Balance + where + T: Config, + I: NativeApi, + { + let base_weight = ::BlockWeights::get() + .get(DispatchClass::Normal) + .base_extrinsic; + let call_weight = submit_data_weight::(table, data); + let byte_len = match block_number { + Some(block_number) => (table, batch_id, data, block_number).encoded_size(), + None => (table, batch_id, data).encoded_size(), + }; + + u128::from(base_weight.saturating_add(call_weight).ref_time()) + .saturating_mul(WEIGHT_FEE) + .saturating_add( + byte_len + .saturated_into::() + .saturating_mul(TARGET_BYTE_FEE), + ) + .saturated_into::() + } + + /// Refunds the submission fee to each submitter of a finalized quorum, drawing from the + /// table's treasury account, and emits `RefundProcessed`/`RefundError` events accordingly. + fn refund_quorum(quorum: DataQuorum, refund: T::Balance) + where + T: Config, + I: NativeApi, + { + let Some(treasury) = sxt_core::utils::account_id_from_table_id::(&quorum.table) else { + // This only occurs if `AccountId32` can not be converted to the runtime's `AccountId` type, + // which should never happen unless the `AccountId` type changes. + return; + }; + + for recipient in quorum.agreements { + if let Err(error) = polkadot_sdk::pallet_balances::Pallet::::transfer( + &treasury, + &recipient, + refund, + Preservation::Expendable, + ) { + Pallet::::deposit_event(Event::RefundError { 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`. From 7e41da4a90db6f0f98de84af145659fdbfdbc137 Mon Sep 17 00:00:00 2001 From: Jay White Date: Mon, 20 Jul 2026 13:31:57 -0400 Subject: [PATCH 5/5] fixup! feat: refund submission fee to quorum submitters --- pallets/indexing/src/tests.rs | 358 ++++++++++++++++++++++++++++++++++ 1 file changed, 358 insertions(+) diff --git a/pallets/indexing/src/tests.rs b/pallets/indexing/src/tests.rs index ec4988a1..1d64c981 100644 --- a/pallets/indexing/src/tests.rs +++ b/pallets/indexing/src/tests.rs @@ -14,6 +14,7 @@ use pallet_tables::{CommitmentCreationCmd, UpdateTable}; use polkadot_sdk::frame_support::__private::RuntimeDebug; use polkadot_sdk::frame_support::dispatch::DispatchResult; use polkadot_sdk::frame_support::pallet_prelude::TypeInfo; +use polkadot_sdk::frame_support::traits::fungible::Mutate; use polkadot_sdk::frame_support::{assert_err, assert_ok}; use polkadot_sdk::frame_system::ensure_signed; use polkadot_sdk::sp_core::Hasher; @@ -32,6 +33,7 @@ use sxt_core::tables::{ TableNamespace, TableType, }; +use sxt_core::utils::account_id_from_table_id; use crate::mock::*; use crate::{build_inner_batch_id, BatchId, Event, RowData}; @@ -2533,3 +2535,359 @@ fn submit_empty_blocks_respects_quorum() { ); }); } + +#[test] +fn refund_is_paid_to_submitter_when_quorum_is_reached_and_treasury_is_funded() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + let (table_id, create_statement) = sample_table_definition(); + Tables::create_tables( + RuntimeOrigin::root(), + vec![UpdateTable { + ident: table_id.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(); + + let treasury = account_id_from_table_id::(&table_id).unwrap(); + assert_ok!(polkadot_sdk::pallet_balances::Pallet::::mint_into( + &treasury, + 1_000_000_000_000_000_000, + )); + + let signer_key = sp_runtime::AccountId32::new([1; 32]); + let signer = RuntimeOrigin::signed(signer_key.clone()); + let who = ensure_signed(signer.clone()).unwrap(); + pallet_permissions::Permissions::::insert( + who, + PermissionList::try_from(vec![PermissionLevel::IndexingPallet( + IndexingPalletPermission::SubmitDataForPublicQuorum, + )]) + .unwrap(), + ); + + let test_batch = BatchId::try_from(b"test_batch".to_vec()).unwrap(); + let internal_batch_id = build_inner_batch_id::(&test_batch, &table_id); + + assert_eq!( + polkadot_sdk::pallet_balances::Pallet::::free_balance(&signer_key), + 0 + ); + let treasury_balance_before = + polkadot_sdk::pallet_balances::Pallet::::free_balance(&treasury); + + assert_ok!(Indexing::submit_data( + signer, + table_id.clone(), + test_batch.clone(), + row_data(), + )); + + // Quorum should be reached immediately given `public: Some(0)` + assert!(Indexing::final_data(&internal_batch_id).is_some()); + + let refunded_balance = + polkadot_sdk::pallet_balances::Pallet::::free_balance(&signer_key); + assert!(refunded_balance > 0, "submitter should have been refunded"); + + let treasury_balance_after = + polkadot_sdk::pallet_balances::Pallet::::free_balance(&treasury); + assert_eq!( + treasury_balance_before - treasury_balance_after, + refunded_balance + ); + + let events = System::read_events_for_pallet::>(); + let refund_event = events + .into_iter() + .find(|e| matches!(e, Event::RefundProcessed { .. })) + .expect("expected a RefundProcessed event"); + match refund_event { + Event::RefundProcessed { + table, + batch_id, + refund, + } => { + assert_eq!(table, table_id); + assert_eq!(batch_id, internal_batch_id); + assert_eq!(refund, refunded_balance); + } + _ => unreachable!(), + } + }); +} + +#[test] +fn refund_is_paid_to_each_submitter_when_quorum_is_reached_with_two_agreements() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + let (table_id, create_statement) = sample_table_definition(); + Tables::create_tables( + RuntimeOrigin::root(), + vec![UpdateTable { + ident: table_id.clone(), + create_statement, + table_type: TableType::Testing(InsertQuorumSize { + public: Some(1), + privileged: None, + }), + commitment: CommitmentCreationCmd::Empty(CommitmentSchemeFlags { + hyper_kzg: true, + dynamic_dory: true, + }), + source: sxt_core::tables::Source::Ethereum, + }] + .try_into() + .unwrap(), + ) + .unwrap(); + + let treasury = account_id_from_table_id::(&table_id).unwrap(); + assert_ok!(polkadot_sdk::pallet_balances::Pallet::::mint_into( + &treasury, + 1_000_000_000_000_000_000, + )); + + let permissions = PermissionList::try_from(vec![PermissionLevel::IndexingPallet( + IndexingPalletPermission::SubmitDataForPublicQuorum, + )]) + .unwrap(); + let submitter_1 = sp_runtime::AccountId32::new([1; 32]); + let submitter_2 = sp_runtime::AccountId32::new([2; 32]); + for account in [&submitter_1, &submitter_2] { + let who = ensure_signed(RuntimeOrigin::signed(account.clone())).unwrap(); + pallet_permissions::Permissions::::insert(who, permissions.clone()); + } + + let test_submission = TestSubmission { + table: table_id.clone(), + batch_id: BatchId::try_from(b"test_batch".to_vec()).unwrap(), + data: row_data(), + }; + let internal_batch_id = + build_inner_batch_id::(&test_submission.batch_id, &table_id); + + let treasury_balance_before = + polkadot_sdk::pallet_balances::Pallet::::free_balance(&treasury); + + // First submission — quorum not yet reached (needs to exceed 1) + assert_ok!(submit_test_data( + RuntimeOrigin::signed(submitter_1.clone()), + test_submission.clone() + )); + assert!(Indexing::final_data(&internal_batch_id).is_none()); + + // Second matching submission — quorum reached + assert_ok!(submit_test_data( + RuntimeOrigin::signed(submitter_2.clone()), + test_submission.clone() + )); + assert!(Indexing::final_data(&internal_batch_id).is_some()); + + let refund_1 = polkadot_sdk::pallet_balances::Pallet::::free_balance(&submitter_1); + let refund_2 = polkadot_sdk::pallet_balances::Pallet::::free_balance(&submitter_2); + + assert!(refund_1 > 0, "first submitter should have been refunded"); + assert_eq!( + refund_1, refund_2, + "both submitters should receive the same refund" + ); + + let treasury_balance_after = + polkadot_sdk::pallet_balances::Pallet::::free_balance(&treasury); + assert_eq!( + treasury_balance_before - treasury_balance_after, + refund_1 + refund_2 + ); + + let refund_events: Vec<_> = System::read_events_for_pallet::>() + .into_iter() + .filter(|e| matches!(e, Event::RefundProcessed { .. })) + .collect(); + // A single `RefundProcessed` event is emitted per finalized quorum, covering + // every submitter that agreed on the winning data. + assert_eq!(refund_events.len(), 1); + match &refund_events[0] { + Event::RefundProcessed { + table, + batch_id, + refund, + } => { + assert_eq!(table, &table_id); + assert_eq!(batch_id, &internal_batch_id); + assert_eq!(*refund, refund_1); + } + _ => unreachable!(), + } + + let error_events: Vec<_> = System::read_events_for_pallet::>() + .into_iter() + .filter(|e| matches!(e, Event::RefundError { .. })) + .collect(); + assert!(error_events.is_empty()); + }); +} + +#[test] +fn refund_is_paid_when_quorum_is_reached_via_submit_blockchain_data_with_block_number() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + let (table_id, create_statement) = sample_table_definition(); + Tables::create_tables( + RuntimeOrigin::root(), + vec![UpdateTable { + ident: table_id.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(); + + let treasury = account_id_from_table_id::(&table_id).unwrap(); + assert_ok!(polkadot_sdk::pallet_balances::Pallet::::mint_into( + &treasury, + 1_000_000_000_000_000_000, + )); + + let signer_key = sp_runtime::AccountId32::new([1; 32]); + let signer = RuntimeOrigin::signed(signer_key.clone()); + let who = ensure_signed(signer.clone()).unwrap(); + pallet_permissions::Permissions::::insert( + who, + PermissionList::try_from(vec![PermissionLevel::IndexingPallet( + IndexingPalletPermission::SubmitDataForPublicQuorum, + )]) + .unwrap(), + ); + + let test_batch = BatchId::try_from(b"blockchain_batch".to_vec()).unwrap(); + let internal_batch_id = build_inner_batch_id::(&test_batch, &table_id); + + assert_eq!( + polkadot_sdk::pallet_balances::Pallet::::free_balance(&signer_key), + 0 + ); + + // Exercises the `Some(block_number)` branch of `submission_extrinsic_fee`, which + // hashes `(table, batch_id, data, block_number)` rather than `(table, batch_id, data)`. + assert_ok!(Indexing::submit_blockchain_data( + signer, + table_id.clone(), + test_batch.clone(), + row_data(), + 12345, + )); + + assert!(Indexing::final_data(&internal_batch_id).is_some()); + + let refunded_balance = + polkadot_sdk::pallet_balances::Pallet::::free_balance(&signer_key); + assert!(refunded_balance > 0, "submitter should have been refunded"); + + let events = System::read_events_for_pallet::>(); + let refund_event = events + .into_iter() + .find(|e| matches!(e, Event::RefundProcessed { .. })) + .expect("expected a RefundProcessed event"); + match refund_event { + Event::RefundProcessed { + table, + batch_id, + refund, + } => { + assert_eq!(table, table_id); + assert_eq!(batch_id, internal_batch_id); + assert_eq!(refund, refunded_balance); + } + _ => unreachable!(), + } + }); +} + +#[test] +fn refund_error_is_emitted_when_treasury_has_insufficient_funds() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + let (table_id, create_statement) = sample_table_definition(); + Tables::create_tables( + RuntimeOrigin::root(), + vec![UpdateTable { + ident: table_id.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(); + + // Note: the treasury account is intentionally left unfunded here. + let signer_key = sp_runtime::AccountId32::new([1; 32]); + let signer = RuntimeOrigin::signed(signer_key.clone()); + let who = ensure_signed(signer.clone()).unwrap(); + pallet_permissions::Permissions::::insert( + who, + PermissionList::try_from(vec![PermissionLevel::IndexingPallet( + IndexingPalletPermission::SubmitDataForPublicQuorum, + )]) + .unwrap(), + ); + + let test_batch = BatchId::try_from(b"test_batch".to_vec()).unwrap(); + + // The quorum should still finalize successfully even though the refund transfer fails. + assert_ok!(Indexing::submit_data( + signer, + table_id.clone(), + test_batch.clone(), + row_data(), + )); + + assert_eq!( + polkadot_sdk::pallet_balances::Pallet::::free_balance(&signer_key), + 0 + ); + + let events = System::read_events_for_pallet::>(); + let error_event = events + .into_iter() + .find(|e| matches!(e, Event::RefundError { .. })) + .expect("expected a RefundError event"); + match error_event { + Event::RefundError { recipient, .. } => { + assert_eq!(recipient, signer_key); + } + _ => unreachable!(), + } + }); +}