From 8e3e520fe629ba409a7b88fc80e38514012882d3 Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Thu, 22 Jan 2026 13:35:55 +0000 Subject: [PATCH 01/26] Removed deprecated ValidateUnsigned --- pallets/transaction-storage/src/lib.rs | 125 +++++++++++++++++------ pallets/transaction-storage/src/tests.rs | 30 ++++-- runtimes/bulletin-westend/tests/tests.rs | 68 ++++++++++++ 3 files changed, 184 insertions(+), 39 deletions(-) diff --git a/pallets/transaction-storage/src/lib.rs b/pallets/transaction-storage/src/lib.rs index 118f8cf70..291021cef 100644 --- a/pallets/transaction-storage/src/lib.rs +++ b/pallets/transaction-storage/src/lib.rs @@ -366,8 +366,23 @@ pub mod pallet { /// O(n*log(n)) of data size, as all data is pushed to an in-memory trie. #[pallet::call_index(0)] #[pallet::weight(T::WeightInfo::store(data.len() as u32))] - #[pallet::feeless_if(|origin: &OriginFor, data: &Vec| -> bool { true })] - pub fn store(_origin: OriginFor, data: Vec) -> DispatchResult { + #[pallet::feeless_if(|origin: &OriginFor, data: &Vec| -> bool { /*TODO: add here correct validation */ true })] + #[pallet::authorize(|_source, data| { + Pallet::::to_validity_with_refund( + Pallet::::check_unsigned_store(data.as_slice(), CheckContext::Validate), + ) + })] + #[pallet::weight_of_authorize(Weight::zero())] + pub fn store(origin: OriginFor, data: Vec) -> DispatchResult { + let is_authorized = matches!( + origin.into(), + Ok(frame_system::RawOrigin::Authorized) + ); + if is_authorized { + Self::check_unsigned_store(data.as_slice(), CheckContext::PreDispatch) + .map_err(Self::dispatch_error_from_validity)?; + } + // In the case of a regular unsigned transaction, this should have been checked by // pre_dispatch. In the case of a regular signed transaction, this should have been // checked by pre_dispatch_signed. @@ -425,11 +440,28 @@ pub mod pallet { /// O(1). #[pallet::call_index(1)] #[pallet::weight(T::WeightInfo::renew())] + #[pallet::authorize(|_source, block, index| { + Pallet::::to_validity_with_refund(Pallet::::check_unsigned_renew( + block, + index, + CheckContext::Validate, + )) + })] + #[pallet::weight_of_authorize(Weight::zero())] pub fn renew( - _origin: OriginFor, + origin: OriginFor, block: BlockNumberFor, index: u32, ) -> DispatchResultWithPostInfo { + let is_authorized = matches!( + origin.into(), + Ok(frame_system::RawOrigin::Authorized) + ); + if is_authorized { + Self::check_unsigned_renew(&block, &index, CheckContext::PreDispatch) + .map_err(Self::dispatch_error_from_validity)?; + } + let info = Self::transaction_info(block, index).ok_or(Error::::RenewedNotFound)?; // In the case of a regular unsigned transaction, this should have been checked by @@ -563,6 +595,12 @@ pub mod pallet { /// when successful. #[pallet::call_index(5)] #[pallet::weight(T::WeightInfo::remove_expired_account_authorization())] + #[pallet::authorize(|_source, who| { + Pallet::::to_validity_with_refund( + Pallet::::check_unsigned_remove_expired_account(who, CheckContext::Validate), + ) + })] + #[pallet::weight_of_authorize(Weight::zero())] pub fn remove_expired_account_authorization( _origin: OriginFor, who: T::AccountId, @@ -583,6 +621,12 @@ pub mod pallet { /// when successful. #[pallet::call_index(6)] #[pallet::weight(T::WeightInfo::remove_expired_preimage_authorization())] + #[pallet::authorize(|_source, hash| { + Pallet::::to_validity_with_refund( + Pallet::::check_unsigned_remove_expired_preimage(hash, CheckContext::Validate), + ) + })] + #[pallet::weight_of_authorize(Weight::zero())] pub fn remove_expired_preimage_authorization( _origin: OriginFor, content_hash: ContentHash, @@ -757,25 +801,20 @@ pub mod pallet { } } - #[pallet::validate_unsigned] - impl ValidateUnsigned for Pallet { - type Call = Call; - - fn validate_unsigned(_source: TransactionSource, call: &Self::Call) -> TransactionValidity { - Self::check_unsigned(call, CheckContext::Validate)?.ok_or(IMPOSSIBLE.into()) + impl Pallet { + fn to_validity_with_refund( + result: Result, TransactionValidityError>, + ) -> Result<(ValidTransaction, Weight), TransactionValidityError> { + let validity = + result?.ok_or(TransactionValidityError::Invalid(IMPOSSIBLE))?; + Ok((validity, Weight::zero())) } - fn pre_dispatch(call: &Self::Call) -> Result<(), TransactionValidityError> { - // Allow inherents here. - if Self::is_inherent(call) { - return Ok(()); - } - - Self::check_unsigned(call, CheckContext::PreDispatch).map(|_| ()) + fn dispatch_error_from_validity(error: TransactionValidityError) -> DispatchError { + let message: &'static str = error.into(); + DispatchError::Other(message) } - } - impl Pallet { /// Returns `true` if the system is beyond the given expiration point. fn expired(expiration: BlockNumberFor) -> bool { let now = frame_system::Pallet::::block_number(); @@ -1061,25 +1100,34 @@ pub mod pallet { })) } - fn check_unsigned( - call: &Call, + fn check_unsigned_store( + data: &[u8], context: CheckContext, ) -> Result, TransactionValidityError> { - match call { - Call::::store { data } => Self::check_store_renew_unsigned( + Self::check_store_renew_unsigned( data.len(), || sp_io::hashing::blake2_256(data), context, - ), - Call::::renew { block, index } => { + ) + } + + fn check_unsigned_renew( + block: &BlockNumberFor, + index: &u32, + context: CheckContext, + ) -> Result, TransactionValidityError> { let info = Self::transaction_info(*block, *index).ok_or(RENEWED_NOT_FOUND)?; Self::check_store_renew_unsigned( info.size as usize, || info.content_hash, context, ) - }, - Call::::remove_expired_account_authorization { who } => { + } + + fn check_unsigned_remove_expired_account( + who: &T::AccountId, + context: CheckContext, + ) -> Result, TransactionValidityError> { Self::check_authorization_expired(AuthorizationScope::Account(who.clone()))?; Ok(context.want_valid_transaction().then(|| { ValidTransaction::with_tag_prefix( @@ -1090,9 +1138,13 @@ pub mod pallet { .longevity(T::RemoveExpiredAuthorizationLongevity::get()) .into() })) - }, - Call::::remove_expired_preimage_authorization { content_hash } => { - Self::check_authorization_expired(AuthorizationScope::Preimage(*content_hash))?; + } + + fn check_unsigned_remove_expired_preimage( + hash: &ContentHash, + context: CheckContext, + ) -> Result, TransactionValidityError> { + Self::check_authorization_expired(AuthorizationScope::Preimage(*hash))?; Ok(context.want_valid_transaction().then(|| { ValidTransaction::with_tag_prefix( "TransactionStorageRemoveExpiredPreimageAuthorization", @@ -1102,7 +1154,20 @@ pub mod pallet { .longevity(T::RemoveExpiredAuthorizationLongevity::get()) .into() })) - }, + } + + fn check_unsigned( + call: &Call, + context: CheckContext, + ) -> Result, TransactionValidityError> { + match call { + Call::::store { data } => Self::check_unsigned_store(data.as_slice(), context), + Call::::renew { block, index } => + Self::check_unsigned_renew(block, index, context), + Call::::remove_expired_account_authorization { who } => + Self::check_unsigned_remove_expired_account(who, context), + Call::::remove_expired_preimage_authorization { hash } => + Self::check_unsigned_remove_expired_preimage(hash, context), _ => Err(InvalidTransaction::Call.into()), } } diff --git a/pallets/transaction-storage/src/tests.rs b/pallets/transaction-storage/src/tests.rs index 789965d67..522b99e93 100644 --- a/pallets/transaction-storage/src/tests.rs +++ b/pallets/transaction-storage/src/tests.rs @@ -38,7 +38,9 @@ use polkadot_sdk_frame::{ testing_prelude::*, traits::StorageVersion, }; +use frame_support::traits::Authorize; use sp_transaction_storage_proof::{random_chunk, registration::build_proof, CHUNK_SIZE}; +use sp_runtime::transaction_validity::TransactionSource; type Call = super::Call; type Error = super::Error; @@ -113,23 +115,30 @@ fn uses_preimage_authorization() { AuthorizationExtent { transactions: 1, bytes: 2002 } ); let call = Call::store { data: vec![1; 2000] }; - assert_noop!(TransactionStorage::pre_dispatch(&call), InvalidTransaction::Payment); + assert_noop!( + call.authorize(TransactionSource::External).unwrap(), + InvalidTransaction::Payment + ); let call = Call::store { data }; - assert_ok!(TransactionStorage::pre_dispatch(&call)); + assert_ok!(call.authorize(TransactionSource::External).unwrap()); assert_eq!( TransactionStorage::preimage_authorization_extent(hash), AuthorizationExtent { transactions: 0, bytes: 0 } ); - assert_ok!(Into::::into(call).dispatch(RuntimeOrigin::none())); + assert_ok!(Into::::into(call).dispatch(RawOrigin::Authorized.into())); run_to_block(3, || None); let call = Call::renew { block: 1, index: 0 }; - assert_noop!(TransactionStorage::pre_dispatch(&call), InvalidTransaction::Payment); + assert_noop!( + call.authorize(TransactionSource::External).unwrap(), + InvalidTransaction::Payment + ); assert_ok!(TransactionStorage::authorize_preimage(RuntimeOrigin::root(), hash, 2000)); - assert_ok!(TransactionStorage::pre_dispatch(&call)); + assert_ok!(call.authorize(TransactionSource::External).unwrap()); assert_eq!( TransactionStorage::preimage_authorization_extent(hash), AuthorizationExtent { transactions: 0, bytes: 0 } ); + assert_ok!(Into::::into(call).dispatch(RawOrigin::Authorized.into())); }); } @@ -301,9 +310,12 @@ fn expired_authorization_clears() { // Can't remove too early run_to_block(10, || None); let remove_call = Call::remove_expired_account_authorization { who }; - assert_noop!(TransactionStorage::pre_dispatch(&remove_call), AUTHORIZATION_NOT_EXPIRED); assert_noop!( - Into::::into(remove_call.clone()).dispatch(RuntimeOrigin::none()), + remove_call.authorize(TransactionSource::External).unwrap(), + AUTHORIZATION_NOT_EXPIRED + ); + assert_noop!( + Into::::into(remove_call.clone()).dispatch(RawOrigin::Authorized.into()), Error::AuthorizationNotExpired, ); @@ -317,8 +329,8 @@ fn expired_authorization_clears() { InvalidTransaction::Payment, ); // Anyone can remove it - assert_ok!(TransactionStorage::pre_dispatch(&remove_call)); - assert_ok!(Into::::into(remove_call).dispatch(RuntimeOrigin::none())); + assert_ok!(remove_call.authorize(TransactionSource::External).unwrap()); + assert_ok!(Into::::into(remove_call).dispatch(RawOrigin::Authorized.into())); System::assert_has_event(RuntimeEvent::TransactionStorage( Event::ExpiredAccountAuthorizationRemoved { who }, )); diff --git a/runtimes/bulletin-westend/tests/tests.rs b/runtimes/bulletin-westend/tests/tests.rs index 88efa3177..898f2a4f1 100644 --- a/runtimes/bulletin-westend/tests/tests.rs +++ b/runtimes/bulletin-westend/tests/tests.rs @@ -137,6 +137,39 @@ fn assert_ok_ok(apply_result: ApplyExtrinsicResult) { assert_ok!(apply_result.unwrap()); } +fn construct_unsigned_extrinsic(call: RuntimeCall) -> UncheckedExtrinsic { + let inner = ( + frame_system::AuthorizeCall::::new(), + frame_system::CheckNonZeroSender::::new(), + frame_system::CheckSpecVersion::::new(), + frame_system::CheckTxVersion::::new(), + frame_system::CheckGenesis::::new(), + frame_system::CheckEra::::from(sp_runtime::generic::Era::immortal()), + frame_system::CheckNonce::::from(0u32), + frame_system::CheckWeight::::new(), + pallet_transaction_payment::ChargeTransactionPayment::::from(0u128), + bulletin_westend_runtime::ValidateSigned, + frame_metadata_hash_extension::CheckMetadataHash::::new(false), + ); + let tx_ext: TxExtension = + cumulus_pallet_weight_reclaim::StorageWeightReclaim::::from(inner); + UncheckedExtrinsic::new_transaction(call, tx_ext) +} + +fn construct_and_apply_unsigned_extrinsic(call: RuntimeCall) -> ApplyExtrinsicResult { + let dispatch_info = call.get_dispatch_info(); + let xt = construct_unsigned_extrinsic(call); + let xt_len = xt.encode().len(); + tracing::info!( + "Applying unsigned extrinsic: class={:?} pays_fee={:?} weight={:?} encoded_len={} bytes", + dispatch_info.class, + dispatch_info.pays_fee, + dispatch_info.total_weight(), + xt_len + ); + bulletin_westend_runtime::Executive::apply_extrinsic(xt) +} + #[test] fn transaction_storage_runtime_sizes() { sp_io::TestExternalities::new(RuntimeGenesisConfig::default().build_storage().unwrap()) @@ -218,6 +251,41 @@ fn transaction_storage_runtime_sizes() { }); } +#[test] +fn transaction_storage_unsigned_preimage_store_works() { + use bulletin_westend_runtime as runtime; + use bulletin_westend_runtime::BuildStorage; + use frame_support::assert_ok; + use pallet_transaction_storage::{AuthorizationExtent, Call as TxStorageCall}; + + sp_io::TestExternalities::new( + runtime::RuntimeGenesisConfig::default().build_storage().unwrap(), + ) + .execute_with(|| { + advance_block(); + + let data = vec![7u8; 2000]; + let hash = sp_io::hashing::blake2_256(&data); + assert_ok!(runtime::TransactionStorage::authorize_preimage( + RuntimeOrigin::root(), + hash, + data.len() as u64, + )); + + let res = construct_and_apply_unsigned_extrinsic(RuntimeCall::TransactionStorage( + TxStorageCall::::store { data }, + )); + assert_ok!(res); + assert_ok!(res.unwrap()); + + assert_eq!( + runtime::TransactionStorage::preimage_authorization_extent(hash), + AuthorizationExtent { transactions: 0, bytes: 0 }, + ); + }); +} + + /// Test maximum write throughput: 8 transactions of 1 MiB each in a single block (8 MiB total). #[test] fn transaction_storage_max_throughput_per_block() { From aa5574aba012673ddeafa9943e90d0a439cc2bb5 Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Thu, 22 Jan 2026 13:36:15 +0000 Subject: [PATCH 02/26] Fixed name --- pallets/transaction-storage/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pallets/transaction-storage/src/lib.rs b/pallets/transaction-storage/src/lib.rs index 291021cef..73d218bfb 100644 --- a/pallets/transaction-storage/src/lib.rs +++ b/pallets/transaction-storage/src/lib.rs @@ -623,7 +623,7 @@ pub mod pallet { #[pallet::weight(T::WeightInfo::remove_expired_preimage_authorization())] #[pallet::authorize(|_source, hash| { Pallet::::to_validity_with_refund( - Pallet::::check_unsigned_remove_expired_preimage(hash, CheckContext::Validate), + Pallet::::check_unsigned_remove_expired_preimage_authorization(hash, CheckContext::Validate), ) })] #[pallet::weight_of_authorize(Weight::zero())] @@ -1140,7 +1140,7 @@ pub mod pallet { })) } - fn check_unsigned_remove_expired_preimage( + fn check_unsigned_remove_expired_preimage_authorization( hash: &ContentHash, context: CheckContext, ) -> Result, TransactionValidityError> { @@ -1167,7 +1167,7 @@ pub mod pallet { Call::::remove_expired_account_authorization { who } => Self::check_unsigned_remove_expired_account(who, context), Call::::remove_expired_preimage_authorization { hash } => - Self::check_unsigned_remove_expired_preimage(hash, context), + Self::check_unsigned_remove_expired_preimage_authorization(hash, context), _ => Err(InvalidTransaction::Call.into()), } } From 0c7c14b1609956e7e3971665aee42e0e392d032a Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Fri, 23 Jan 2026 18:52:45 +0000 Subject: [PATCH 03/26] Removed auth contexts and simplified --- pallets/transaction-storage/src/lib.rs | 175 ++++++++--------------- pallets/transaction-storage/src/tests.rs | 4 +- runtimes/bulletin-westend/tests/tests.rs | 5 +- 3 files changed, 64 insertions(+), 120 deletions(-) diff --git a/pallets/transaction-storage/src/lib.rs b/pallets/transaction-storage/src/lib.rs index 73d218bfb..399ef2578 100644 --- a/pallets/transaction-storage/src/lib.rs +++ b/pallets/transaction-storage/src/lib.rs @@ -152,28 +152,6 @@ impl TransactionInfo { } } -/// Context of a `check_signed`/`check_unsigned` call. -#[derive(Clone, Copy)] -enum CheckContext { - /// `validate_signed` or `validate_unsigned`. - Validate, - /// `pre_dispatch_signed` or `pre_dispatch`. - PreDispatch, -} - -impl CheckContext { - /// Should authorization be consumed in this context? If not, we merely check that - /// authorization exists. - fn consume_authorization(self) -> bool { - matches!(self, CheckContext::PreDispatch) - } - - /// Should `check_signed`/`check_unsigned` return a `ValidTransaction`? - fn want_valid_transaction(self) -> bool { - matches!(self, CheckContext::Validate) - } -} - #[polkadot_sdk_frame::pallet] pub mod pallet { use super::*; @@ -368,9 +346,10 @@ pub mod pallet { #[pallet::weight(T::WeightInfo::store(data.len() as u32))] #[pallet::feeless_if(|origin: &OriginFor, data: &Vec| -> bool { /*TODO: add here correct validation */ true })] #[pallet::authorize(|_source, data| { - Pallet::::to_validity_with_refund( - Pallet::::check_unsigned_store(data.as_slice(), CheckContext::Validate), - ) + Pallet::::to_validity_with_refund(Pallet::::check_unsigned_store( + data.as_slice(), + false, + )) })] #[pallet::weight_of_authorize(Weight::zero())] pub fn store(origin: OriginFor, data: Vec) -> DispatchResult { @@ -379,7 +358,7 @@ pub mod pallet { Ok(frame_system::RawOrigin::Authorized) ); if is_authorized { - Self::check_unsigned_store(data.as_slice(), CheckContext::PreDispatch) + Self::check_unsigned_store(data.as_slice(), true) .map_err(Self::dispatch_error_from_validity)?; } @@ -444,7 +423,7 @@ pub mod pallet { Pallet::::to_validity_with_refund(Pallet::::check_unsigned_renew( block, index, - CheckContext::Validate, + false, )) })] #[pallet::weight_of_authorize(Weight::zero())] @@ -458,7 +437,7 @@ pub mod pallet { Ok(frame_system::RawOrigin::Authorized) ); if is_authorized { - Self::check_unsigned_renew(&block, &index, CheckContext::PreDispatch) + Self::check_unsigned_renew(&block, &index, true) .map_err(Self::dispatch_error_from_validity)?; } @@ -597,7 +576,7 @@ pub mod pallet { #[pallet::weight(T::WeightInfo::remove_expired_account_authorization())] #[pallet::authorize(|_source, who| { Pallet::::to_validity_with_refund( - Pallet::::check_unsigned_remove_expired_account(who, CheckContext::Validate), + Pallet::::check_unsigned_remove_expired_account(who), ) })] #[pallet::weight_of_authorize(Weight::zero())] @@ -623,7 +602,7 @@ pub mod pallet { #[pallet::weight(T::WeightInfo::remove_expired_preimage_authorization())] #[pallet::authorize(|_source, hash| { Pallet::::to_validity_with_refund( - Pallet::::check_unsigned_remove_expired_preimage_authorization(hash, CheckContext::Validate), + Pallet::::check_unsigned_remove_expired_preimage_authorization(hash), ) })] #[pallet::weight_of_authorize(Weight::zero())] @@ -803,10 +782,9 @@ pub mod pallet { impl Pallet { fn to_validity_with_refund( - result: Result, TransactionValidityError>, + result: Result, ) -> Result<(ValidTransaction, Weight), TransactionValidityError> { - let validity = - result?.ok_or(TransactionValidityError::Invalid(IMPOSSIBLE))?; + let validity = result?; Ok((validity, Weight::zero())) } @@ -946,7 +924,7 @@ pub mod pallet { /// This is equivalent to `validate_unsigned` but for signed transactions. It should be /// called from a `SignedExtension` implementation. pub fn validate_signed(who: &T::AccountId, call: &Call) -> TransactionValidity { - Self::check_signed(who, call, CheckContext::Validate)?.ok_or(IMPOSSIBLE.into()) + Ok(Self::check_signed(who, call, false)?) } /// Check the validity of the given call, signed by the given account, and consume @@ -958,7 +936,7 @@ pub mod pallet { who: &T::AccountId, call: &Call, ) -> Result<(), TransactionValidityError> { - Self::check_signed(who, call, CheckContext::PreDispatch).map(|_| ()) + Self::check_signed(who, call, true).map(|_| ()) } /// Get ByteFee storage information from the outside of this pallet. @@ -1073,8 +1051,8 @@ pub mod pallet { fn check_store_renew_unsigned( size: usize, hash: impl FnOnce() -> ContentHash, - context: CheckContext, - ) -> Result, TransactionValidityError> { + consume: bool, + ) -> Result { if !Self::data_size_ok(size) { return Err(BAD_DATA_SIZE.into()); } @@ -1088,100 +1066,73 @@ pub mod pallet { Self::check_authorization( AuthorizationScope::Preimage(hash), size as u32, - context.consume_authorization(), + consume, )?; - Ok(context.want_valid_transaction().then(|| { - ValidTransaction::with_tag_prefix("TransactionStorageStoreRenew") - .and_provides(hash) - .priority(T::StoreRenewPriority::get()) - .longevity(T::StoreRenewLongevity::get()) - .into() - })) + Ok(ValidTransaction::with_tag_prefix("TransactionStorageStoreRenew") + .and_provides(hash) + .priority(T::StoreRenewPriority::get()) + .longevity(T::StoreRenewLongevity::get()) + .into()) } fn check_unsigned_store( data: &[u8], - context: CheckContext, - ) -> Result, TransactionValidityError> { + consume: bool, + ) -> Result { Self::check_store_renew_unsigned( - data.len(), - || sp_io::hashing::blake2_256(data), - context, + data.len(), + || sp_io::hashing::blake2_256(data), + consume, ) } fn check_unsigned_renew( block: &BlockNumberFor, index: &u32, - context: CheckContext, - ) -> Result, TransactionValidityError> { - let info = Self::transaction_info(*block, *index).ok_or(RENEWED_NOT_FOUND)?; - Self::check_store_renew_unsigned( - info.size as usize, - || info.content_hash, - context, - ) + consume: bool, + ) -> Result { + let info = Self::transaction_info(*block, *index).ok_or(RENEWED_NOT_FOUND)?; + Self::check_store_renew_unsigned( + info.size as usize, + || info.content_hash.into(), + consume, + ) } fn check_unsigned_remove_expired_account( who: &T::AccountId, - context: CheckContext, - ) -> Result, TransactionValidityError> { - Self::check_authorization_expired(AuthorizationScope::Account(who.clone()))?; - Ok(context.want_valid_transaction().then(|| { - ValidTransaction::with_tag_prefix( - "TransactionStorageRemoveExpiredAccountAuthorization", - ) - .and_provides(who) - .priority(T::RemoveExpiredAuthorizationPriority::get()) - .longevity(T::RemoveExpiredAuthorizationLongevity::get()) - .into() - })) + ) -> Result { + Self::check_authorization_expired(AuthorizationScope::Account(who.clone()))?; + Ok(ValidTransaction::with_tag_prefix( + "TransactionStorageRemoveExpiredAccountAuthorization", + ) + .and_provides(who) + .priority(T::RemoveExpiredAuthorizationPriority::get()) + .longevity(T::RemoveExpiredAuthorizationLongevity::get()) + .into()) } fn check_unsigned_remove_expired_preimage_authorization( hash: &ContentHash, - context: CheckContext, - ) -> Result, TransactionValidityError> { - Self::check_authorization_expired(AuthorizationScope::Preimage(*hash))?; - Ok(context.want_valid_transaction().then(|| { - ValidTransaction::with_tag_prefix( - "TransactionStorageRemoveExpiredPreimageAuthorization", - ) - .and_provides(content_hash) - .priority(T::RemoveExpiredAuthorizationPriority::get()) - .longevity(T::RemoveExpiredAuthorizationLongevity::get()) - .into() - })) - } - - fn check_unsigned( - call: &Call, - context: CheckContext, - ) -> Result, TransactionValidityError> { - match call { - Call::::store { data } => Self::check_unsigned_store(data.as_slice(), context), - Call::::renew { block, index } => - Self::check_unsigned_renew(block, index, context), - Call::::remove_expired_account_authorization { who } => - Self::check_unsigned_remove_expired_account(who, context), - Call::::remove_expired_preimage_authorization { hash } => - Self::check_unsigned_remove_expired_preimage_authorization(hash, context), - _ => Err(InvalidTransaction::Call.into()), - } + ) -> Result { + Self::check_authorization_expired(AuthorizationScope::Preimage(*hash))?; + Ok(ValidTransaction::with_tag_prefix( + "TransactionStorageRemoveExpiredPreimageAuthorization", + ) + .and_provides(hash) + .priority(T::RemoveExpiredAuthorizationPriority::get()) + .longevity(T::RemoveExpiredAuthorizationLongevity::get()) + .into()) } fn check_signed( who: &T::AccountId, call: &Call, - context: CheckContext, - ) -> Result, TransactionValidityError> { - let (size, content_hash) = match call { - Call::::store { data } => { - let content_hash = sp_io::hashing::blake2_256(data); - (data.len(), content_hash) - }, + consume: bool, + ) -> Result { + let size = match call { + Call::::store { data } => data.len(), Call::::renew { block, index } => { let info = Self::transaction_info(*block, *index).ok_or(RENEWED_NOT_FOUND)?; (info.size as usize, info.content_hash) @@ -1200,25 +1151,17 @@ pub mod pallet { // Prefer preimage authorization if available. // This allows anyone to store/renew pre-authorized content without consuming their // own account authorization. - let consume = context.consume_authorization(); Self::check_authorization( AuthorizationScope::Preimage(content_hash), size as u32, consume, - ) - .or_else(|_| { - Self::check_authorization( - AuthorizationScope::Account(who.clone()), - size as u32, - consume, - ) - })?; + )?; - Ok(context.want_valid_transaction().then(|| ValidTransaction { + Ok(ValidTransaction { priority: T::StoreRenewPriority::get(), longevity: T::StoreRenewLongevity::get(), ..Default::default() - })) + }) } /// Verifies that the provided proof corresponds to a randomly selected chunk from a list of diff --git a/pallets/transaction-storage/src/tests.rs b/pallets/transaction-storage/src/tests.rs index 522b99e93..cf86e2e3d 100644 --- a/pallets/transaction-storage/src/tests.rs +++ b/pallets/transaction-storage/src/tests.rs @@ -28,6 +28,7 @@ use super::{ }; use crate::migrations::v1::OldTransactionInfo; use codec::Encode; +use frame_support::traits::Authorize; use polkadot_sdk_frame::{ deps::frame_support::{ storage::unhashed, @@ -38,9 +39,8 @@ use polkadot_sdk_frame::{ testing_prelude::*, traits::StorageVersion, }; -use frame_support::traits::Authorize; -use sp_transaction_storage_proof::{random_chunk, registration::build_proof, CHUNK_SIZE}; use sp_runtime::transaction_validity::TransactionSource; +use sp_transaction_storage_proof::{random_chunk, registration::build_proof, CHUNK_SIZE}; type Call = super::Call; type Error = super::Error; diff --git a/runtimes/bulletin-westend/tests/tests.rs b/runtimes/bulletin-westend/tests/tests.rs index 898f2a4f1..dbb691302 100644 --- a/runtimes/bulletin-westend/tests/tests.rs +++ b/runtimes/bulletin-westend/tests/tests.rs @@ -147,7 +147,9 @@ fn construct_unsigned_extrinsic(call: RuntimeCall) -> UncheckedExtrinsic { frame_system::CheckEra::::from(sp_runtime::generic::Era::immortal()), frame_system::CheckNonce::::from(0u32), frame_system::CheckWeight::::new(), - pallet_transaction_payment::ChargeTransactionPayment::::from(0u128), + pallet_skip_feeless_payment::SkipCheckIfFeeless::from( + pallet_transaction_payment::ChargeTransactionPayment::::from(0u128), + ), bulletin_westend_runtime::ValidateSigned, frame_metadata_hash_extension::CheckMetadataHash::::new(false), ); @@ -285,7 +287,6 @@ fn transaction_storage_unsigned_preimage_store_works() { }); } - /// Test maximum write throughput: 8 transactions of 1 MiB each in a single block (8 MiB total). #[test] fn transaction_storage_max_throughput_per_block() { From 1c022bd5da3be723ba4be2259d1a0874c2e49633 Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Fri, 23 Jan 2026 18:53:42 +0000 Subject: [PATCH 04/26] Fixed preimage test to reflect consume-at-dispatch --- pallets/transaction-storage/src/tests.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/pallets/transaction-storage/src/tests.rs b/pallets/transaction-storage/src/tests.rs index cf86e2e3d..9f8acc73f 100644 --- a/pallets/transaction-storage/src/tests.rs +++ b/pallets/transaction-storage/src/tests.rs @@ -28,7 +28,6 @@ use super::{ }; use crate::migrations::v1::OldTransactionInfo; use codec::Encode; -use frame_support::traits::Authorize; use polkadot_sdk_frame::{ deps::frame_support::{ storage::unhashed, @@ -37,9 +36,9 @@ use polkadot_sdk_frame::{ }, prelude::{frame_system::RawOrigin, *}, testing_prelude::*, - traits::StorageVersion, + traits::{Authorize, StorageVersion}, }; -use sp_runtime::transaction_validity::TransactionSource; +use polkadot_sdk_frame::prelude::TransactionSource; use sp_transaction_storage_proof::{random_chunk, registration::build_proof, CHUNK_SIZE}; type Call = super::Call; @@ -123,9 +122,13 @@ fn uses_preimage_authorization() { assert_ok!(call.authorize(TransactionSource::External).unwrap()); assert_eq!( TransactionStorage::preimage_authorization_extent(hash), - AuthorizationExtent { transactions: 0, bytes: 0 } + AuthorizationExtent { transactions: 1, bytes: 2002 } ); assert_ok!(Into::::into(call).dispatch(RawOrigin::Authorized.into())); + assert_eq!( + TransactionStorage::preimage_authorization_extent(hash), + AuthorizationExtent { transactions: 0, bytes: 0 } + ); run_to_block(3, || None); let call = Call::renew { block: 1, index: 0 }; assert_noop!( @@ -136,9 +139,13 @@ fn uses_preimage_authorization() { assert_ok!(call.authorize(TransactionSource::External).unwrap()); assert_eq!( TransactionStorage::preimage_authorization_extent(hash), - AuthorizationExtent { transactions: 0, bytes: 0 } + AuthorizationExtent { transactions: 1, bytes: 2000 } ); assert_ok!(Into::::into(call).dispatch(RawOrigin::Authorized.into())); + assert_eq!( + TransactionStorage::preimage_authorization_extent(hash), + AuthorizationExtent { transactions: 0, bytes: 0 } + ); }); } From 8f2e817dd0db53a7226457d59f5655cecd13d8f3 Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Fri, 23 Jan 2026 18:55:04 +0000 Subject: [PATCH 05/26] Format --- pallets/transaction-storage/src/lib.rs | 16 +++------------- pallets/transaction-storage/src/tests.rs | 3 +-- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/pallets/transaction-storage/src/lib.rs b/pallets/transaction-storage/src/lib.rs index 399ef2578..cd703a063 100644 --- a/pallets/transaction-storage/src/lib.rs +++ b/pallets/transaction-storage/src/lib.rs @@ -353,10 +353,7 @@ pub mod pallet { })] #[pallet::weight_of_authorize(Weight::zero())] pub fn store(origin: OriginFor, data: Vec) -> DispatchResult { - let is_authorized = matches!( - origin.into(), - Ok(frame_system::RawOrigin::Authorized) - ); + let is_authorized = matches!(origin.into(), Ok(frame_system::RawOrigin::Authorized)); if is_authorized { Self::check_unsigned_store(data.as_slice(), true) .map_err(Self::dispatch_error_from_validity)?; @@ -432,10 +429,7 @@ pub mod pallet { block: BlockNumberFor, index: u32, ) -> DispatchResultWithPostInfo { - let is_authorized = matches!( - origin.into(), - Ok(frame_system::RawOrigin::Authorized) - ); + let is_authorized = matches!(origin.into(), Ok(frame_system::RawOrigin::Authorized)); if is_authorized { Self::check_unsigned_renew(&block, &index, true) .map_err(Self::dispatch_error_from_validity)?; @@ -1063,11 +1057,7 @@ pub mod pallet { let hash = hash(); - Self::check_authorization( - AuthorizationScope::Preimage(hash), - size as u32, - consume, - )?; + Self::check_authorization(AuthorizationScope::Preimage(hash), size as u32, consume)?; Ok(ValidTransaction::with_tag_prefix("TransactionStorageStoreRenew") .and_provides(hash) diff --git a/pallets/transaction-storage/src/tests.rs b/pallets/transaction-storage/src/tests.rs index 9f8acc73f..aa977b113 100644 --- a/pallets/transaction-storage/src/tests.rs +++ b/pallets/transaction-storage/src/tests.rs @@ -34,11 +34,10 @@ use polkadot_sdk_frame::{ traits::{GetStorageVersion, OnRuntimeUpgrade}, BoundedVec, }, - prelude::{frame_system::RawOrigin, *}, + prelude::{frame_system::RawOrigin, TransactionSource, *}, testing_prelude::*, traits::{Authorize, StorageVersion}, }; -use polkadot_sdk_frame::prelude::TransactionSource; use sp_transaction_storage_proof::{random_chunk, registration::build_proof, CHUNK_SIZE}; type Call = super::Call; From ca6dd6e9abe6506bd7fa929dc3c7083320bdea32 Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Fri, 23 Jan 2026 19:20:01 +0000 Subject: [PATCH 06/26] Clippy fix --- pallets/transaction-storage/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pallets/transaction-storage/src/lib.rs b/pallets/transaction-storage/src/lib.rs index cd703a063..27c98f65d 100644 --- a/pallets/transaction-storage/src/lib.rs +++ b/pallets/transaction-storage/src/lib.rs @@ -918,7 +918,7 @@ pub mod pallet { /// This is equivalent to `validate_unsigned` but for signed transactions. It should be /// called from a `SignedExtension` implementation. pub fn validate_signed(who: &T::AccountId, call: &Call) -> TransactionValidity { - Ok(Self::check_signed(who, call, false)?) + Self::check_signed(who, call, false) } /// Check the validity of the given call, signed by the given account, and consume From 42d021f094bec3b8085e8cd2027abb2eb69ba748 Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Mon, 26 Jan 2026 15:10:49 +0000 Subject: [PATCH 07/26] Added preimage test --- examples/authorize_and_store_preimage_papi.js | 204 +++++++++++++ ...thorize_and_store_preimage_papi_smoldot.js | 284 ++++++++++++++++++ examples/justfile | 33 ++ pallets/transaction-storage/src/lib.rs | 86 ++++-- 4 files changed, 581 insertions(+), 26 deletions(-) create mode 100644 examples/authorize_and_store_preimage_papi.js create mode 100644 examples/authorize_and_store_preimage_papi_smoldot.js diff --git a/examples/authorize_and_store_preimage_papi.js b/examples/authorize_and_store_preimage_papi.js new file mode 100644 index 000000000..fcbc740e0 --- /dev/null +++ b/examples/authorize_and_store_preimage_papi.js @@ -0,0 +1,204 @@ +import assert from "assert"; +import { createClient } from 'polkadot-api'; +import { getWsProvider } from 'polkadot-api/ws-provider'; +import { cryptoWaitReady, blake2AsU8a } from '@polkadot/util-crypto'; +import { Binary } from '@polkadot-api/substrate-bindings'; +import { fetchCid, TX_MODE_FINALIZED_BLOCK } from './api.js'; +import { setupKeyringAndSigners } from './common.js'; +import { cidFromBytes } from "./cid_dag_metadata.js"; +import { bulletin } from './.papi/descriptors/dist/index.mjs'; + +// Command line arguments: [ws_url] [seed] +const args = process.argv.slice(2); +const NODE_WS = args[0] || 'ws://localhost:10000'; +const SEED = args[1] || '//Alice'; +const HTTP_IPFS_API = 'http://127.0.0.1:8080'; // Local IPFS HTTP gateway + +const TX_MODE_CONFIG = { + [TX_MODE_FINALIZED_BLOCK]: { + match: (ev) => ev.type === "finalized", + log: (txName, ev) => `๐Ÿ“ฆ ${txName} included in finalized block: ${ev.block.hash}`, + }, +}; + +const DEFAULT_TX_TIMEOUT_MS = 120_000; // 120 seconds or 20 blocks + +async function waitForTransaction(tx, signer, txName, txMode = TX_MODE_FINALIZED_BLOCK, timeoutMs = DEFAULT_TX_TIMEOUT_MS) { + const config = TX_MODE_CONFIG[txMode]; + if (!config) { + throw new Error(`Unhandled txMode: ${txMode}`); + } + + return new Promise((resolve, reject) => { + let sub; + let resolved = false; + + const cleanup = () => { + resolved = true; + clearTimeout(timeoutId); + if (sub) sub.unsubscribe(); + }; + + const timeoutId = setTimeout(() => { + if (!resolved) { + cleanup(); + reject(new Error(`${txName} transaction timed out after ${timeoutMs}ms waiting for ${txMode}`)); + } + }, timeoutMs); + + sub = tx.signSubmitAndWatch(signer).subscribe({ + next: (ev) => { + console.log(`โœ… ${txName} event:`, ev.type); + if (!resolved && config.match(ev)) { + console.log(config.log(txName, ev)); + cleanup(); + resolve(ev); + } + }, + error: (err) => { + console.error(`โŒ ${txName} error:`, err); + if (!resolved) { + cleanup(); + reject(err); + } + }, + }); + }); +} + +async function waitForRawTransaction(client, tx, txName, txMode = TX_MODE_FINALIZED_BLOCK, timeoutMs = DEFAULT_TX_TIMEOUT_MS) { + const config = TX_MODE_CONFIG[txMode]; + if (!config) { + throw new Error(`Unhandled txMode: ${txMode}`); + } + + return new Promise((resolve, reject) => { + let sub; + let resolved = false; + + const cleanup = () => { + resolved = true; + clearTimeout(timeoutId); + if (sub) sub.unsubscribe(); + }; + + const timeoutId = setTimeout(() => { + if (!resolved) { + cleanup(); + reject(new Error(`${txName} transaction timed out after ${timeoutMs}ms waiting for ${txMode}`)); + } + }, timeoutMs); + + sub = client.submitAndWatch(tx).subscribe({ + next: (ev) => { + console.log(`โœ… ${txName} event:`, ev.type); + if (!resolved && config.match(ev)) { + console.log(config.log(txName, ev)); + cleanup(); + resolve(ev); + } + }, + error: (err) => { + console.error(`โŒ ${txName} error:`, err); + if (!resolved) { + cleanup(); + reject(err); + } + }, + }); + }); +} + +async function authorizePreimage(typedApi, sudoSigner, contentHash, maxSize, txMode) { + console.log( + `โฌ†๏ธ Authorizing preimage with content hash: ${contentHash} and max size: ${maxSize}...` + ); + const authorizeCall = typedApi.tx.TransactionStorage.authorize_preimage({ + content_hash: contentHash, + max_size: maxSize, + }).decodedCall; + + const batchTx = typedApi.tx.Utility.batch_all({ + calls: [authorizeCall], + }); + const sudoTx = typedApi.tx.Sudo.sudo({ + call: batchTx.decodedCall, + }); + + await waitForTransaction(sudoTx, sudoSigner, "BatchAuthorize Preimages", txMode); +} + +async function storeUnsigned(typedApi, client, data, txMode) { + console.log('โฌ†๏ธ Submitting unsigned Store'); + const cid = await cidFromBytes(data); + + const binaryData = new Binary(data); + const tx = typedApi.tx.TransactionStorage.store({ data: binaryData }); + const bareTx = await tx.getBareTx(); + await waitForRawTransaction(client, bareTx, "Store", txMode); + return cid; +} + +async function main() { + await cryptoWaitReady(); + + console.log(`Connecting to: ${NODE_WS}`); + console.log(`Using seed: ${SEED}`); + + let client, resultCode; + try { + // Init WS PAPI client and typed api. + client = createClient(getWsProvider(NODE_WS)); + const bulletinAPI = client.getTypedApi(bulletin); + + // Signers. + const { sudoSigner } = setupKeyringAndSigners(SEED, '//PapiPreimageSigner'); + + // Data to store. + const dataToStore = "Hello, Bulletin with preimage auth - " + new Date().toString(); + const dataBytes = new Uint8Array(Buffer.from(dataToStore)); + const expectedCid = await cidFromBytes(dataBytes); + const contentHash = new Binary(blake2AsU8a(dataBytes)); + + // Authorize preimage. + await authorizePreimage( + bulletinAPI, + sudoSigner, + contentHash, + BigInt(dataBytes.length), + TX_MODE_FINALIZED_BLOCK, + ); + + // Store data as unsigned. + const cid = await storeUnsigned(bulletinAPI, client, dataBytes, TX_MODE_FINALIZED_BLOCK); + console.log("โœ… Data stored successfully with CID:", cid); + + // Read back from IPFS + const downloadedContent = await fetchCid(HTTP_IPFS_API, cid); + console.log("โœ… Downloaded content:", downloadedContent.toString()); + assert.deepStrictEqual( + cid, + expectedCid, + 'โŒ expectedCid does not match cid!' + ); + assert.deepStrictEqual( + dataToStore, + downloadedContent.toString(), + 'โŒ dataToStore does not match downloadedContent!' + ); + console.log(`โœ… Verified content!`); + + console.log(`\n\n\nโœ…โœ…โœ… Test passed! โœ…โœ…โœ…`); + resultCode = 0; + } catch (error) { + console.error("โŒ Error:", error); + resultCode = 1; + } finally { + if (client) client.destroy(); + process.exit(resultCode); + } +} + +await main(); + + diff --git a/examples/authorize_and_store_preimage_papi_smoldot.js b/examples/authorize_and_store_preimage_papi_smoldot.js new file mode 100644 index 000000000..fd8a9fc76 --- /dev/null +++ b/examples/authorize_and_store_preimage_papi_smoldot.js @@ -0,0 +1,284 @@ +import assert from "assert"; +import * as smoldot from 'smoldot'; +import { readFileSync } from 'fs'; +import { createClient } from 'polkadot-api'; +import { getSmProvider } from 'polkadot-api/sm-provider'; +import { cryptoWaitReady, blake2AsU8a } from '@polkadot/util-crypto'; +import { Binary } from '@polkadot-api/substrate-bindings'; +import { fetchCid, TX_MODE_FINALIZED_BLOCK } from './api.js'; +import { setupKeyringAndSigners, waitForChainReady } from './common.js'; +import { cidFromBytes } from "./cid_dag_metadata.js"; +import { bulletin } from './.papi/descriptors/dist/index.mjs'; + +// Constants +const SYNC_WAIT_SEC = 30; +const SMOLDOT_LOG_LEVEL = 3; // 0=off, 1=error, 2=warn, 3=info, 4=debug, 5=trace +const HTTP_IPFS_API = 'http://127.0.0.1:8080'; // Local IPFS HTTP gateway + +const TCP_BOOTNODE_REGEX = /^(\/ip[46]\/[^/]+)\/tcp\/(\d+)\/p2p\/(.+)$/; +const WS_BOOTNODE_REGEX = /\/tcp\/\d+\/ws\/p2p\//; + +function convertBootNodeToWebSocket(addr) { + if (WS_BOOTNODE_REGEX.test(addr)) { + console.log(` โœ… Already WebSocket: ${addr.substring(0, 50)}...`); + return addr; + } + + const match = addr.match(TCP_BOOTNODE_REGEX); + if (match) { + const [, hostPart, portStr, peerId] = match; + const wsPort = parseInt(portStr, 10) + 1; + console.log(` ๐Ÿ“ก Converted: tcp/${portStr} -> tcp/${wsPort}/ws`); + return `${hostPart}/tcp/${wsPort}/ws/p2p/${peerId}`; + } + + return null; +} + +function readChainSpec(chainspecPath) { + const chainSpecObj = JSON.parse(readFileSync(chainspecPath, 'utf8')); + chainSpecObj.protocolId = null; + + const bootNodes = chainSpecObj.bootNodes || []; + if (bootNodes.length === 0) { + console.log(`โš ๏ธ No bootnodes found in chain spec: ${chainspecPath}`); + return JSON.stringify(chainSpecObj); + } + + console.log(`๐Ÿ”„ Converting ${bootNodes.length} bootnode(s) to WebSocket for smoldot...`); + const wsBootNodes = bootNodes.map(convertBootNodeToWebSocket).filter(Boolean); + + if (wsBootNodes.length > 0) { + chainSpecObj.bootNodes = wsBootNodes; + console.log(`โœ… Using ${wsBootNodes.length} WebSocket bootnode(s)`); + } else { + console.log(`โš ๏ธ No bootnodes could be converted to WebSocket`); + } + + return JSON.stringify(chainSpecObj); +} + +function initSmoldot() { + return smoldot.start({ + maxLogLevel: SMOLDOT_LOG_LEVEL, + logCallback: (level, target, message) => { + const levelName = ['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'][level - 1] || 'UNKNOWN'; + console.log(`[smoldot:${levelName}] ${target}: ${message}`); + } + }); +} + +async function createSmoldotClient(chainSpecPath, parachainSpecPath = null) { + const sd = initSmoldot(); + + const mainChain = await sd.addChain({ chainSpec: readChainSpec(chainSpecPath) }); + console.log(`โœ… Added main chain: ${chainSpecPath}`); + + let targetChain = mainChain; + if (parachainSpecPath) { + targetChain = await sd.addChain({ + chainSpec: readChainSpec(parachainSpecPath), + potentialRelayChains: [mainChain] + }); + console.log(`โœ… Added parachain: ${parachainSpecPath}`); + } + + return { client: createClient(getSmProvider(targetChain)), sd }; +} + +const TX_MODE_CONFIG = { + [TX_MODE_FINALIZED_BLOCK]: { + match: (ev) => ev.type === "finalized", + log: (txName, ev) => `๐Ÿ“ฆ ${txName} included in finalized block: ${ev.block.hash}`, + }, +}; + +const DEFAULT_TX_TIMEOUT_MS = 120_000; + +async function waitForTransaction(tx, signer, txName, txMode = TX_MODE_FINALIZED_BLOCK, timeoutMs = DEFAULT_TX_TIMEOUT_MS) { + const config = TX_MODE_CONFIG[txMode]; + if (!config) { + throw new Error(`Unhandled txMode: ${txMode}`); + } + + return new Promise((resolve, reject) => { + let sub; + let resolved = false; + + const cleanup = () => { + resolved = true; + clearTimeout(timeoutId); + if (sub) sub.unsubscribe(); + }; + + const timeoutId = setTimeout(() => { + if (!resolved) { + cleanup(); + reject(new Error(`${txName} transaction timed out after ${timeoutMs}ms waiting for ${txMode}`)); + } + }, timeoutMs); + + sub = tx.signSubmitAndWatch(signer).subscribe({ + next: (ev) => { + console.log(`โœ… ${txName} event:`, ev.type); + if (!resolved && config.match(ev)) { + console.log(config.log(txName, ev)); + cleanup(); + resolve(ev); + } + }, + error: (err) => { + console.error(`โŒ ${txName} error:`, err); + if (!resolved) { + cleanup(); + reject(err); + } + }, + }); + }); +} + +async function waitForRawTransaction(client, tx, txName, txMode = TX_MODE_FINALIZED_BLOCK, timeoutMs = DEFAULT_TX_TIMEOUT_MS) { + const config = TX_MODE_CONFIG[txMode]; + if (!config) { + throw new Error(`Unhandled txMode: ${txMode}`); + } + + return new Promise((resolve, reject) => { + let sub; + let resolved = false; + + const cleanup = () => { + resolved = true; + clearTimeout(timeoutId); + if (sub) sub.unsubscribe(); + }; + + const timeoutId = setTimeout(() => { + if (!resolved) { + cleanup(); + reject(new Error(`${txName} transaction timed out after ${timeoutMs}ms waiting for ${txMode}`)); + } + }, timeoutMs); + + sub = client.submitAndWatch(tx).subscribe({ + next: (ev) => { + console.log(`โœ… ${txName} event:`, ev.type); + if (!resolved && config.match(ev)) { + console.log(config.log(txName, ev)); + cleanup(); + resolve(ev); + } + }, + error: (err) => { + console.error(`โŒ ${txName} error:`, err); + if (!resolved) { + cleanup(); + reject(err); + } + }, + }); + }); +} + +async function authorizePreimage(typedApi, sudoSigner, contentHash, maxSize, txMode) { + console.log( + `โฌ†๏ธ Authorizing preimage with content hash: ${contentHash} and max size: ${maxSize}...` + ); + const authorizeCall = typedApi.tx.TransactionStorage.authorize_preimage({ + content_hash: contentHash, + max_size: maxSize, + }).decodedCall; + + const batchTx = typedApi.tx.Utility.batch_all({ + calls: [authorizeCall], + }); + const sudoTx = typedApi.tx.Sudo.sudo({ + call: batchTx.decodedCall, + }); + + await waitForTransaction(sudoTx, sudoSigner, "BatchAuthorize Preimages", txMode); +} + +async function storeUnsigned(typedApi, client, data, txMode) { + console.log('โฌ†๏ธ Submitting unsigned Store'); + const cid = await cidFromBytes(data); + + const binaryData = new Binary(data); + const tx = typedApi.tx.TransactionStorage.store({ data: binaryData }); + const bareTx = await tx.getBareTx(); + await waitForRawTransaction(client, bareTx, "Store", txMode); + return cid; +} + +async function main() { + await cryptoWaitReady(); + + const chainSpecPath = process.argv[2]; + if (!chainSpecPath) { + console.error('โŒ Error: Chain spec path is required as first argument'); + console.error('Usage: node authorize_and_store_preimage_papi_smoldot.js [parachain-spec-path]'); + console.error(' For parachains: '); + console.error(' For solochains: '); + process.exit(1); + } + + const parachainSpecPath = process.argv[3] || null; + + let sd, client, resultCode; + try { + ({ client, sd } = await createSmoldotClient(chainSpecPath, parachainSpecPath)); + console.log(`โญ๏ธ Waiting ${SYNC_WAIT_SEC} seconds for smoldot to sync...`); + await new Promise(resolve => setTimeout(resolve, SYNC_WAIT_SEC * 1000)); + + console.log('๐Ÿ” Checking if chain is ready...'); + const bulletinAPI = client.getTypedApi(bulletin); + await waitForChainReady(bulletinAPI); + + const { sudoSigner } = setupKeyringAndSigners('//Alice', '//PapiPreimageSmolSigner'); + + const dataToStore = "Hello, Bulletin with preimage auth + Smoldot - " + new Date().toString(); + const dataBytes = new Uint8Array(Buffer.from(dataToStore)); + const expectedCid = await cidFromBytes(dataBytes); + const contentHash = new Binary(blake2AsU8a(dataBytes)); + + await authorizePreimage( + bulletinAPI, + sudoSigner, + contentHash, + BigInt(dataBytes.length), + TX_MODE_FINALIZED_BLOCK, + ); + + const cid = await storeUnsigned(bulletinAPI, client, dataBytes, TX_MODE_FINALIZED_BLOCK); + console.log("โœ… Data stored successfully with CID:", cid); + + const downloadedContent = await fetchCid(HTTP_IPFS_API, cid); + console.log("โœ… Downloaded content:", downloadedContent.toString()); + assert.deepStrictEqual( + cid, + expectedCid, + 'โŒ expectedCid does not match cid!' + ); + assert.deepStrictEqual( + dataToStore, + downloadedContent.toString(), + 'โŒ dataToStore does not match downloadedContent!' + ); + console.log(`โœ… Verified content!`); + + console.log(`\n\n\nโœ…โœ…โœ… Test passed! โœ…โœ…โœ…`); + resultCode = 0; + } catch (error) { + console.error("โŒ Error:", error); + resultCode = 1; + } finally { + if (client) client.destroy(); + if (sd) sd.terminate(); + process.exit(resultCode); + } +} + +await main(); + + diff --git a/examples/justfile b/examples/justfile index 9866e46e3..90ce310ed 100644 --- a/examples/justfile +++ b/examples/justfile @@ -474,6 +474,39 @@ run-test-authorize-and-store test_dir runtime mode="ws" ws_url="ws://localhost:1 node $SCRIPT_NAME "{{ ws_url }}" "{{ seed }}" "{{ http_ipfs_api }}" fi +# Run authorize-preimage-and-store test only (services must already be running via start-services) +# Parameters: +# test_dir - Test directory where services are running +# runtime - Runtime name for smoldot chainspec path resolution +# mode - Connection mode: "ws" (WebSocket RPC node) or "smoldot" (light client) +run-test-authorize-preimage-and-store test_dir runtime mode="ws": + #!/usr/bin/env bash + set -e + + if [ "{{ mode }}" = "smoldot" ]; then + echo "๐Ÿงช Running authorize preimage and store test (mode: smoldot, runtime: {{ runtime }})..." + SCRIPT_NAME="authorize_and_store_preimage_papi_smoldot.js" + elif [ "{{ mode }}" = "ws" ]; then + echo "๐Ÿงช Running authorize preimage and store test (mode: ws, runtime: {{ runtime }})..." + SCRIPT_NAME="authorize_and_store_preimage_papi.js" + else + echo "โŒ Error: Invalid mode '{{ mode }}'. Must be 'ws' or 'smoldot'" + exit 1 + fi + + if [ "{{ mode }}" = "smoldot" ]; then + if [ "{{ runtime }}" = "bulletin-westend-runtime" ]; then + RELAY_CHAINSPEC_PATH="{{ test_dir }}/bob/cfg/westend-local.json" + PARACHAIN_CHAINSPEC_PATH="{{ test_dir }}/bulletin-westend-collator-2/cfg/westend-local-1006.json" + node $SCRIPT_NAME "$RELAY_CHAINSPEC_PATH" "$PARACHAIN_CHAINSPEC_PATH" + else + CHAINSPEC_PATH="{{ test_dir }}/bob/cfg/bulletin-polkadot-local.json" + node $SCRIPT_NAME "$CHAINSPEC_PATH" + fi + else + node $SCRIPT_NAME + fi + # Run store-chunked-data test only (services must already be running via start-services) # Parameters: # test_dir - Test directory where services are running diff --git a/pallets/transaction-storage/src/lib.rs b/pallets/transaction-storage/src/lib.rs index 27c98f65d..4466c585c 100644 --- a/pallets/transaction-storage/src/lib.rs +++ b/pallets/transaction-storage/src/lib.rs @@ -353,8 +353,12 @@ pub mod pallet { })] #[pallet::weight_of_authorize(Weight::zero())] pub fn store(origin: OriginFor, data: Vec) -> DispatchResult { - let is_authorized = matches!(origin.into(), Ok(frame_system::RawOrigin::Authorized)); - if is_authorized { + let (is_authorized, is_unsigned) = match origin.into() { + Ok(frame_system::RawOrigin::Authorized) => (true, false), + Ok(frame_system::RawOrigin::None) => (false, true), + _ => (false, false), + }; + if is_authorized || is_unsigned { Self::check_unsigned_store(data.as_slice(), true) .map_err(Self::dispatch_error_from_validity)?; } @@ -429,8 +433,12 @@ pub mod pallet { block: BlockNumberFor, index: u32, ) -> DispatchResultWithPostInfo { - let is_authorized = matches!(origin.into(), Ok(frame_system::RawOrigin::Authorized)); - if is_authorized { + let (is_authorized, is_unsigned) = match origin.into() { + Ok(frame_system::RawOrigin::Authorized) => (true, false), + Ok(frame_system::RawOrigin::None) => (false, true), + _ => (false, false), + }; + if is_authorized || is_unsigned { Self::check_unsigned_renew(&block, &index, true) .map_err(Self::dispatch_error_from_validity)?; } @@ -774,6 +782,19 @@ pub mod pallet { } } + #[pallet::validate_unsigned] + impl ValidateUnsigned for Pallet { + type Call = Call; + + fn validate_unsigned(_source: TransactionSource, call: &Self::Call) -> TransactionValidity { + Self::check_unsigned_call(call) + } + + fn pre_dispatch(_call: &Self::Call) -> Result<(), TransactionValidityError> { + Ok(()) + } + } + impl Pallet { fn to_validity_with_refund( result: Result, @@ -1060,9 +1081,9 @@ pub mod pallet { Self::check_authorization(AuthorizationScope::Preimage(hash), size as u32, consume)?; Ok(ValidTransaction::with_tag_prefix("TransactionStorageStoreRenew") - .and_provides(hash) - .priority(T::StoreRenewPriority::get()) - .longevity(T::StoreRenewLongevity::get()) + .and_provides(hash) + .priority(T::StoreRenewPriority::get()) + .longevity(T::StoreRenewLongevity::get()) .into()) } @@ -1071,8 +1092,8 @@ pub mod pallet { consume: bool, ) -> Result { Self::check_store_renew_unsigned( - data.len(), - || sp_io::hashing::blake2_256(data), + data.len(), + || sp_io::hashing::blake2_256(data), consume, ) } @@ -1082,40 +1103,53 @@ pub mod pallet { index: &u32, consume: bool, ) -> Result { - let info = Self::transaction_info(*block, *index).ok_or(RENEWED_NOT_FOUND)?; - Self::check_store_renew_unsigned( - info.size as usize, - || info.content_hash.into(), + let info = Self::transaction_info(*block, *index).ok_or(RENEWED_NOT_FOUND)?; + Self::check_store_renew_unsigned( + info.size as usize, + || info.content_hash.into(), consume, - ) + ) } fn check_unsigned_remove_expired_account( who: &T::AccountId, ) -> Result { - Self::check_authorization_expired(AuthorizationScope::Account(who.clone()))?; + Self::check_authorization_expired(AuthorizationScope::Account(who.clone()))?; Ok(ValidTransaction::with_tag_prefix( - "TransactionStorageRemoveExpiredAccountAuthorization", - ) - .and_provides(who) - .priority(T::RemoveExpiredAuthorizationPriority::get()) - .longevity(T::RemoveExpiredAuthorizationLongevity::get()) + "TransactionStorageRemoveExpiredAccountAuthorization", + ) + .and_provides(who) + .priority(T::RemoveExpiredAuthorizationPriority::get()) + .longevity(T::RemoveExpiredAuthorizationLongevity::get()) .into()) } fn check_unsigned_remove_expired_preimage_authorization( hash: &ContentHash, ) -> Result { - Self::check_authorization_expired(AuthorizationScope::Preimage(*hash))?; + Self::check_authorization_expired(AuthorizationScope::Preimage(*hash))?; Ok(ValidTransaction::with_tag_prefix( - "TransactionStorageRemoveExpiredPreimageAuthorization", - ) - .and_provides(hash) - .priority(T::RemoveExpiredAuthorizationPriority::get()) - .longevity(T::RemoveExpiredAuthorizationLongevity::get()) + "TransactionStorageRemoveExpiredPreimageAuthorization", + ) + .and_provides(hash) + .priority(T::RemoveExpiredAuthorizationPriority::get()) + .longevity(T::RemoveExpiredAuthorizationLongevity::get()) .into()) } + fn check_unsigned_call(call: &Call) -> Result { + match call { + Call::::store { data } => Self::check_unsigned_store(data.as_slice(), false), + Call::::renew { block, index } => + Self::check_unsigned_renew(block, index, false), + Call::::remove_expired_account_authorization { who } => + Self::check_unsigned_remove_expired_account(who), + Call::::remove_expired_preimage_authorization { content_hash } => + Self::check_unsigned_remove_expired_preimage_authorization(content_hash), + _ => Err(InvalidTransaction::Call.into()), + } + } + fn check_signed( who: &T::AccountId, call: &Call, From e1cd9f5262da6cd8130bfe88c8645db17a1d43d0 Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Wed, 4 Feb 2026 13:15:23 +0000 Subject: [PATCH 08/26] Moved preimage test to subxt --- .github/workflows/integration-test.yml | 4 + Cargo.toml | 1 + examples/authorize_and_store_preimage_papi.js | 204 ------------- ...thorize_and_store_preimage_papi_smoldot.js | 284 ------------------ examples/justfile | 19 +- .../authorize-preimage-subxt/Cargo.toml | 15 + .../authorize-preimage-subxt/src/main.rs | 84 ++++++ pallets/transaction-storage/src/lib.rs | 56 +--- 8 files changed, 129 insertions(+), 538 deletions(-) delete mode 100644 examples/authorize_and_store_preimage_papi.js delete mode 100644 examples/authorize_and_store_preimage_papi_smoldot.js create mode 100644 integration-tests/authorize-preimage-subxt/Cargo.toml create mode 100644 integration-tests/authorize-preimage-subxt/src/main.rs diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 06a2eadaa..c653394df 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -151,6 +151,10 @@ jobs: working-directory: examples run: just run-test-authorize-and-store "${{ env.TEST_DIR }}" "bulletin-westend-runtime" "ws" + - name: Test authorize-preimage-and-store ws (Westend parachain) + working-directory: examples + run: just run-test-authorize-preimage-and-store "${{ env.TEST_DIR }}" "bulletin-westend-runtime" "ws" + - name: Test authorize-and-store smoldot (Westend parachain) working-directory: examples run: just run-test-authorize-and-store "${{ env.TEST_DIR }}" "bulletin-westend-runtime" "smoldot" diff --git a/Cargo.toml b/Cargo.toml index 5357e6ce6..c8fd9115e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -171,6 +171,7 @@ substrate-wasm-builder = { git = "https://github.com/paritytech/polkadot-sdk.git resolver = "2" members = [ "node", + "integration-tests/authorize-preimage-subxt", "pallets/common", "pallets/relayer-set", "pallets/transaction-storage", diff --git a/examples/authorize_and_store_preimage_papi.js b/examples/authorize_and_store_preimage_papi.js deleted file mode 100644 index fcbc740e0..000000000 --- a/examples/authorize_and_store_preimage_papi.js +++ /dev/null @@ -1,204 +0,0 @@ -import assert from "assert"; -import { createClient } from 'polkadot-api'; -import { getWsProvider } from 'polkadot-api/ws-provider'; -import { cryptoWaitReady, blake2AsU8a } from '@polkadot/util-crypto'; -import { Binary } from '@polkadot-api/substrate-bindings'; -import { fetchCid, TX_MODE_FINALIZED_BLOCK } from './api.js'; -import { setupKeyringAndSigners } from './common.js'; -import { cidFromBytes } from "./cid_dag_metadata.js"; -import { bulletin } from './.papi/descriptors/dist/index.mjs'; - -// Command line arguments: [ws_url] [seed] -const args = process.argv.slice(2); -const NODE_WS = args[0] || 'ws://localhost:10000'; -const SEED = args[1] || '//Alice'; -const HTTP_IPFS_API = 'http://127.0.0.1:8080'; // Local IPFS HTTP gateway - -const TX_MODE_CONFIG = { - [TX_MODE_FINALIZED_BLOCK]: { - match: (ev) => ev.type === "finalized", - log: (txName, ev) => `๐Ÿ“ฆ ${txName} included in finalized block: ${ev.block.hash}`, - }, -}; - -const DEFAULT_TX_TIMEOUT_MS = 120_000; // 120 seconds or 20 blocks - -async function waitForTransaction(tx, signer, txName, txMode = TX_MODE_FINALIZED_BLOCK, timeoutMs = DEFAULT_TX_TIMEOUT_MS) { - const config = TX_MODE_CONFIG[txMode]; - if (!config) { - throw new Error(`Unhandled txMode: ${txMode}`); - } - - return new Promise((resolve, reject) => { - let sub; - let resolved = false; - - const cleanup = () => { - resolved = true; - clearTimeout(timeoutId); - if (sub) sub.unsubscribe(); - }; - - const timeoutId = setTimeout(() => { - if (!resolved) { - cleanup(); - reject(new Error(`${txName} transaction timed out after ${timeoutMs}ms waiting for ${txMode}`)); - } - }, timeoutMs); - - sub = tx.signSubmitAndWatch(signer).subscribe({ - next: (ev) => { - console.log(`โœ… ${txName} event:`, ev.type); - if (!resolved && config.match(ev)) { - console.log(config.log(txName, ev)); - cleanup(); - resolve(ev); - } - }, - error: (err) => { - console.error(`โŒ ${txName} error:`, err); - if (!resolved) { - cleanup(); - reject(err); - } - }, - }); - }); -} - -async function waitForRawTransaction(client, tx, txName, txMode = TX_MODE_FINALIZED_BLOCK, timeoutMs = DEFAULT_TX_TIMEOUT_MS) { - const config = TX_MODE_CONFIG[txMode]; - if (!config) { - throw new Error(`Unhandled txMode: ${txMode}`); - } - - return new Promise((resolve, reject) => { - let sub; - let resolved = false; - - const cleanup = () => { - resolved = true; - clearTimeout(timeoutId); - if (sub) sub.unsubscribe(); - }; - - const timeoutId = setTimeout(() => { - if (!resolved) { - cleanup(); - reject(new Error(`${txName} transaction timed out after ${timeoutMs}ms waiting for ${txMode}`)); - } - }, timeoutMs); - - sub = client.submitAndWatch(tx).subscribe({ - next: (ev) => { - console.log(`โœ… ${txName} event:`, ev.type); - if (!resolved && config.match(ev)) { - console.log(config.log(txName, ev)); - cleanup(); - resolve(ev); - } - }, - error: (err) => { - console.error(`โŒ ${txName} error:`, err); - if (!resolved) { - cleanup(); - reject(err); - } - }, - }); - }); -} - -async function authorizePreimage(typedApi, sudoSigner, contentHash, maxSize, txMode) { - console.log( - `โฌ†๏ธ Authorizing preimage with content hash: ${contentHash} and max size: ${maxSize}...` - ); - const authorizeCall = typedApi.tx.TransactionStorage.authorize_preimage({ - content_hash: contentHash, - max_size: maxSize, - }).decodedCall; - - const batchTx = typedApi.tx.Utility.batch_all({ - calls: [authorizeCall], - }); - const sudoTx = typedApi.tx.Sudo.sudo({ - call: batchTx.decodedCall, - }); - - await waitForTransaction(sudoTx, sudoSigner, "BatchAuthorize Preimages", txMode); -} - -async function storeUnsigned(typedApi, client, data, txMode) { - console.log('โฌ†๏ธ Submitting unsigned Store'); - const cid = await cidFromBytes(data); - - const binaryData = new Binary(data); - const tx = typedApi.tx.TransactionStorage.store({ data: binaryData }); - const bareTx = await tx.getBareTx(); - await waitForRawTransaction(client, bareTx, "Store", txMode); - return cid; -} - -async function main() { - await cryptoWaitReady(); - - console.log(`Connecting to: ${NODE_WS}`); - console.log(`Using seed: ${SEED}`); - - let client, resultCode; - try { - // Init WS PAPI client and typed api. - client = createClient(getWsProvider(NODE_WS)); - const bulletinAPI = client.getTypedApi(bulletin); - - // Signers. - const { sudoSigner } = setupKeyringAndSigners(SEED, '//PapiPreimageSigner'); - - // Data to store. - const dataToStore = "Hello, Bulletin with preimage auth - " + new Date().toString(); - const dataBytes = new Uint8Array(Buffer.from(dataToStore)); - const expectedCid = await cidFromBytes(dataBytes); - const contentHash = new Binary(blake2AsU8a(dataBytes)); - - // Authorize preimage. - await authorizePreimage( - bulletinAPI, - sudoSigner, - contentHash, - BigInt(dataBytes.length), - TX_MODE_FINALIZED_BLOCK, - ); - - // Store data as unsigned. - const cid = await storeUnsigned(bulletinAPI, client, dataBytes, TX_MODE_FINALIZED_BLOCK); - console.log("โœ… Data stored successfully with CID:", cid); - - // Read back from IPFS - const downloadedContent = await fetchCid(HTTP_IPFS_API, cid); - console.log("โœ… Downloaded content:", downloadedContent.toString()); - assert.deepStrictEqual( - cid, - expectedCid, - 'โŒ expectedCid does not match cid!' - ); - assert.deepStrictEqual( - dataToStore, - downloadedContent.toString(), - 'โŒ dataToStore does not match downloadedContent!' - ); - console.log(`โœ… Verified content!`); - - console.log(`\n\n\nโœ…โœ…โœ… Test passed! โœ…โœ…โœ…`); - resultCode = 0; - } catch (error) { - console.error("โŒ Error:", error); - resultCode = 1; - } finally { - if (client) client.destroy(); - process.exit(resultCode); - } -} - -await main(); - - diff --git a/examples/authorize_and_store_preimage_papi_smoldot.js b/examples/authorize_and_store_preimage_papi_smoldot.js deleted file mode 100644 index fd8a9fc76..000000000 --- a/examples/authorize_and_store_preimage_papi_smoldot.js +++ /dev/null @@ -1,284 +0,0 @@ -import assert from "assert"; -import * as smoldot from 'smoldot'; -import { readFileSync } from 'fs'; -import { createClient } from 'polkadot-api'; -import { getSmProvider } from 'polkadot-api/sm-provider'; -import { cryptoWaitReady, blake2AsU8a } from '@polkadot/util-crypto'; -import { Binary } from '@polkadot-api/substrate-bindings'; -import { fetchCid, TX_MODE_FINALIZED_BLOCK } from './api.js'; -import { setupKeyringAndSigners, waitForChainReady } from './common.js'; -import { cidFromBytes } from "./cid_dag_metadata.js"; -import { bulletin } from './.papi/descriptors/dist/index.mjs'; - -// Constants -const SYNC_WAIT_SEC = 30; -const SMOLDOT_LOG_LEVEL = 3; // 0=off, 1=error, 2=warn, 3=info, 4=debug, 5=trace -const HTTP_IPFS_API = 'http://127.0.0.1:8080'; // Local IPFS HTTP gateway - -const TCP_BOOTNODE_REGEX = /^(\/ip[46]\/[^/]+)\/tcp\/(\d+)\/p2p\/(.+)$/; -const WS_BOOTNODE_REGEX = /\/tcp\/\d+\/ws\/p2p\//; - -function convertBootNodeToWebSocket(addr) { - if (WS_BOOTNODE_REGEX.test(addr)) { - console.log(` โœ… Already WebSocket: ${addr.substring(0, 50)}...`); - return addr; - } - - const match = addr.match(TCP_BOOTNODE_REGEX); - if (match) { - const [, hostPart, portStr, peerId] = match; - const wsPort = parseInt(portStr, 10) + 1; - console.log(` ๐Ÿ“ก Converted: tcp/${portStr} -> tcp/${wsPort}/ws`); - return `${hostPart}/tcp/${wsPort}/ws/p2p/${peerId}`; - } - - return null; -} - -function readChainSpec(chainspecPath) { - const chainSpecObj = JSON.parse(readFileSync(chainspecPath, 'utf8')); - chainSpecObj.protocolId = null; - - const bootNodes = chainSpecObj.bootNodes || []; - if (bootNodes.length === 0) { - console.log(`โš ๏ธ No bootnodes found in chain spec: ${chainspecPath}`); - return JSON.stringify(chainSpecObj); - } - - console.log(`๐Ÿ”„ Converting ${bootNodes.length} bootnode(s) to WebSocket for smoldot...`); - const wsBootNodes = bootNodes.map(convertBootNodeToWebSocket).filter(Boolean); - - if (wsBootNodes.length > 0) { - chainSpecObj.bootNodes = wsBootNodes; - console.log(`โœ… Using ${wsBootNodes.length} WebSocket bootnode(s)`); - } else { - console.log(`โš ๏ธ No bootnodes could be converted to WebSocket`); - } - - return JSON.stringify(chainSpecObj); -} - -function initSmoldot() { - return smoldot.start({ - maxLogLevel: SMOLDOT_LOG_LEVEL, - logCallback: (level, target, message) => { - const levelName = ['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'][level - 1] || 'UNKNOWN'; - console.log(`[smoldot:${levelName}] ${target}: ${message}`); - } - }); -} - -async function createSmoldotClient(chainSpecPath, parachainSpecPath = null) { - const sd = initSmoldot(); - - const mainChain = await sd.addChain({ chainSpec: readChainSpec(chainSpecPath) }); - console.log(`โœ… Added main chain: ${chainSpecPath}`); - - let targetChain = mainChain; - if (parachainSpecPath) { - targetChain = await sd.addChain({ - chainSpec: readChainSpec(parachainSpecPath), - potentialRelayChains: [mainChain] - }); - console.log(`โœ… Added parachain: ${parachainSpecPath}`); - } - - return { client: createClient(getSmProvider(targetChain)), sd }; -} - -const TX_MODE_CONFIG = { - [TX_MODE_FINALIZED_BLOCK]: { - match: (ev) => ev.type === "finalized", - log: (txName, ev) => `๐Ÿ“ฆ ${txName} included in finalized block: ${ev.block.hash}`, - }, -}; - -const DEFAULT_TX_TIMEOUT_MS = 120_000; - -async function waitForTransaction(tx, signer, txName, txMode = TX_MODE_FINALIZED_BLOCK, timeoutMs = DEFAULT_TX_TIMEOUT_MS) { - const config = TX_MODE_CONFIG[txMode]; - if (!config) { - throw new Error(`Unhandled txMode: ${txMode}`); - } - - return new Promise((resolve, reject) => { - let sub; - let resolved = false; - - const cleanup = () => { - resolved = true; - clearTimeout(timeoutId); - if (sub) sub.unsubscribe(); - }; - - const timeoutId = setTimeout(() => { - if (!resolved) { - cleanup(); - reject(new Error(`${txName} transaction timed out after ${timeoutMs}ms waiting for ${txMode}`)); - } - }, timeoutMs); - - sub = tx.signSubmitAndWatch(signer).subscribe({ - next: (ev) => { - console.log(`โœ… ${txName} event:`, ev.type); - if (!resolved && config.match(ev)) { - console.log(config.log(txName, ev)); - cleanup(); - resolve(ev); - } - }, - error: (err) => { - console.error(`โŒ ${txName} error:`, err); - if (!resolved) { - cleanup(); - reject(err); - } - }, - }); - }); -} - -async function waitForRawTransaction(client, tx, txName, txMode = TX_MODE_FINALIZED_BLOCK, timeoutMs = DEFAULT_TX_TIMEOUT_MS) { - const config = TX_MODE_CONFIG[txMode]; - if (!config) { - throw new Error(`Unhandled txMode: ${txMode}`); - } - - return new Promise((resolve, reject) => { - let sub; - let resolved = false; - - const cleanup = () => { - resolved = true; - clearTimeout(timeoutId); - if (sub) sub.unsubscribe(); - }; - - const timeoutId = setTimeout(() => { - if (!resolved) { - cleanup(); - reject(new Error(`${txName} transaction timed out after ${timeoutMs}ms waiting for ${txMode}`)); - } - }, timeoutMs); - - sub = client.submitAndWatch(tx).subscribe({ - next: (ev) => { - console.log(`โœ… ${txName} event:`, ev.type); - if (!resolved && config.match(ev)) { - console.log(config.log(txName, ev)); - cleanup(); - resolve(ev); - } - }, - error: (err) => { - console.error(`โŒ ${txName} error:`, err); - if (!resolved) { - cleanup(); - reject(err); - } - }, - }); - }); -} - -async function authorizePreimage(typedApi, sudoSigner, contentHash, maxSize, txMode) { - console.log( - `โฌ†๏ธ Authorizing preimage with content hash: ${contentHash} and max size: ${maxSize}...` - ); - const authorizeCall = typedApi.tx.TransactionStorage.authorize_preimage({ - content_hash: contentHash, - max_size: maxSize, - }).decodedCall; - - const batchTx = typedApi.tx.Utility.batch_all({ - calls: [authorizeCall], - }); - const sudoTx = typedApi.tx.Sudo.sudo({ - call: batchTx.decodedCall, - }); - - await waitForTransaction(sudoTx, sudoSigner, "BatchAuthorize Preimages", txMode); -} - -async function storeUnsigned(typedApi, client, data, txMode) { - console.log('โฌ†๏ธ Submitting unsigned Store'); - const cid = await cidFromBytes(data); - - const binaryData = new Binary(data); - const tx = typedApi.tx.TransactionStorage.store({ data: binaryData }); - const bareTx = await tx.getBareTx(); - await waitForRawTransaction(client, bareTx, "Store", txMode); - return cid; -} - -async function main() { - await cryptoWaitReady(); - - const chainSpecPath = process.argv[2]; - if (!chainSpecPath) { - console.error('โŒ Error: Chain spec path is required as first argument'); - console.error('Usage: node authorize_and_store_preimage_papi_smoldot.js [parachain-spec-path]'); - console.error(' For parachains: '); - console.error(' For solochains: '); - process.exit(1); - } - - const parachainSpecPath = process.argv[3] || null; - - let sd, client, resultCode; - try { - ({ client, sd } = await createSmoldotClient(chainSpecPath, parachainSpecPath)); - console.log(`โญ๏ธ Waiting ${SYNC_WAIT_SEC} seconds for smoldot to sync...`); - await new Promise(resolve => setTimeout(resolve, SYNC_WAIT_SEC * 1000)); - - console.log('๐Ÿ” Checking if chain is ready...'); - const bulletinAPI = client.getTypedApi(bulletin); - await waitForChainReady(bulletinAPI); - - const { sudoSigner } = setupKeyringAndSigners('//Alice', '//PapiPreimageSmolSigner'); - - const dataToStore = "Hello, Bulletin with preimage auth + Smoldot - " + new Date().toString(); - const dataBytes = new Uint8Array(Buffer.from(dataToStore)); - const expectedCid = await cidFromBytes(dataBytes); - const contentHash = new Binary(blake2AsU8a(dataBytes)); - - await authorizePreimage( - bulletinAPI, - sudoSigner, - contentHash, - BigInt(dataBytes.length), - TX_MODE_FINALIZED_BLOCK, - ); - - const cid = await storeUnsigned(bulletinAPI, client, dataBytes, TX_MODE_FINALIZED_BLOCK); - console.log("โœ… Data stored successfully with CID:", cid); - - const downloadedContent = await fetchCid(HTTP_IPFS_API, cid); - console.log("โœ… Downloaded content:", downloadedContent.toString()); - assert.deepStrictEqual( - cid, - expectedCid, - 'โŒ expectedCid does not match cid!' - ); - assert.deepStrictEqual( - dataToStore, - downloadedContent.toString(), - 'โŒ dataToStore does not match downloadedContent!' - ); - console.log(`โœ… Verified content!`); - - console.log(`\n\n\nโœ…โœ…โœ… Test passed! โœ…โœ…โœ…`); - resultCode = 0; - } catch (error) { - console.error("โŒ Error:", error); - resultCode = 1; - } finally { - if (client) client.destroy(); - if (sd) sd.terminate(); - process.exit(resultCode); - } -} - -await main(); - - diff --git a/examples/justfile b/examples/justfile index 90ce310ed..200896576 100644 --- a/examples/justfile +++ b/examples/justfile @@ -402,14 +402,17 @@ setup-services test_dir runtime: npm-install just papi-generate # Start IPFS - echo "๐Ÿ”ง Tearing down Docker services if they are running..." - just ipfs-shutdown "{{ test_dir }}" - echo "๐Ÿณ Setting up services with Docker IPFS (runtime: {{ runtime }})..." - just ipfs-start "{{ test_dir }}" - just ipfs-connect "{{ runtime }}" - just ipfs-reconnect-start "{{ test_dir }}" "{{ runtime }}" - - echo "โœ… Services setup complete (Docker mode)" + if [ -n "$SKIP_IPFS" ]; then + echo "โญ๏ธ Skipping IPFS setup (SKIP_IPFS set)" + else + echo "๐Ÿ”ง Tearing down Docker services if they are running..." + just ipfs-shutdown "{{ test_dir }}" + echo "๐Ÿณ Setting up services with Docker IPFS (runtime: {{ runtime }})..." + just ipfs-start "{{ test_dir }}" + just ipfs-connect "{{ runtime }}" + just ipfs-reconnect-start "{{ test_dir }}" "{{ runtime }}" + echo "โœ… Services setup complete (Docker mode)" + fi # Stop all Docker services teardown-services test_dir: diff --git a/integration-tests/authorize-preimage-subxt/Cargo.toml b/integration-tests/authorize-preimage-subxt/Cargo.toml new file mode 100644 index 000000000..bff6561d5 --- /dev/null +++ b/integration-tests/authorize-preimage-subxt/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "authorize-preimage-subxt" +version = "0.1.0" +edition = "2021" + +[dependencies] +subxt = "0.43.1" +subxt-core = "0.43.0" +subxt-signer = "0.43.0" +sp-core = { workspace = true } +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } + + + + diff --git a/integration-tests/authorize-preimage-subxt/src/main.rs b/integration-tests/authorize-preimage-subxt/src/main.rs new file mode 100644 index 000000000..30e355d22 --- /dev/null +++ b/integration-tests/authorize-preimage-subxt/src/main.rs @@ -0,0 +1,84 @@ +use std::env; +use std::time::{SystemTime, UNIX_EPOCH}; + +use sp_core::hashing::blake2_256; +use subxt::config::PolkadotConfig; +use subxt::dynamic::Value; +use subxt::tx::SubmittableTransaction; +use subxt::{OnlineClient}; +use subxt_core::client::ClientState; +use subxt_core::config::DefaultExtrinsicParamsBuilder; +use subxt_core::tx; +use subxt_signer::sr25519::dev; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let ws_url = env::args() + .nth(1) + .unwrap_or_else(|| "ws://localhost:10000".to_string()); + + println!("Connecting to: {ws_url}"); + + let client = OnlineClient::::from_url(ws_url).await?; + + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let data = format!("Hello, Bulletin with subxt preimage - {now}"); + let data_bytes = data.as_bytes().to_vec(); + let content_hash = blake2_256(&data_bytes); + + // Authorize the preimage using sudo. + let authorize_preimage = subxt::dynamic::tx( + "TransactionStorage", + "authorize_preimage", + vec![ + Value::from_bytes(content_hash), + Value::u128(data_bytes.len() as u128), + ], + ); + let sudo_call = subxt::dynamic::tx("Sudo", "sudo", vec![authorize_preimage.into_value()]); + + let sudo_signer = dev::alice(); + client + .tx() + .sign_and_submit_then_watch_default(&sudo_call, &sudo_signer) + .await? + .wait_for_finalized_success() + .await?; + + // Submit the store call as an unsigned authorized transaction. + let store_call = subxt::dynamic::tx( + "TransactionStorage", + "store", + vec![Value::from_bytes(&data_bytes)], + ); + + let metadata = client.metadata(); + let state = ClientState:: { + metadata: metadata.clone(), + genesis_hash: client.genesis_hash(), + runtime_version: client.runtime_version(), + }; + + let supported_versions = metadata.extrinsic().supported_versions(); + if !supported_versions.contains(&5) { + return Err("Transaction version v5 is required for AuthorizeCall flow".into()); + } + + let params = DefaultExtrinsicParamsBuilder::::new() + .immortal() + .nonce(0) + .build(); + let partial = tx::create_v5_general(&store_call, &state, params)?; + let tx = partial.to_transaction(); + let submittable = SubmittableTransaction::from_bytes(client.clone(), tx.into_encoded()); + submittable + .submit_and_watch() + .await? + .wait_for_finalized_success() + .await?; + + println!("โœ… Preimage authorized unsigned store succeeded"); + Ok(()) +} + + diff --git a/pallets/transaction-storage/src/lib.rs b/pallets/transaction-storage/src/lib.rs index 4466c585c..19bdaa300 100644 --- a/pallets/transaction-storage/src/lib.rs +++ b/pallets/transaction-storage/src/lib.rs @@ -353,14 +353,13 @@ pub mod pallet { })] #[pallet::weight_of_authorize(Weight::zero())] pub fn store(origin: OriginFor, data: Vec) -> DispatchResult { - let (is_authorized, is_unsigned) = match origin.into() { - Ok(frame_system::RawOrigin::Authorized) => (true, false), - Ok(frame_system::RawOrigin::None) => (false, true), - _ => (false, false), - }; - if is_authorized || is_unsigned { - Self::check_unsigned_store(data.as_slice(), true) - .map_err(Self::dispatch_error_from_validity)?; + match origin.into() { + Ok(frame_system::RawOrigin::Authorized) => { + Self::check_unsigned_store(data.as_slice(), true) + .map_err(Self::dispatch_error_from_validity)?; + }, + Ok(frame_system::RawOrigin::Signed(_)) => {}, + _ => return Err(DispatchError::BadOrigin.into()), } // In the case of a regular unsigned transaction, this should have been checked by @@ -433,14 +432,13 @@ pub mod pallet { block: BlockNumberFor, index: u32, ) -> DispatchResultWithPostInfo { - let (is_authorized, is_unsigned) = match origin.into() { - Ok(frame_system::RawOrigin::Authorized) => (true, false), - Ok(frame_system::RawOrigin::None) => (false, true), - _ => (false, false), - }; - if is_authorized || is_unsigned { - Self::check_unsigned_renew(&block, &index, true) - .map_err(Self::dispatch_error_from_validity)?; + match origin.into() { + Ok(frame_system::RawOrigin::Authorized) => { + Self::check_unsigned_renew(&block, &index, true) + .map_err(Self::dispatch_error_from_validity)?; + }, + Ok(frame_system::RawOrigin::Signed(_)) => {}, + _ => return Err(DispatchError::BadOrigin.into()), } let info = Self::transaction_info(block, index).ok_or(Error::::RenewedNotFound)?; @@ -782,19 +780,6 @@ pub mod pallet { } } - #[pallet::validate_unsigned] - impl ValidateUnsigned for Pallet { - type Call = Call; - - fn validate_unsigned(_source: TransactionSource, call: &Self::Call) -> TransactionValidity { - Self::check_unsigned_call(call) - } - - fn pre_dispatch(_call: &Self::Call) -> Result<(), TransactionValidityError> { - Ok(()) - } - } - impl Pallet { fn to_validity_with_refund( result: Result, @@ -1137,19 +1122,6 @@ pub mod pallet { .into()) } - fn check_unsigned_call(call: &Call) -> Result { - match call { - Call::::store { data } => Self::check_unsigned_store(data.as_slice(), false), - Call::::renew { block, index } => - Self::check_unsigned_renew(block, index, false), - Call::::remove_expired_account_authorization { who } => - Self::check_unsigned_remove_expired_account(who), - Call::::remove_expired_preimage_authorization { content_hash } => - Self::check_unsigned_remove_expired_preimage_authorization(content_hash), - _ => Err(InvalidTransaction::Call.into()), - } - } - fn check_signed( who: &T::AccountId, call: &Call, From 5de03b6e62e90e4a96211cdf2a8fcd69389c28a4 Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Wed, 4 Feb 2026 15:08:48 +0000 Subject: [PATCH 09/26] Fixed merge --- pallets/transaction-storage/src/lib.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pallets/transaction-storage/src/lib.rs b/pallets/transaction-storage/src/lib.rs index 19bdaa300..1fe0c2b3e 100644 --- a/pallets/transaction-storage/src/lib.rs +++ b/pallets/transaction-storage/src/lib.rs @@ -1088,12 +1088,12 @@ pub mod pallet { index: &u32, consume: bool, ) -> Result { - let info = Self::transaction_info(*block, *index).ok_or(RENEWED_NOT_FOUND)?; - Self::check_store_renew_unsigned( - info.size as usize, - || info.content_hash.into(), + let info = Self::transaction_info(*block, *index).ok_or(RENEWED_NOT_FOUND)?; + Self::check_store_renew_unsigned( + info.size as usize, + || info.content_hash, consume, - ) + ) } fn check_unsigned_remove_expired_account( @@ -1127,8 +1127,8 @@ pub mod pallet { call: &Call, consume: bool, ) -> Result { - let size = match call { - Call::::store { data } => data.len(), + let (size, content_hash) = match call { + Call::::store { data } => (data.len(), sp_io::hashing::blake2_256(data)), Call::::renew { block, index } => { let info = Self::transaction_info(*block, *index).ok_or(RENEWED_NOT_FOUND)?; (info.size as usize, info.content_hash) From a7851225c9ecfa05deed2cfda9194b75c0447abd Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Wed, 4 Feb 2026 15:10:46 +0000 Subject: [PATCH 10/26] Fixed merge again --- runtimes/bulletin-westend/tests/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/runtimes/bulletin-westend/tests/tests.rs b/runtimes/bulletin-westend/tests/tests.rs index dbb691302..b2569c4d5 100644 --- a/runtimes/bulletin-westend/tests/tests.rs +++ b/runtimes/bulletin-westend/tests/tests.rs @@ -152,6 +152,7 @@ fn construct_unsigned_extrinsic(call: RuntimeCall) -> UncheckedExtrinsic { ), bulletin_westend_runtime::ValidateSigned, frame_metadata_hash_extension::CheckMetadataHash::::new(false), + pallet_transaction_storage::extension::ProvideCidConfig::::new(None), ); let tx_ext: TxExtension = cumulus_pallet_weight_reclaim::StorageWeightReclaim::::from(inner); From 6ac215492dcf6fc65727dd7dbb6f3bc7aac28e82 Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Wed, 4 Feb 2026 15:16:51 +0000 Subject: [PATCH 11/26] Format --- Cargo.toml | 2 +- .../authorize-preimage-subxt/Cargo.toml | 4 - .../authorize-preimage-subxt/src/main.rs | 117 ++++++++---------- pallets/transaction-storage/src/lib.rs | 40 +++--- 4 files changed, 69 insertions(+), 94 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c8fd9115e..8104880a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -170,8 +170,8 @@ substrate-wasm-builder = { git = "https://github.com/paritytech/polkadot-sdk.git [workspace] resolver = "2" members = [ - "node", "integration-tests/authorize-preimage-subxt", + "node", "pallets/common", "pallets/relayer-set", "pallets/transaction-storage", diff --git a/integration-tests/authorize-preimage-subxt/Cargo.toml b/integration-tests/authorize-preimage-subxt/Cargo.toml index bff6561d5..ca37c34fa 100644 --- a/integration-tests/authorize-preimage-subxt/Cargo.toml +++ b/integration-tests/authorize-preimage-subxt/Cargo.toml @@ -9,7 +9,3 @@ subxt-core = "0.43.0" subxt-signer = "0.43.0" sp-core = { workspace = true } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } - - - - diff --git a/integration-tests/authorize-preimage-subxt/src/main.rs b/integration-tests/authorize-preimage-subxt/src/main.rs index 30e355d22..14084fd48 100644 --- a/integration-tests/authorize-preimage-subxt/src/main.rs +++ b/integration-tests/authorize-preimage-subxt/src/main.rs @@ -1,84 +1,67 @@ -use std::env; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::{ + env, + time::{SystemTime, UNIX_EPOCH}, +}; use sp_core::hashing::blake2_256; -use subxt::config::PolkadotConfig; -use subxt::dynamic::Value; -use subxt::tx::SubmittableTransaction; -use subxt::{OnlineClient}; -use subxt_core::client::ClientState; -use subxt_core::config::DefaultExtrinsicParamsBuilder; -use subxt_core::tx; +use subxt::{config::PolkadotConfig, dynamic::Value, tx::SubmittableTransaction, OnlineClient}; +use subxt_core::{client::ClientState, config::DefaultExtrinsicParamsBuilder, tx}; use subxt_signer::sr25519::dev; #[tokio::main] async fn main() -> Result<(), Box> { - let ws_url = env::args() - .nth(1) - .unwrap_or_else(|| "ws://localhost:10000".to_string()); + let ws_url = env::args().nth(1).unwrap_or_else(|| "ws://localhost:10000".to_string()); - println!("Connecting to: {ws_url}"); + println!("Connecting to: {ws_url}"); - let client = OnlineClient::::from_url(ws_url).await?; + let client = OnlineClient::::from_url(ws_url).await?; - let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); - let data = format!("Hello, Bulletin with subxt preimage - {now}"); - let data_bytes = data.as_bytes().to_vec(); - let content_hash = blake2_256(&data_bytes); + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let data = format!("Hello, Bulletin with subxt preimage - {now}"); + let data_bytes = data.as_bytes().to_vec(); + let content_hash = blake2_256(&data_bytes); - // Authorize the preimage using sudo. - let authorize_preimage = subxt::dynamic::tx( - "TransactionStorage", - "authorize_preimage", - vec![ - Value::from_bytes(content_hash), - Value::u128(data_bytes.len() as u128), - ], - ); - let sudo_call = subxt::dynamic::tx("Sudo", "sudo", vec![authorize_preimage.into_value()]); + // Authorize the preimage using sudo. + let authorize_preimage = subxt::dynamic::tx( + "TransactionStorage", + "authorize_preimage", + vec![Value::from_bytes(content_hash), Value::u128(data_bytes.len() as u128)], + ); + let sudo_call = subxt::dynamic::tx("Sudo", "sudo", vec![authorize_preimage.into_value()]); - let sudo_signer = dev::alice(); - client - .tx() - .sign_and_submit_then_watch_default(&sudo_call, &sudo_signer) - .await? - .wait_for_finalized_success() - .await?; + let sudo_signer = dev::alice(); + client + .tx() + .sign_and_submit_then_watch_default(&sudo_call, &sudo_signer) + .await? + .wait_for_finalized_success() + .await?; - // Submit the store call as an unsigned authorized transaction. - let store_call = subxt::dynamic::tx( - "TransactionStorage", - "store", - vec![Value::from_bytes(&data_bytes)], - ); + // Submit the store call as an unsigned authorized transaction. + let store_call = + subxt::dynamic::tx("TransactionStorage", "store", vec![Value::from_bytes(&data_bytes)]); - let metadata = client.metadata(); - let state = ClientState:: { - metadata: metadata.clone(), - genesis_hash: client.genesis_hash(), - runtime_version: client.runtime_version(), - }; + let metadata = client.metadata(); + let state = ClientState:: { + metadata: metadata.clone(), + genesis_hash: client.genesis_hash(), + runtime_version: client.runtime_version(), + }; - let supported_versions = metadata.extrinsic().supported_versions(); - if !supported_versions.contains(&5) { - return Err("Transaction version v5 is required for AuthorizeCall flow".into()); - } + let supported_versions = metadata.extrinsic().supported_versions(); + if !supported_versions.contains(&5) { + return Err("Transaction version v5 is required for AuthorizeCall flow".into()); + } - let params = DefaultExtrinsicParamsBuilder::::new() - .immortal() - .nonce(0) - .build(); - let partial = tx::create_v5_general(&store_call, &state, params)?; - let tx = partial.to_transaction(); - let submittable = SubmittableTransaction::from_bytes(client.clone(), tx.into_encoded()); - submittable - .submit_and_watch() - .await? - .wait_for_finalized_success() - .await?; + let params = DefaultExtrinsicParamsBuilder::::new() + .immortal() + .nonce(0) + .build(); + let partial = tx::create_v5_general(&store_call, &state, params)?; + let tx = partial.to_transaction(); + let submittable = SubmittableTransaction::from_bytes(client.clone(), tx.into_encoded()); + submittable.submit_and_watch().await?.wait_for_finalized_success().await?; - println!("โœ… Preimage authorized unsigned store succeeded"); - Ok(()) + println!("โœ… Preimage authorized unsigned store succeeded"); + Ok(()) } - - diff --git a/pallets/transaction-storage/src/lib.rs b/pallets/transaction-storage/src/lib.rs index 1fe0c2b3e..4072362e9 100644 --- a/pallets/transaction-storage/src/lib.rs +++ b/pallets/transaction-storage/src/lib.rs @@ -1066,9 +1066,9 @@ pub mod pallet { Self::check_authorization(AuthorizationScope::Preimage(hash), size as u32, consume)?; Ok(ValidTransaction::with_tag_prefix("TransactionStorageStoreRenew") - .and_provides(hash) - .priority(T::StoreRenewPriority::get()) - .longevity(T::StoreRenewLongevity::get()) + .and_provides(hash) + .priority(T::StoreRenewPriority::get()) + .longevity(T::StoreRenewLongevity::get()) .into()) } @@ -1077,8 +1077,8 @@ pub mod pallet { consume: bool, ) -> Result { Self::check_store_renew_unsigned( - data.len(), - || sp_io::hashing::blake2_256(data), + data.len(), + || sp_io::hashing::blake2_256(data), consume, ) } @@ -1089,36 +1089,32 @@ pub mod pallet { consume: bool, ) -> Result { let info = Self::transaction_info(*block, *index).ok_or(RENEWED_NOT_FOUND)?; - Self::check_store_renew_unsigned( - info.size as usize, - || info.content_hash, - consume, - ) + Self::check_store_renew_unsigned(info.size as usize, || info.content_hash, consume) } fn check_unsigned_remove_expired_account( who: &T::AccountId, ) -> Result { - Self::check_authorization_expired(AuthorizationScope::Account(who.clone()))?; + Self::check_authorization_expired(AuthorizationScope::Account(who.clone()))?; Ok(ValidTransaction::with_tag_prefix( - "TransactionStorageRemoveExpiredAccountAuthorization", - ) - .and_provides(who) - .priority(T::RemoveExpiredAuthorizationPriority::get()) - .longevity(T::RemoveExpiredAuthorizationLongevity::get()) + "TransactionStorageRemoveExpiredAccountAuthorization", + ) + .and_provides(who) + .priority(T::RemoveExpiredAuthorizationPriority::get()) + .longevity(T::RemoveExpiredAuthorizationLongevity::get()) .into()) } fn check_unsigned_remove_expired_preimage_authorization( hash: &ContentHash, ) -> Result { - Self::check_authorization_expired(AuthorizationScope::Preimage(*hash))?; + Self::check_authorization_expired(AuthorizationScope::Preimage(*hash))?; Ok(ValidTransaction::with_tag_prefix( - "TransactionStorageRemoveExpiredPreimageAuthorization", - ) - .and_provides(hash) - .priority(T::RemoveExpiredAuthorizationPriority::get()) - .longevity(T::RemoveExpiredAuthorizationLongevity::get()) + "TransactionStorageRemoveExpiredPreimageAuthorization", + ) + .and_provides(hash) + .priority(T::RemoveExpiredAuthorizationPriority::get()) + .longevity(T::RemoveExpiredAuthorizationLongevity::get()) .into()) } From 27d797df2bbb46255bcf05576a57ad0633c64c4c Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Wed, 4 Feb 2026 15:23:42 +0000 Subject: [PATCH 12/26] Fixed duplicate just --- examples/justfile | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/examples/justfile b/examples/justfile index 200896576..2058c4369 100644 --- a/examples/justfile +++ b/examples/justfile @@ -477,39 +477,6 @@ run-test-authorize-and-store test_dir runtime mode="ws" ws_url="ws://localhost:1 node $SCRIPT_NAME "{{ ws_url }}" "{{ seed }}" "{{ http_ipfs_api }}" fi -# Run authorize-preimage-and-store test only (services must already be running via start-services) -# Parameters: -# test_dir - Test directory where services are running -# runtime - Runtime name for smoldot chainspec path resolution -# mode - Connection mode: "ws" (WebSocket RPC node) or "smoldot" (light client) -run-test-authorize-preimage-and-store test_dir runtime mode="ws": - #!/usr/bin/env bash - set -e - - if [ "{{ mode }}" = "smoldot" ]; then - echo "๐Ÿงช Running authorize preimage and store test (mode: smoldot, runtime: {{ runtime }})..." - SCRIPT_NAME="authorize_and_store_preimage_papi_smoldot.js" - elif [ "{{ mode }}" = "ws" ]; then - echo "๐Ÿงช Running authorize preimage and store test (mode: ws, runtime: {{ runtime }})..." - SCRIPT_NAME="authorize_and_store_preimage_papi.js" - else - echo "โŒ Error: Invalid mode '{{ mode }}'. Must be 'ws' or 'smoldot'" - exit 1 - fi - - if [ "{{ mode }}" = "smoldot" ]; then - if [ "{{ runtime }}" = "bulletin-westend-runtime" ]; then - RELAY_CHAINSPEC_PATH="{{ test_dir }}/bob/cfg/westend-local.json" - PARACHAIN_CHAINSPEC_PATH="{{ test_dir }}/bulletin-westend-collator-2/cfg/westend-local-1006.json" - node $SCRIPT_NAME "$RELAY_CHAINSPEC_PATH" "$PARACHAIN_CHAINSPEC_PATH" - else - CHAINSPEC_PATH="{{ test_dir }}/bob/cfg/bulletin-polkadot-local.json" - node $SCRIPT_NAME "$CHAINSPEC_PATH" - fi - else - node $SCRIPT_NAME - fi - # Run store-chunked-data test only (services must already be running via start-services) # Parameters: # test_dir - Test directory where services are running From a6549f4de606fb3ffd7d5652a014a0b495ddc9ff Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Wed, 4 Feb 2026 15:47:43 +0000 Subject: [PATCH 13/26] Fix test + verify preimage data --- examples/justfile | 14 +- .../authorize-preimage-subxt/Cargo.toml | 1 + .../authorize-preimage-subxt/src/main.rs | 181 ++++++++++++------ pallets/transaction-storage/src/lib.rs | 47 +++-- 4 files changed, 158 insertions(+), 85 deletions(-) diff --git a/examples/justfile b/examples/justfile index 2058c4369..0e555e465 100644 --- a/examples/justfile +++ b/examples/justfile @@ -503,13 +503,19 @@ run-test-store-big-data test_dir image_size="big64" ws_url="ws://localhost:10000 # Run authorize-preimage-and-store test only (services must already be running via start-services) # Parameters: # test_dir - Test directory where services are running +# runtime - Runtime name for compatibility with CI invocation +# mode - Connection mode: "ws" (default) or "smoldot" (not supported by subxt) # ws_url - WebSocket URL of the Bulletin chain node (default: ws://localhost:10000) -# seed - Account seed phrase or dev seed (default: //Alice) -# http_ipfs_api - IPFS API URL (default: http://127.0.0.1:8283) -run-test-authorize-preimage-and-store test_dir ws_url="ws://localhost:10000" seed="//Alice" http_ipfs_api="http://127.0.0.1:8283": +run-test-authorize-preimage-and-store test_dir runtime="" mode="ws" ws_url="ws://localhost:10000": #!/usr/bin/env bash set -e - node authorize_preimage_and_store_papi.js "{{ ws_url }}" "{{ seed }}" "{{ http_ipfs_api }}" + + if [ "{{ mode }}" != "ws" ]; then + echo "โŒ Error: authorize-preimage-and-store requires ws mode for subxt" + exit 1 + fi + + (cd .. && cargo run -p authorize-preimage-subxt -- "{{ ws_url }}") # Run Chopsticks compatibility check (services must already be running or use a live endpoint) # Parameters: diff --git a/integration-tests/authorize-preimage-subxt/Cargo.toml b/integration-tests/authorize-preimage-subxt/Cargo.toml index ca37c34fa..ef748934e 100644 --- a/integration-tests/authorize-preimage-subxt/Cargo.toml +++ b/integration-tests/authorize-preimage-subxt/Cargo.toml @@ -7,5 +7,6 @@ edition = "2021" subxt = "0.43.1" subxt-core = "0.43.0" subxt-signer = "0.43.0" +scale-value = "0.18.1" sp-core = { workspace = true } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/integration-tests/authorize-preimage-subxt/src/main.rs b/integration-tests/authorize-preimage-subxt/src/main.rs index 14084fd48..34ed6d9ac 100644 --- a/integration-tests/authorize-preimage-subxt/src/main.rs +++ b/integration-tests/authorize-preimage-subxt/src/main.rs @@ -1,67 +1,128 @@ -use std::{ - env, - time::{SystemTime, UNIX_EPOCH}, -}; +use std::env; +use std::time::{SystemTime, UNIX_EPOCH}; use sp_core::hashing::blake2_256; -use subxt::{config::PolkadotConfig, dynamic::Value, tx::SubmittableTransaction, OnlineClient}; -use subxt_core::{client::ClientState, config::DefaultExtrinsicParamsBuilder, tx}; +use scale_value::{Composite as ScaleComposite, Value as ScaleValue, ValueDef as ScaleValueDef}; +use subxt::config::PolkadotConfig; +use subxt::dynamic::Value; +use subxt::tx::SubmittableTransaction; +use subxt::{OnlineClient}; +use subxt_core::client::ClientState; +use subxt_core::config::DefaultExtrinsicParamsBuilder; +use subxt_core::tx; use subxt_signer::sr25519::dev; +fn bytes_from_scale_value(value: ScaleValue) -> Option> { + match value.value { + ScaleValueDef::Composite(ScaleComposite::Unnamed(values)) => { + let mut bytes = Vec::with_capacity(values.len()); + for item in values { + let byte = item.as_u128()? as u8; + bytes.push(byte); + } + Some(bytes) + } + _ => None, + } +} + +fn extract_content_hash(fields: ScaleComposite) -> Option<[u8; 32]> { + let ScaleComposite::Named(values) = fields else { + return None; + }; + let value = values + .into_iter() + .find(|(name, _)| name == "content_hash") + .map(|(_, value)| value)?; + let bytes = bytes_from_scale_value(value)?; + bytes.try_into().ok() +} + #[tokio::main] async fn main() -> Result<(), Box> { - let ws_url = env::args().nth(1).unwrap_or_else(|| "ws://localhost:10000".to_string()); - - println!("Connecting to: {ws_url}"); - - let client = OnlineClient::::from_url(ws_url).await?; - - let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); - let data = format!("Hello, Bulletin with subxt preimage - {now}"); - let data_bytes = data.as_bytes().to_vec(); - let content_hash = blake2_256(&data_bytes); - - // Authorize the preimage using sudo. - let authorize_preimage = subxt::dynamic::tx( - "TransactionStorage", - "authorize_preimage", - vec![Value::from_bytes(content_hash), Value::u128(data_bytes.len() as u128)], - ); - let sudo_call = subxt::dynamic::tx("Sudo", "sudo", vec![authorize_preimage.into_value()]); - - let sudo_signer = dev::alice(); - client - .tx() - .sign_and_submit_then_watch_default(&sudo_call, &sudo_signer) - .await? - .wait_for_finalized_success() - .await?; - - // Submit the store call as an unsigned authorized transaction. - let store_call = - subxt::dynamic::tx("TransactionStorage", "store", vec![Value::from_bytes(&data_bytes)]); - - let metadata = client.metadata(); - let state = ClientState:: { - metadata: metadata.clone(), - genesis_hash: client.genesis_hash(), - runtime_version: client.runtime_version(), - }; - - let supported_versions = metadata.extrinsic().supported_versions(); - if !supported_versions.contains(&5) { - return Err("Transaction version v5 is required for AuthorizeCall flow".into()); - } - - let params = DefaultExtrinsicParamsBuilder::::new() - .immortal() - .nonce(0) - .build(); - let partial = tx::create_v5_general(&store_call, &state, params)?; - let tx = partial.to_transaction(); - let submittable = SubmittableTransaction::from_bytes(client.clone(), tx.into_encoded()); - submittable.submit_and_watch().await?.wait_for_finalized_success().await?; - - println!("โœ… Preimage authorized unsigned store succeeded"); - Ok(()) + let ws_url = env::args() + .nth(1) + .unwrap_or_else(|| "ws://localhost:10000".to_string()); + + println!("Connecting to: {ws_url}"); + + let client = OnlineClient::::from_url(ws_url).await?; + + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let data = format!("Hello, Bulletin with subxt preimage - {now}"); + let data_bytes = data.as_bytes().to_vec(); + let content_hash = blake2_256(&data_bytes); + + // Authorize the preimage using sudo. + let authorize_preimage = subxt::dynamic::tx( + "TransactionStorage", + "authorize_preimage", + vec![ + Value::from_bytes(content_hash), + Value::u128(data_bytes.len() as u128), + ], + ); + let sudo_call = subxt::dynamic::tx("Sudo", "sudo", vec![authorize_preimage.into_value()]); + + let sudo_signer = dev::alice(); + client + .tx() + .sign_and_submit_then_watch_default(&sudo_call, &sudo_signer) + .await? + .wait_for_finalized_success() + .await?; + + // Submit the store call as an unsigned authorized transaction. + let store_call = subxt::dynamic::tx( + "TransactionStorage", + "store", + vec![Value::from_bytes(&data_bytes)], + ); + + let metadata = client.metadata(); + let state = ClientState:: { + metadata: metadata.clone(), + genesis_hash: client.genesis_hash(), + runtime_version: client.runtime_version(), + }; + + let supported_versions = metadata.extrinsic().supported_versions(); + if !supported_versions.contains(&5) { + return Err("Transaction version v5 is required for AuthorizeCall flow".into()); + } + + let params = DefaultExtrinsicParamsBuilder::::new() + .immortal() + .nonce(0) + .build(); + let partial = tx::create_v5_general(&store_call, &state, params)?; + let tx = partial.to_transaction(); + let submittable = SubmittableTransaction::from_bytes(client.clone(), tx.into_encoded()); + let in_block = submittable + .submit_and_watch() + .await? + .wait_for_finalized_success() + .await?; + + let events = in_block.fetch_events().await?; + let mut found = false; + for event in events.iter() { + let event = event?; + if event.pallet_name() == "TransactionStorage" && event.variant_name() == "Stored" { + if let Some(event_hash) = extract_content_hash(event.field_values()?) { + if event_hash == content_hash { + found = true; + break; + } + } + } + } + if !found { + return Err("Stored event with matching content hash not found".into()); + } + + println!("โœ… Preimage authorized unsigned store succeeded (event verified)"); + Ok(()) } + + diff --git a/pallets/transaction-storage/src/lib.rs b/pallets/transaction-storage/src/lib.rs index 4072362e9..d6adb2a26 100644 --- a/pallets/transaction-storage/src/lib.rs +++ b/pallets/transaction-storage/src/lib.rs @@ -1066,9 +1066,9 @@ pub mod pallet { Self::check_authorization(AuthorizationScope::Preimage(hash), size as u32, consume)?; Ok(ValidTransaction::with_tag_prefix("TransactionStorageStoreRenew") - .and_provides(hash) - .priority(T::StoreRenewPriority::get()) - .longevity(T::StoreRenewLongevity::get()) + .and_provides(hash) + .priority(T::StoreRenewPriority::get()) + .longevity(T::StoreRenewLongevity::get()) .into()) } @@ -1077,8 +1077,8 @@ pub mod pallet { consume: bool, ) -> Result { Self::check_store_renew_unsigned( - data.len(), - || sp_io::hashing::blake2_256(data), + data.len(), + || sp_io::hashing::blake2_256(data), consume, ) } @@ -1088,33 +1088,37 @@ pub mod pallet { index: &u32, consume: bool, ) -> Result { - let info = Self::transaction_info(*block, *index).ok_or(RENEWED_NOT_FOUND)?; - Self::check_store_renew_unsigned(info.size as usize, || info.content_hash, consume) + let info = Self::transaction_info(*block, *index).ok_or(RENEWED_NOT_FOUND)?; + Self::check_store_renew_unsigned( + info.size as usize, + || info.content_hash, + context, + ) } fn check_unsigned_remove_expired_account( who: &T::AccountId, ) -> Result { - Self::check_authorization_expired(AuthorizationScope::Account(who.clone()))?; + Self::check_authorization_expired(AuthorizationScope::Account(who.clone()))?; Ok(ValidTransaction::with_tag_prefix( - "TransactionStorageRemoveExpiredAccountAuthorization", - ) - .and_provides(who) - .priority(T::RemoveExpiredAuthorizationPriority::get()) - .longevity(T::RemoveExpiredAuthorizationLongevity::get()) + "TransactionStorageRemoveExpiredAccountAuthorization", + ) + .and_provides(who) + .priority(T::RemoveExpiredAuthorizationPriority::get()) + .longevity(T::RemoveExpiredAuthorizationLongevity::get()) .into()) } fn check_unsigned_remove_expired_preimage_authorization( hash: &ContentHash, ) -> Result { - Self::check_authorization_expired(AuthorizationScope::Preimage(*hash))?; + Self::check_authorization_expired(AuthorizationScope::Preimage(*hash))?; Ok(ValidTransaction::with_tag_prefix( - "TransactionStorageRemoveExpiredPreimageAuthorization", - ) - .and_provides(hash) - .priority(T::RemoveExpiredAuthorizationPriority::get()) - .longevity(T::RemoveExpiredAuthorizationLongevity::get()) + "TransactionStorageRemoveExpiredPreimageAuthorization", + ) + .and_provides(hash) + .priority(T::RemoveExpiredAuthorizationPriority::get()) + .longevity(T::RemoveExpiredAuthorizationLongevity::get()) .into()) } @@ -1123,8 +1127,8 @@ pub mod pallet { call: &Call, consume: bool, ) -> Result { - let (size, content_hash) = match call { - Call::::store { data } => (data.len(), sp_io::hashing::blake2_256(data)), + let size = match call { + Call::::store { data } => data.len(), Call::::renew { block, index } => { let info = Self::transaction_info(*block, *index).ok_or(RENEWED_NOT_FOUND)?; (info.size as usize, info.content_hash) @@ -1143,6 +1147,7 @@ pub mod pallet { // Prefer preimage authorization if available. // This allows anyone to store/renew pre-authorized content without consuming their // own account authorization. + let consume = context.consume_authorization(); Self::check_authorization( AuthorizationScope::Preimage(content_hash), size as u32, From 6e5751de9e20bee7a59c36df510491b717a5adaf Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Wed, 4 Feb 2026 15:52:24 +0000 Subject: [PATCH 14/26] Fix compilation --- pallets/transaction-storage/src/lib.rs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/pallets/transaction-storage/src/lib.rs b/pallets/transaction-storage/src/lib.rs index d6adb2a26..1fe0c2b3e 100644 --- a/pallets/transaction-storage/src/lib.rs +++ b/pallets/transaction-storage/src/lib.rs @@ -1088,12 +1088,12 @@ pub mod pallet { index: &u32, consume: bool, ) -> Result { - let info = Self::transaction_info(*block, *index).ok_or(RENEWED_NOT_FOUND)?; - Self::check_store_renew_unsigned( - info.size as usize, - || info.content_hash, - context, - ) + let info = Self::transaction_info(*block, *index).ok_or(RENEWED_NOT_FOUND)?; + Self::check_store_renew_unsigned( + info.size as usize, + || info.content_hash, + consume, + ) } fn check_unsigned_remove_expired_account( @@ -1127,8 +1127,8 @@ pub mod pallet { call: &Call, consume: bool, ) -> Result { - let size = match call { - Call::::store { data } => data.len(), + let (size, content_hash) = match call { + Call::::store { data } => (data.len(), sp_io::hashing::blake2_256(data)), Call::::renew { block, index } => { let info = Self::transaction_info(*block, *index).ok_or(RENEWED_NOT_FOUND)?; (info.size as usize, info.content_hash) @@ -1147,7 +1147,6 @@ pub mod pallet { // Prefer preimage authorization if available. // This allows anyone to store/renew pre-authorized content without consuming their // own account authorization. - let consume = context.consume_authorization(); Self::check_authorization( AuthorizationScope::Preimage(content_hash), size as u32, From c5edd6f48d4cc20098de8fd9ecc2d6e0b69227af Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Wed, 4 Feb 2026 15:56:35 +0000 Subject: [PATCH 15/26] Format yet again --- .../authorize-preimage-subxt/src/main.rs | 213 ++++++++---------- pallets/transaction-storage/src/lib.rs | 40 ++-- 2 files changed, 116 insertions(+), 137 deletions(-) diff --git a/integration-tests/authorize-preimage-subxt/src/main.rs b/integration-tests/authorize-preimage-subxt/src/main.rs index 34ed6d9ac..1c9a5c3ff 100644 --- a/integration-tests/authorize-preimage-subxt/src/main.rs +++ b/integration-tests/authorize-preimage-subxt/src/main.rs @@ -1,128 +1,111 @@ -use std::env; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::{ + env, + time::{SystemTime, UNIX_EPOCH}, +}; -use sp_core::hashing::blake2_256; use scale_value::{Composite as ScaleComposite, Value as ScaleValue, ValueDef as ScaleValueDef}; -use subxt::config::PolkadotConfig; -use subxt::dynamic::Value; -use subxt::tx::SubmittableTransaction; -use subxt::{OnlineClient}; -use subxt_core::client::ClientState; -use subxt_core::config::DefaultExtrinsicParamsBuilder; -use subxt_core::tx; +use sp_core::hashing::blake2_256; +use subxt::{config::PolkadotConfig, dynamic::Value, tx::SubmittableTransaction, OnlineClient}; +use subxt_core::{client::ClientState, config::DefaultExtrinsicParamsBuilder, tx}; use subxt_signer::sr25519::dev; fn bytes_from_scale_value(value: ScaleValue) -> Option> { - match value.value { - ScaleValueDef::Composite(ScaleComposite::Unnamed(values)) => { - let mut bytes = Vec::with_capacity(values.len()); - for item in values { - let byte = item.as_u128()? as u8; - bytes.push(byte); - } - Some(bytes) - } - _ => None, - } + match value.value { + ScaleValueDef::Composite(ScaleComposite::Unnamed(values)) => { + let mut bytes = Vec::with_capacity(values.len()); + for item in values { + let byte = item.as_u128()? as u8; + bytes.push(byte); + } + Some(bytes) + }, + _ => None, + } } fn extract_content_hash(fields: ScaleComposite) -> Option<[u8; 32]> { - let ScaleComposite::Named(values) = fields else { - return None; - }; - let value = values - .into_iter() - .find(|(name, _)| name == "content_hash") - .map(|(_, value)| value)?; - let bytes = bytes_from_scale_value(value)?; - bytes.try_into().ok() + let ScaleComposite::Named(values) = fields else { + return None; + }; + let value = values + .into_iter() + .find(|(name, _)| name == "content_hash") + .map(|(_, value)| value)?; + let bytes = bytes_from_scale_value(value)?; + bytes.try_into().ok() } #[tokio::main] async fn main() -> Result<(), Box> { - let ws_url = env::args() - .nth(1) - .unwrap_or_else(|| "ws://localhost:10000".to_string()); - - println!("Connecting to: {ws_url}"); - - let client = OnlineClient::::from_url(ws_url).await?; - - let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); - let data = format!("Hello, Bulletin with subxt preimage - {now}"); - let data_bytes = data.as_bytes().to_vec(); - let content_hash = blake2_256(&data_bytes); - - // Authorize the preimage using sudo. - let authorize_preimage = subxt::dynamic::tx( - "TransactionStorage", - "authorize_preimage", - vec![ - Value::from_bytes(content_hash), - Value::u128(data_bytes.len() as u128), - ], - ); - let sudo_call = subxt::dynamic::tx("Sudo", "sudo", vec![authorize_preimage.into_value()]); - - let sudo_signer = dev::alice(); - client - .tx() - .sign_and_submit_then_watch_default(&sudo_call, &sudo_signer) - .await? - .wait_for_finalized_success() - .await?; - - // Submit the store call as an unsigned authorized transaction. - let store_call = subxt::dynamic::tx( - "TransactionStorage", - "store", - vec![Value::from_bytes(&data_bytes)], - ); - - let metadata = client.metadata(); - let state = ClientState:: { - metadata: metadata.clone(), - genesis_hash: client.genesis_hash(), - runtime_version: client.runtime_version(), - }; - - let supported_versions = metadata.extrinsic().supported_versions(); - if !supported_versions.contains(&5) { - return Err("Transaction version v5 is required for AuthorizeCall flow".into()); - } - - let params = DefaultExtrinsicParamsBuilder::::new() - .immortal() - .nonce(0) - .build(); - let partial = tx::create_v5_general(&store_call, &state, params)?; - let tx = partial.to_transaction(); - let submittable = SubmittableTransaction::from_bytes(client.clone(), tx.into_encoded()); - let in_block = submittable - .submit_and_watch() - .await? - .wait_for_finalized_success() - .await?; - - let events = in_block.fetch_events().await?; - let mut found = false; - for event in events.iter() { - let event = event?; - if event.pallet_name() == "TransactionStorage" && event.variant_name() == "Stored" { - if let Some(event_hash) = extract_content_hash(event.field_values()?) { - if event_hash == content_hash { - found = true; - break; - } - } - } - } - if !found { - return Err("Stored event with matching content hash not found".into()); - } - - println!("โœ… Preimage authorized unsigned store succeeded (event verified)"); - Ok(()) + let ws_url = env::args().nth(1).unwrap_or_else(|| "ws://localhost:10000".to_string()); + + println!("Connecting to: {ws_url}"); + + let client = OnlineClient::::from_url(ws_url).await?; + + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let data = format!("Hello, Bulletin with subxt preimage - {now}"); + let data_bytes = data.as_bytes().to_vec(); + let content_hash = blake2_256(&data_bytes); + + // Authorize the preimage using sudo. + let authorize_preimage = subxt::dynamic::tx( + "TransactionStorage", + "authorize_preimage", + vec![Value::from_bytes(content_hash), Value::u128(data_bytes.len() as u128)], + ); + let sudo_call = subxt::dynamic::tx("Sudo", "sudo", vec![authorize_preimage.into_value()]); + + let sudo_signer = dev::alice(); + client + .tx() + .sign_and_submit_then_watch_default(&sudo_call, &sudo_signer) + .await? + .wait_for_finalized_success() + .await?; + + // Submit the store call as an unsigned authorized transaction. + let store_call = + subxt::dynamic::tx("TransactionStorage", "store", vec![Value::from_bytes(&data_bytes)]); + + let metadata = client.metadata(); + let state = ClientState:: { + metadata: metadata.clone(), + genesis_hash: client.genesis_hash(), + runtime_version: client.runtime_version(), + }; + + let supported_versions = metadata.extrinsic().supported_versions(); + if !supported_versions.contains(&5) { + return Err("Transaction version v5 is required for AuthorizeCall flow".into()); + } + + let params = DefaultExtrinsicParamsBuilder::::new() + .immortal() + .nonce(0) + .build(); + let partial = tx::create_v5_general(&store_call, &state, params)?; + let tx = partial.to_transaction(); + let submittable = SubmittableTransaction::from_bytes(client.clone(), tx.into_encoded()); + let in_block = submittable.submit_and_watch().await?.wait_for_finalized_success().await?; + + let events = in_block.fetch_events().await?; + let mut found = false; + for event in events.iter() { + let event = event?; + if event.pallet_name() == "TransactionStorage" && event.variant_name() == "Stored" { + if let Some(event_hash) = extract_content_hash(event.field_values()?) { + if event_hash == content_hash { + found = true; + break; + } + } + } + } + if !found { + return Err("Stored event with matching content hash not found".into()); + } + + println!("โœ… Preimage authorized unsigned store succeeded (event verified)"); + Ok(()) } - - diff --git a/pallets/transaction-storage/src/lib.rs b/pallets/transaction-storage/src/lib.rs index 1fe0c2b3e..4072362e9 100644 --- a/pallets/transaction-storage/src/lib.rs +++ b/pallets/transaction-storage/src/lib.rs @@ -1066,9 +1066,9 @@ pub mod pallet { Self::check_authorization(AuthorizationScope::Preimage(hash), size as u32, consume)?; Ok(ValidTransaction::with_tag_prefix("TransactionStorageStoreRenew") - .and_provides(hash) - .priority(T::StoreRenewPriority::get()) - .longevity(T::StoreRenewLongevity::get()) + .and_provides(hash) + .priority(T::StoreRenewPriority::get()) + .longevity(T::StoreRenewLongevity::get()) .into()) } @@ -1077,8 +1077,8 @@ pub mod pallet { consume: bool, ) -> Result { Self::check_store_renew_unsigned( - data.len(), - || sp_io::hashing::blake2_256(data), + data.len(), + || sp_io::hashing::blake2_256(data), consume, ) } @@ -1089,36 +1089,32 @@ pub mod pallet { consume: bool, ) -> Result { let info = Self::transaction_info(*block, *index).ok_or(RENEWED_NOT_FOUND)?; - Self::check_store_renew_unsigned( - info.size as usize, - || info.content_hash, - consume, - ) + Self::check_store_renew_unsigned(info.size as usize, || info.content_hash, consume) } fn check_unsigned_remove_expired_account( who: &T::AccountId, ) -> Result { - Self::check_authorization_expired(AuthorizationScope::Account(who.clone()))?; + Self::check_authorization_expired(AuthorizationScope::Account(who.clone()))?; Ok(ValidTransaction::with_tag_prefix( - "TransactionStorageRemoveExpiredAccountAuthorization", - ) - .and_provides(who) - .priority(T::RemoveExpiredAuthorizationPriority::get()) - .longevity(T::RemoveExpiredAuthorizationLongevity::get()) + "TransactionStorageRemoveExpiredAccountAuthorization", + ) + .and_provides(who) + .priority(T::RemoveExpiredAuthorizationPriority::get()) + .longevity(T::RemoveExpiredAuthorizationLongevity::get()) .into()) } fn check_unsigned_remove_expired_preimage_authorization( hash: &ContentHash, ) -> Result { - Self::check_authorization_expired(AuthorizationScope::Preimage(*hash))?; + Self::check_authorization_expired(AuthorizationScope::Preimage(*hash))?; Ok(ValidTransaction::with_tag_prefix( - "TransactionStorageRemoveExpiredPreimageAuthorization", - ) - .and_provides(hash) - .priority(T::RemoveExpiredAuthorizationPriority::get()) - .longevity(T::RemoveExpiredAuthorizationLongevity::get()) + "TransactionStorageRemoveExpiredPreimageAuthorization", + ) + .and_provides(hash) + .priority(T::RemoveExpiredAuthorizationPriority::get()) + .longevity(T::RemoveExpiredAuthorizationLongevity::get()) .into()) } From d889e161619abf75575d0ed01bfa74611d0ca084 Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Wed, 4 Feb 2026 19:16:58 +0000 Subject: [PATCH 16/26] Test fix --- .../authorize-preimage-subxt/src/main.rs | 211 ++++++++++-------- 1 file changed, 113 insertions(+), 98 deletions(-) diff --git a/integration-tests/authorize-preimage-subxt/src/main.rs b/integration-tests/authorize-preimage-subxt/src/main.rs index 1c9a5c3ff..8f0c55796 100644 --- a/integration-tests/authorize-preimage-subxt/src/main.rs +++ b/integration-tests/authorize-preimage-subxt/src/main.rs @@ -1,111 +1,126 @@ -use std::{ - env, - time::{SystemTime, UNIX_EPOCH}, -}; +use std::env; +use std::time::{SystemTime, UNIX_EPOCH}; -use scale_value::{Composite as ScaleComposite, Value as ScaleValue, ValueDef as ScaleValueDef}; use sp_core::hashing::blake2_256; -use subxt::{config::PolkadotConfig, dynamic::Value, tx::SubmittableTransaction, OnlineClient}; -use subxt_core::{client::ClientState, config::DefaultExtrinsicParamsBuilder, tx}; +use scale_value::{Composite as ScaleComposite, Value as ScaleValue, ValueDef as ScaleValueDef}; +use subxt::config::PolkadotConfig; +use subxt::dynamic::Value; +use subxt::tx::SubmittableTransaction; +use subxt::{OnlineClient}; +use subxt_core::client::ClientState; +use subxt_core::config::DefaultExtrinsicParamsBuilder; +use subxt_core::tx; use subxt_signer::sr25519::dev; fn bytes_from_scale_value(value: ScaleValue) -> Option> { - match value.value { - ScaleValueDef::Composite(ScaleComposite::Unnamed(values)) => { - let mut bytes = Vec::with_capacity(values.len()); - for item in values { - let byte = item.as_u128()? as u8; - bytes.push(byte); - } - Some(bytes) - }, - _ => None, - } + match value.value { + ScaleValueDef::Composite(ScaleComposite::Unnamed(values)) => { + let mut bytes = Vec::with_capacity(values.len()); + for item in values { + let byte = item.as_u128()? as u8; + bytes.push(byte); + } + Some(bytes) + } + _ => None, + } } fn extract_content_hash(fields: ScaleComposite) -> Option<[u8; 32]> { - let ScaleComposite::Named(values) = fields else { - return None; - }; - let value = values - .into_iter() - .find(|(name, _)| name == "content_hash") - .map(|(_, value)| value)?; - let bytes = bytes_from_scale_value(value)?; - bytes.try_into().ok() + let ScaleComposite::Named(values) = fields else { + return None; + }; + let value = values + .into_iter() + .find(|(name, _)| name == "content_hash") + .map(|(_, value)| value)?; + let bytes = bytes_from_scale_value(value)?; + bytes.try_into().ok() } #[tokio::main] async fn main() -> Result<(), Box> { - let ws_url = env::args().nth(1).unwrap_or_else(|| "ws://localhost:10000".to_string()); - - println!("Connecting to: {ws_url}"); - - let client = OnlineClient::::from_url(ws_url).await?; - - let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); - let data = format!("Hello, Bulletin with subxt preimage - {now}"); - let data_bytes = data.as_bytes().to_vec(); - let content_hash = blake2_256(&data_bytes); - - // Authorize the preimage using sudo. - let authorize_preimage = subxt::dynamic::tx( - "TransactionStorage", - "authorize_preimage", - vec![Value::from_bytes(content_hash), Value::u128(data_bytes.len() as u128)], - ); - let sudo_call = subxt::dynamic::tx("Sudo", "sudo", vec![authorize_preimage.into_value()]); - - let sudo_signer = dev::alice(); - client - .tx() - .sign_and_submit_then_watch_default(&sudo_call, &sudo_signer) - .await? - .wait_for_finalized_success() - .await?; - - // Submit the store call as an unsigned authorized transaction. - let store_call = - subxt::dynamic::tx("TransactionStorage", "store", vec![Value::from_bytes(&data_bytes)]); - - let metadata = client.metadata(); - let state = ClientState:: { - metadata: metadata.clone(), - genesis_hash: client.genesis_hash(), - runtime_version: client.runtime_version(), - }; - - let supported_versions = metadata.extrinsic().supported_versions(); - if !supported_versions.contains(&5) { - return Err("Transaction version v5 is required for AuthorizeCall flow".into()); - } - - let params = DefaultExtrinsicParamsBuilder::::new() - .immortal() - .nonce(0) - .build(); - let partial = tx::create_v5_general(&store_call, &state, params)?; - let tx = partial.to_transaction(); - let submittable = SubmittableTransaction::from_bytes(client.clone(), tx.into_encoded()); - let in_block = submittable.submit_and_watch().await?.wait_for_finalized_success().await?; - - let events = in_block.fetch_events().await?; - let mut found = false; - for event in events.iter() { - let event = event?; - if event.pallet_name() == "TransactionStorage" && event.variant_name() == "Stored" { - if let Some(event_hash) = extract_content_hash(event.field_values()?) { - if event_hash == content_hash { - found = true; - break; - } - } - } - } - if !found { - return Err("Stored event with matching content hash not found".into()); - } - - println!("โœ… Preimage authorized unsigned store succeeded (event verified)"); - Ok(()) + let ws_url = env::args() + .nth(1) + .unwrap_or_else(|| "ws://localhost:10000".to_string()); + + println!("Connecting to: {ws_url}"); + + let client = OnlineClient::::from_url(ws_url).await?; + + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let data = format!("Hello, Bulletin with subxt preimage - {now}"); + let data_bytes = data.as_bytes().to_vec(); + let content_hash = blake2_256(&data_bytes); + + // Authorize the preimage using sudo. + let authorize_preimage = subxt::dynamic::tx( + "TransactionStorage", + "authorize_preimage", + vec![ + Value::from_bytes(content_hash), + Value::u128(data_bytes.len() as u128), + ], + ); + let sudo_call = subxt::dynamic::tx("Sudo", "sudo", vec![authorize_preimage.into_value()]); + + let sudo_signer = dev::alice(); + client + .tx() + .sign_and_submit_then_watch_default(&sudo_call, &sudo_signer) + .await? + .wait_for_finalized_success() + .await?; + + // Submit the store call as an unsigned authorized transaction. + let store_call = subxt::dynamic::tx( + "TransactionStorage", + "store", + vec![Value::from_bytes(&data_bytes)], + ); + + let metadata = client.metadata(); + let state = ClientState:: { + metadata: metadata.clone(), + genesis_hash: client.genesis_hash(), + runtime_version: client.runtime_version(), + }; + + let supported_versions = metadata.extrinsic().supported_versions(); + if !supported_versions.contains(&5) { + return Err("Transaction version v5 is required for AuthorizeCall flow".into()); + } + + let params = DefaultExtrinsicParamsBuilder::::new() + .immortal() + .nonce(0) + .build(); + let partial = tx::create_v5_general(&store_call, &state, params)?; + let tx = partial.to_transaction(); + let submittable = SubmittableTransaction::from_bytes(client.clone(), tx.into_encoded()); + let events = submittable + .submit_and_watch() + .await? + .wait_for_finalized_success() + .await?; + let mut found = false; + for event in events.iter() { + let event = event?; + if event.pallet_name() == "TransactionStorage" && event.variant_name() == "Stored" { + if let Some(event_hash) = extract_content_hash(event.field_values()?) { + if event_hash == content_hash { + found = true; + break; + } + } + } + } + if !found { + return Err("Stored event with matching content hash not found".into()); + } + + println!("โœ… Preimage authorized unsigned store succeeded (event verified)"); + Ok(()) } + + From 827228761ea511eea8ab7d90438cfe5e8ad3bfbe Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Wed, 11 Feb 2026 14:32:02 +0000 Subject: [PATCH 17/26] Fixed compilation --- .../authorize-preimage-subxt/Cargo.toml | 2 + .../authorize-preimage-subxt/src/main.rs | 334 ++++++++++++------ 2 files changed, 223 insertions(+), 113 deletions(-) diff --git a/integration-tests/authorize-preimage-subxt/Cargo.toml b/integration-tests/authorize-preimage-subxt/Cargo.toml index ef748934e..16b9a99fd 100644 --- a/integration-tests/authorize-preimage-subxt/Cargo.toml +++ b/integration-tests/authorize-preimage-subxt/Cargo.toml @@ -8,5 +8,7 @@ subxt = "0.43.1" subxt-core = "0.43.0" subxt-signer = "0.43.0" scale-value = "0.18.1" +scale-info = "2" +parity-scale-codec = { version = "3", features = ["derive"] } sp-core = { workspace = true } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/integration-tests/authorize-preimage-subxt/src/main.rs b/integration-tests/authorize-preimage-subxt/src/main.rs index 8f0c55796..2253fec15 100644 --- a/integration-tests/authorize-preimage-subxt/src/main.rs +++ b/integration-tests/authorize-preimage-subxt/src/main.rs @@ -1,126 +1,234 @@ -use std::env; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::{ + env, + time::{SystemTime, UNIX_EPOCH}, +}; -use sp_core::hashing::blake2_256; use scale_value::{Composite as ScaleComposite, Value as ScaleValue, ValueDef as ScaleValueDef}; -use subxt::config::PolkadotConfig; -use subxt::dynamic::Value; -use subxt::tx::SubmittableTransaction; -use subxt::{OnlineClient}; -use subxt_core::client::ClientState; -use subxt_core::config::DefaultExtrinsicParamsBuilder; -use subxt_core::tx; +use sp_core::hashing::blake2_256; +use std::marker::PhantomData; + +use parity_scale_codec::{Decode, Encode}; +use scale_info::PortableRegistry; +use subxt::{ + config::{Config, PolkadotConfig}, + dynamic::Value, + tx::SubmittableTransaction, + OnlineClient, +}; +use subxt_core::{ + client::ClientState, + config::{ + transaction_extensions::{ + AnyOf, ChargeAssetTxPayment, ChargeTransactionPayment, CheckGenesis, CheckMetadataHash, + CheckMortality, CheckNonce, CheckSpecVersion, CheckTxVersion, Params as TxParams, + TransactionExtension, + }, + DefaultExtrinsicParamsBuilder, ExtrinsicParams, ExtrinsicParamsEncoder, + }, + error::ExtrinsicParamsError, + tx, + utils::Static, +}; use subxt_signer::sr25519::dev; +#[derive(Clone, Copy, Debug, PartialEq, Eq, Encode, Decode)] +enum CidHashingAlgorithm { + Blake2b256, + Sha2_256, + Keccak256, +} + +#[derive(Clone, Debug, PartialEq, Eq, Encode, Decode)] +struct CidConfig { + codec: u64, + hashing: CidHashingAlgorithm, +} + +#[derive(Clone, Default)] +struct ProvideCidConfigParams(Option); + +impl ProvideCidConfigParams { + fn new(config: Option) -> Self { + Self(config) + } +} + +impl TxParams for ProvideCidConfigParams {} + +struct ProvideCidConfigExtension { + config: Option, + _marker: PhantomData, +} + +impl ExtrinsicParams for ProvideCidConfigExtension { + type Params = ProvideCidConfigParams; + + fn new(_client: &ClientState, params: Self::Params) -> Result { + Ok(Self { config: params.0, _marker: PhantomData }) + } +} + +impl ExtrinsicParamsEncoder for ProvideCidConfigExtension { + fn encode_value_to(&self, v: &mut Vec) { + self.config.encode_to(v); + } +} + +impl TransactionExtension for ProvideCidConfigExtension { + type Decoded = Static>; + + fn matches(identifier: &str, _type_id: u32, _types: &PortableRegistry) -> bool { + identifier == "ProvideCidConfig" + } +} + +type BulletinExtrinsicParams = AnyOf< + T, + ( + subxt_core::config::transaction_extensions::VerifySignature, + CheckSpecVersion, + CheckTxVersion, + CheckNonce, + CheckGenesis, + CheckMortality, + ChargeAssetTxPayment, + ChargeTransactionPayment, + CheckMetadataHash, + ProvideCidConfigExtension, + ), +>; + +#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] +enum BulletinConfig {} + +impl Config for BulletinConfig { + type AccountId = ::AccountId; + type Signature = ::Signature; + type Hasher = ::Hasher; + type Header = ::Header; + type AssetId = ::AssetId; + type Address = ::Address; + type ExtrinsicParams = BulletinExtrinsicParams; +} + fn bytes_from_scale_value(value: ScaleValue) -> Option> { - match value.value { - ScaleValueDef::Composite(ScaleComposite::Unnamed(values)) => { - let mut bytes = Vec::with_capacity(values.len()); - for item in values { - let byte = item.as_u128()? as u8; - bytes.push(byte); - } - Some(bytes) - } - _ => None, - } + match value.value { + ScaleValueDef::Composite(ScaleComposite::Unnamed(values)) => { + let mut bytes = Vec::with_capacity(values.len()); + for item in values { + let byte = item.as_u128()? as u8; + bytes.push(byte); + } + Some(bytes) + }, + _ => None, + } } fn extract_content_hash(fields: ScaleComposite) -> Option<[u8; 32]> { - let ScaleComposite::Named(values) = fields else { - return None; - }; - let value = values - .into_iter() - .find(|(name, _)| name == "content_hash") - .map(|(_, value)| value)?; - let bytes = bytes_from_scale_value(value)?; - bytes.try_into().ok() + let ScaleComposite::Named(values) = fields else { + return None; + }; + let value = values + .into_iter() + .find(|(name, _)| name == "content_hash") + .map(|(_, value)| value)?; + let bytes = bytes_from_scale_value(value)?; + bytes.try_into().ok() } #[tokio::main] async fn main() -> Result<(), Box> { - let ws_url = env::args() - .nth(1) - .unwrap_or_else(|| "ws://localhost:10000".to_string()); - - println!("Connecting to: {ws_url}"); - - let client = OnlineClient::::from_url(ws_url).await?; - - let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); - let data = format!("Hello, Bulletin with subxt preimage - {now}"); - let data_bytes = data.as_bytes().to_vec(); - let content_hash = blake2_256(&data_bytes); - - // Authorize the preimage using sudo. - let authorize_preimage = subxt::dynamic::tx( - "TransactionStorage", - "authorize_preimage", - vec![ - Value::from_bytes(content_hash), - Value::u128(data_bytes.len() as u128), - ], - ); - let sudo_call = subxt::dynamic::tx("Sudo", "sudo", vec![authorize_preimage.into_value()]); - - let sudo_signer = dev::alice(); - client - .tx() - .sign_and_submit_then_watch_default(&sudo_call, &sudo_signer) - .await? - .wait_for_finalized_success() - .await?; - - // Submit the store call as an unsigned authorized transaction. - let store_call = subxt::dynamic::tx( - "TransactionStorage", - "store", - vec![Value::from_bytes(&data_bytes)], - ); - - let metadata = client.metadata(); - let state = ClientState:: { - metadata: metadata.clone(), - genesis_hash: client.genesis_hash(), - runtime_version: client.runtime_version(), - }; - - let supported_versions = metadata.extrinsic().supported_versions(); - if !supported_versions.contains(&5) { - return Err("Transaction version v5 is required for AuthorizeCall flow".into()); - } - - let params = DefaultExtrinsicParamsBuilder::::new() - .immortal() - .nonce(0) - .build(); - let partial = tx::create_v5_general(&store_call, &state, params)?; - let tx = partial.to_transaction(); - let submittable = SubmittableTransaction::from_bytes(client.clone(), tx.into_encoded()); - let events = submittable - .submit_and_watch() - .await? - .wait_for_finalized_success() - .await?; - let mut found = false; - for event in events.iter() { - let event = event?; - if event.pallet_name() == "TransactionStorage" && event.variant_name() == "Stored" { - if let Some(event_hash) = extract_content_hash(event.field_values()?) { - if event_hash == content_hash { - found = true; - break; - } - } - } - } - if !found { - return Err("Stored event with matching content hash not found".into()); - } - - println!("โœ… Preimage authorized unsigned store succeeded (event verified)"); - Ok(()) + let ws_url = env::args().nth(1).unwrap_or_else(|| "ws://localhost:10000".to_string()); + + println!("Connecting to: {ws_url}"); + + let client = OnlineClient::::from_url(ws_url).await?; + + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let data = format!("Hello, Bulletin with subxt preimage - {now}"); + let data_bytes = data.as_bytes().to_vec(); + let content_hash = blake2_256(&data_bytes); + + // Authorize the preimage using sudo. + let authorize_preimage = subxt::dynamic::tx( + "TransactionStorage", + "authorize_preimage", + vec![Value::from_bytes(content_hash), Value::u128(data_bytes.len() as u128)], + ); + let sudo_call = subxt::dynamic::tx("Sudo", "sudo", vec![authorize_preimage.into_value()]); + + let sudo_signer = dev::alice(); + client + .tx() + .sign_and_submit_then_watch_default(&sudo_call, &sudo_signer) + .await? + .wait_for_finalized_success() + .await?; + + // Submit the store call as an unsigned authorized transaction. + let store_call = + subxt::dynamic::tx("TransactionStorage", "store", vec![Value::from_bytes(&data_bytes)]); + + let metadata = client.metadata(); + let state = ClientState:: { + metadata: metadata.clone(), + genesis_hash: client.genesis_hash(), + runtime_version: client.runtime_version(), + }; + + let supported_versions = metadata.extrinsic().supported_versions(); + if !supported_versions.contains(&5) { + return Err("Transaction version v5 is required for AuthorizeCall flow".into()); + } + + let ( + verify_sig, + check_spec, + check_tx, + check_nonce, + check_genesis, + check_mortality, + charge_asset, + charge_tx, + check_metadata, + ) = DefaultExtrinsicParamsBuilder::::new() + .immortal() + .nonce(0) + .build(); + let cid_config = Some(CidConfig { codec: 0x55, hashing: CidHashingAlgorithm::Blake2b256 }); + let params = ( + verify_sig, + check_spec, + check_tx, + check_nonce, + check_genesis, + check_mortality, + charge_asset, + charge_tx, + check_metadata, + ProvideCidConfigParams::new(cid_config), + ); + let partial = tx::create_v5_general(&store_call, &state, params)?; + let tx = partial.to_transaction(); + let submittable = SubmittableTransaction::from_bytes(client.clone(), tx.into_encoded()); + let events = submittable.submit_and_watch().await?.wait_for_finalized_success().await?; + let mut found = false; + for event in events.iter() { + let event = event?; + if event.pallet_name() == "TransactionStorage" && event.variant_name() == "Stored" { + if let Some(event_hash) = extract_content_hash(event.field_values()?) { + if event_hash == content_hash { + found = true; + break; + } + } + } + } + if !found { + return Err("Stored event with matching content hash not found".into()); + } + + println!("โœ… Preimage authorized unsigned store succeeded (event verified)"); + Ok(()) } - - From 15c767d2ee3998299c11dab326533a3b6e74389f Mon Sep 17 00:00:00 2001 From: Francisco Aguirre Date: Thu, 12 Feb 2026 21:43:11 -0300 Subject: [PATCH 18/26] fix: feeless_if --- Cargo.lock | 17 +++++++++ pallets/transaction-storage/src/lib.rs | 27 +++++++++++-- pallets/transaction-storage/src/tests.rs | 48 ++++++++++++++---------- runtimes/bulletin-polkadot/src/lib.rs | 7 +--- 4 files changed, 72 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4111ffad9..d9666815c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1418,6 +1418,20 @@ dependencies = [ "num", ] +[[package]] +name = "authorize-preimage-subxt" +version = "0.1.0" +dependencies = [ + "parity-scale-codec", + "scale-info", + "scale-value", + "sp-core", + "subxt", + "subxt-core", + "subxt-signer", + "tokio", +] + [[package]] name = "auto_impl" version = "1.3.0" @@ -14850,6 +14864,7 @@ dependencies = [ "frame-metadata", "futures", "hex", + "jsonrpsee", "parity-scale-codec", "primitive-types 0.13.1", "scale-bits", @@ -14870,6 +14885,7 @@ dependencies = [ "tokio-util", "tracing", "url", + "wasm-bindgen-futures", "web-time", ] @@ -14988,6 +15004,7 @@ dependencies = [ "subxt-core", "subxt-lightclient", "thiserror 2.0.18", + "tokio-util", "tracing", "url", ] diff --git a/pallets/transaction-storage/src/lib.rs b/pallets/transaction-storage/src/lib.rs index 4072362e9..c7e4a242d 100644 --- a/pallets/transaction-storage/src/lib.rs +++ b/pallets/transaction-storage/src/lib.rs @@ -344,7 +344,21 @@ pub mod pallet { /// O(n*log(n)) of data size, as all data is pushed to an in-memory trie. #[pallet::call_index(0)] #[pallet::weight(T::WeightInfo::store(data.len() as u32))] - #[pallet::feeless_if(|origin: &OriginFor, data: &Vec| -> bool { /*TODO: add here correct validation */ true })] + #[pallet::feeless_if(|origin: &OriginFor, data: &Vec| -> bool { + // Check preimage authorization + let hash = sp_io::hashing::blake2_256(data); + let preimage_extent = Pallet::::preimage_authorization_extent(hash); + if preimage_extent.transactions > 0 && preimage_extent.bytes >= data.len() as u64 { + return true; + } + // Check account authorization for signed origins + if let Ok(who) = frame_system::ensure_signed(origin.clone()) { + let account_extent = Pallet::::account_authorization_extent(who); + return account_extent.transactions > 0 && + account_extent.bytes >= data.len() as u64; + } + false + })] #[pallet::authorize(|_source, data| { Pallet::::to_validity_with_refund(Pallet::::check_unsigned_store( data.as_slice(), @@ -359,7 +373,7 @@ pub mod pallet { .map_err(Self::dispatch_error_from_validity)?; }, Ok(frame_system::RawOrigin::Signed(_)) => {}, - _ => return Err(DispatchError::BadOrigin.into()), + _ => return Err(DispatchError::BadOrigin), } // In the case of a regular unsigned transaction, this should have been checked by @@ -1147,7 +1161,14 @@ pub mod pallet { AuthorizationScope::Preimage(content_hash), size as u32, consume, - )?; + ) + .or_else(|_| { + Self::check_authorization( + AuthorizationScope::Account(who.clone()), + size as u32, + consume, + ) + })?; Ok(ValidTransaction { priority: T::StoreRenewPriority::get(), diff --git a/pallets/transaction-storage/src/tests.rs b/pallets/transaction-storage/src/tests.rs index aa977b113..4abf98703 100644 --- a/pallets/transaction-storage/src/tests.rs +++ b/pallets/transaction-storage/src/tests.rs @@ -49,12 +49,29 @@ type Transactions = super::Transactions; const MAX_DATA_SIZE: u32 = DEFAULT_MAX_TRANSACTION_SIZE; +/// Helper: authorize preimage and store data via the `Authorized` origin. +fn authorize_and_store(data: Vec) { + let hash = blake2_256(&data); + assert_ok!(TransactionStorage::authorize_preimage( + RuntimeOrigin::root(), + hash, + data.len() as u64, + )); + assert_ok!(TransactionStorage::store(RawOrigin::Authorized.into(), data)); +} + +/// Helper: authorize preimage and renew via the `Authorized` origin. +fn authorize_and_renew(block: u64, index: u32, content_hash: [u8; 32], size: u64) { + assert_ok!(TransactionStorage::authorize_preimage(RuntimeOrigin::root(), content_hash, size,)); + assert_ok!(TransactionStorage::renew(RawOrigin::Authorized.into(), block, index)); +} + #[test] fn discards_data() { new_test_ext().execute_with(|| { run_to_block(1, || None); - assert_ok!(TransactionStorage::store(RuntimeOrigin::none(), vec![0u8; 2000])); - assert_ok!(TransactionStorage::store(RuntimeOrigin::none(), vec![0u8; 2000])); + authorize_and_store(vec![0u8; 2000]); + authorize_and_store(vec![0u8; 2000]); let proof_provider = || { let block_num = System::block_number(); if block_num == 11 { @@ -152,10 +169,7 @@ fn uses_preimage_authorization() { fn checks_proof() { new_test_ext().execute_with(|| { run_to_block(1, || None); - assert_ok!(TransactionStorage::store( - RuntimeOrigin::none(), - vec![0u8; MAX_DATA_SIZE as usize] - )); + authorize_and_store(vec![0u8; MAX_DATA_SIZE as usize]); run_to_block(10, || None); let parent_hash = System::parent_hash(); let proof = build_proof(parent_hash.as_ref(), vec![vec![0u8; MAX_DATA_SIZE as usize]]) @@ -239,14 +253,10 @@ fn verify_chunk_proof_works() { fn renews_data() { new_test_ext().execute_with(|| { run_to_block(1, || None); - assert_ok!(TransactionStorage::store(RuntimeOrigin::none(), vec![0u8; 2000])); + authorize_and_store(vec![0u8; 2000]); let info = BlockTransactions::get().last().unwrap().clone(); run_to_block(6, || None); - assert_ok!(TransactionStorage::renew( - RuntimeOrigin::none(), - 1, // block - 0, // transaction - )); + authorize_and_renew(1, 0, info.content_hash, info.size as u64); let proof_provider = || { let block_num = System::block_number(); if block_num == 11 || block_num == 16 { @@ -404,7 +414,7 @@ fn stores_various_sizes_with_account_authorization() { for size in sizes { let call = Call::store { data: vec![0u8; size] }; assert_ok!(TransactionStorage::pre_dispatch_signed(&who, &call)); - assert_ok!(Into::::into(call).dispatch(RuntimeOrigin::none())); + assert_ok!(Into::::into(call).dispatch(RuntimeOrigin::signed(who))); } // After consuming the authorized sizes, authorization should be removed and providers @@ -426,7 +436,7 @@ fn stores_various_sizes_with_account_authorization() { assert_noop!(TransactionStorage::pre_dispatch_signed(&who, &too_big_call), BAD_DATA_SIZE); // dispatch should also reject with pallet Error::BadDataSize assert_noop!( - Into::::into(too_big_call).dispatch(RuntimeOrigin::none()), + Into::::into(too_big_call).dispatch(RuntimeOrigin::signed(who)), Error::BadDataSize, ); run_to_block(2, || None); @@ -544,8 +554,8 @@ fn signed_renew_uses_account_authorization() { 2000 )); let store_call = Call::store { data }; - assert_ok!(TransactionStorage::pre_dispatch(&store_call)); - assert_ok!(Into::::into(store_call).dispatch(RuntimeOrigin::none())); + assert_ok!(store_call.authorize(TransactionSource::External).unwrap()); + assert_ok!(Into::::into(store_call).dispatch(RawOrigin::Authorized.into())); run_to_block(3, || None); @@ -582,7 +592,7 @@ fn signed_renew_prefers_preimage_authorization() { assert_ok!(TransactionStorage::authorize_account(RuntimeOrigin::root(), who, 1, 2000)); let store_call = Call::store { data }; assert_ok!(TransactionStorage::pre_dispatch_signed(&who, &store_call)); - assert_ok!(Into::::into(store_call).dispatch(RuntimeOrigin::none())); + assert_ok!(Into::::into(store_call).dispatch(RuntimeOrigin::signed(who))); // Account authorization consumed after store assert_eq!( @@ -700,7 +710,7 @@ fn migration_v1_new_entries_only() { run_to_block(1, || None); // Store via normal (new-format) code path - assert_ok!(TransactionStorage::store(RuntimeOrigin::none(), vec![0u8; 2000])); + authorize_and_store(vec![0u8; 2000]); run_to_block(2, || None); let original = Transactions::get(1).expect("should decode"); @@ -726,7 +736,7 @@ fn migration_v1_mixed_entries() { // New-format entry at block 10 run_to_block(10, || None); - assert_ok!(TransactionStorage::store(RuntimeOrigin::none(), vec![42u8; 500])); + authorize_and_store(vec![42u8; 500]); run_to_block(11, || None); let new_entry_before = Transactions::get(10).expect("new format decodes"); diff --git a/runtimes/bulletin-polkadot/src/lib.rs b/runtimes/bulletin-polkadot/src/lib.rs index b2b8008c6..046e7352e 100644 --- a/runtimes/bulletin-polkadot/src/lib.rs +++ b/runtimes/bulletin-polkadot/src/lib.rs @@ -386,16 +386,13 @@ impl pallet_sudo::Config for Runtime { Debug, codec::MaxEncodedLen, scale_info::TypeInfo, + Default, )] pub enum ProxyType { /// Fully permissioned proxy. Can execute any call on behalf of _proxied_. + #[default] Any, } -impl Default for ProxyType { - fn default() -> Self { - Self::Any - } -} impl InstanceFilter for ProxyType { fn filter(&self, _c: &RuntimeCall) -> bool { true From a328ebd198b23f50c554f54cb32a73aac93258a2 Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Fri, 13 Feb 2026 15:57:31 +0000 Subject: [PATCH 19/26] Fixed integration test --- integration-tests/authorize-preimage-subxt/src/main.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/integration-tests/authorize-preimage-subxt/src/main.rs b/integration-tests/authorize-preimage-subxt/src/main.rs index 2253fec15..a306c6e22 100644 --- a/integration-tests/authorize-preimage-subxt/src/main.rs +++ b/integration-tests/authorize-preimage-subxt/src/main.rs @@ -143,7 +143,8 @@ async fn main() -> Result<(), Box> { println!("Connecting to: {ws_url}"); - let client = OnlineClient::::from_url(ws_url).await?; + let client = OnlineClient::::from_url(ws_url.clone()).await?; + let sudo_client = OnlineClient::::from_url(ws_url).await?; let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); let data = format!("Hello, Bulletin with subxt preimage - {now}"); @@ -159,7 +160,7 @@ async fn main() -> Result<(), Box> { let sudo_call = subxt::dynamic::tx("Sudo", "sudo", vec![authorize_preimage.into_value()]); let sudo_signer = dev::alice(); - client + sudo_client .tx() .sign_and_submit_then_watch_default(&sudo_call, &sudo_signer) .await? From 18970e25cb6eea5130f040d2c35f74fc9046bec3 Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Fri, 13 Feb 2026 19:00:11 +0000 Subject: [PATCH 20/26] Benchmark fix? --- .../transaction-storage/src/benchmarking.rs | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/pallets/transaction-storage/src/benchmarking.rs b/pallets/transaction-storage/src/benchmarking.rs index e195111a5..b59a62769 100644 --- a/pallets/transaction-storage/src/benchmarking.rs +++ b/pallets/transaction-storage/src/benchmarking.rs @@ -122,14 +122,33 @@ pub fn run_to_block(n: frame_system::pallet_prelude::BlockNumberFor( + who: &T::AccountId, + transactions: u32, + bytes: u64, + ) -> Result<(), BenchmarkError> { + let origin = T::Authorizer::try_successful_origin() + .map_err(|_| BenchmarkError::Stop("unable to compute origin"))?; + TransactionStorage::::authorize_account( + origin as T::RuntimeOrigin, + who.clone(), + transactions, + bytes, + ) + .map_err(|_| BenchmarkError::Stop("unable to authorize account"))?; + Ok(()) + } + #[benchmark] fn store(l: Linear<{ 1 }, { T::MaxTransactionSize::get() }>) -> Result<(), BenchmarkError> { let data = vec![0u8; l as usize]; let content_hash = sp_io::hashing::blake2_256(&data); let cid = calculate_cid(&data, None).unwrap().to_bytes(); + let caller: T::AccountId = whitelisted_caller(); + authorize_for_store::(&caller, 1, l as u64)?; #[extrinsic_call] - _(RawOrigin::None, data); + _(RawOrigin::Signed(caller), data); assert!(!BlockTransactions::::get().is_empty()); assert_last_event::(Event::Stored { index: 0, content_hash, cid }.into()); @@ -140,11 +159,17 @@ mod benchmarks { fn renew() -> Result<(), BenchmarkError> { let data = vec![0u8; T::MaxTransactionSize::get() as usize]; let content_hash = sp_io::hashing::blake2_256(&data); - TransactionStorage::::store(RawOrigin::None.into(), data)?; + let caller: T::AccountId = whitelisted_caller(); + authorize_for_store::( + &caller, + 2, + T::MaxTransactionSize::get() as u64, + )?; + TransactionStorage::::store(RawOrigin::Signed(caller.clone()).into(), data)?; run_to_block::(1u32.into()); #[extrinsic_call] - _(RawOrigin::None, BlockNumberFor::::zero(), 0); + _(RawOrigin::Signed(caller), BlockNumberFor::::zero(), 0); assert_last_event::(Event::Renewed { index: 0, content_hash }.into()); Ok(()) @@ -153,9 +178,13 @@ mod benchmarks { #[benchmark] fn check_proof() -> Result<(), BenchmarkError> { run_to_block::(1u32.into()); + let caller: T::AccountId = whitelisted_caller(); + let tx_count = T::MaxBlockTransactions::get(); + let max_size = T::MaxTransactionSize::get() as u64; + authorize_for_store::(&caller, tx_count, max_size.saturating_mul(tx_count as u64))?; for _ in 0..T::MaxBlockTransactions::get() { TransactionStorage::::store( - RawOrigin::None.into(), + RawOrigin::Signed(caller.clone()).into(), vec![0u8; T::MaxTransactionSize::get() as usize], )?; } From 7a3348ddffe04f95cd228291ad4d08e113638f37 Mon Sep 17 00:00:00 2001 From: Andrii Date: Fri, 13 Feb 2026 10:38:05 +0100 Subject: [PATCH 21/26] Use GitHub Actions matrix for integration tests (#238) * Use GitHub Actions matrix for integration tests Run Westend parachain and Polkadot solochain tests in parallel instead of sequentially, eliminating ~75 lines of duplication and cutting CI wall time roughly in half. Each runtime gets its own isolated runner, avoiding flaky cross-runtime state issues (#237). * Reuse runtimes-matrix.json for integration test matrix Instead of hardcoding the runtime list in the workflow, read from scripts/runtimes-matrix.json and filter by a new `integration_tests` flag. This keeps the single source of truth for runtime definitions. Co-Authored-By: Claude Opus 4.6 * Add summary job for Integration Tests status check The matrix job reports per-runtime check names (e.g. "Integration Tests (bulletin-polkadot)") which don't match the branch protection rule expecting a single "Integration Tests" status. Add a summary job that aggregates matrix results under that exact name. Co-Authored-By: Claude Opus 4.6 * Load env vars in Setup job to fix cache key resolution The Setup job uses POLKADOT_SDK_VERSION and ZOMBIENET_VERSION in cache keys, but these are defined in .github/env. Without loading that file, the cache keys resolve to empty strings causing cache misses every run. Co-Authored-By: Claude Opus 4.6 * Use GITHUB_ENV for RUNTIME_PACKAGE instead of per-step matrix refs Set RUNTIME_PACKAGE once in $GITHUB_ENV alongside TEST_DIR, then reference both as plain env vars in all subsequent steps. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Branislav Kontur --- .github/workflows/integration-test.yml | 114 ++++++++++++------------- scripts/runtimes-matrix.json | 2 + 2 files changed, 56 insertions(+), 60 deletions(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index ae3f9016b..08660123c 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -78,11 +78,33 @@ jobs: "https://github.com/paritytech/zombienet/releases/download/${ZOMBIENET_VERSION}/zombienet-linux-x64" chmod +x zombienet-linux-x64 + runtime-matrix: + runs-on: ubuntu-latest + outputs: + runtime: ${{ steps.runtime.outputs.runtime }} + name: Extract tasks from matrix + steps: + - uses: actions/checkout@v6 + - id: runtime + run: | + TASKS=$(jq '[.[] | select(.integration_tests == true)]' scripts/runtimes-matrix.json) + SKIPPED=$(jq '[.[] | select(.integration_tests != true)]' scripts/runtimes-matrix.json) + echo "--- Running integration tests for ---" + echo "$TASKS" + echo "--- Skipping integration tests for ---" + echo "$SKIPPED" + TASKS=$(echo "$TASKS" | jq -c .) + echo "runtime=$TASKS" >> $GITHUB_OUTPUT + integration-tests: - needs: [setup] - name: Integration Tests + needs: [setup, runtime-matrix] + name: Integration Tests (${{ matrix.runtime.name }}) runs-on: ubuntu-latest timeout-minutes: 200 + strategy: + fail-fast: false + matrix: + runtime: ${{ fromJSON(needs.runtime-matrix.outputs.runtime) }} steps: - name: Checkout sources @@ -143,93 +165,65 @@ jobs: echo "${ZOMBIENET_BIN_DIR}" >> "$GITHUB_PATH" echo "ZOMBIENET_BINARY=zombienet-linux-x64" >> "$GITHUB_ENV" - # ======================================================================== - # Westend parachain tests - # ======================================================================== - - name: Start services (Westend parachain) + - name: Start services working-directory: examples run: | TEST_DIR="$(mktemp -d $GITHUB_WORKSPACE/bulletin-tests-run-XXXXX)/test" echo "TEST_DIR=$TEST_DIR" >> "$GITHUB_ENV" - just start-services "$TEST_DIR" "bulletin-westend-runtime" + echo "RUNTIME_PACKAGE=${{ matrix.runtime.package }}" >> "$GITHUB_ENV" + just start-services "$TEST_DIR" "${{ matrix.runtime.package }}" - - name: Test authorize-and-store ws (Westend parachain) + - name: Test authorize-and-store ws working-directory: examples - run: just run-test-authorize-and-store "${{ env.TEST_DIR }}" "bulletin-westend-runtime" "ws" + run: just run-test-authorize-and-store "$TEST_DIR" "$RUNTIME_PACKAGE" "ws" - name: Test authorize-preimage-and-store ws (Westend parachain) working-directory: examples run: just run-test-authorize-preimage-and-store "${{ env.TEST_DIR }}" "bulletin-westend-runtime" "ws" - - name: Test authorize-and-store smoldot (Westend parachain) + - name: Test authorize-and-store smoldot working-directory: examples - run: just run-test-authorize-and-store "${{ env.TEST_DIR }}" "bulletin-westend-runtime" "smoldot" + run: just run-test-authorize-and-store "$TEST_DIR" "$RUNTIME_PACKAGE" "smoldot" - - name: Test store-chunked-data (Westend parachain) + - name: Test store-chunked-data working-directory: examples - run: just run-test-store-chunked-data "${{ env.TEST_DIR }}" + run: just run-test-store-chunked-data "$TEST_DIR" - - name: Test store-big-data (Westend parachain) + - name: Test store-big-data working-directory: examples - run: just run-test-store-big-data "${{ env.TEST_DIR }}" "big32" + run: just run-test-store-big-data "$TEST_DIR" "big32" - - name: Test authorize-preimage-and-store (Westend parachain) + - name: Test authorize-preimage-and-store working-directory: examples - run: just run-test-authorize-preimage-and-store "${{ env.TEST_DIR }}" + run: just run-test-authorize-preimage-and-store "$TEST_DIR" - - name: Test chopsticks compatibility (Westend parachain) + - name: Test chopsticks compatibility working-directory: examples run: just run-test-chopsticks "ws://localhost:10000" - - name: Stop services (Westend parachain) + - name: Stop services if: always() working-directory: examples - run: just stop-services "${{ env.TEST_DIR }}" - - # ======================================================================== - # Polkadot solochain tests - # ======================================================================== - - name: Start services (Polkadot solochain) - working-directory: examples - run: | - TEST_DIR="$(mktemp -d $GITHUB_WORKSPACE/bulletin-tests-run-XXXXX)/test" - echo "TEST_DIR=$TEST_DIR" >> "$GITHUB_ENV" - just start-services "$TEST_DIR" "bulletin-polkadot-runtime" - - - name: Test authorize-and-store ws (Polkadot solochain) - working-directory: examples - run: just run-test-authorize-and-store "${{ env.TEST_DIR }}" "bulletin-polkadot-runtime" "ws" - - - name: Test authorize-and-store smoldot (Polkadot solochain) - working-directory: examples - run: just run-test-authorize-and-store "${{ env.TEST_DIR }}" "bulletin-polkadot-runtime" "smoldot" - - - name: Test store-chunked-data (Polkadot solochain) - working-directory: examples - run: just run-test-store-chunked-data "${{ env.TEST_DIR }}" - - - name: Test store-big-data (Polkadot solochain) - working-directory: examples - run: just run-test-store-big-data "${{ env.TEST_DIR }}" "big32" - - - name: Test authorize-preimage-and-store (Polkadot solochain) - working-directory: examples - run: just run-test-authorize-preimage-and-store "${{ env.TEST_DIR }}" - - - name: Test chopsticks compatibility (Polkadot solochain) - working-directory: examples - run: just run-test-chopsticks "ws://localhost:10000" - - - name: Stop services (Polkadot solochain) - if: always() - working-directory: examples - run: just stop-services "${{ env.TEST_DIR }}" + run: just stop-services "$TEST_DIR" # Collects logs from the last failed zombienet run. - name: Upload Zombienet logs (on failure) if: failure() uses: actions/upload-artifact@v4 with: - name: failed-zombienet-logs + name: failed-zombienet-logs-${{ matrix.runtime.name }} path: | ${{ env.TEST_DIR }}/*.log + + integration-tests-complete: + name: Integration Tests + needs: [integration-tests] + if: always() + runs-on: ubuntu-latest + steps: + - name: Check integration test results + run: | + if [ "${{ needs.integration-tests.result }}" != "success" ]; then + echo "Integration tests failed or were cancelled" + exit 1 + fi diff --git a/scripts/runtimes-matrix.json b/scripts/runtimes-matrix.json index d33e76f4d..56bcf9946 100644 --- a/scripts/runtimes-matrix.json +++ b/scripts/runtimes-matrix.json @@ -4,6 +4,7 @@ "package": "bulletin-polkadot-runtime", "path": "runtimes/bulletin-polkadot", "blocktime": 6000, + "integration_tests": true, "benchmarks_templates": { "pallet_xcm_benchmarks::generic": "templates/xcm-bench-template.hbs", "pallet_xcm_benchmarks::fungible": "templates/xcm-bench-template.hbs" @@ -20,6 +21,7 @@ "wss://westend-bulletin-rpc.polkadot.io" ], "blocktime": 6000, + "integration_tests": true, "benchmarks_templates": { "pallet_xcm_benchmarks::generic": "templates/xcm-bench-template.hbs", "pallet_xcm_benchmarks::fungible": "templates/xcm-bench-template.hbs" From 45f79510119b95306042141bbff9d7a0ecfae7c2 Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Mon, 16 Feb 2026 11:41:24 +0000 Subject: [PATCH 22/26] Format --- pallets/transaction-storage/src/benchmarking.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/pallets/transaction-storage/src/benchmarking.rs b/pallets/transaction-storage/src/benchmarking.rs index b59a62769..b63b2041e 100644 --- a/pallets/transaction-storage/src/benchmarking.rs +++ b/pallets/transaction-storage/src/benchmarking.rs @@ -160,11 +160,7 @@ mod benchmarks { let data = vec![0u8; T::MaxTransactionSize::get() as usize]; let content_hash = sp_io::hashing::blake2_256(&data); let caller: T::AccountId = whitelisted_caller(); - authorize_for_store::( - &caller, - 2, - T::MaxTransactionSize::get() as u64, - )?; + authorize_for_store::(&caller, 2, T::MaxTransactionSize::get() as u64)?; TransactionStorage::::store(RawOrigin::Signed(caller.clone()).into(), data)?; run_to_block::(1u32.into()); From 12644e596da12f781464e3bcc787f53a5208b538 Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Wed, 25 Mar 2026 01:14:51 +0000 Subject: [PATCH 23/26] Cleaned up and removed providecidconfig from tests --- .github/workflows/integration-test.yml | 4 - Cargo.lock | 260 ++---------------- Cargo.toml | 2 +- .../authorize-preimage-subxt/Cargo.toml | 14 - .../authorize-preimage-subxt/src/main.rs | 235 ---------------- 5 files changed, 19 insertions(+), 496 deletions(-) delete mode 100644 integration-tests/authorize-preimage-subxt/Cargo.toml delete mode 100644 integration-tests/authorize-preimage-subxt/src/main.rs diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index a3e208a8c..b94ff7efd 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -191,10 +191,6 @@ jobs: working-directory: examples run: just run-test-authorize-and-store "$TEST_DIR" "$RUNTIME_PACKAGE" "ws" - - name: Test authorize-preimage-and-store ws (Westend parachain) - working-directory: examples - run: just run-test-authorize-preimage-and-store "$TEST_DIR" "$RUNTIME_PACKAGE" "ws" - - name: Test authorize-and-store smoldot working-directory: examples run: just run-test-authorize-and-store "$TEST_DIR" "$RUNTIME_PACKAGE" "smoldot" diff --git a/Cargo.lock b/Cargo.lock index 3546a959b..058f9e076 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1410,20 +1410,6 @@ dependencies = [ "num", ] -[[package]] -name = "authorize-preimage-subxt" -version = "0.1.0" -dependencies = [ - "parity-scale-codec", - "scale-info", - "scale-value", - "sp-core", - "subxt 0.43.1", - "subxt-core 0.43.0", - "subxt-signer 0.43.0", - "tokio", -] - [[package]] name = "auto_impl" version = "1.3.0" @@ -4521,26 +4507,12 @@ dependencies = [ "sp-trie", "sp-version", "sp-wasm-interface", - "subxt 0.44.2", - "subxt-signer 0.44.2", + "subxt", + "subxt-signer", "thiserror 1.0.69", "thousands", ] -[[package]] -name = "frame-decode" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e56c0e51972d7b26ff76966c4d0f2307030df9daa5ce0885149ece1ab7ca5ad" -dependencies = [ - "frame-metadata", - "parity-scale-codec", - "scale-decode", - "scale-info", - "scale-type-resolver", - "sp-crypto-hashing 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", -] - [[package]] name = "frame-decode" version = "0.9.0" @@ -8804,7 +8776,7 @@ dependencies = [ "sp-runtime", "sp-version", "substrate-bn", - "subxt-signer 0.44.2", + "subxt-signer", ] [[package]] @@ -14968,43 +14940,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "subxt" -version = "0.43.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8c6dc0f90e23c521465b8f7e026af04a48cc6f00c51d88a8d313d33096149de" -dependencies = [ - "async-trait", - "derive-where", - "either", - "frame-metadata", - "futures", - "hex", - "jsonrpsee", - "parity-scale-codec", - "primitive-types 0.13.1", - "scale-bits", - "scale-decode", - "scale-encode", - "scale-info", - "scale-value", - "serde", - "serde_json", - "sp-crypto-hashing 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "subxt-core 0.43.0", - "subxt-lightclient 0.43.0", - "subxt-macro 0.43.1", - "subxt-metadata 0.43.0", - "subxt-rpcs 0.43.0", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", - "url", - "wasm-bindgen-futures", - "web-time", -] - [[package]] name = "subxt" version = "0.44.2" @@ -15027,11 +14962,11 @@ dependencies = [ "serde", "serde_json", "sp-crypto-hashing 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "subxt-core 0.44.2", - "subxt-lightclient 0.44.2", - "subxt-macro 0.44.2", - "subxt-metadata 0.44.2", - "subxt-rpcs 0.44.2", + "subxt-core", + "subxt-lightclient", + "subxt-macro", + "subxt-metadata", + "subxt-rpcs", "thiserror 2.0.18", "tokio", "tokio-util", @@ -15040,23 +14975,6 @@ dependencies = [ "web-time", ] -[[package]] -name = "subxt-codegen" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1728caecd9700391e78cc30dc298221d6f5ca0ea28258a452aa76b0b7c229842" -dependencies = [ - "heck 0.5.0", - "parity-scale-codec", - "proc-macro2", - "quote", - "scale-info", - "scale-typegen", - "subxt-metadata 0.43.0", - "syn 2.0.117", - "thiserror 2.0.18", -] - [[package]] name = "subxt-codegen" version = "0.44.2" @@ -15069,41 +14987,11 @@ dependencies = [ "quote", "scale-info", "scale-typegen", - "subxt-metadata 0.44.2", + "subxt-metadata", "syn 2.0.117", "thiserror 2.0.18", ] -[[package]] -name = "subxt-core" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25338dd11ae34293b8d0c5807064f2e00194ba1bd84cccfa694030c8d185b941" -dependencies = [ - "base58", - "blake2 0.10.6", - "derive-where", - "frame-decode 0.8.3", - "frame-metadata", - "hashbrown 0.14.5", - "hex", - "impl-serde", - "keccak-hash", - "parity-scale-codec", - "primitive-types 0.13.1", - "scale-bits", - "scale-decode", - "scale-encode", - "scale-info", - "scale-value", - "serde", - "serde_json", - "sp-crypto-hashing 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "subxt-metadata 0.43.0", - "thiserror 2.0.18", - "tracing", -] - [[package]] name = "subxt-core" version = "0.44.2" @@ -15113,7 +15001,7 @@ dependencies = [ "base58", "blake2 0.10.6", "derive-where", - "frame-decode 0.9.0", + "frame-decode", "frame-metadata", "hashbrown 0.14.5", "hex", @@ -15129,28 +15017,11 @@ dependencies = [ "serde", "serde_json", "sp-crypto-hashing 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "subxt-metadata 0.44.2", + "subxt-metadata", "thiserror 2.0.18", "tracing", ] -[[package]] -name = "subxt-lightclient" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9097ef356e534ce0b6a50b95233512afc394347b971a4f929c4830adc52bbc6f" -dependencies = [ - "futures", - "futures-util", - "serde", - "serde_json", - "smoldot-light", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tracing", -] - [[package]] name = "subxt-lightclient" version = "0.44.2" @@ -15168,23 +15039,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "subxt-macro" -version = "0.43.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c269228a2e5de4c0c61ed872b701967ee761df0f167d5b91ecec1185bca65793" -dependencies = [ - "darling 0.20.11", - "parity-scale-codec", - "proc-macro-error2", - "quote", - "scale-typegen", - "subxt-codegen 0.43.0", - "subxt-metadata 0.43.0", - "subxt-utils-fetchmetadata 0.43.0", - "syn 2.0.117", -] - [[package]] name = "subxt-macro" version = "0.44.2" @@ -15196,34 +15050,19 @@ dependencies = [ "proc-macro-error2", "quote", "scale-typegen", - "subxt-codegen 0.44.2", - "subxt-metadata 0.44.2", - "subxt-utils-fetchmetadata 0.44.2", + "subxt-codegen", + "subxt-metadata", + "subxt-utils-fetchmetadata", "syn 2.0.117", ] -[[package]] -name = "subxt-metadata" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c134068711c0c46906abc0e6e4911204420331530738e18ca903a5469364d9f" -dependencies = [ - "frame-decode 0.8.3", - "frame-metadata", - "hashbrown 0.14.5", - "parity-scale-codec", - "scale-info", - "sp-crypto-hashing 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "thiserror 2.0.18", -] - [[package]] name = "subxt-metadata" version = "0.44.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc80c07a71e180a42ba0f12727b1f9f39bf03746df6d546d24edbbc137f64fa1" dependencies = [ - "frame-decode 0.9.0", + "frame-decode", "frame-metadata", "hashbrown 0.14.5", "parity-scale-codec", @@ -15232,30 +15071,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "subxt-rpcs" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25de7727144780d780a6a7d78bbfd28414b8adbab68b05e87329c367d7705be4" -dependencies = [ - "derive-where", - "frame-metadata", - "futures", - "hex", - "impl-serde", - "jsonrpsee", - "parity-scale-codec", - "primitive-types 0.13.1", - "serde", - "serde_json", - "subxt-core 0.43.0", - "subxt-lightclient 0.43.0", - "thiserror 2.0.18", - "tokio-util", - "tracing", - "url", -] - [[package]] name = "subxt-rpcs" version = "0.44.2" @@ -15272,41 +15087,13 @@ dependencies = [ "primitive-types 0.13.1", "serde", "serde_json", - "subxt-core 0.44.2", - "subxt-lightclient 0.44.2", + "subxt-core", + "subxt-lightclient", "thiserror 2.0.18", "tracing", "url", ] -[[package]] -name = "subxt-signer" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a9bd240ae819f64ac6898d7ec99a88c8b838dba2fb9d83b843feb70e77e34c8" -dependencies = [ - "base64", - "bip39", - "cfg-if", - "crypto_secretbox", - "hex", - "hmac 0.12.1", - "parity-scale-codec", - "pbkdf2", - "regex", - "schnorrkel", - "scrypt", - "secp256k1 0.30.0", - "secrecy 0.10.3", - "serde", - "serde_json", - "sha2 0.10.9", - "sp-crypto-hashing 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "subxt-core 0.43.0", - "thiserror 2.0.18", - "zeroize", -] - [[package]] name = "subxt-signer" version = "0.44.2" @@ -15332,22 +15119,11 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sp-crypto-hashing 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "subxt-core 0.44.2", + "subxt-core", "thiserror 2.0.18", "zeroize", ] -[[package]] -name = "subxt-utils-fetchmetadata" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c4fb8fd6b16ecd3537a29d70699f329a68c1e47f70ed1a46d64f76719146563" -dependencies = [ - "hex", - "parity-scale-codec", - "thiserror 2.0.18", -] - [[package]] name = "subxt-utils-fetchmetadata" version = "0.44.2" diff --git a/Cargo.toml b/Cargo.toml index 57693526b..4564c87e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -171,7 +171,7 @@ substrate-wasm-builder = { git = "https://github.com/paritytech/polkadot-sdk.git [workspace] resolver = "2" members = [ - "integration-tests/authorize-preimage-subxt", + "node", "pallets/common", "pallets/relayer-set", diff --git a/integration-tests/authorize-preimage-subxt/Cargo.toml b/integration-tests/authorize-preimage-subxt/Cargo.toml deleted file mode 100644 index 16b9a99fd..000000000 --- a/integration-tests/authorize-preimage-subxt/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "authorize-preimage-subxt" -version = "0.1.0" -edition = "2021" - -[dependencies] -subxt = "0.43.1" -subxt-core = "0.43.0" -subxt-signer = "0.43.0" -scale-value = "0.18.1" -scale-info = "2" -parity-scale-codec = { version = "3", features = ["derive"] } -sp-core = { workspace = true } -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/integration-tests/authorize-preimage-subxt/src/main.rs b/integration-tests/authorize-preimage-subxt/src/main.rs deleted file mode 100644 index a306c6e22..000000000 --- a/integration-tests/authorize-preimage-subxt/src/main.rs +++ /dev/null @@ -1,235 +0,0 @@ -use std::{ - env, - time::{SystemTime, UNIX_EPOCH}, -}; - -use scale_value::{Composite as ScaleComposite, Value as ScaleValue, ValueDef as ScaleValueDef}; -use sp_core::hashing::blake2_256; -use std::marker::PhantomData; - -use parity_scale_codec::{Decode, Encode}; -use scale_info::PortableRegistry; -use subxt::{ - config::{Config, PolkadotConfig}, - dynamic::Value, - tx::SubmittableTransaction, - OnlineClient, -}; -use subxt_core::{ - client::ClientState, - config::{ - transaction_extensions::{ - AnyOf, ChargeAssetTxPayment, ChargeTransactionPayment, CheckGenesis, CheckMetadataHash, - CheckMortality, CheckNonce, CheckSpecVersion, CheckTxVersion, Params as TxParams, - TransactionExtension, - }, - DefaultExtrinsicParamsBuilder, ExtrinsicParams, ExtrinsicParamsEncoder, - }, - error::ExtrinsicParamsError, - tx, - utils::Static, -}; -use subxt_signer::sr25519::dev; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Encode, Decode)] -enum CidHashingAlgorithm { - Blake2b256, - Sha2_256, - Keccak256, -} - -#[derive(Clone, Debug, PartialEq, Eq, Encode, Decode)] -struct CidConfig { - codec: u64, - hashing: CidHashingAlgorithm, -} - -#[derive(Clone, Default)] -struct ProvideCidConfigParams(Option); - -impl ProvideCidConfigParams { - fn new(config: Option) -> Self { - Self(config) - } -} - -impl TxParams for ProvideCidConfigParams {} - -struct ProvideCidConfigExtension { - config: Option, - _marker: PhantomData, -} - -impl ExtrinsicParams for ProvideCidConfigExtension { - type Params = ProvideCidConfigParams; - - fn new(_client: &ClientState, params: Self::Params) -> Result { - Ok(Self { config: params.0, _marker: PhantomData }) - } -} - -impl ExtrinsicParamsEncoder for ProvideCidConfigExtension { - fn encode_value_to(&self, v: &mut Vec) { - self.config.encode_to(v); - } -} - -impl TransactionExtension for ProvideCidConfigExtension { - type Decoded = Static>; - - fn matches(identifier: &str, _type_id: u32, _types: &PortableRegistry) -> bool { - identifier == "ProvideCidConfig" - } -} - -type BulletinExtrinsicParams = AnyOf< - T, - ( - subxt_core::config::transaction_extensions::VerifySignature, - CheckSpecVersion, - CheckTxVersion, - CheckNonce, - CheckGenesis, - CheckMortality, - ChargeAssetTxPayment, - ChargeTransactionPayment, - CheckMetadataHash, - ProvideCidConfigExtension, - ), ->; - -#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] -enum BulletinConfig {} - -impl Config for BulletinConfig { - type AccountId = ::AccountId; - type Signature = ::Signature; - type Hasher = ::Hasher; - type Header = ::Header; - type AssetId = ::AssetId; - type Address = ::Address; - type ExtrinsicParams = BulletinExtrinsicParams; -} - -fn bytes_from_scale_value(value: ScaleValue) -> Option> { - match value.value { - ScaleValueDef::Composite(ScaleComposite::Unnamed(values)) => { - let mut bytes = Vec::with_capacity(values.len()); - for item in values { - let byte = item.as_u128()? as u8; - bytes.push(byte); - } - Some(bytes) - }, - _ => None, - } -} - -fn extract_content_hash(fields: ScaleComposite) -> Option<[u8; 32]> { - let ScaleComposite::Named(values) = fields else { - return None; - }; - let value = values - .into_iter() - .find(|(name, _)| name == "content_hash") - .map(|(_, value)| value)?; - let bytes = bytes_from_scale_value(value)?; - bytes.try_into().ok() -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - let ws_url = env::args().nth(1).unwrap_or_else(|| "ws://localhost:10000".to_string()); - - println!("Connecting to: {ws_url}"); - - let client = OnlineClient::::from_url(ws_url.clone()).await?; - let sudo_client = OnlineClient::::from_url(ws_url).await?; - - let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); - let data = format!("Hello, Bulletin with subxt preimage - {now}"); - let data_bytes = data.as_bytes().to_vec(); - let content_hash = blake2_256(&data_bytes); - - // Authorize the preimage using sudo. - let authorize_preimage = subxt::dynamic::tx( - "TransactionStorage", - "authorize_preimage", - vec![Value::from_bytes(content_hash), Value::u128(data_bytes.len() as u128)], - ); - let sudo_call = subxt::dynamic::tx("Sudo", "sudo", vec![authorize_preimage.into_value()]); - - let sudo_signer = dev::alice(); - sudo_client - .tx() - .sign_and_submit_then_watch_default(&sudo_call, &sudo_signer) - .await? - .wait_for_finalized_success() - .await?; - - // Submit the store call as an unsigned authorized transaction. - let store_call = - subxt::dynamic::tx("TransactionStorage", "store", vec![Value::from_bytes(&data_bytes)]); - - let metadata = client.metadata(); - let state = ClientState:: { - metadata: metadata.clone(), - genesis_hash: client.genesis_hash(), - runtime_version: client.runtime_version(), - }; - - let supported_versions = metadata.extrinsic().supported_versions(); - if !supported_versions.contains(&5) { - return Err("Transaction version v5 is required for AuthorizeCall flow".into()); - } - - let ( - verify_sig, - check_spec, - check_tx, - check_nonce, - check_genesis, - check_mortality, - charge_asset, - charge_tx, - check_metadata, - ) = DefaultExtrinsicParamsBuilder::::new() - .immortal() - .nonce(0) - .build(); - let cid_config = Some(CidConfig { codec: 0x55, hashing: CidHashingAlgorithm::Blake2b256 }); - let params = ( - verify_sig, - check_spec, - check_tx, - check_nonce, - check_genesis, - check_mortality, - charge_asset, - charge_tx, - check_metadata, - ProvideCidConfigParams::new(cid_config), - ); - let partial = tx::create_v5_general(&store_call, &state, params)?; - let tx = partial.to_transaction(); - let submittable = SubmittableTransaction::from_bytes(client.clone(), tx.into_encoded()); - let events = submittable.submit_and_watch().await?.wait_for_finalized_success().await?; - let mut found = false; - for event in events.iter() { - let event = event?; - if event.pallet_name() == "TransactionStorage" && event.variant_name() == "Stored" { - if let Some(event_hash) = extract_content_hash(event.field_values()?) { - if event_hash == content_hash { - found = true; - break; - } - } - } - } - if !found { - return Err("Stored event with matching content hash not found".into()); - } - - println!("โœ… Preimage authorized unsigned store succeeded (event verified)"); - Ok(()) -} From a92f6e12622257c3a9dc17dee32abaf914e94156 Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Wed, 25 Mar 2026 11:02:53 +0000 Subject: [PATCH 24/26] General tx with papi --- examples/api.js | 38 +++- examples/authorize_preimage_and_store_papi.js | 10 +- examples/general_tx.js | 194 ++++++++++++++++++ runtimes/bulletin-polkadot/src/lib.rs | 1 + 4 files changed, 231 insertions(+), 12 deletions(-) create mode 100644 examples/general_tx.js diff --git a/examples/api.js b/examples/api.js index ea48697c3..be834a8f1 100644 --- a/examples/api.js +++ b/examples/api.js @@ -3,6 +3,7 @@ import assert from 'assert'; import { cidFromBytes } from "./cid_dag_metadata.js"; import { Binary, Enum } from '@polkadot-api/substrate-bindings'; import { CHUNK_SIZE, toHex, toHashingEnum } from './common.js'; +import { bareToGeneralTx, getRuntimeSpecName, getExtensionDefaults } from './general_tx.js'; // Convert data to Binary for PAPI (handles string, Uint8Array, and array-like types) function toBinary(data) { @@ -108,7 +109,7 @@ export async function authorizePreimage( } } -export async function store(typedApi, signer, data, cidCodec = null, mhCode = null, txMode = TX_MODE_IN_BLOCK, client = null) { +export async function store(typedApi, signer, data, cidCodec = null, mhCode = null, txMode = TX_MODE_IN_BLOCK, client = null, wsUrl = null) { console.log('โฌ†๏ธ Storing data with length=', data.length); let expectedCid; @@ -127,7 +128,7 @@ export async function store(typedApi, signer, data, cidCodec = null, mhCode = nu tx = typedApi.tx.TransactionStorage.store({ data: toBinary(data) }); } - const result = await waitForTransaction(tx, signer, "Store", txMode, DEFAULT_TX_TIMEOUT_MS, client); + const result = await waitForTransaction(tx, signer, "Store", txMode, DEFAULT_TX_TIMEOUT_MS, client, {}, wsUrl); return { cid: expectedCid, blockHash: result?.block?.hash, blockNumber: result?.block?.number }; } @@ -138,6 +139,23 @@ export const TX_MODE_IN_POOL = "in-tx-pool"; const DEFAULT_TX_TIMEOUT_MS = 180_000; // 180 seconds or 30 blocks +// Cache for extension defaults (fetched once per WS URL) +let cachedExtensionDefaults = null; + +/** + * Get the default extension bytes for unsigned general transactions. + * Queries the runtime version to determine the runtime, then returns the + * appropriate default extension bytes. Cached after first call. + */ +async function getExtensionDefaultsForRuntime(wsUrl) { + if (cachedExtensionDefaults) return cachedExtensionDefaults; + + const specName = await getRuntimeSpecName(wsUrl); + console.log(`๐Ÿ“‹ Runtime spec: ${specName}`); + cachedExtensionDefaults = getExtensionDefaults(specName); + return cachedExtensionDefaults; +} + const TX_MODE_CONFIG = { [TX_MODE_IN_BLOCK]: { match: (ev) => ev.type === "txBestBlocksState" && ev.found, @@ -153,23 +171,27 @@ const TX_MODE_CONFIG = { }, }; -export async function waitForTransaction(tx, signer = null, txName, txMode = TX_MODE_IN_BLOCK, timeoutMs = DEFAULT_TX_TIMEOUT_MS, client = null, txOpts = {}) { +export async function waitForTransaction(tx, signer = null, txName, txMode = TX_MODE_IN_BLOCK, timeoutMs = DEFAULT_TX_TIMEOUT_MS, client = null, txOpts = {}, wsUrl = null) { const config = TX_MODE_CONFIG[txMode]; if (!config) { throw new Error(`Unhandled txMode: ${txMode}`); } - // Get the observable - either signed or unsigned + // Get the observable - either signed or unsigned (general) let observable; if (signer === null) { console.log(`โฌ†๏ธ Submitting unsigned ${txName}`); - // TODO: https://github.com/polkadot-api/polkadot-api/issues/760 - // const bareTx = await tx.getBareTx(txOpts); if (Object.keys(txOpts).length > 0) { - throw new Error(`txOpts not supported for unsigned transactions (getBareTx doesn't accept options). See: https://github.com/polkadot-api/polkadot-api/issues/760`); + throw new Error(`txOpts not supported for unsigned transactions`); } + // With #[pallet::authorize], unsigned transactions must be submitted as "general" + // extrinsics (with extension pipeline) rather than "bare" extrinsics (which bypass + // all extensions including AuthorizeCall). const bareTx = await tx.getBareTx(); - observable = client.submitAndWatch(bareTx); + const extensionDefaults = await getExtensionDefaultsForRuntime(wsUrl || 'ws://127.0.0.1:10000'); + const generalTx = bareToGeneralTx(bareTx, extensionDefaults); + console.log(`๐Ÿ”„ Converted bare tx to general tx for AuthorizeCall extension`); + observable = client.submitAndWatch(generalTx); } else { observable = tx.signSubmitAndWatch(signer, txOpts); } diff --git a/examples/authorize_preimage_and_store_papi.js b/examples/authorize_preimage_and_store_papi.js index 0bb85bbef..4bde433ae 100644 --- a/examples/authorize_preimage_and_store_papi.js +++ b/examples/authorize_preimage_and_store_papi.js @@ -26,7 +26,7 @@ const HTTP_IPFS_API = args[2] || DEFAULT_IPFS_GATEWAY_URL; * @param {number|null} mhCode - Multihash code (null for default) * @param {object|null} client - Client for unsigned transactions */ -async function runPreimageStoreTest(testName, bulletinAPI, authorizationSigner, signer, signerAddress, cidCodec, mhCode, client) { +async function runPreimageStoreTest(testName, bulletinAPI, authorizationSigner, signer, signerAddress, cidCodec, mhCode, client, wsUrl) { logSection(testName); // Data to store @@ -61,7 +61,7 @@ async function runPreimageStoreTest(testName, bulletinAPI, authorizationSigner, } // Store data - const { cid } = await store(bulletinAPI, signer, dataToStore, cidCodec, mhCode, TX_MODE_IN_BLOCK, client); + const { cid } = await store(bulletinAPI, signer, dataToStore, cidCodec, mhCode, TX_MODE_IN_BLOCK, client, wsUrl); logSuccess(`Data stored successfully with CID: ${cid.toString()}`); // Read back from IPFS @@ -110,7 +110,8 @@ async function main() { null, // no signer address null, // default codec null, // default hash - client + client, + NODE_WS ); // Test 2: Signed store with preimage auth and custom CID config (raw + SHA2-256) @@ -123,7 +124,8 @@ async function main() { whoAddress, // signer address for account auth 0x55, // raw 0x12, // sha2-256 - client + client, + NODE_WS ); logTestResult(true, 'Authorize Preimage and Store Test'); diff --git a/examples/general_tx.js b/examples/general_tx.js new file mode 100644 index 000000000..1781e241f --- /dev/null +++ b/examples/general_tx.js @@ -0,0 +1,194 @@ +/** + * General extrinsic construction utilities. + * + * With #[pallet::authorize] replacing ValidateUnsigned, unsigned transactions must be + * submitted as "general" extrinsics (Preamble::General) rather than "bare" extrinsics + * (Preamble::Bare). General extrinsics include the transaction extension pipeline but + * no signature, allowing AuthorizeCall to process the call's authorization logic. + * + * Extrinsic v5 format: + * Bare: compact_length | 0x05 | call_data + * Signed: compact_length | 0xC5 | address | signature | extension_data | call_data + * General: compact_length | 0x45 | 0x00 | extension_data | call_data + * + * For unsigned general transactions, all extensions use default/zero values since they + * skip validation when the origin is None/Authorized. + */ + +const EXTRINSIC_FORMAT_VERSION = 5; +const GENERAL_EXTRINSIC = 0b0100_0000; +const BARE_EXTRINSIC = 0b0000_0000; +const EXTENSION_VERSION = 0; + +const GENERAL_PREAMBLE = EXTRINSIC_FORMAT_VERSION | GENERAL_EXTRINSIC; // 0x45 +const BARE_PREAMBLE = EXTRINSIC_FORMAT_VERSION | BARE_EXTRINSIC; // 0x05 + +/** + * Default extension explicit bytes for unsigned general transactions per runtime. + * + * For unsigned transactions, all extensions skip validation when there's no signer, + * so default zero values are safe. The bytes are the SCALE-encoded concatenation of + * each extension's explicit type in TxExtension order. + * + * Extensions with unit type encode as 0 bytes. + * Non-unit extensions all happen to encode as 0x00 for their default: + * - CheckEra: Era::Immortal = 0x00 + * - CheckNonce: Compact(0) = 0x00 + * - ChargeTransactionPayment: Compact(0) = 0x00 (tip = 0) + * - CheckMetadataHash: Mode::Disabled = 0x00 + */ +const RUNTIME_EXTENSION_DEFAULTS = { + // bulletin-polkadot (solochain): TxExtension = ( + // AuthorizeCall, CheckNonZeroSender, CheckSpecVersion, CheckTxVersion, + // CheckGenesis, CheckEra, CheckNonce, CheckWeight, + // ValidateStorageCalls, AllowedSignedCalls, BridgeRejectObsoleteHeadersAndMessages + // ) + // Non-unit: CheckEra(0x00) + CheckNonce(0x00) + 'bulletin-polkadot': new Uint8Array([0x00, 0x00]), + + // bulletin-westend (parachain): TxExtension = StorageWeightReclaim, + // ValidateStorageCalls, CheckMetadataHash + // )> + // Non-unit: CheckEra(0x00) + CheckNonce(0x00) + ChargeTransactionPayment(0x00) + CheckMetadataHash(0x00) + 'bulletin-westend': new Uint8Array([0x00, 0x00, 0x00, 0x00]), +}; + +// ---- SCALE compact encoding/decoding ---- + +function encodeCompact(value) { + if (value <= 0x3f) { + return new Uint8Array([(value << 2)]); + } else if (value <= 0x3fff) { + const v = (value << 2) | 0x01; + return new Uint8Array([v & 0xff, (v >> 8) & 0xff]); + } else if (value <= 0x3fffffff) { + const v = (value << 2) | 0x02; + return new Uint8Array([v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >> 24) & 0xff]); + } else { + throw new Error(`Value ${value} too large for compact encoding`); + } +} + +function decodeCompact(bytes, offset = 0) { + const mode = bytes[offset] & 0x03; + if (mode === 0) { + return { value: bytes[offset] >> 2, bytesRead: 1 }; + } else if (mode === 1) { + const value = ((bytes[offset] | (bytes[offset + 1] << 8)) >> 2) >>> 0; + return { value, bytesRead: 2 }; + } else if (mode === 2) { + const value = ((bytes[offset] | (bytes[offset + 1] << 8) | + (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24)) >> 2) >>> 0; + return { value, bytesRead: 4 }; + } else { + throw new Error('Big integer compact encoding not supported'); + } +} + +// ---- Hex conversion ---- + +function hexToBytes(hex) { + if (hex.startsWith('0x')) hex = hex.slice(2); + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.substr(i * 2, 2), 16); + } + return bytes; +} + +function bytesToHex(bytes) { + return '0x' + Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Query the runtime's specName via JSON-RPC. + * + * @param {string} wsUrl - WebSocket URL (converted to HTTP for the RPC call) + * @returns {Promise} The runtime's specName + */ +export async function getRuntimeSpecName(wsUrl) { + const httpUrl = wsUrl.replace(/^ws(s?):\/\//, 'http$1://'); + const response = await fetch(httpUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'state_getRuntimeVersion', + params: [], + }), + }); + + if (!response.ok) { + throw new Error(`Failed to fetch runtime version: HTTP ${response.status}`); + } + + const json = await response.json(); + if (json.error) { + throw new Error(`RPC error: ${json.error.message}`); + } + + return json.result.specName; +} + +/** + * Get the default extension bytes for a given runtime spec name. + * + * @param {string} specName - Runtime spec name (e.g., "bulletin-polkadot", "bulletin-westend") + * @returns {Uint8Array} Default extension bytes for unsigned general transactions + */ +export function getExtensionDefaults(specName) { + const defaults = RUNTIME_EXTENSION_DEFAULTS[specName]; + if (!defaults) { + throw new Error( + `Unknown runtime "${specName}". Known runtimes: ${Object.keys(RUNTIME_EXTENSION_DEFAULTS).join(', ')}. ` + + `Add extension defaults for this runtime in general_tx.js.` + ); + } + return defaults; +} + +/** + * Convert a bare extrinsic (from PAPI's getBareTx) to a general extrinsic. + * + * @param {string} bareTxHex - Hex-encoded bare extrinsic from PAPI's tx.getBareTx() + * @param {Uint8Array} extensionBytes - SCALE-encoded default extension data + * @returns {string} Hex-encoded general extrinsic ready for submission + */ +export function bareToGeneralTx(bareTxHex, extensionBytes) { + const bareTxBytes = hexToBytes(bareTxHex); + + // Parse compact length prefix of the bare extrinsic + const { value: bodyLen, bytesRead } = decodeCompact(bareTxBytes); + const bodyStart = bytesRead; + + // Verify bare preamble byte + const preamble = bareTxBytes[bodyStart]; + if (preamble !== BARE_PREAMBLE) { + throw new Error( + `Expected bare extrinsic preamble 0x${BARE_PREAMBLE.toString(16)}, ` + + `got 0x${preamble.toString(16)}` + ); + } + + // Extract call data (everything after the preamble byte within the body) + const callBytes = bareTxBytes.slice(bodyStart + 1, bodyStart + bodyLen); + + // Build general extrinsic body: preamble + ext_version + extension_data + call_data + const body = new Uint8Array(1 + 1 + extensionBytes.length + callBytes.length); + body[0] = GENERAL_PREAMBLE; + body[1] = EXTENSION_VERSION; + body.set(extensionBytes, 2); + body.set(callBytes, 2 + extensionBytes.length); + + // Prepend compact-encoded body length + const lengthPrefix = encodeCompact(body.length); + const generalTx = new Uint8Array(lengthPrefix.length + body.length); + generalTx.set(lengthPrefix); + generalTx.set(body, lengthPrefix.length); + + return bytesToHex(generalTx); +} diff --git a/runtimes/bulletin-polkadot/src/lib.rs b/runtimes/bulletin-polkadot/src/lib.rs index 6d21b47f4..2c5db9a46 100644 --- a/runtimes/bulletin-polkadot/src/lib.rs +++ b/runtimes/bulletin-polkadot/src/lib.rs @@ -837,6 +837,7 @@ generate_bridge_reject_obsolete_headers_and_messages! { /// NOTE: `ValidateStorageCalls` must come before `AllowedSignedCalls` because it transforms /// the origin for signed TransactionStorage calls, and `AllowedSignedCalls` needs to detect this. pub type TxExtension = ( + frame_system::AuthorizeCall, frame_system::CheckNonZeroSender, frame_system::CheckSpecVersion, frame_system::CheckTxVersion, From 655bc31b1e7de4e50ffad7f01d691ef6493fd426 Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Wed, 25 Mar 2026 11:24:04 +0000 Subject: [PATCH 25/26] Fixed test --- runtimes/bulletin-polkadot/tests/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/runtimes/bulletin-polkadot/tests/tests.rs b/runtimes/bulletin-polkadot/tests/tests.rs index 68a69cb61..8b00622b7 100644 --- a/runtimes/bulletin-polkadot/tests/tests.rs +++ b/runtimes/bulletin-polkadot/tests/tests.rs @@ -271,6 +271,7 @@ fn construct_extrinsic( let account_id = sp_runtime::AccountId32::from(sender.public()); frame_system::BlockHash::::insert(0, Hash::default()); let tx_ext: TxExtension = ( + frame_system::AuthorizeCall::::new(), frame_system::CheckNonZeroSender::::new(), frame_system::CheckSpecVersion::::new(), frame_system::CheckTxVersion::::new(), From 723db1838d3268667d61f734a4273fc54859cf30 Mon Sep 17 00:00:00 2001 From: Anthony Kveder Date: Fri, 27 Mar 2026 10:54:15 +0000 Subject: [PATCH 26/26] Updated general TX script to recommended generalSigner syntax --- examples/api.js | 38 +-- examples/authorize_preimage_and_store_papi.js | 10 +- examples/general_tx.js | 223 ++++-------------- 3 files changed, 60 insertions(+), 211 deletions(-) diff --git a/examples/api.js b/examples/api.js index be834a8f1..15e113a88 100644 --- a/examples/api.js +++ b/examples/api.js @@ -3,7 +3,7 @@ import assert from 'assert'; import { cidFromBytes } from "./cid_dag_metadata.js"; import { Binary, Enum } from '@polkadot-api/substrate-bindings'; import { CHUNK_SIZE, toHex, toHashingEnum } from './common.js'; -import { bareToGeneralTx, getRuntimeSpecName, getExtensionDefaults } from './general_tx.js'; +import { createGeneralSigner } from './general_tx.js'; // Convert data to Binary for PAPI (handles string, Uint8Array, and array-like types) function toBinary(data) { @@ -109,7 +109,7 @@ export async function authorizePreimage( } } -export async function store(typedApi, signer, data, cidCodec = null, mhCode = null, txMode = TX_MODE_IN_BLOCK, client = null, wsUrl = null) { +export async function store(typedApi, signer, data, cidCodec = null, mhCode = null, txMode = TX_MODE_IN_BLOCK, client = null) { console.log('โฌ†๏ธ Storing data with length=', data.length); let expectedCid; @@ -128,7 +128,7 @@ export async function store(typedApi, signer, data, cidCodec = null, mhCode = nu tx = typedApi.tx.TransactionStorage.store({ data: toBinary(data) }); } - const result = await waitForTransaction(tx, signer, "Store", txMode, DEFAULT_TX_TIMEOUT_MS, client, {}, wsUrl); + const result = await waitForTransaction(tx, signer, "Store", txMode, DEFAULT_TX_TIMEOUT_MS, client); return { cid: expectedCid, blockHash: result?.block?.hash, blockNumber: result?.block?.number }; } @@ -139,22 +139,8 @@ export const TX_MODE_IN_POOL = "in-tx-pool"; const DEFAULT_TX_TIMEOUT_MS = 180_000; // 180 seconds or 30 blocks -// Cache for extension defaults (fetched once per WS URL) -let cachedExtensionDefaults = null; - -/** - * Get the default extension bytes for unsigned general transactions. - * Queries the runtime version to determine the runtime, then returns the - * appropriate default extension bytes. Cached after first call. - */ -async function getExtensionDefaultsForRuntime(wsUrl) { - if (cachedExtensionDefaults) return cachedExtensionDefaults; - - const specName = await getRuntimeSpecName(wsUrl); - console.log(`๐Ÿ“‹ Runtime spec: ${specName}`); - cachedExtensionDefaults = getExtensionDefaults(specName); - return cachedExtensionDefaults; -} +// Singleton general signer for unsigned transactions (reusable across calls) +const generalSigner = createGeneralSigner(); const TX_MODE_CONFIG = { [TX_MODE_IN_BLOCK]: { @@ -171,7 +157,7 @@ const TX_MODE_CONFIG = { }, }; -export async function waitForTransaction(tx, signer = null, txName, txMode = TX_MODE_IN_BLOCK, timeoutMs = DEFAULT_TX_TIMEOUT_MS, client = null, txOpts = {}, wsUrl = null) { +export async function waitForTransaction(tx, signer = null, txName, txMode = TX_MODE_IN_BLOCK, timeoutMs = DEFAULT_TX_TIMEOUT_MS, client = null, txOpts = {}) { const config = TX_MODE_CONFIG[txMode]; if (!config) { throw new Error(`Unhandled txMode: ${txMode}`); @@ -180,18 +166,12 @@ export async function waitForTransaction(tx, signer = null, txName, txMode = TX_ // Get the observable - either signed or unsigned (general) let observable; if (signer === null) { - console.log(`โฌ†๏ธ Submitting unsigned ${txName}`); - if (Object.keys(txOpts).length > 0) { - throw new Error(`txOpts not supported for unsigned transactions`); - } + console.log(`โฌ†๏ธ Submitting unsigned ${txName} as general transaction`); // With #[pallet::authorize], unsigned transactions must be submitted as "general" // extrinsics (with extension pipeline) rather than "bare" extrinsics (which bypass // all extensions including AuthorizeCall). - const bareTx = await tx.getBareTx(); - const extensionDefaults = await getExtensionDefaultsForRuntime(wsUrl || 'ws://127.0.0.1:10000'); - const generalTx = bareToGeneralTx(bareTx, extensionDefaults); - console.log(`๐Ÿ”„ Converted bare tx to general tx for AuthorizeCall extension`); - observable = client.submitAndWatch(generalTx); + // See: https://github.com/polkadot-api/polkadot-api/issues/760 + observable = tx.signSubmitAndWatch(generalSigner, txOpts); } else { observable = tx.signSubmitAndWatch(signer, txOpts); } diff --git a/examples/authorize_preimage_and_store_papi.js b/examples/authorize_preimage_and_store_papi.js index 4bde433ae..0bb85bbef 100644 --- a/examples/authorize_preimage_and_store_papi.js +++ b/examples/authorize_preimage_and_store_papi.js @@ -26,7 +26,7 @@ const HTTP_IPFS_API = args[2] || DEFAULT_IPFS_GATEWAY_URL; * @param {number|null} mhCode - Multihash code (null for default) * @param {object|null} client - Client for unsigned transactions */ -async function runPreimageStoreTest(testName, bulletinAPI, authorizationSigner, signer, signerAddress, cidCodec, mhCode, client, wsUrl) { +async function runPreimageStoreTest(testName, bulletinAPI, authorizationSigner, signer, signerAddress, cidCodec, mhCode, client) { logSection(testName); // Data to store @@ -61,7 +61,7 @@ async function runPreimageStoreTest(testName, bulletinAPI, authorizationSigner, } // Store data - const { cid } = await store(bulletinAPI, signer, dataToStore, cidCodec, mhCode, TX_MODE_IN_BLOCK, client, wsUrl); + const { cid } = await store(bulletinAPI, signer, dataToStore, cidCodec, mhCode, TX_MODE_IN_BLOCK, client); logSuccess(`Data stored successfully with CID: ${cid.toString()}`); // Read back from IPFS @@ -110,8 +110,7 @@ async function main() { null, // no signer address null, // default codec null, // default hash - client, - NODE_WS + client ); // Test 2: Signed store with preimage auth and custom CID config (raw + SHA2-256) @@ -124,8 +123,7 @@ async function main() { whoAddress, // signer address for account auth 0x55, // raw 0x12, // sha2-256 - client, - NODE_WS + client ); logTestResult(true, 'Authorize Preimage and Store Test'); diff --git a/examples/general_tx.js b/examples/general_tx.js index 1781e241f..5216bdf03 100644 --- a/examples/general_tx.js +++ b/examples/general_tx.js @@ -1,194 +1,65 @@ /** - * General extrinsic construction utilities. + * General transaction signer for #[pallet::authorize] calls. * * With #[pallet::authorize] replacing ValidateUnsigned, unsigned transactions must be * submitted as "general" extrinsics (Preamble::General) rather than "bare" extrinsics * (Preamble::Bare). General extrinsics include the transaction extension pipeline but * no signature, allowing AuthorizeCall to process the call's authorization logic. * - * Extrinsic v5 format: - * Bare: compact_length | 0x05 | call_data - * Signed: compact_length | 0xC5 | address | signature | extension_data | call_data - * General: compact_length | 0x45 | 0x00 | extension_data | call_data + * This uses a custom PolkadotSigner that constructs v5 general transactions using the + * runtime metadata to properly encode extension data, rather than hardcoding defaults. * - * For unsigned general transactions, all extensions use default/zero values since they - * skip validation when the origin is None/Authorized. + * See: https://github.com/polkadot-api/polkadot-api/issues/760 */ -const EXTRINSIC_FORMAT_VERSION = 5; -const GENERAL_EXTRINSIC = 0b0100_0000; -const BARE_EXTRINSIC = 0b0000_0000; -const EXTENSION_VERSION = 0; - -const GENERAL_PREAMBLE = EXTRINSIC_FORMAT_VERSION | GENERAL_EXTRINSIC; // 0x45 -const BARE_PREAMBLE = EXTRINSIC_FORMAT_VERSION | BARE_EXTRINSIC; // 0x05 - -/** - * Default extension explicit bytes for unsigned general transactions per runtime. - * - * For unsigned transactions, all extensions skip validation when there's no signer, - * so default zero values are safe. The bytes are the SCALE-encoded concatenation of - * each extension's explicit type in TxExtension order. - * - * Extensions with unit type encode as 0 bytes. - * Non-unit extensions all happen to encode as 0x00 for their default: - * - CheckEra: Era::Immortal = 0x00 - * - CheckNonce: Compact(0) = 0x00 - * - ChargeTransactionPayment: Compact(0) = 0x00 (tip = 0) - * - CheckMetadataHash: Mode::Disabled = 0x00 - */ -const RUNTIME_EXTENSION_DEFAULTS = { - // bulletin-polkadot (solochain): TxExtension = ( - // AuthorizeCall, CheckNonZeroSender, CheckSpecVersion, CheckTxVersion, - // CheckGenesis, CheckEra, CheckNonce, CheckWeight, - // ValidateStorageCalls, AllowedSignedCalls, BridgeRejectObsoleteHeadersAndMessages - // ) - // Non-unit: CheckEra(0x00) + CheckNonce(0x00) - 'bulletin-polkadot': new Uint8Array([0x00, 0x00]), - - // bulletin-westend (parachain): TxExtension = StorageWeightReclaim, - // ValidateStorageCalls, CheckMetadataHash - // )> - // Non-unit: CheckEra(0x00) + CheckNonce(0x00) + ChargeTransactionPayment(0x00) + CheckMetadataHash(0x00) - 'bulletin-westend': new Uint8Array([0x00, 0x00, 0x00, 0x00]), -}; - -// ---- SCALE compact encoding/decoding ---- - -function encodeCompact(value) { - if (value <= 0x3f) { - return new Uint8Array([(value << 2)]); - } else if (value <= 0x3fff) { - const v = (value << 2) | 0x01; - return new Uint8Array([v & 0xff, (v >> 8) & 0xff]); - } else if (value <= 0x3fffffff) { - const v = (value << 2) | 0x02; - return new Uint8Array([v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >> 24) & 0xff]); - } else { - throw new Error(`Value ${value} too large for compact encoding`); - } -} - -function decodeCompact(bytes, offset = 0) { - const mode = bytes[offset] & 0x03; - if (mode === 0) { - return { value: bytes[offset] >> 2, bytesRead: 1 }; - } else if (mode === 1) { - const value = ((bytes[offset] | (bytes[offset + 1] << 8)) >> 2) >>> 0; - return { value, bytesRead: 2 }; - } else if (mode === 2) { - const value = ((bytes[offset] | (bytes[offset + 1] << 8) | - (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24)) >> 2) >>> 0; - return { value, bytesRead: 4 }; - } else { - throw new Error('Big integer compact encoding not supported'); - } -} - -// ---- Hex conversion ---- - -function hexToBytes(hex) { - if (hex.startsWith('0x')) hex = hex.slice(2); - const bytes = new Uint8Array(hex.length / 2); - for (let i = 0; i < bytes.length; i++) { - bytes[i] = parseInt(hex.substr(i * 2, 2), 16); - } - return bytes; -} - -function bytesToHex(bytes) { - return '0x' + Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); -} +import { + compact, + decAnyMetadata, + extrinsicFormat, + unifyMetadata, +} from "@polkadot-api/substrate-bindings" +import { mergeUint8 } from "polkadot-api/utils" -/** - * Query the runtime's specName via JSON-RPC. - * - * @param {string} wsUrl - WebSocket URL (converted to HTTP for the RPC call) - * @returns {Promise} The runtime's specName - */ -export async function getRuntimeSpecName(wsUrl) { - const httpUrl = wsUrl.replace(/^ws(s?):\/\//, 'http$1://'); - const response = await fetch(httpUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'state_getRuntimeVersion', - params: [], - }), - }); - - if (!response.ok) { - throw new Error(`Failed to fetch runtime version: HTTP ${response.status}`); - } - - const json = await response.json(); - if (json.error) { - throw new Error(`RPC error: ${json.error.message}`); - } - - return json.result.specName; -} +const EXTENSION_VERSION = 0; /** - * Get the default extension bytes for a given runtime spec name. + * Create a PolkadotSigner that produces v5 general transactions (unsigned with extensions). * - * @param {string} specName - Runtime spec name (e.g., "bulletin-polkadot", "bulletin-westend") - * @returns {Uint8Array} Default extension bytes for unsigned general transactions - */ -export function getExtensionDefaults(specName) { - const defaults = RUNTIME_EXTENSION_DEFAULTS[specName]; - if (!defaults) { - throw new Error( - `Unknown runtime "${specName}". Known runtimes: ${Object.keys(RUNTIME_EXTENSION_DEFAULTS).join(', ')}. ` + - `Add extension defaults for this runtime in general_tx.js.` - ); - } - return defaults; -} - -/** - * Convert a bare extrinsic (from PAPI's getBareTx) to a general extrinsic. + * Usage: + * const signer = createGeneralSigner(); + * await tx.signSubmitAndWatch(signer).subscribe(...); * - * @param {string} bareTxHex - Hex-encoded bare extrinsic from PAPI's tx.getBareTx() - * @param {Uint8Array} extensionBytes - SCALE-encoded default extension data - * @returns {string} Hex-encoded general extrinsic ready for submission + * @returns {import("polkadot-api").PolkadotSigner} */ -export function bareToGeneralTx(bareTxHex, extensionBytes) { - const bareTxBytes = hexToBytes(bareTxHex); - - // Parse compact length prefix of the bare extrinsic - const { value: bodyLen, bytesRead } = decodeCompact(bareTxBytes); - const bodyStart = bytesRead; - - // Verify bare preamble byte - const preamble = bareTxBytes[bodyStart]; - if (preamble !== BARE_PREAMBLE) { - throw new Error( - `Expected bare extrinsic preamble 0x${BARE_PREAMBLE.toString(16)}, ` + - `got 0x${preamble.toString(16)}` - ); +export function createGeneralSigner() { + return { + publicKey: new Uint8Array(32), + signBytes() { + throw new Error("Unsupported: generalSigner does not support signBytes") + }, + async signTx(callData, signedExtensions, metadata) { + const decMeta = unifyMetadata(decAnyMetadata(metadata)) + + const extra = decMeta.extrinsic.signedExtensions[EXTENSION_VERSION].map( + ({ identifier }) => { + const signedExtension = signedExtensions[identifier] + if (!signedExtension) + throw new Error(`Missing ${identifier} signed extension`) + return signedExtension.value + }, + ) + + const preResult = mergeUint8([ + extrinsicFormat.enc({ + version: 5, + type: "general", + }), + new Uint8Array([EXTENSION_VERSION]), + ...extra, + callData, + ]) + + return mergeUint8([compact.enc(preResult.length), preResult]) + }, } - - // Extract call data (everything after the preamble byte within the body) - const callBytes = bareTxBytes.slice(bodyStart + 1, bodyStart + bodyLen); - - // Build general extrinsic body: preamble + ext_version + extension_data + call_data - const body = new Uint8Array(1 + 1 + extensionBytes.length + callBytes.length); - body[0] = GENERAL_PREAMBLE; - body[1] = EXTENSION_VERSION; - body.set(extensionBytes, 2); - body.set(callBytes, 2 + extensionBytes.length); - - // Prepend compact-encoded body length - const lengthPrefix = encodeCompact(body.length); - const generalTx = new Uint8Array(lengthPrefix.length + body.length); - generalTx.set(lengthPrefix); - generalTx.set(body, lengthPrefix.length); - - return bytesToHex(generalTx); }