Blockchain networks depend on transaction fees in order to incentivize the nodes and miners that make the entire network operate. These transaction fees also serve to protect their networks against DDoS attacks by malicious hackers — it’s not so easy to cripple a network with an unmanageable burst of transaction activity if each transaction costs some amount of money.
But there are certain ways to send transactions in Polkadot at a cost of zero dollars. Let’s discuss the cases where a zero-fee transaction makes sense, as well as how to actually implement them while protecting yourself against cyberattacks.
Why would I want to send zero-fee transactions on Polkadot?
There are two main instances where killing the transaction fee makes sense. First is for onboarding new users, second is for taking technical action to maintain the system’s business logic.
When a new user already has tokens attributed to him and wants to claim them, they face the problem of not having an account from which to pay transaction fees. Whether those tokens were distributed via initial offering, lockdrop, or any other related mechanism, a zero-fee transaction solves the problem of delivering that user their token allocation.
These kinds of transactions also serve some behind-the-scenes technical purposes, like determining staking reward distribution, for example. It’s critical that a DeFi system stays up to date by getting relevant prices from oracles and holding liquidation auctions that are held instantly. But these kinds of actions in standard blockchains are just ordinary transactions that require transaction fees. Someone has to pay these fees to support the system, and it can get quite expensive to keep prices current from many sources. It’s also not profitable to pay off the debt in auctions if the profit is less than the network’s transaction cost.
Thankfully, substrate blockchains let us make these kinds of critical transactions for free, making the system cheaper and more stable for customers.
So how do I actually do this while staying safe from DDoS attacks?
Let’s get into the three types of no-fee transactions that exist, as well as cover how effectively they prevent cyberattacks. These transactions are the signed paid refund transaction, free signed transaction, and the unsigned transactions with a signed payload.
Signed paid refund transactions
This is probably the easiest way to implement free transactions. It’s best for those cases when an existing user wants to take a technical action without paying for it, like using the sudo pallet in the substrate repository.
#[pallet::weight({
let dispatch_info = call.get_dispatch_info();
(dispatch_info.weight.saturating_add(10_000), dispatch_info.class)
})]
pub fn sudo(
origin: OriginFor<T>,
call: Box<<T as Config>::Call>,
) -> DispatchResultWithPostInfo {
// This is a public call, so we ensure that the origin is some signed account.
let sender = ensure_signed(origin)?;
ensure!(sender == Self::key(), Error::<T>::RequireSudo);
let res = call.dispatch_bypass_filter(frame_system::RawOrigin::Root.into());
Self::deposit_event(Event::Sudid(res.map(|_| ()).map_err(|e| e.error)));
// Sudo user does not pay a fee.
Ok(Pays::No.into())
}
The main idea here is that a fee is actually taken from the user at the beginning of the transaction, but those funds are returned right back to them at the end of a successful transaction.This means only a potential attacker who sends invalid transactions will actually pay fees.
Free signed transactions
Consider the example of getting GENS tokens in exchange for locking EQ tokens in the network:
#[pallet::weight((10_000 + T::DbWeight::get().writes(1), Pays::No))]
pub fn claim(origin: OriginFor<T>) -> DispatchResultWithPostInfo {
let who = ensure_signed(origin)?;
Self::do_claim(who)?;
Ok(().into())
}
This method of free transactions is suitable when the calling account is new and doesn’t have assets to pay for transaction fees. (This is usually a request to receive tokens for a new account.) Here the DDoS vulnerability is most noticeable — any newly generated account can saturate the entire block with its transactions without paying anything for them. To avoid this problem, we need to create a SignedExtension of the following type:
pub struct CheckAllocation<T: Config + Send + Sync>(PhantomData<T>);
impl<T: Config + Send + Sync> SignedExtension for CheckAllocation<T>
where
T::Call: IsSubType<Call<T>>,
{
type AccountId = T::AccountId;
type Call = T::Call;
type AdditionalSigned = ();
type Pre = ();
const IDENTIFIER: &'static str = "CheckAllocation";
fn additional_signed(&self) -> sp_std::result::Result<(), TransactionValidityError> {
Ok(())
}
fn validate(
&self,
who: &Self::AccountId,
call: &Self::Call,
_info: &DispatchInfoOf<Self::Call>,
_len: usize,
) -> TransactionValidity {
match call.is_sub_type() {
Some(Call::claim(..)) => {
if !Allocations::<T>::contains_key(&who) ||
ClaimStart::<T>::get().is_none() {
InvalidTransaction::Call.into()
} else {
Ok(Default::default())
}
}
_ => Ok(Default::default())
}
}
}
Now when a transaction is added to the transaction pool, the transaction’s validity gets checked. In our case, we check that the account indeed has an allocation. The validate method should contain exactly the same checks found in the runtime method itself. Other calls should be default — see the code above.
Unsigned transactions with a signed payload
This is the most flexible way to create free transactions, but it is also the most difficult to implement. Here’s how it works.
The account sending the request may not have any funds, which is very convenient for technical nodes as there’s no danger of those funds being stolen. There is a possibility to optimally process technical actions independently of the sender. For example, it doesn't matter to us who sent the margin call request for the client's position. It is only important that the request is valid, and that there is exactly one request. If we used free signed transactions, then several margin calls for one client sent by different accounts could end up in the transaction pool. It is possible to arrange transactions in the desired order or priority. For example, we want all margin call transactions to be the first in the block.
But each one of these opportunities comes with some potential risks and vulnerabilities.
As with free signed transactions, it is important to duplicate all checks from runtime when validating a transaction. Because a transaction is not signed, then the nonces function is not available to us. We have to do the checks for "replaying" identical transactions and building the necessary transaction sequences ourselves. And if you make a mistake with your transaction prioritization, it is then possible to spam the entire transaction pool with only technical actions, clogging the rest of the functionality for everyone else.
Let’s take a deeper look at our oracle implementation:
impl<T: Config> frame_support::unsigned::ValidateUnsigned for Pallet<T> {
type Call = Call<T>;
fn validate_unsigned(_source: TransactionSource, call: &Self::Call) -> TransactionValidity {
if let Call::set_price_unsigned(payload, signature) = call {
let signature_valid =
SignedPayload::<T>::verify::<T::AuthorityId>(payload, signature.clone());
if !signature_valid {
return InvalidTransaction::BadProof.into();
}
let current_block = <frame_system::Pallet<T>>::block_number();
if payload.block_number > current_block {
// transaction in future?
return InvalidTransaction::Stale.into();
} else if payload.block_number + 5u32.into() < current_block {
// transaction was in pool for 5 blocks
return InvalidTransaction::Stale.into();
}
let account = payload.public.clone().into_account();
Self::validate_params(account, payload.asset, payload.price)
.map_err(|_| InvalidTransaction::Call)?;
let priority = T::UnsignedPriority::get()
.saturating_add(
// BlockNumber is less than u64::MAX, so it is never default value on unwrapping
TryInto::<u64>::try_into(payload.block_number).unwrap_or(0));
ValidTransaction::with_tag_prefix("EqPrice")
.priority(priority)
.and_provides((payload.public.clone(), payload.asset))
.longevity(5)
.propagate(true)
.build()
} else {
InvalidTransaction::Call.into()
}
}
}
We need to ensure in the validation method that we allow unsigned transactions only for a single method, and immediately send an error to the rest.
We also need to check that the signature is valid. This verification is necessary if the transaction is only allowed for certain types of accounts, like PoS or PoA systems. We allow transactions to live in the pool for no more than five blocks — after that, we determine that the price is outdated. This gets rid of cases where a transaction can be added to the pool an infinite number of times. (This check is not necessary if all other checks are performed correctly, but it is always better to double-check.)
It is necessary to validate a transaction the same way it will be validated in runtime. In our case, all checks are moved to a separate method validate_params so that the checks are 100% identical.
Lastly, you need to configure tags and priorities. This is the biggest difference from previous implementations. In our case, we don’t want more than one transaction from one account for one currency in the pool. This is achieved by setting and_provides with unique values for the pool (these parameters will have their own values for each pallet). We also want to take the latest price from transactions if there are several transactions within the pool with an identical and_provides property. We achieve this by setting the correct priority. In our case, the higher the block the transaction was included in, the higher its priority.
Here’s the bottom line: you have to carefully think about the correct implementation for each pallet, which affects development time and increases the chances of making a mistake. This approach should be used only when the first two approaches described above fall short.
Free transactions in Substrate are a powerful tool for maintaining system stability and onboarding new customers, but it is very important to choose the right way to implement them. Otherwise, one small mistake can leave you vulnerable to would-be DDoS attackers.