Staking pallet (#373)

* initial staking pallet

* add staking pallet to runtime

* support session rotation for serai

* optimizations & cleaning

* fix deny

* add serai network to initial networks

* a few tweaks & comments

* fix some pr comments

* Rewrite validator-sets with logarithmic algorithms

Uses the fact the underlying DB is sorted to achieve sorting of potential
validators by stake.

Removes release of deallocated stake for now.

---------

Co-authored-by: Luke Parker <lukeparker5132@gmail.com>
This commit is contained in:
akildemir 2023-10-10 13:53:24 +03:00 committed by GitHub
parent 2f45bba2d4
commit 98190b7b83
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 635 additions and 149 deletions

20
Cargo.lock generated
View file

@ -8386,7 +8386,6 @@ dependencies = [
name = "serai-primitives"
version = "0.1.0"
dependencies = [
"lazy_static",
"parity-scale-codec",
"scale-info",
"serde",
@ -8504,6 +8503,7 @@ dependencies = [
"scale-info",
"serai-in-instructions-pallet",
"serai-primitives",
"serai-staking-pallet",
"serai-tokens-pallet",
"serai-validator-sets-pallet",
"sp-api",
@ -8522,6 +8522,22 @@ dependencies = [
"substrate-wasm-builder",
]
[[package]]
name = "serai-staking-pallet"
version = "0.1.0"
dependencies = [
"frame-support",
"frame-system",
"pallet-session",
"parity-scale-codec",
"scale-info",
"serai-primitives",
"serai-validator-sets-pallet",
"serai-validator-sets-primitives",
"sp-runtime",
"sp-std",
]
[[package]]
name = "serai-tokens-pallet"
version = "0.1.0"
@ -8554,12 +8570,14 @@ dependencies = [
"frame-support",
"frame-system",
"hashbrown 0.14.0",
"pallet-session",
"parity-scale-codec",
"scale-info",
"serai-primitives",
"serai-validator-sets-primitives",
"sp-application-crypto",
"sp-core",
"sp-io",
"sp-runtime",
"sp-std",
]

View file

@ -46,6 +46,8 @@ members = [
"substrate/validator-sets/primitives",
"substrate/validator-sets/pallet",
"substrate/staking/pallet",
"substrate/runtime",
"substrate/node",

View file

@ -254,7 +254,10 @@ pub(crate) async fn scan_tributaries<
// TODO2: Differentiate connection errors from invariants
Err(e) => {
// Check if this failed because the keys were already set by someone else
if matches!(serai.get_keys(spec.set()).await, Ok(Some(_))) {
// TODO: hash_with_keys is latest, yet we'll remove old keys from storage
let hash_with_keys = serai.get_latest_block_hash().await.unwrap();
if matches!(serai.get_keys(spec.set(), hash_with_keys).await, Ok(Some(_)))
{
log::info!("another coordinator set key pair for {:?}", set);
break;
}

View file

@ -35,12 +35,14 @@ async fn in_set(
key: &Zeroizing<<Ristretto as Ciphersuite>::F>,
serai: &Serai,
set: ValidatorSet,
block_hash: [u8; 32],
) -> Result<Option<bool>, SeraiError> {
let Some(data) = serai.get_validator_set(set).await? else {
let Some(participants) = serai.get_validator_set_participants(set.network, block_hash).await?
else {
return Ok(None);
};
let key = (Ristretto::generator() * key.deref()).to_bytes();
Ok(Some(data.participants.iter().any(|(participant, _)| participant.0 == key)))
Ok(Some(participants.iter().any(|participant| participant.0 == key)))
}
async fn handle_new_set<D: Db, CNT: Clone + Fn(&mut D, TributarySpec)>(
@ -51,10 +53,13 @@ async fn handle_new_set<D: Db, CNT: Clone + Fn(&mut D, TributarySpec)>(
block: &Block,
set: ValidatorSet,
) -> Result<(), SeraiError> {
if in_set(key, serai, set).await?.expect("NewSet for set which doesn't exist") {
if in_set(key, serai, set, block.hash()).await?.expect("NewSet for set which doesn't exist") {
log::info!("present in set {:?}", set);
let set_data = serai.get_validator_set(set).await?.expect("NewSet for set which doesn't exist");
let set_participants = serai
.get_validator_set_participants(set.network, block.hash())
.await?
.expect("NewSet for set which doesn't exist");
let time = if let Ok(time) = block.time() {
time
@ -77,7 +82,7 @@ async fn handle_new_set<D: Db, CNT: Clone + Fn(&mut D, TributarySpec)>(
const SUBSTRATE_TO_TRIBUTARY_TIME_DELAY: u64 = 120;
let time = time + SUBSTRATE_TO_TRIBUTARY_TIME_DELAY;
let spec = TributarySpec::new(block.hash(), time, set, set_data);
let spec = TributarySpec::new(block.hash(), time, set, set_participants);
create_new_tributary(db, spec.clone());
} else {
log::info!("not present in set {:?}", set);

View file

@ -15,8 +15,8 @@ use ciphersuite::{
use sp_application_crypto::sr25519;
use serai_client::{
primitives::{NETWORKS, NetworkId, Amount},
validator_sets::primitives::{Session, ValidatorSet, ValidatorSetData},
primitives::NetworkId,
validator_sets::primitives::{Session, ValidatorSet},
};
use tokio::time::sleep;
@ -52,20 +52,12 @@ pub fn new_spec<R: RngCore + CryptoRng>(
let set = ValidatorSet { session: Session(0), network: NetworkId::Bitcoin };
let set_data = ValidatorSetData {
bond: Amount(100),
network: NETWORKS[&NetworkId::Bitcoin].clone(),
participants: keys
.iter()
.map(|key| {
(sr25519::Public((<Ristretto as Ciphersuite>::generator() * **key).to_bytes()), Amount(100))
})
.collect::<Vec<_>>()
.try_into()
.unwrap(),
};
let set_participants = keys
.iter()
.map(|key| sr25519::Public((<Ristretto as Ciphersuite>::generator() * **key).to_bytes()))
.collect::<Vec<_>>();
let res = TributarySpec::new(serai_block, start_time, set, set_data);
let res = TributarySpec::new(serai_block, start_time, set, set_participants);
assert_eq!(TributarySpec::read::<&[u8]>(&mut res.serialize().as_ref()).unwrap(), res);
res
}

View file

@ -17,8 +17,8 @@ use frost::Participant;
use scale::{Encode, Decode};
use serai_client::{
primitives::NetworkId,
validator_sets::primitives::{Session, ValidatorSet, ValidatorSetData},
primitives::{NetworkId, PublicKey},
validator_sets::primitives::{Session, ValidatorSet},
};
#[rustfmt::skip]
@ -51,16 +51,16 @@ impl TributarySpec {
serai_block: [u8; 32],
start_time: u64,
set: ValidatorSet,
set_data: ValidatorSetData,
set_participants: Vec<PublicKey>,
) -> TributarySpec {
let mut validators = vec![];
for (participant, amount) in set_data.participants {
for participant in set_participants {
// TODO: Ban invalid keys from being validators on the Serai side
// (make coordinator key a session key?)
let participant = <Ristretto as Ciphersuite>::read_G::<&[u8]>(&mut participant.0.as_ref())
.expect("invalid key registered as participant");
// Give one weight on Tributary per bond instance
validators.push((participant, amount.0 / set_data.bond.0));
// TODO: Give one weight on Tributary per bond instance
validators.push((participant, 1));
}
Self { serai_block, start_time, set, validators }

View file

@ -60,6 +60,8 @@ exceptions = [
{ allow = ["AGPL-3.0"], name = "serai-validator-sets-pallet" },
{ allow = ["AGPL-3.0"], name = "serai-staking-pallet" },
{ allow = ["AGPL-3.0"], name = "serai-runtime" },
{ allow = ["AGPL-3.0"], name = "serai-node" },

View file

@ -1,8 +1,8 @@
use sp_core::sr25519::Signature;
use sp_core::sr25519::{Public, Signature};
use serai_runtime::{validator_sets, ValidatorSets, Runtime};
pub use validator_sets::primitives;
use primitives::{ValidatorSet, ValidatorSetData, KeyPair};
use primitives::{ValidatorSet, KeyPair};
use subxt::utils::Encoded;
@ -31,39 +31,29 @@ impl Serai {
.await
}
pub async fn get_validator_set(
pub async fn get_validator_set_participants(
&self,
set: ValidatorSet,
) -> Result<Option<ValidatorSetData>, SeraiError> {
self
.storage(
PALLET,
"ValidatorSets",
Some(vec![scale_value(set)]),
self.get_latest_block_hash().await?,
)
.await
network: NetworkId,
at_hash: [u8; 32],
) -> Result<Option<Vec<Public>>, SeraiError> {
self.storage(PALLET, "Participants", Some(vec![scale_value(network)]), at_hash).await
}
pub async fn get_validator_set_musig_key(
&self,
set: ValidatorSet,
at_hash: [u8; 32],
) -> Result<Option<[u8; 32]>, SeraiError> {
self
.storage(
PALLET,
"MuSigKeys",
Some(vec![scale_value(set)]),
self.get_latest_block_hash().await?,
)
.await
self.storage(PALLET, "MuSigKeys", Some(vec![scale_value(set)]), at_hash).await
}
// TODO: Store these separately since we almost never need both at once?
pub async fn get_keys(&self, set: ValidatorSet) -> Result<Option<KeyPair>, SeraiError> {
self
.storage(PALLET, "Keys", Some(vec![scale_value(set)]), self.get_latest_block_hash().await?)
.await
pub async fn get_keys(
&self,
set: ValidatorSet,
at_hash: [u8; 32],
) -> Result<Option<KeyPair>, SeraiError> {
self.storage(PALLET, "Keys", Some(vec![scale_value(set)]), at_hash).await
}
pub fn set_validator_set_keys(

View file

@ -26,7 +26,9 @@ pub async fn provide_batch(batch: Batch) -> [u8; 32] {
// TODO: Get the latest session
let set = ValidatorSet { session: Session(0), network: batch.network };
let pair = insecure_pair_from_name(&format!("ValidatorSet {:?}", set));
let keys = if let Some(keys) = serai.get_keys(set).await.unwrap() {
let keys = if let Some(keys) =
serai.get_keys(set, serai.get_latest_block_hash().await.unwrap()).await.unwrap()
{
keys
} else {
let keys = (pair.public(), vec![].try_into().unwrap());

View file

@ -28,7 +28,11 @@ pub async fn set_validator_set_keys(set: ValidatorSet, key_pair: KeyPair) -> [u8
let serai = serai().await;
let public_key = <Ristretto as Ciphersuite>::read_G::<&[u8]>(&mut public.0.as_ref()).unwrap();
assert_eq!(
serai.get_validator_set_musig_key(set).await.unwrap().unwrap(),
serai
.get_validator_set_musig_key(set, serai.get_latest_block_hash().await.unwrap())
.await
.unwrap()
.unwrap(),
musig_key(set, &[public]).0
);
@ -40,7 +44,11 @@ pub async fn set_validator_set_keys(set: ValidatorSet, key_pair: KeyPair) -> [u8
let threshold_keys =
musig::<Ristretto>(&musig_context(set), &Zeroizing::new(secret_key), &[public_key]).unwrap();
assert_eq!(
serai.get_validator_set_musig_key(set).await.unwrap().unwrap(),
serai
.get_validator_set_musig_key(set, serai.get_latest_block_hash().await.unwrap())
.await
.unwrap()
.unwrap(),
threshold_keys.group_key().to_bytes()
);
@ -66,7 +74,7 @@ pub async fn set_validator_set_keys(set: ValidatorSet, key_pair: KeyPair) -> [u8
serai.get_key_gen_events(block).await.unwrap(),
vec![ValidatorSetsEvent::KeyGen { set, key_pair: key_pair.clone() }]
);
assert_eq!(serai.get_keys(set).await.unwrap(), Some(key_pair));
assert_eq!(serai.get_keys(set, block).await.unwrap(), Some(key_pair));
block
}

View file

@ -3,7 +3,7 @@ use rand_core::{RngCore, OsRng};
use sp_core::{sr25519::Public, Pair};
use serai_client::{
primitives::{NETWORKS, NetworkId, insecure_pair_from_name},
primitives::{NetworkId, insecure_pair_from_name},
validator_sets::{
primitives::{Session, ValidatorSet, musig_key},
ValidatorSetsEvent,
@ -38,7 +38,7 @@ serai_test!(
.get_new_set_events(serai.get_block_by_number(0).await.unwrap().unwrap().hash())
.await
.unwrap(),
[NetworkId::Bitcoin, NetworkId::Ethereum, NetworkId::Monero]
[NetworkId::Serai, NetworkId::Bitcoin, NetworkId::Ethereum, NetworkId::Monero]
.iter()
.copied()
.map(|network| ValidatorSetsEvent::NewSet {
@ -47,12 +47,19 @@ serai_test!(
.collect::<Vec<_>>(),
);
let set_data = serai.get_validator_set(set).await.unwrap().unwrap();
assert_eq!(set_data.network, NETWORKS[&NetworkId::Bitcoin]);
let participants_ref: &[_] = set_data.participants.as_ref();
assert_eq!(participants_ref, [(public, set_data.bond)].as_ref());
let participants = serai
.get_validator_set_participants(set.network, serai.get_latest_block_hash().await.unwrap())
.await
.unwrap()
.unwrap();
let participants_ref: &[_] = participants.as_ref();
assert_eq!(participants_ref, [public].as_ref());
assert_eq!(
serai.get_validator_set_musig_key(set).await.unwrap().unwrap(),
serai
.get_validator_set_musig_key(set, serai.get_latest_block_hash().await.unwrap())
.await
.unwrap()
.unwrap(),
musig_key(set, &[public]).0
);
@ -64,6 +71,6 @@ serai_test!(
serai.get_key_gen_events(block).await.unwrap(),
vec![ValidatorSetsEvent::KeyGen { set, key_pair: key_pair.clone() }]
);
assert_eq!(serai.get_keys(set).await.unwrap(), Some(key_pair));
assert_eq!(serai.get_keys(set, block).await.unwrap(), Some(key_pair));
}
);

View file

@ -26,6 +26,7 @@ fn testnet_genesis(
(
key,
key,
// TODO: Properly diversify these?
SessionKeys { babe: key.into(), grandpa: key.into(), authority_discovery: key.into() },
)
};
@ -54,12 +55,9 @@ fn testnet_genesis(
},
validator_sets: ValidatorSetsConfig {
bond: Amount(1_000_000 * 10_u64.pow(8)),
networks: vec![
(NetworkId::Bitcoin, NETWORKS[&NetworkId::Bitcoin].clone()),
(NetworkId::Ethereum, NETWORKS[&NetworkId::Ethereum].clone()),
(NetworkId::Monero, NETWORKS[&NetworkId::Monero].clone()),
],
stake: Amount(1_000_000 * 10_u64.pow(8)),
// TODO: Array of these in primitives
networks: vec![NetworkId::Serai, NetworkId::Bitcoin, NetworkId::Ethereum, NetworkId::Monero],
participants: validators.iter().map(|name| account_from_name(name)).collect(),
},
session: SessionConfig { keys: validators.iter().map(|name| session_key(*name)).collect() },

View file

@ -12,8 +12,6 @@ all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[dependencies]
lazy_static = { version = "1", optional = true }
zeroize = { version = "^1.5", features = ["derive"], optional = true }
serde = { version = "1", default-features = false, features = ["derive", "alloc"] }
@ -26,5 +24,5 @@ sp-core = { git = "https://github.com/serai-dex/substrate", default-features = f
sp-runtime = { git = "https://github.com/serai-dex/substrate", default-features = false }
[features]
std = ["lazy_static", "zeroize", "scale/std", "serde/std", "scale-info/std", "sp-core/std", "sp-runtime/std"]
std = ["zeroize", "scale/std", "serde/std", "scale-info/std", "sp-core/std", "sp-runtime/std"]
default = ["std"]

View file

@ -1,6 +1,3 @@
#[cfg(feature = "std")]
use std::collections::HashMap;
#[cfg(feature = "std")]
use zeroize::Zeroize;
@ -120,12 +117,3 @@ impl Network {
&self.coins
}
}
#[cfg(feature = "std")]
lazy_static::lazy_static! {
pub static ref NETWORKS: HashMap<NetworkId, Network> = HashMap::from([
(NetworkId::Bitcoin, Network::new(vec![Coin::Bitcoin]).unwrap()),
(NetworkId::Ethereum, Network::new(vec![Coin::Ether, Coin::Dai]).unwrap()),
(NetworkId::Monero, Network::new(vec![Coin::Monero]).unwrap()),
]);
}

View file

@ -50,7 +50,9 @@ pallet-transaction-payment = { git = "https://github.com/serai-dex/substrate", d
tokens-pallet = { package = "serai-tokens-pallet", path = "../tokens/pallet", default-features = false }
in-instructions-pallet = { package = "serai-in-instructions-pallet", path = "../in-instructions/pallet", default-features = false }
staking-pallet = { package = "serai-staking-pallet", path = "../staking/pallet", default-features = false }
validator-sets-pallet = { package = "serai-validator-sets-pallet", path = "../validator-sets/pallet", default-features = false }
pallet-session = { git = "https://github.com/serai-dex/substrate", default-features = false }
pallet-babe = { git = "https://github.com/serai-dex/substrate", default-features = false }
pallet-grandpa = { git = "https://github.com/serai-dex/substrate", default-features = false }
@ -102,7 +104,9 @@ std = [
"tokens-pallet/std",
"in-instructions-pallet/std",
"staking-pallet/std",
"validator-sets-pallet/std",
"pallet-session/std",
"pallet-babe/std",
"pallet-grandpa/std",

View file

@ -21,6 +21,7 @@ pub use pallet_assets as assets;
pub use tokens_pallet as tokens;
pub use in_instructions_pallet as in_instructions;
pub use staking_pallet as staking;
pub use validator_sets_pallet as validator_sets;
pub use pallet_session as session;
@ -142,7 +143,7 @@ parameter_types! {
NORMAL_DISPATCH_RATIO,
);
pub const MaxAuthorities: u32 = 100;
pub const MaxAuthorities: u32 = validator_sets::primitives::MAX_VALIDATORS_PER_SET;
}
pub struct CallFilter;
@ -172,10 +173,24 @@ impl Contains<RuntimeCall> for CallFilter {
return matches!(call, in_instructions::Call::execute_batch { .. });
}
if let RuntimeCall::Staking(call) = call {
return matches!(
call,
staking::Call::stake { .. } |
staking::Call::unstake { .. } |
staking::Call::allocate { .. } |
staking::Call::deallocate { .. }
);
}
if let RuntimeCall::ValidatorSets(call) = call {
return matches!(call, validator_sets::Call::set_keys { .. });
}
if let RuntimeCall::Session(call) = call {
return matches!(call, session::Call::set_keys { .. });
}
false
}
}
@ -300,6 +315,10 @@ impl in_instructions::Config for Runtime {
type RuntimeEvent = RuntimeEvent;
}
impl staking::Config for Runtime {
type Currency = Balances;
}
impl validator_sets::Config for Runtime {
type RuntimeEvent = RuntimeEvent;
}
@ -317,7 +336,7 @@ impl session::Config for Runtime {
type ValidatorIdOf = IdentityValidatorIdOf;
type ShouldEndSession = Babe;
type NextSessionRotation = Babe;
type SessionManager = (); // TODO?
type SessionManager = Staking;
type SessionHandler = <SessionKeys as OpaqueKeys>::KeyTypeIdProviders;
type Keys = SessionKeys;
type WeightInfo = session::weights::SubstrateWeight<Runtime>;
@ -393,6 +412,8 @@ construct_runtime!(
ValidatorSets: validator_sets,
Staking: staking,
Session: session,
Babe: babe,
Grandpa: grandpa,

View file

@ -0,0 +1,46 @@
[package]
name = "serai-staking-pallet"
version = "0.1.0"
description = "Staking pallet for Serai"
license = "AGPL-3.0-only"
repository = "https://github.com/serai-dex/serai/tree/develop/substrate/staking/pallet"
authors = ["Luke Parker <lukeparker5132@gmail.com>"]
edition = "2021"
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[dependencies]
parity-scale-codec = { version = "3", default-features = false, features = ["derive"] }
scale-info = { version = "2", default-features = false, features = ["derive"] }
sp-runtime = { git = "https://github.com/serai-dex/substrate", default-features = false }
sp-std = { git = "https://github.com/serai-dex/substrate", default-features = false }
frame-system = { git = "https://github.com/serai-dex/substrate", default-features = false }
frame-support = { git = "https://github.com/serai-dex/substrate", default-features = false }
validator-sets-pallet = { package = "serai-validator-sets-pallet", path = "../../validator-sets/pallet", default-features = false }
pallet-session = { git = "https://github.com/serai-dex/substrate", default-features = false }
serai-primitives = { path = "../../primitives", default-features = false }
serai-validator-sets-primitives = { path = "../../validator-sets/primitives", default-features = false }
[features]
std = [
"frame-system/std",
"frame-support/std",
"sp-std/std",
"validator-sets-pallet/std",
"pallet-session/std",
]
runtime-benchmarks = [
"frame-system/runtime-benchmarks",
"frame-support/runtime-benchmarks",
]
default = ["std"]

View file

@ -0,0 +1,15 @@
AGPL-3.0-only license
Copyright (c) 2022-2023 Luke Parker
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License Version 3 as
published by the Free Software Foundation.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.

View file

@ -0,0 +1,180 @@
#![cfg_attr(not(feature = "std"), no_std)]
#[frame_support::pallet]
pub mod pallet {
use sp_runtime::{traits::TrailingZeroInput, DispatchError};
use sp_std::vec::Vec;
use frame_system::pallet_prelude::*;
use frame_support::{
pallet_prelude::*,
traits::{Currency, tokens::ExistenceRequirement},
};
use serai_primitives::{NetworkId, Amount, PublicKey};
use validator_sets_pallet::{Config as VsConfig, Pallet as VsPallet};
use pallet_session::{Config as SessionConfig, SessionManager};
#[pallet::error]
pub enum Error<T> {
StakeUnavilable,
}
// TODO: Event
#[pallet::config]
pub trait Config:
frame_system::Config + VsConfig + SessionConfig<ValidatorId = PublicKey>
{
type Currency: Currency<Self::AccountId, Balance = u64>;
}
#[pallet::pallet]
pub struct Pallet<T>(PhantomData<T>);
/// The amount of funds this account has staked.
#[pallet::storage]
#[pallet::getter(fn staked)]
pub type Staked<T: Config> = StorageMap<_, Blake2_128Concat, T::AccountId, u64, ValueQuery>;
/// The amount of stake this account has allocated to validator sets.
#[pallet::storage]
#[pallet::getter(fn allocated)]
pub type Allocated<T: Config> = StorageMap<_, Blake2_128Concat, T::AccountId, u64, ValueQuery>;
impl<T: Config> Pallet<T> {
fn account() -> T::AccountId {
// Substrate has a pattern of using simply using 8-bytes (as a PalletId) directly as an
// AccountId. This replicates its internals to remove the 8-byte limit
T::AccountId::decode(&mut TrailingZeroInput::new(b"staking")).unwrap()
}
fn add_stake(account: &T::AccountId, amount: u64) {
Staked::<T>::mutate(account, |staked| *staked += amount);
}
fn remove_stake(account: &T::AccountId, amount: u64) -> DispatchResult {
Staked::<T>::mutate(account, |staked| {
let available = *staked - Self::allocated(account);
if available < amount {
Err(Error::<T>::StakeUnavilable)?;
}
*staked -= amount;
Ok::<_, DispatchError>(())
})
}
fn allocate_internal(account: &T::AccountId, amount: u64) -> Result<(), Error<T>> {
Allocated::<T>::try_mutate(account, |allocated| {
let available = Self::staked(account) - *allocated;
if available < amount {
Err(Error::<T>::StakeUnavilable)?;
}
*allocated += amount;
Ok(())
})
}
#[allow(unused)] // TODO
fn deallocate_internal(account: &T::AccountId, amount: u64) -> Result<(), Error<T>> {
Allocated::<T>::try_mutate(account, |allocated| {
if *allocated < amount {
Err(Error::<T>::StakeUnavilable)?;
}
*allocated -= amount;
Ok(())
})
}
}
#[pallet::call]
impl<T: Config> Pallet<T> {
/// Stake funds from this account.
#[pallet::call_index(0)]
#[pallet::weight((0, DispatchClass::Operational))] // TODO
pub fn stake(origin: OriginFor<T>, #[pallet::compact] amount: u64) -> DispatchResult {
let signer = ensure_signed(origin)?;
// Serai accounts are solely public keys. Accordingly, there's no harm to letting accounts
// die. They'll simply be re-instantiated later
// AllowDeath accordingly to not add additional requirements (and therefore annoyances)
T::Currency::transfer(&signer, &Self::account(), amount, ExistenceRequirement::AllowDeath)?;
Self::add_stake(&signer, amount);
Ok(())
}
/// Unstake funds from this account. Only unallocated funds may be unstaked.
#[pallet::call_index(1)]
#[pallet::weight((0, DispatchClass::Operational))] // TODO
pub fn unstake(origin: OriginFor<T>, #[pallet::compact] amount: u64) -> DispatchResult {
let signer = ensure_signed(origin)?;
Self::remove_stake(&signer, amount)?;
// This should never be out of funds as there should always be stakers. Accordingly...
T::Currency::transfer(&Self::account(), &signer, amount, ExistenceRequirement::KeepAlive)?;
Ok(())
}
/// Allocate `amount` to a given validator set.
#[pallet::call_index(2)]
#[pallet::weight((0, DispatchClass::Operational))] // TODO
pub fn allocate(
origin: OriginFor<T>,
network: NetworkId,
#[pallet::compact] amount: u64,
) -> DispatchResult {
let account = ensure_signed(origin)?;
// add to amount allocated
Self::allocate_internal(&account, amount)?;
// increase allocation for participant in validator set
VsPallet::<T>::increase_allocation(network, account, Amount(amount))
}
/// Deallocate `amount` from a given validator set.
#[pallet::call_index(3)]
#[pallet::weight((0, DispatchClass::Operational))] // TODO
pub fn deallocate(
origin: OriginFor<T>,
network: NetworkId,
#[pallet::compact] amount: u64,
) -> DispatchResult {
let account = ensure_signed(origin)?;
// decrease allocation in validator set
VsPallet::<T>::decrease_allocation(network, account, Amount(amount))?;
// We don't immediately call deallocate since the deallocation only takes effect in the next
// session
// TODO: If this validator isn't active, allow immediate deallocation
Ok(())
}
// TODO: Add a function to reclaim deallocated funds
}
// Call order is end_session(i - 1) -> start_session(i) -> new_session(i + 1)
// new_session(i + 1) is called immediately after start_session(i)
// then we wait until the session ends then get a call to end_session(i) and so on.
impl<T: Config> SessionManager<T::ValidatorId> for Pallet<T> {
fn new_session(_new_index: u32) -> Option<Vec<T::ValidatorId>> {
// Don't call new_session multiple times on genesis
// TODO: Will this cause pallet_session::Pallet::current_index to desync from validator-sets?
if frame_system::Pallet::<T>::block_number() > 1u32.into() {
VsPallet::<T>::new_session();
}
// TODO: Where do we return their stake?
Some(VsPallet::<T>::validators(NetworkId::Serai))
}
fn new_session_genesis(_: u32) -> Option<Vec<T::ValidatorId>> {
Some(VsPallet::<T>::validators(NetworkId::Serai))
}
fn end_session(_end_index: u32) {}
fn start_session(_start_index: u32) {}
}
}
pub use pallet::*;

View file

@ -53,7 +53,7 @@ pub mod pallet {
}
pub fn mint(address: SeraiAddress, balance: Balance) {
// TODO: Prevent minting when it'd cause an amount exceeding the bond
// TODO: Prevent minting when it'd cause an amount exceeding the allocated stake
AssetsPallet::<T>::mint(
RawOrigin::Signed(ADDRESS.into()).into(),
balance.coin,

View file

@ -18,6 +18,7 @@ scale = { package = "parity-scale-codec", version = "3", default-features = fals
scale-info = { version = "2", default-features = false, features = ["derive"] }
sp-core = { git = "https://github.com/serai-dex/substrate", default-features = false }
sp-io = { git = "https://github.com/serai-dex/substrate", default-features = false }
sp-std = { git = "https://github.com/serai-dex/substrate", default-features = false }
sp-application-crypto = { git = "https://github.com/serai-dex/substrate", default-features = false }
sp-runtime = { git = "https://github.com/serai-dex/substrate", default-features = false }
@ -25,6 +26,8 @@ sp-runtime = { git = "https://github.com/serai-dex/substrate", default-features
frame-system = { git = "https://github.com/serai-dex/substrate", default-features = false }
frame-support = { git = "https://github.com/serai-dex/substrate", default-features = false }
pallet-session = { git = "https://github.com/serai-dex/substrate", default-features = false }
serai-primitives = { path = "../../primitives", default-features = false }
validator-sets-primitives = { package = "serai-validator-sets-primitives", path = "../primitives", default-features = false }
@ -39,6 +42,8 @@ std = [
"frame-system/std",
"frame-support/std",
"pallet-session/std",
"serai-primitives/std",
"validator-sets-primitives/std",
]

View file

@ -6,30 +6,34 @@ pub mod pallet {
use scale_info::TypeInfo;
use sp_core::sr25519::{Public, Signature};
use sp_std::vec::Vec;
use sp_std::{vec, vec::Vec};
use sp_application_crypto::RuntimePublic;
use frame_system::pallet_prelude::*;
use frame_support::pallet_prelude::*;
use frame_support::{pallet_prelude::*, StoragePrefixedMap};
use serai_primitives::*;
pub use validator_sets_primitives as primitives;
use primitives::*;
#[pallet::config]
pub trait Config: frame_system::Config<AccountId = Public> + TypeInfo {
pub trait Config:
frame_system::Config<AccountId = Public> + pallet_session::Config + TypeInfo
{
type RuntimeEvent: IsType<<Self as frame_system::Config>::RuntimeEvent> + From<Event<Self>>;
}
#[pallet::genesis_config]
#[derive(Clone, PartialEq, Eq, Debug, Encode, Decode)]
pub struct GenesisConfig<T: Config> {
/// Bond requirement to join the initial validator sets.
/// Every participant at genesis will automatically be assumed to have this much bond.
/// This bond cannot be withdrawn however as there's no stake behind it.
pub bond: Amount,
/// Stake requirement to join the initial validator sets.
///
/// Every participant at genesis will automatically be assumed to have this much stake.
/// This stake cannot be withdrawn however as there's no actual stake behind it.
// TODO: Localize stake to network?
pub stake: Amount,
/// Networks to spawn Serai with.
pub networks: Vec<(NetworkId, Network)>,
pub networks: Vec<NetworkId>,
/// List of participants to place in the initial validator sets.
pub participants: Vec<T::AccountId>,
}
@ -37,7 +41,7 @@ pub mod pallet {
impl<T: Config> Default for GenesisConfig<T> {
fn default() -> Self {
GenesisConfig {
bond: Amount(1),
stake: Amount(1),
networks: Default::default(),
participants: Default::default(),
}
@ -47,18 +51,82 @@ pub mod pallet {
#[pallet::pallet]
pub struct Pallet<T>(PhantomData<T>);
/// The details of a validator set instance.
/// The current session for a network.
///
/// This does not store the current session for Serai. pallet_session handles that.
// Uses Identity for the lookup to avoid a hash of a severely limited fixed key-space.
#[pallet::storage]
#[pallet::getter(fn validator_set)]
pub type ValidatorSets<T: Config> =
StorageMap<_, Twox64Concat, ValidatorSet, ValidatorSetData, OptionQuery>;
pub type CurrentSession<T: Config> = StorageMap<_, Identity, NetworkId, Session, OptionQuery>;
impl<T: Config> Pallet<T> {
fn session(network: NetworkId) -> Session {
if network == NetworkId::Serai {
Session(pallet_session::Pallet::<T>::current_index())
} else {
CurrentSession::<T>::get(network).unwrap()
}
}
}
/// The minimum allocation required to join a validator set.
// Uses Identity for the lookup to avoid a hash of a severely limited fixed key-space.
#[pallet::storage]
#[pallet::getter(fn minimum_allocation)]
pub type MinimumAllocation<T: Config> = StorageMap<_, Identity, NetworkId, Amount, OptionQuery>;
/// The validators selected to be in-set.
#[pallet::storage]
#[pallet::getter(fn participants)]
pub type Participants<T: Config> = StorageMap<
_,
Identity,
NetworkId,
BoundedVec<Public, ConstU32<{ MAX_VALIDATORS_PER_SET }>>,
ValueQuery,
>;
/// The validators selected to be in-set, yet with the ability to perform a check for presence.
#[pallet::storage]
pub type InSet<T: Config> = StorageMap<_, Blake2_128Concat, (NetworkId, Public), (), OptionQuery>;
/// The current amount allocated to a validator set by a validator.
#[pallet::storage]
#[pallet::getter(fn allocation)]
pub type Allocations<T: Config> =
StorageMap<_, Blake2_128Concat, (NetworkId, Public), Amount, OptionQuery>;
/// A sorted view of the current allocations premised on the underlying DB itself being sorted.
// Uses Identity so we can iterate over the key space from highest-to-lowest allocated.
// While this does enable attacks the hash is meant to prevent, the minimum stake should resolve
// these.
#[pallet::storage]
type SortedAllocations<T: Config> =
StorageMap<_, Identity, (NetworkId, [u8; 8], Public), (), OptionQuery>;
impl<T: Config> Pallet<T> {
/// A function which takes an amount and generates a byte array with a lexicographic order from
/// high amount to low amount.
#[inline]
fn lexicographic_amount(amount: Amount) -> [u8; 8] {
let mut bytes = amount.0.to_be_bytes();
for byte in &mut bytes {
*byte = !*byte;
}
bytes
}
fn set_allocation(network: NetworkId, key: Public, amount: Amount) {
let prior = Allocations::<T>::take((network, key));
if prior.is_some() {
SortedAllocations::<T>::remove((network, Self::lexicographic_amount(amount), key));
}
if amount.0 != 0 {
Allocations::<T>::set((network, key), Some(amount));
SortedAllocations::<T>::set((network, Self::lexicographic_amount(amount), key), Some(()));
}
}
}
/// The MuSig key for a validator set.
#[pallet::storage]
#[pallet::getter(fn musig_key)]
pub type MuSigKeys<T: Config> = StorageMap<_, Twox64Concat, ValidatorSet, Public, OptionQuery>;
/// The key pair for a given validator set instance.
/// The generated key pair for a given validator set instance.
#[pallet::storage]
#[pallet::getter(fn keys)]
pub type Keys<T: Config> = StorageMap<_, Twox64Concat, ValidatorSet, KeyPair, OptionQuery>;
@ -70,33 +138,62 @@ pub mod pallet {
KeyGen { set: ValidatorSet, key_pair: KeyPair },
}
#[pallet::genesis_build]
impl<T: Config> BuildGenesisConfig for GenesisConfig<T> {
fn build(&self) {
let hash_set =
self.participants.iter().map(|key| key.0).collect::<hashbrown::HashSet<[u8; 32]>>();
if hash_set.len() != self.participants.len() {
panic!("participants contained duplicates");
impl<T: Config> Pallet<T> {
fn new_set(network: NetworkId) {
// Update CurrentSession
let session = if network != NetworkId::Serai {
CurrentSession::<T>::mutate(network, |session| {
Some(session.map(|session| Session(session.0 + 1)).unwrap_or(Session(0)))
})
.unwrap()
} else {
Self::session(network)
};
// Clear the current InSet
{
let mut in_set_key = InSet::<T>::final_prefix().to_vec();
in_set_key.extend(network.encode());
assert!(matches!(
sp_io::storage::clear_prefix(&in_set_key, Some(MAX_VALIDATORS_PER_SET)),
sp_io::KillStorageResult::AllRemoved(_)
));
}
let mut participants = Vec::new();
for participant in self.participants.clone() {
participants.push((participant, self.bond));
}
let participants = BoundedVec::try_from(participants).unwrap();
let mut prefix = SortedAllocations::<T>::final_prefix().to_vec();
prefix.extend(&network.encode());
let prefix = prefix;
for (id, network) in self.networks.clone() {
let set = ValidatorSet { session: Session(0), network: id };
// TODO: Should this be split up? Substrate will read this entire struct into mem on every
// read, not just accessed variables
ValidatorSets::<T>::set(
set,
Some(ValidatorSetData { bond: self.bond, network, participants: participants.clone() }),
);
let mut last = prefix.clone();
MuSigKeys::<T>::set(set, Some(musig_key(set, &self.participants)));
Pallet::<T>::deposit_event(Event::NewSet { set })
let mut participants = vec![];
for _ in 0 .. MAX_VALIDATORS_PER_SET {
let Some(next) = sp_io::storage::next_key(&last) else { break };
if !next.starts_with(&prefix) {
break;
}
assert_eq!(next.len(), (32 + 1 + 8 + 32));
let key = Public(next[(next.len() - 32) .. next.len()].try_into().unwrap());
InSet::<T>::set((network, key), Some(()));
participants.push(key);
last = next;
}
assert!(!participants.is_empty());
let set = ValidatorSet { network, session };
Pallet::<T>::deposit_event(Event::NewSet { set });
if network != NetworkId::Serai {
// Remove the keys for the set prior to the one now rotating out
if session.0 >= 2 {
let prior_to_now_rotating = ValidatorSet { network, session: Session(session.0 - 2) };
MuSigKeys::<T>::remove(prior_to_now_rotating);
Keys::<T>::remove(prior_to_now_rotating);
}
MuSigKeys::<T>::set(set, Some(musig_key(set, &participants)));
}
Participants::<T>::set(network, participants.try_into().unwrap());
}
}
@ -104,10 +201,40 @@ pub mod pallet {
pub enum Error<T> {
/// Validator Set doesn't exist.
NonExistentValidatorSet,
/// Not enough stake to participate in a set.
InsufficientStake,
/// Trying to deallocate more than allocated.
InsufficientAllocation,
/// Deallocation would remove the participant from the set, despite the validator not
/// specifying so.
DeallocationWouldRemoveParticipant,
/// Validator Set already generated keys.
AlreadyGeneratedKeys,
/// An invalid MuSig signature was provided.
BadSignature,
/// Validator wasn't registered or active.
NonExistentValidator,
}
#[pallet::genesis_build]
impl<T: Config> BuildGenesisConfig for GenesisConfig<T> {
fn build(&self) {
{
let hash_set =
self.participants.iter().map(|key| key.0).collect::<hashbrown::HashSet<[u8; 32]>>();
if hash_set.len() != self.participants.len() {
panic!("participants contained duplicates");
}
}
for id in self.networks.clone() {
MinimumAllocation::<T>::set(id, Some(self.stake));
for participant in self.participants.clone() {
Pallet::<T>::set_allocation(id, participant, self.stake);
}
Pallet::<T>::new_set(id);
}
}
}
impl<T: Config> Pallet<T> {
@ -116,6 +243,7 @@ pub mod pallet {
key_pair: &KeyPair,
signature: &Signature,
) -> Result<(), Error<T>> {
// Confirm a key hasn't been set for this set instance
if Keys::<T>::get(set).is_some() {
Err(Error::AlreadyGeneratedKeys)?
}
@ -141,10 +269,8 @@ pub mod pallet {
) -> DispatchResult {
ensure_none(origin)?;
// TODO: Get session
let session: Session = Session(0);
let session = Session(pallet_session::Pallet::<T>::current_index());
// Confirm a key hasn't been set for this set instance
let set = ValidatorSet { session, network };
// TODO: Is this needed? validate_unsigned should be called before this and ensure it's Ok
Self::verify_signature(set, &key_pair, &signature)?;
@ -167,15 +293,17 @@ pub mod pallet {
Call::__Ignore(_, _) => unreachable!(),
};
// TODO: Get the latest session
let session = Session(0);
let session = Session(pallet_session::Pallet::<T>::current_index());
let set = ValidatorSet { session, network: *network };
match Self::verify_signature(set, key_pair, signature) {
Err(Error::AlreadyGeneratedKeys) => Err(InvalidTransaction::Stale)?,
Err(Error::NonExistentValidatorSet) | Err(Error::BadSignature) => {
Err(InvalidTransaction::BadProof)?
}
Err(Error::NonExistentValidatorSet) |
Err(Error::InsufficientStake) |
Err(Error::InsufficientAllocation) |
Err(Error::DeallocationWouldRemoveParticipant) |
Err(Error::NonExistentValidator) |
Err(Error::BadSignature) => Err(InvalidTransaction::BadProof)?,
Err(Error::__Ignore(_, _)) => unreachable!(),
Ok(()) => (),
}
@ -189,7 +317,80 @@ pub mod pallet {
}
}
// TODO: Support session rotation
impl<T: Config> Pallet<T> {
pub fn increase_allocation(
network: NetworkId,
account: T::AccountId,
amount: Amount,
) -> DispatchResult {
let new_allocation = Self::allocation((network, account)).unwrap_or(Amount(0)).0 + amount.0;
if new_allocation < Self::minimum_allocation(network).unwrap().0 {
Err(Error::<T>::InsufficientStake)?;
}
Self::set_allocation(network, account, Amount(new_allocation));
Ok(())
}
/// Decreases a validator's allocation to a set.
///
/// Errors if the capacity provided by this allocation is in use.
///
/// Errors if a partial decrease of allocation which puts the allocation below the minimum.
///
/// The capacity prior provided by the allocation is immediately removed, in order to ensure it
/// doesn't become used (preventing deallocation).
pub fn decrease_allocation(
network: NetworkId,
account: T::AccountId,
amount: Amount,
) -> DispatchResult {
// TODO: Check it's safe to decrease this set's stake by this amount
let new_allocation = Self::allocation((network, account))
.ok_or(Error::<T>::NonExistentValidator)?
.0
.checked_sub(amount.0)
.ok_or(Error::<T>::InsufficientAllocation)?;
// If we're not removing the entire allocation, yet the allocation is no longer at or above
// the minimum stake, error
if (new_allocation != 0) &&
(new_allocation < Self::minimum_allocation(network).unwrap_or(Amount(0)).0)
{
Err(Error::<T>::DeallocationWouldRemoveParticipant)?;
}
// TODO: Error if we're about to be removed, and the remaining set size would be <4
// Decrease the allocation now
Self::set_allocation(network, account, Amount(new_allocation));
// Set it to PendingDeallocation, letting the staking pallet release it AFTER this session
// TODO
// TODO: We can immediately free it if it doesn't cross a key share threshold
Ok(())
}
pub fn new_session() {
// TODO: Define an array of all networks in primitives
let networks = [NetworkId::Serai, NetworkId::Bitcoin, NetworkId::Ethereum, NetworkId::Monero];
for network in networks {
// Handover is automatically complete for Serai as it doesn't have a handover protocol
// TODO: Update how handover completed is determined. It's not on set keys. It's on new
// set accepting responsibility
let handover_completed = (network == NetworkId::Serai) ||
Keys::<T>::contains_key(ValidatorSet { network, session: Self::session(network) });
// Only spawn a NewSet if the current set was actually established with a completed
// handover protocol
if handover_completed {
Pallet::<T>::new_set(network);
}
}
}
pub fn validators(network: NetworkId) -> Vec<Public> {
Self::participants(network).into()
}
}
}
pub use pallet::*;

View file

@ -13,8 +13,10 @@ use sp_core::{ConstU32, sr25519::Public, bounded::BoundedVec};
#[cfg(not(feature = "std"))]
use sp_std::vec::Vec;
use serai_primitives::{NetworkId, Network, Amount};
use serai_primitives::NetworkId;
/// The maximum amount of validators per set.
pub const MAX_VALIDATORS_PER_SET: u32 = 150;
// Support keys up to 96 bytes (BLS12-381 G2).
const MAX_KEY_LEN: u32 = 96;
@ -32,6 +34,7 @@ const MAX_KEY_LEN: u32 = 96;
Decode,
TypeInfo,
MaxEncodedLen,
Default,
)]
#[cfg_attr(feature = "std", derive(Zeroize))]
pub struct Session(pub u32);
@ -57,17 +60,6 @@ pub struct ValidatorSet {
pub network: NetworkId,
}
/// The data for a validator set.
#[derive(Clone, PartialEq, Eq, Debug, Encode, Decode, TypeInfo, MaxEncodedLen)]
pub struct ValidatorSetData {
pub bond: Amount,
pub network: Network,
// Participant and their amount bonded to this set
// Limit each set to 100 participants for now
pub participants: BoundedVec<(Public, Amount), ConstU32<100>>,
}
type MaxKeyLen = ConstU32<MAX_KEY_LEN>;
/// The type representing a Key from an external network.
pub type ExternalKey = BoundedVec<u8, MaxKeyLen>;

View file

@ -164,7 +164,11 @@ pub async fn key_gen<C: Ciphersuite>(
}
}
assert_eq!(
serai.get_keys(set).await.unwrap().unwrap(),
serai
.get_keys(set, serai.get_block_by_number(last_serai_block).await.unwrap().unwrap().hash())
.await
.unwrap()
.unwrap(),
(Public(substrate_key), network_key.try_into().unwrap())
);

View file

@ -195,8 +195,13 @@ async fn mint_and_burn_test() {
let halt_at = if additional { 5 * 10 } else { 10 * 10 };
let print_at = halt_at / 2;
for i in 0 .. halt_at {
if let Some(key_pair) =
serai.get_keys(ValidatorSet { network, session: Session(0) }).await.unwrap()
if let Some(key_pair) = serai
.get_keys(
ValidatorSet { network, session: Session(0) },
serai.get_latest_block_hash().await.unwrap(),
)
.await
.unwrap()
{
return key_pair;
}