joystream icon indicating copy to clipboard operation
joystream copied to clipboard

Review of bloat bond pricing in Polkadot & Kusama

Open bedeho opened this issue 2 years ago • 0 comments

Review

There are explicit functions found in each codebase to do this pricing

Polkadot

pub const fn deposit(items: u32, bytes: u32) -> Balance {
	items as Balance * 20 * DOLLARS + (bytes as Balance) * 100 * MILLICENTS
}

and Kusama

pub const fn deposit(items: u32, bytes: u32) -> Balance {
	items as Balance * 2_000 * CENTS + (bytes as Balance) * 100 * MILLICENTS
}

These functions are a bit confusing when first contemplated, because the inputs are clearly not independent in general, yet the pricing is.

Curiously, these price identically, despite fees in general on the former being much more expensive, such as existential deposit being 100x (1$) the latter (1c), which is in effect the canonical bloat bond. For us, this kind of pricing would be particularly disadvantageous, because we use it for low value objects like threads, posts and non-NFT videos. We should however try to follow the general pricing strategy of these chains, as they are based on deep insights about the true cost to validators of increasing the storage database.

It takes some effort to work out the model here, and it's useful to look into some uses in the in the codebases, which are identical across the two projects, so we look at pallet_multisig.

The bloat bonds here refer to storage in this map

/// The set of open multisig operations.
#[pallet::storage]
pub type Multisigs<T: Config> = StorageDoubleMap<
  _,
  Twox64Concat,
  T::AccountId,
  Blake2_128Concat,
  [u8; 32],
  Multisig<T::BlockNumber, BalanceOf<T>, T::AccountId>,
>;

where Multisig is a struct which refers to each multisig, and it is defined as

/// An open multisig operation.
#[derive(Clone, Eq, PartialEq, Encode, Decode, Default, RuntimeDebug, TypeInfo)]
pub struct Multisig<BlockNumber, Balance, AccountId> {
	/// The extrinsic when the multisig operation was opened.
	when: Timepoint<BlockNumber>,
	/// The amount held in reserve of the `depositor`, to be returned once the operation ends.
	deposit: Balance,
	/// The account who opened it (i.e. the first to approve it).
	depositor: AccountId,
	/// The approvals achieved so far, including the depositor. Always sorted.
	approvals: Vec<AccountId>,
}

There are two bloat bonds

// One storage item; key size is 32; value is size 4+4+16+32 bytes = 56 bytes.
pub const DepositBase: Balance = deposit(1, 88);
// Additional storage item size of 32 bytes.
pub const DepositFactor: Balance = deposit(0, 32);

They have distinct roles, where DepositBase serves the purpose of adding a new Multisig to the map Multisigs upon creation of a call, thus only charging for fields when, deposit and depositor, which have a static length, and DepositFactor charges for each account that may be added to approvals when some multisig member successfully signs, as this increases the total footprint of the multisig in storage. Everything is charged up front when multisig is created, assuming max number (threshold) will sign, as can be seen in this snippet

let deposit = T::DepositBase::get() + T::DepositFactor::get() * threshold.into();

It is quite surprising that they go to such lengths to charge granularly, rather than just charghing at the max length right away, but they do.

Now interestingly, the runtime configruation appears to mismatch the actuall pallet details, specifically the key size in the configuration is 32, while in reality it should be 32 + sizeof(AccountId) = 32 + 32 = 64, as also reflected by this remark for DepositBase

/// This is held for an additional storage item whose value size is
/// `4 + sizeof((BlockNumber, Balance, AccountId))` bytes and whose key size is
/// `32 + sizeof(AccountId)` bytes.

Conclusion

  1. One should price based on the actual size of each mapping added to storage, count both the key and value contributions, and importantly, count them equally.
  2. We should price per mapping much more cheaply than Polkadot and Kusama, arguably same as our existential deposit, as it has no other bloat bond than itself, and has already been deemed as a sufficient economic deterrent against adversarial bloat.
  3. This review highlights a fundamental problem with how the configuration is done here. The runtime has to make detailed assumptions about the storage representations of the pallet, and keeping these assumptions in synch will fail very quickly. We should avoid this, each pallet should export some automated size calculation for each relevant quantity, and the runtime should use those.
  4. Lastly, observe that deposit(0, .) is used to effectively recover the marginal cost of an extra byte in storage, this is a confusing overloading of deposit which makes it harder to understand what is going on. We should separate out some way to ask for price of adding a new value (mapping to the dbase vs price of adding an additional byte to the database without increasing number of values (mappings).

bedeho avatar Aug 21 '22 13:08 bedeho