From 05a30802d77d119c13f5a4df3cfabfede640c561 Mon Sep 17 00:00:00 2001 From: Rodrigo Quelhas Date: Tue, 30 Dec 2025 21:30:11 +0000 Subject: [PATCH] Migrate pallet-example-offchain-worker to TransactionExtension API This commit migrates `pallet-example-offchain-worker` from the deprecated `ValidateUnsigned` trait to the modern `TransactionExtension` API using the `#[pallet::authorize]` attribute. Changes: - Replaced `#[pallet::validate_unsigned]` implementation with `#[pallet::authorize]` attributes on unsigned transaction calls - Updated `submit_price_unsigned` to use authorization attribute - Updated `submit_price_unsigned_with_signed_payload` to use authorization with signature verification - Changed `ensure_none` to `ensure_authorized` in unsigned transaction handlers - Updated documentation to reflect the new validation approach - Added comprehensive documentation about unsigned transaction validation This change demonstrates the recommended pattern for validating unsigned transactions and serves as a reference for other pallets migrating away from `ValidateUnsigned`. --- prdoc/pr_10716.prdoc | 21 ++++ .../frame/examples/offchain-worker/README.md | 22 ++++- .../frame/examples/offchain-worker/src/lib.rs | 99 +++++++++++-------- 3 files changed, 97 insertions(+), 45 deletions(-) create mode 100644 prdoc/pr_10716.prdoc diff --git a/prdoc/pr_10716.prdoc b/prdoc/pr_10716.prdoc new file mode 100644 index 0000000000000..9b630b59c4641 --- /dev/null +++ b/prdoc/pr_10716.prdoc @@ -0,0 +1,21 @@ +title: Migrate `pallet-example-offchain-worker` to use `TransactionExtension` API + +doc: + - audience: [Runtime Dev] + description: | + Migrates `pallet-example-offchain-worker` from the deprecated `ValidateUnsigned` trait + to the modern `TransactionExtension` API using the `#[pallet::authorize]` attribute. + + This change demonstrates how to validate unsigned transactions using the new approach, + which provides better composability and flexibility for transaction validation. + + The pallet now uses `#[pallet::authorize]` on unsigned transaction calls to validate: + - Block number is within the expected window + - Price data is valid + - For signed payloads, signature verification using the authority's public key + + This serves as a reference example for other pallets migrating away from `ValidateUnsigned`. + +crates: + - name: pallet-example-offchain-worker + bump: major diff --git a/substrate/frame/examples/offchain-worker/README.md b/substrate/frame/examples/offchain-worker/README.md index 7b8905cda3074..2c99d657b2f8a 100644 --- a/substrate/frame/examples/offchain-worker/README.md +++ b/substrate/frame/examples/offchain-worker/README.md @@ -9,7 +9,8 @@ documentation. - [`pallet_example_offchain_worker::Trait`](./trait.Trait.html) - [`Call`](./enum.Call.html) -- [`Module`](./struct.Module.html) +- [`Pallet`](./struct.Pallet.html) +- [`ValidateUnsignedPriceSubmission`](./struct.ValidateUnsignedPriceSubmission.html) **This pallet serves as an example showcasing Substrate off-chain worker and is not meant to be used in production.** @@ -23,7 +24,22 @@ and prepare either signed or unsigned transaction to feed the result back on cha The on-chain logic will simply aggregate the results and store last `64` values to compute the average price. Additional logic in OCW is put in place to prevent spamming the network with both signed -and unsigned transactions, and custom `UnsignedValidator` makes sure that there is only -one unsigned transaction floating in the network. +and unsigned transactions. The pallet uses the `#[pallet::authorize]` attribute to validate +unsigned transactions, ensuring that only one unsigned transaction can be accepted per +`UnsignedInterval` blocks. + +## Unsigned Transaction Validation + +This pallet demonstrates how to validate unsigned transactions using the modern +`#[pallet::authorize]` attribute instead of the deprecated `ValidateUnsigned` trait. + +The `#[pallet::authorize]` attribute is used on the unsigned transaction calls: +- `submit_price_unsigned` - Validates a simple unsigned transaction +- `submit_price_unsigned_with_signed_payload` - Validates an unsigned transaction with a signed payload + +The authorization logic checks: +1. Block number is within the expected window (via `NextUnsignedAt` storage) +2. The price data is valid +3. For signed payloads, verifies the signature using the authority's public key License: MIT-0 diff --git a/substrate/frame/examples/offchain-worker/src/lib.rs b/substrate/frame/examples/offchain-worker/src/lib.rs index 58a21a8395b4a..ec71e851810e2 100644 --- a/substrate/frame/examples/offchain-worker/src/lib.rs +++ b/substrate/frame/examples/offchain-worker/src/lib.rs @@ -46,8 +46,9 @@ //! The on-chain logic will simply aggregate the results and store last `64` values to compute //! the average price. //! Additional logic in OCW is put in place to prevent spamming the network with both signed -//! and unsigned transactions, and custom `UnsignedValidator` makes sure that there is only -//! one unsigned transaction floating in the network. +//! and unsigned transactions. The pallet uses the `#[pallet::authorize]` attribute to validate +//! unsigned transactions, ensuring that only one unsigned transaction can be accepted per +//! `UnsignedInterval` blocks. #![cfg_attr(not(feature = "std"), no_std)] @@ -55,7 +56,7 @@ extern crate alloc; use alloc::vec::Vec; use codec::{Decode, DecodeWithMemTracking, Encode}; -use frame_support::traits::Get; +use frame_support::{traits::Get, weights::Weight}; use frame_system::{ self as system, offchain::{ @@ -73,7 +74,10 @@ use sp_runtime::{ Duration, }, traits::Zero, - transaction_validity::{InvalidTransaction, TransactionValidity, ValidTransaction}, + transaction_validity::{ + InvalidTransaction, TransactionSource, TransactionValidity, TransactionValidityWithRefund, + ValidTransaction, + }, Debug, }; @@ -252,9 +256,6 @@ pub mod pallet { /// transaction without a signature, and hence without paying any fees, /// we need a way to make sure that only some transactions are accepted. /// This function can be called only once every `T::UnsignedInterval` blocks. - /// Transactions that call that function are de-duplicated on the pool level - /// via `validate_unsigned` implementation and also are rendered invalid if - /// the function has already been called in current "session". /// /// It's important to specify `weight` for unsigned calls as well, because even though /// they don't charge fees, we still don't want a single block to contain unlimited @@ -264,13 +265,33 @@ pub mod pallet { /// purpose is to showcase offchain worker capabilities. #[pallet::call_index(1)] #[pallet::weight({0})] + // Minimal weight since validation is lightweight. But in real-world scenarios, this should + // be benchmarked. + #[pallet::weight_of_authorize(Weight::from_parts(5_000, 0))] + #[pallet::authorize(| + _source: TransactionSource, + block_number: &BlockNumberFor, + new_price: &u32, + | -> TransactionValidityWithRefund { + let validity = Pallet::::validate_transaction_parameters(block_number, new_price); + match validity { + Ok(valid_tx) => { + // This is the amount to refund, here we refund nothing. + let refund = Weight::zero(); + + Ok((valid_tx, refund)) + }, + Err(e) => Err(e), + } + })] pub fn submit_price_unsigned( origin: OriginFor, _block_number: BlockNumberFor, price: u32, ) -> DispatchResultWithPostInfo { - // This ensures that the function can only be called via unsigned transaction. - ensure_none(origin)?; + // This ensures that the function can only be called via unsigned transaction + // after being validated in `pallet::authorize`. + ensure_authorized(origin)?; // Add the price to the on-chain list, but mark it as coming from an empty address. Self::add_price(None, price); // now increment the block number at which we expect next unsigned transaction. @@ -281,13 +302,37 @@ pub mod pallet { #[pallet::call_index(2)] #[pallet::weight({0})] + // Minimal weight since validation is lightweight. But in real-world scenarios, this should + // be benchmarked. + #[pallet::weight_of_authorize(Weight::from_parts(5_000, 0))] + #[pallet::authorize(| + _source: TransactionSource, + price_payload: &PricePayload>, + signature: &T::Signature, + | -> TransactionValidityWithRefund { + let signature_valid = SignedPayload::::verify::(price_payload, signature.clone()); + if !signature_valid { + return Err(InvalidTransaction::BadProof.into()) + } + let validity = Pallet::::validate_transaction_parameters(&price_payload.block_number, &price_payload.price); + match validity { + Ok(valid_tx) => { + // This is the amount to refund, here we refund nothing. + let refund = Weight::zero(); + + Ok((valid_tx, refund)) + }, + Err(e) => Err(e), + } + })] pub fn submit_price_unsigned_with_signed_payload( origin: OriginFor, price_payload: PricePayload>, _signature: T::Signature, ) -> DispatchResultWithPostInfo { - // This ensures that the function can only be called via unsigned transaction. - ensure_none(origin)?; + // This ensures that the function can only be called via unsigned transaction + // after being validated in `pallet::authorize`. + ensure_authorized(origin)?; // Add the price to the on-chain list, but mark it as coming from an empty address. Self::add_price(None, price_payload.price); // now increment the block number at which we expect next unsigned transaction. @@ -305,36 +350,6 @@ pub mod pallet { NewPrice { price: u32, maybe_who: Option }, } - #[pallet::validate_unsigned] - impl ValidateUnsigned for Pallet { - type Call = Call; - - /// Validate unsigned call to this module. - /// - /// By default unsigned transactions are disallowed, but implementing the validator - /// here we make sure that some particular calls (the ones produced by offchain worker) - /// are being whitelisted and marked as valid. - fn validate_unsigned(_source: TransactionSource, call: &Self::Call) -> TransactionValidity { - // Firstly let's check that we call the right function. - if let Call::submit_price_unsigned_with_signed_payload { - price_payload: ref payload, - ref signature, - } = call - { - let signature_valid = - SignedPayload::::verify::(payload, signature.clone()); - if !signature_valid { - return InvalidTransaction::BadProof.into() - } - Self::validate_transaction_parameters(&payload.block_number, &payload.price) - } else if let Call::submit_price_unsigned { block_number, price: new_price } = call { - Self::validate_transaction_parameters(block_number, new_price) - } else { - InvalidTransaction::Call.into() - } - } - } - /// A vector of recently submitted prices. /// /// This is used to calculate average price, should have bounded size. @@ -504,7 +519,7 @@ impl Pallet { // Here we showcase two ways to send an unsigned transaction / unsigned payload (raw) // // By default unsigned transactions are disallowed, so we need to whitelist this case - // by writing `UnsignedValidator`. Note that it's EXTREMELY important to carefully + // by implementing a `TransactionExtension`. Note that it's EXTREMELY important to carefully // implement unsigned validation logic, as any mistakes can lead to opening DoS or spam // attack vectors. See validation logic docs for more details. //