Closed ignazio-bovo closed 2 years ago
polkadot::assets
? link?polkadot::assets
have vesting features? These are part of the requirements. I think we should consider dropping it if its not supported and there is no easy workaround.Grouping
, what does that mean? it should be done by one person alone?create
, not just via a root origin call from inside the runtime. There are a lot of invasive extrinsic here, like set_team
, have you ensured some of these can't be called to violate expectations of what project_token pallet may need? Another example is how you say you will build crt_token::transfer
on top to respect desired policy, but what does that help if someone can directly call asset:transfer
and sidestep this? Can you please review and elaborate on why this module overall will work?What do we do when a $CRT holder gets removed from whitelist in a Permissioned Transfer policy? Can he still transact with whitelisted members?
Removal from whitelist is not supported, it was not part of the requirements.r
Looking a bit at XCM, its not actually clear to me that we could not just implement our own minimal asset module inside project_token
, with exactly the features we need, and then configure the XCM executor to work with it. It is not however clear to me how well integrations with wallets and block explorers will work.
After looking closer at XCM, it seems to me that we are better off just implementing our own fungible token inside of the project_token
pallet, because
There would possibly be some friction with getting full support at the browser extenstion level (so that the extension would list the different users assets), however, as long basic features around this are present in the apps we care most about (Atlas, Pioneer, etc.), this is acceptable, and I am sure we can get those integrations later.
Project Token Design ====================
src/
issue_token(initial_issuance: Balance)
deissue_token(token_id: Self::TokenId)
balance_of(token_id: TokenId, account_id: SourceLocation) -> Result<Balance, Error>
issuance(token_id: TokenId) -> Result<Balance, Error>
burn(token_id: TokenId, amount: Balance) -> Result<(), Error>
mint(token_id: TokenId, amount: Balance) -> Result<(), Error>
slash(token_id: TokenId, account_id: SourceLocation, amount: Balance) -> Result<(), Error>
deposit(token_id: TokenId, account_id: DestinationLocation, amount: Balance) -> Result<(), Error>
transfer(token_id: TokenId, source: SourceLocation, destination: DestinationLocation, amount: Balance) -> Result<(), Error>
can_transfer_to(location: TransferLocation) -> bool
is_permissionless(&self) -> bool
is_permissioned(&self) -> bool
change_to_permissionless(self) -> Self
change_to_permissioned(self, whitelist_commitment: Hash) -> Self
change_state_to_permissionless(origin, token_id) -> Result<(), Error>
change_state_to_permissioned(origin, whitelist_commitment: Hash) -> Result<(), Error>
transfer(token_id: TokenId, source: AccountId, destination: TransferLocation, amount: Balance)
transfer_multi_output(token_id: TokenId, source: AccountId, outputs: Vec<(TransferLocation, Balance)>)
transfer(origin, token_id: TokenId, destination: TransferLocation, amount: Balance)
transfer_multi_output(token_id: TokenId, source: AccountId, outputs: Vec<(TransferLocation, Balance)>)
issue_sale(origin, token_id: Self::TokenId, start: BlockNumber, duration: BlockNumber, exchange_rate: Balance, whitelist_commitment: Hash, cap: Balance, locked: Balance)
update_sale_start(origin, token_id: Self::TokenId, sale_id: Self::SaleId, start: BlockNumber)
update_sale_duration(origin, sale_id: Self::SaleId, duration: BlockNumber)
puchase(origin, token_id: TokenId, sale_id: SaleId, to_location: TransferLocation<AccountId>)
This is design document for the CRT pallet. It is also meant to be a reference for the implementation and auxiliary document for the PRs reviewer(s). Implementation will try to follow this design as close as possible (once approved). There will be for sure some mistakes/typos/unclear things, just point them out and I will try to update the document.
Error
denotes a generic Error type, in the implementation it will be replaced by a more
descriptive type like VestingScheduleNotFoundError
for example.OLD[expr]
notation means refers to the value of expr
BEFORE the function in question was calledPOSTCONDITIONS[f(..)]
postconditions for function f
verifiedPRECONDITIONS[f(..)]
preconditions for function f
verifiedA => B
decl_storage
map using index of type A
yielding data of type B
A1 x A2 => B
decl_storage
double map using indices of type A1, A2
yielding data of type B
First I will describe the pallet file structure.
Then I will introduce the actual storage state for the pallet. This will also helps to
read the traits specifications later.
the traits, these traits will be put in a separate trait.rs
file
and they will provide the behaviour that the ProjectToken
Pallet will implement
Along with the Traits methods I have put some design by contract specifications, which will
provide intuition about the actual implementation I am planning to write. (Keep in mind
that here I have just a prototype for the pallet, actual implementation might slightly
vary).
src/
trait.rs
Where the trait declaration will go
types.rs
Where auxiliary types eg TokenData
struct will go
tests
Folder where tests will go, in particular:
mod.rs
Rust module file including the test files
mock.rs
Mock-test scenario setup, along with helper functions
patronage.rs
Tests related to the minting and ensuring that the proper patronage policy will comply
transfer.rs
Tests related to the minting and ensuring that the policy transfer will comply.
sale.rs
Tests for the sale functionality
revenue_split.rs
Tests for the revenue split functionality
ibco.rs
Tests for the initial bonding curve offering functionality
canonical.rs
Tests for the standard MultiCurrencyBase
and ReservableMultiCurrency
traits implementation.
In particular issuing, burning (with appropriate existential deposit checks in place).
lib.rs
Will contain the state storage layout and the trait implementations for the Pallet::Module<T>
and the trait configuration
errors.rs
Error enum definitions
events.rs
Events enum definition, I would split events since, quite frankly the lib.rs
will probably
be a very long file and it will be easier (for a QN integration viewpoint) to just navigate
between the events.
TokenInfoById: TokenId => TokenData
: TokenData
is a record contain token-specific
information such as symbol, issuance, patronage rate, split percentage etc.. see next section.
This can be accessed via the ensure_token_exists(token_id)
AccountDataByTokenAndId: TokenId x AccountId => AccountData
with AccountData
a structure
containing relevant account information such as free balance.
This can be accessed via the ensure_account_data_exists(token_id, account_id)
SplitParticipationByAccountAndId: AccountId x SplitId => =Balance
: contains the staked amount for each account, for each split,
which must be then redeemed.
This can be accessed via the ensure_split_exists(account_id, split_id)
SaleByTokenAndId: TokenId x SaleId => =SaleRecord
: contains the sale(s) history for each token. Now this is under the assumption
that there can be multiple sale for each token. Otherwise this can be incorporated into the state
field into the TokenData
This can be accessed via the ensure_sale_exists(token_id, sale_id)
NextSplitId
nonce for the split idNextSaleId
nonce for the sale idHere I sketch the idea for possible auxiliary types
Record for the unique characteristics of a token
TransmissionPolicy
InitialOfferingState
enum
Enum containing either:
Implements the TransferPermissionPolicy
Trait
simple struct wrapper around an account Id for which is_valid_location_for_policy
gives always true
record of:
is_valid_location_for_policy
record containing the characteristic info. for a token sale
Encapsulate the basic business logic (i.e. primitives) for a currency. This trait provides functionality for:
Issuance and supply for a token are used intercheangebly. Since I am not introducing the
orthogonal concept of reservable currency here balance_of
is meant to be intended as free balance.
Implemented in the pallet Module
. Originally I wanted to have one base implementation.
and then decorate it in the by means of the Decorator pattern.
I am no longer sure about that approach for two reason:
Module
implement both the simple and the decorated implementationIt was a nice application of the single responsibility principle however…
Hereafter (and probably also in the codebase) base_transfer
will denote a call the
bare bones (undecorated) transfer
primitive.
pub trait MultiCurrencyBase<SourceLocation, DestinationLocation = SourceLocation> {
// types defined
type TokenId;
type Balance;
// API
fn issue_token(initial_issuance: Balance);
fn burn(token_id: TokenId, amount: Balance) -> Result<(), Error>;
fn mint(token_id: TokenId, amount: Balance) -> Result<(), Error>;
fn slash(token_id: TokenId, account_id: SourceLocation, amount: Balance) -> Result<(), Error>;
fn deposit(
token_id: TokenId,
account_id: DestinationLocation,
amount: Balance
) -> Result<(), Error>;
fn transfer(
token_id: TokenId,
source: SourceLocation,
destination: DestinationLocation,
amount: Balance,
) -> Result<(), Error>;
fn balance_of(token_id: TokenId, account_id: SourceLocation) -> Result<Balance, Error>;
fn issuance(token_id: TokenId) -> Result<Balance, Error>;
}
balance_of(token_id, account_id)?
>= 0
issuance(token_id)?
>= 0
issue_token(initial_issuance: Balance)
Issue a new token with the specified issuance and idle state. Several other similar method
can be added like issue_token_with_sale(sale_parameters..)
that sets the initial
issuance state
Preconditions
Postconditions
TokenData
struct added to the TokenDataById
map in with Idle
state and issuance set
to the initial issuance provideddeissue_token(token_id: Self::TokenId)
De issue an existing token and remove all the existing account data.
Preconditions
ensure_token_exists(token_id).is_ok())
Postconditions
TokenDataById::remove(token_id)
AccountDataByTokenAndId::remove_prefix(token_id)
SaleDataByTokenAndId::remove_prefix(token_id)
SplitPartecipationByTokenAndId::remove_prefix(token_id)
balance_of(token_id: TokenId, account_id: SourceLocation) -> Result<Balance, Error>
Getter function for account_data.free_balance
Preconditions
token_id
must existsaccount_id
for token_id
must existsissuance(token_id: TokenId) -> Result<Balance, Error>
Getter function for token_data.current_issuance
Preconditions
ensure_token_exists(token_id).is_ok()
burn(token_id: TokenId, amount: Balance) -> Result<(), Error>
Decreases of amount
the token_Id
current issuance
Preconditions
ensure_token_exists(token_id).is_ok()
issuance(token_id)
>= amount
Postconditions
OLD[issuance(token_id)] - amount =
issuance(token_id)
mint(token_id: TokenId, amount: Balance) -> Result<(), Error>
Increases of amount
the token_Id
current issuance
Preconditions
ensure_token_exists(token_id).is_ok()
Postconditions
OLD[issuance(token_id)] + amount =
issuance(token_id)
slash(token_id: TokenId, account_id: SourceLocation, amount: Balance) -> Result<(), Error>
Slashes amount of token_Id
from existng account_id
Preconditions
token_id
must existsaccount_id
for token_id
must existsbalance_of(token_id, account_id)
>= amount
Postconditions
OLD[balance_of(token_id, account_id) - amount = balance_of(token_id, account_id)
balance_of(token_id, account_id)
is lower than existential deposit then POSTCONDITIONS[AccountDataByTokenAndId::remove(token_id, account_id)]
deposit(token_id: TokenId, account_id: DestinationLocation, amount: Balance) -> Result<(), Error>
Deposit token_id
amount to an existing account_id
. There should be also a deposit_creating
counterpart that can be used to at least create accounts
Preconditions
ensure_token_exists(token_id).is_ok()
matches!(Ok(account_data), ensure_account_data_exists(token_Id, account_id)
Postconditions
OLD[_data.free_balance] - amount =
account_data.free_balance
transfer(token_id: TokenId, source: SourceLocation, destination: DestinationLocation, amount: Balance) -> Result<(), Error>
Transfer amount
of token_id
between source and destination accounts
Preconditions
token_id
must existssource
account must exists for token_id
destination
account must exists for token_id
balance_of(token_id, source)
>= amount
Postconditions
OLD[balance_of(token_id, source)] - amount
= =balance_of(token_id, source)
OLD[balance_of(token_id, destination)] + amount
= =balance_of(token_id, destination)
Encapsules the functionalities for reserving/unreserving amount
's from account_id
for
token_id
. Introduces the concepts of freebalance and reservebalance
implemented in the pallet Module
pub trait ReservableMultiCurrency<AccountId> {
// types defined
type Balance;
type TokenId;
// API
fn reserve(token_id: TokenId, account_id: AccountId, amount: Balance);
fn unreserve(token_id: TokenId, account_id: AccountId, amount: Balance);
fn reserved_balance(tokenid: TokenId, account_id: AccountId) -> Result<Balance, Error>;
fn free_balance(tokenid: TokenId, account_id: AccountId) -> Result<Balance, Error>;
}
reserved_balance(token_id, account_id) >
0
reserve(token_id: TokenId, account_id: AccountId, amount: Balance)
Preconditions
token_id
exsistsaccount_id
for token_id
existsfree_balance(token_id, account_id)
>= amount
Postconditions
OLD[free_balance(token_id, account_id)] - amount =
freebalance(tokenid, accountid)=OLD[reserved_balance(token_id, account_id)] + amount =
reservedbalance(tokenid, accountid)=unreserve(token_id: TokenId, account_id: AccountId, amount: Balance)
Preconditions
token_id
exsistsaccount_id
for token_id
existsSelf::reserved_balance(token_id, account_id)
>= amount
Postconditions
OLD[free_balance(token_id, account_id)] + amount =
freebalance(tokenid, accountid)=OLD[reserved_balance(token_id, account_id)] - amount =
reservedbalance(tokenid, accountid)=Introduces the concept of administrator (for the token) I decoupled this since I am
not sure how to implemnt it yet.
Used to establish autentication between a signed origin and a token_id
owner
Implemented by the InitialOfferingState
pub trait AdminAuthenticator<AccountId, AdminId> {
// API
fn ensure_admin_authentication(origin, admin_id: AdminId) -> Result<(), Error>;
fn admin_account(admin_id: AdminId) -> Result<AccountId, Error>;
}
ensure_admin_authentication(origin, admin_id: AdminId) -> Result<(), Error>
is Ok(())
when origin
is signed by admin_account(admin_id)
State in which the token find itself for bootstrapping initial liquidity. One of the following. Idle, Sale, IBCO. Encapsulates the State pattern and proper transition functions
is_idle(self) -> bool
Predicate for the idle state
is_sale(self) -> bool
Predicate for the Sale state
is_ibco(self) -> bool
Predicate for the IBCO state
change_to_idle(self) -> bool
change to the idle state
Preconditions
self.is_idle()
Postconditions
self.is_idle()
change_to_sale(self) -> bool
Change to the Sale state
Preconditions
self.is_idle()
|| self.is_sale()
Postconditions
self.is_sale()
change_to_ibco(self) -> bool
Changes to the IBCO state
Preconditions
self.is_idle()
|| self.is_sale()
|| self.is_ibco()
Postconditions
self.is_ibco()
for each of the change_to_*
there should be a change_to_*(origin, token_id, state_params...)
implemented in the pallet Module
, used to change TokenData
state after owner authentication
pub trait TransferLocation<AccountId, TransferPolicy> {
fn is_valid_location_for_policy(policy: TransferPolicy) -> bool;
fn location_account() -> AccountId;
}
AccountId
wrapper that interoperates with the transfer policy in order to validate transfer destination location.
Encapsules the merkle proof logic in validating the location for the permissioned case. This component is
the Visitor in the visitor pattern
is_valid_location_for_policy(policy: TransferPolicy)
Preconditions
Postconditions
policy.is_permissionless()
|| policiy.is_permissioned() && self.validate_location()
location_account()
getter for the wrapped account
pub trait TransferPermissionPolicy<AccountId, Hash> {
fn can_transfer_to(location: TransferLocation<AccountId>) -> bool;
fn is_permissionless(&self) -> bool;
fn is_permissioned(&self) -> bool;
fn change_to_permissionless(self) -> Self;
fn change_to_permissioned(self, whitelist_commitment: Hash) -> Self;
}
Encapsulates the state of the transfer policy (via the State pattern), also together with
the TransferLocation
trait it allows account destination validation via the visitor pattern.
can_transfer_to(location: TransferLocation) -> bool
Establish whether transfer location is allowed for the policy by means of the Visitor pattern
Preconditions
Postconditions
POSTCONDITIONS[location.is_valid_for_policy(self)]
is_permissionless(&self) -> bool
Predicate method for distinguishing permissionless state
is_permissioned(&self) -> bool
Predicate method for distinguishing permissioned state
change_to_permissionless(self) -> Self
Transition function to permissionless state
Preconditions
Postconditions
self.is_permissionless()
change_to_permissioned(self, whitelist_commitment: Hash) -> Self
Transition function to permissioned state with the given whitelist commitment
Preconditions
self.is_permissionless()
Postconditions
self.is_permissioned()
Converting the last two to extrinsics (in order to allow the creator to change permission), these are not
included in the trait but they will be implemented in the pallet::Module<T>
.
change_state_to_permissionless(origin, token_id) -> Result<(), Error>
Transition function to permissionless state
Preconditions
-matches!(Ok(token_data), ensure_token_exists(token_id))
-PRECONDITIONS[token_data.state.change_to_permissionless]
Postconditions
-POSTCONDITIONS[token_data.state.change_to_permissionless]
change_state_to_permissioned(origin, whitelist_commitment: Hash) -> Result<(), Error>
Transition function to permissioned state with the given whitelist commitment
Preconditions
-matches!(Ok(token_data), ensure_token_exists(token_id))
-PRECONDITIONS[token_data.state.change_to_permissioned(white_list_commitment)]
Postconditions
-POSTCONDITIONS[token_data.state.change_to_permissioned(white_list_commitment)]
Encapsulates:
The logic for validate destination accounts (i.e. the policy itself) is delegates to the TransferPermissionPolicy
trait.
Non source signed version are meant to be used as non-extrinsics calls (for example withing AMM extrinsics).
transfer(token_id: TokenId, source: AccountId, destination: TransferLocation, amount: Balance)
Transfer amount amount
of token_id
to account destination
from signer account (source
)
Preconditions
PRECONDITIONS[MultiCurrency::base_transfer(token_id, source, destination, amount)]
matches!(Ok(policy), ensure_policy_for_token_exists(token_id))
policy.can_transfer_to(destination)
Postconditions
POSTCONDITIONS[MultiCurrency::base_transfer(token_id, source, destination, amount)
transfer_multi_output(token_id: TokenId, source: AccountId, outputs: Vec<(TransferLocation, Balance)>)
Transfer amount outputs[i].1
of token_id
to account outputs[i].0
from signer account (source
)
Preconditions
output.iter().all(|(destination, amount)| PRECONDITIONS[MultiCurrency::base_transfer(token_id, source, destination, amount)])
matches!(Ok(policy), ensure_policy_for_token_exists(token_id))
output.iter().all(|(destination, _)| policy.can_transfer_to(destination))
Postconditions
outputs.iter().all(|(destination, amount) POSTCONDITIONS[MultiCurrency::transfer(token_id, source, destination, amount)])
transfer(origin, token_id: TokenId, destination: TransferLocation, amount: Balance)
Same as transfer
with origin
signed by the source
account. This is meant to be used as extrinsic when two
normal user wants to exchange CRT
transfer_multi_output(token_id: TokenId, source: AccountId, outputs: Vec<(TransferLocation, Balance)>)
Same as transfer_multi_output
with origin
signed by the source
account. This is meant to be used as extrinsic when sevaral
normal user wants to exchange CRT
Functionality for the desired revenue split
pub trait RevenueSplit<AccountId> {
type MultiCurrency: MultiCurrencyBase<AccountId> + ReservableMultiCurrency<AccountId>;
type TokenId = MultiCurrency::TokenId;
type Balace = MultiCurrency::Balance;
type AdminAuthentication: AdminAuthenticator<Self::AccountId, Self::TokenId>;
// provided types
type SplitId: Parameter + Member + Copy + Default + FullCodec;
fn issue_revenue_split(
origin,
token_id: Self::TokenId,
start: BlockNumber,
duration: BlockNumber,
percentage: Perbill
) -> Result<(), Error>;
fn partake(
origin,
split_id: Self::SplitId,
start: BlockNumber,
amount_to_stake: Self::Balance
) -> Result<(), Error>;
fn claim_revenue(
origin,
split_id: Self::SplitId
) -> Result<(), Error>;
}
issue_revenue_split(origin, token_id: Self::TokenId, start: BlockNumber, duration: BlockNumber, percentage: Perbill)
Preconditions
AdminAuthentication::ensure_admin_authentication(origin, token_id).is_ok()
System::block_number()
<= start
!percentage.is_zero()
Postconditions
OLD[next_split_id()] + 1 =
next_split_id()
OLD[next_split_id()]
partake(origin, split_id: Self::SplitId, start: BlockNumber, amount_to_stake: Self::Balance)
Preconditions
matches!(Ok(sender), ensure_signed(origin))
revenue
Revenue split split_id
must existsPRECONDITIONS[MultiCurrency::base_stake(revenue.token_id, sender, amount_to_stake)]
Postconditions
POSTCONDITIONS[MultiCurrency::base_stake(revenue.token_id, sender, amount_to_stake)]
(sender, amount_to_stake)
added to ParticipationsByAccountAndId
claim_revenue(origin, split_id: Self::SplitId) -> Result<(), Error>
Preconditions
matches!(Ok(sender), ensure_signed(origin))
matches!(Ok(split), ensure_split_exists(split_id))
matches!(Ok(revenue), ensure_split_partecipation_exists(sender, split_id))
Postconditions
POSTCONDITIONS[MultiCurrency::base_transfer(split_id.token_id, split_id.source, sender, revenue)]
This encapsulates the patronage mechanism. The actual implementation
consists of decorating the MultiCurrencyBase::mint
implementation function
so that percentage of the amount is added to the creator credit. Creator
can later cash out this credit with the effects that credit (in CRT) is
minted to his account
Implemented in pallet Module
pub trait Patronage<AccountId> {
type MultiCurrency: MultiCurrency<AccountId>;
type TokenId = MultiCurrency::TokenId;
type AdminAuthentication: AdminAuthenticator<AccountId, Self::TokenId>;
type Balance = MultiCurrency::Balance;
type TokenId = MultiCurrency::TokenId;
fn mint(token_id: TokenId, amount: Balance) -> Result<(), Error>;
fn claim_creator_credit(origin, token_id: TokenId) -> Result<(), Error>;
fn change_patronage_rate(origin, token_id: TokenId, new_rate: PerBill) -> Result<(), Error>;
}
mint(token_id: TokenId, amount: Balance) -> Result<(), Error>
Decorates the MultiCurrency::base_mint
function and accounts for creator credit
Preconditions
PRECONDITIONS[MultiCurrency::base_mint(token_id, amount)
Postconditions
POSTCONDITIONS[MultiCurrency::base_mint(token_id, amount)
OLD[CreatorCredit(tokenf_id)] + credit =
CreatorCredit(tokenid)= , credit is amonut * patronage_rate
claim_creator_credit(origin, token_id: TokenId) -> Result<(), Error>
Allow the token owner to claim his amounts (credit amount commit needed?)
Preconditions
matches!(Ok(token_info), MultiCurrency::ensure_token_exists(token_id))
AdminAuthentication::ensure_admin_authentication(origin, token_id)
Postconditions
CreatorCredit(token_id).is_zero()
OLD[MultiCurrency::balance_of(token_id, AdminAuthentication::admin_account(token_id))] + OLD[CreatorCredit(token_id)] =
MultiCurrency::balencof(tokenid, AdminAuthentication::adminaccount(tokenid))=change_patronage_rate(origin, token_id: TokenId, new_rate: PerBill) -> Result<(), Error>
Update the patronage rate for token,
Preconditions
matches!(Ok(token_info), MultiCurrency::ensure_token_exists(token_id))
AdminAuthentication::ensure_admin_authentication(origin, token_id)
token_info.patronage_rate >
new_rate
Postconditions
token_info.patronage_rate =
new_rate
Encapsules bonding / unbonding algorithm for the AMM (namely the shape of the bonding curve). The rationale for having 2 balance types is that you might need more precision for expressing JOY in terms of CRT
pub trait BondingCurveShape<InputBalance, OutputBalance = InputBalance> {
fn bonding_function(input_amount: InputBalance, input_reserve: InputBalance) -> Result<OutputBalance, Error>;
fn unbonding_function(output_amount: OutputBalance, output_supply: OutputBalance) -> Result<InputBalance, Error>
}
bonding_function(input_amount: Balance, input_reserve: Balance)
Computes the amount to return for the output currency in exchange for the input currency
Preconditions
input_amount >
input_reserve
Postconditions
unbonding_function(output_amount: Balance, output_reserve: Balance)
Computes the amount to return for the output currency in exchange for the input currency
Preconditions
output_amount >
output_reserve
Postconditions
(Initial bonding curve offering) Encapsules the bonding/unbonding functionality for the liquidity curve offering. The underlying implementation idea is the following:
token_id
.This is so that pricing function can be called with
bonding_function(amount_to_exchange, TokenInfoById::get(token_id).supply, current_reserve(token_id))
analogous with the unbonding_function
Implemented in pallet Module
pub trait BondingAmm<AccountId> {
// type functions
type OutputCurrency: MultiCurrencyBase<AccountId, TransferLocation<AccountId>>;
type ReserveCurrency: Currency<AccountId>,
type OutputBalance = OutputCurrency::Balance;
type ReserveBalance = ReserveCurrency::Balance;
type Bonding = BondingCurveShape<Self::OutputBalance, Self::ReserveBalance>;
type TokenId = OutputCurrency::TokenId;
// reserve functions
fn current_reserve(token_id: Self::TokenId) -> Balance;
fn reserve_account() -> Balance;
// actual bonding extrinsics
fn bond(
origin,
token_id: Self::TokenId,
to_account: TransferLocation<AccountId>,
amount: Self::OutputBalance,
) -> Result<Balance, Error>;
fn unbond(
origin,
token_id: Self::TokenId,
token_account: AccountId,
amount: Self::OutputBalance,
) -> Result<Balance, Error>;
}
token_ids.iter().all(|id| Self::current_reserve(id) >
0)
, token_id
all token issuedbond(origin, token_id: TokenId, to_account: TransferLocation<AccountId>, amount: ReserveBalance)
Mints in the corresponding amount_to_mint
derived using the BondingCurve::bonding_function
algorithm into the signer supplied to_account
, Transferring funds from the signer
account into the AMM reserve account.
Preconditions:
matches!(Ok(sender), ensure_signed(origin))
matches!(Ok(token_data, ensure_token_exists(token_id)))
PRECONDITIONS[transfer(sender, reserve_account, amount)]
Postconditions:
POSTCONDITIONS[MultiCurrency::base_deposit(token_id, to_account, amount_to_mint)]
token_data
AMM state reserved increased by amount
POSTCONDITIONS[transfer(sender, reserve_account, amount)]
unbond(origin, token_id: TokenId, from_account: AccountId, amount: OutputBalance)
Burns the corresponding amount_to_burn
derived using the BondingCurve::inverse_bonding_function
algorithm into the signer supplied from_account
, Transferring funds from the AMM reserve
account into the signer account.
Preconditions:
matches!(Ok(sender, ensure_signed(origin)))
matches!(Ok(token_data, ensure_token_exists(token_id)))
PRECONDITIONS[MultiCurrency::base_slash(token_id, to_account, amount)]
PRECONDITIONS[BaseCurrency::transfer(reserve_account, sender, output_amount)]
, output_amonut
appropriate amount computed using the pricing rulePostconditions:
POSTCONDITIONS[MultiCurrency::base_slash(token_id, to_account, amount)]
token_data
AMM state reserved decreased by amount
POSTCONDITIONS[BaseCurrency::transfer(reserve_account, sender, output_amount)]
, output_amonut
appropriate amount computed using the pricing ruleEncapsulate the logic for the initial sale of a token. The actual implementation is still a bit vague because it is still not 100% clear to me the vesting logic, but fortunately Sales is one of the latest component to implement in terms of timeline.
Implemented in the pallet Module
pub trait Sale<AccountId, BlockNumber> {
// types computed
type MultiCurrency: MultiCurrencyBase<AccountId> + ReservableMultiCurrency<AccountId>;
type TokenId = MultiCurrency::TokenId;
type Balace = MultiCurrency::Balance;
type AdminAuthentication: AdminAuthenticator<AccountId, Self::TokenId>;
// types provided
type SaleId;
fn issue_sale(
origin,
token_id: Self::TokenId,
start: BlockNumber,
duration: BlockNumber,
exchange_rate: Balance, // this should be a floating point
whitelist_commitment: Hash,
) -> Result<(), Error>;
fn update_sale_start(
origin,
sale_id: Self::SaleId,
start: BlockNumber,
) -> Result<(), Error>;
fn update_sale_duration(
origin,
sale_id: Self::SaleId,
duration: BlockNumber,
) -> Result<(), Error>;
fn purchase(
origin,
token_id: Self::TokenId,
sale_id: Self::SaleId,
amount: Self::Balance
) -> Result<(), Error>;
}
issue_sale(origin, token_id: Self::TokenId, start: BlockNumber, duration: BlockNumber, exchange_rate: Balance, whitelist_commitment: Hash, cap: Balance, locked: Balance)
Issues the sales for the token token_id
with the specified parameters
Preconditions
ensure_token_exists(token_id).is_ok())
AdminAuthentication::ensure_admin_authentication(origin, token_id).is_ok()
System::block_number() <
start
!cap.is_zero()
Postconditions
OLD[NextSaleId::get()] + 1 =
NextSaleId::get()
SaleByTokenAndId::insert(token_id, sale_id, Sale{..})
update_sale_start(origin, token_id: Self::TokenId, sale_id: Self::SaleId, start: BlockNumber)
Preconditions
ensure_token_exists(token_id).is_ok())
matches!(Ok(sale), ensure_sale_exists(sale_id))
AdminAuthentication::ensure_admin_authentication(origin, token_id).is_ok()
Postconditions
sale.start =
start
update_sale_duration(origin, sale_id: Self::SaleId, duration: BlockNumber)
Preconditions
ensure_token_exists(token_id).is_ok())
and state is Sale
matches!(Ok(sale), ensure_sale_exists(sale_id))
AdminAuthentication::ensure_admin_authentication(origin, token_id).is_ok()
Postconditions
sale.duration =
duration
puchase(origin, token_id: TokenId, sale_id: SaleId, to_location: TransferLocation<AccountId>)
Allows a participant to purchase specified amount of token after the sale has ended
Preconditions
matches!(Ok(sender), ensure_origin_signer(origin))
to_location.account() =
sender
ensure_token_exists(token_id).is_ok()
and state is Sale
matches!(Ok(sale), ensure_sale_exists(token_id, sale_id))
sales.balance - max(blocks_since_sale_ended*unlocking_rate + cliff, crt_sold)
Postconditions
max(blocks_since_sale_ended*unlocking_rate + cliff, sale.cap)
transferred to purchaser account according to ongoing policysales.balance
updated accordinglyAddendum:
org
file to markdown
might have changed some >=
into >
. So all >
are meant to be greater or equalOverall, phenomenal effort on this, in particular on the rigorous specification of extrinsics. I think however, this become quite a bit more detailed than was needed, as the overall question that we were trying to pin down was mostly just how this pallet interfaces with the rest of the runtime:
Anyway, I gave a review of some topics, I did not go into detail of all extrinsics, as I think ti will actually be easier to get synched on that through iterative implementation work. However, the fundamental questions (1-3) above seem unanswered to me still
SaleByTokenAndId
: Sale related state should live inside of the tokenstate, so TokenData
, there is no need to separate these for same reason as always, it's a 1-1 relationship.AccountData
=> TokenAccountData
, as this information is specific to an account in a token, not for one account across all tokens. I think this type is actually one of the most important types, so would have been good to see, since you did so much else at this low level of detail, but I think we may as well cover it in the implementation stage.SplitParticipationByAccountAndId
: This state can just live in AccountData
? You just apply whatever locking is required explicilty on the account. Transfers and attempt to cashout must respect this.InitialOfferingState
I would just call this TokenState
or something.Extrinsic list:
transfer(source_origin, token_id: TokenId, destination: TransferLocation, amount: Balance)
, transfer single output according to current policytransfer_multi_output(source_origin, token_id: TokenId, outputs: Vec<(TransferLocation,Balance)>)
, transfer multi output according to current bolicybond(origin, token_id: Self::TokenId, to_account: TransferLocation<AccountId>, amount: Self::Balance)
, buy CRT from bonding curveunbond(origin, token_id: Self::TokenId, token_account: AccountId, amount: Self::OutputBalance)
, sell CRT to bonding curveissue_sale(origin, token_id: TokenId, ...)
, owner-authenticated issue CRT sale update_sale_*(origin, token_id, ...)
, owner-authenticated update salepurchase(origin, token_id: TokenId, amount: Balance)
, purchase amount of token in a saleissue_split(origin, token_id: TokenId, ..)
, owner-authenticated revenue split for tokenpartake(origin, token_id: TokenId, split_id: SplitId, ..)
take part in a revenue split for tokenclaim_split(origin, token_id: TokenId, split_id: SplitId, ..)
claim respective amount after a split is endedclaim_patronage_credit(origin, token_id: TokenId)
owner-authenticated claims patronage creditupdate_patronage_rate(origin, token_id: TokenId, new_rate: Perbill)
owner-authenticated diminishing of the patronage ratechange_to_permissionless(origin, token_id: TokenId)
token owner change transfer policy to permissionlesschange_to_permissioned(origin, token_id: TokenId, whitelist_commit: Hash)
token owner changes transfer policy to permissioned with given whitelist commitThe above all require account authentication (via origin
parameter) and some also owner authentication via the (origin, token_id)
pair. (This is what the AdminAuthenticator
trait was meant to be for).
All other functions that are not listed above are meant to be library calls and they assume previous authentication.
For example if someone wants to issue a token in the Content
pallet then he has to:
issue_crt_token(origin, channel_id: ChannelId, params..)
, extrinsic in the Content palletproject_token
pallet via a issue_token(owner_account, other_params..)
callowner_account
which will be then reused by the implementor of the AdminAuthenticator
trait.This means that, in the example above, is the content pallet responsibility to ensure that for each channel there can be at most 1 CRT, and for each CRT token there must be exactly one underlying channel. I think that this is necessary if we want to keep the project token pallet agnostic about any other possible pallet where it's needed
The pallet itself doesn`t depend/uses on any other (Joystream) pallets. At least at this stage of the implementation process
This was very useful, list of extrinsics was quite sensible and clear.
change_to_permissioned
, as you are then effectiely opennig up the door to freezing people's assets. That is a non-starter.update_patronage_rate
=> reduce_patronage_rate
issue_token(owner_account ..
: This would mean we have to keep account in synch, because remember that members can change their account later, or the channel may swap owner. This is not a big deal, a much bigger problem is that it would prevent a curator group to manage the token! Remember that we will have channel swaps, so even if we prevented curator groups from issuing a creator token (which would also be a bad limitation), they could still later come to become the owner of such a channel, because the prior member owner had issued it. I think this is not OK. Instead, both kinds of owner should be able to issue and control the CRT.AdminAuthenticator
seems like a useful construct, but
TokenIssuerAuthenticator
.admin_account
call has to be removed, as there won't be a single account as owner, due to comments above.project_token
codebase, we need to do something like this: The CRT pallet extrinsics just accept an opaque ownership identifier, so Vec<u8>
, and then it has some sort of authenticator injected through Trait
which can accept this opaque value, so ensure_is_token_issuer(origin, opaque_issuer_id: Vec<u8>)
. The implementation would here try to deserialize into ChannelOwner
, hence user app has to submit a serialized version of this. Then it can do normal auth check as in content directory. I don't think we can do change_to_permissioned, as you are then effectiely opennig up the door to freezing people's assets. That is a non-starter.
So you are saying that Permissionless
state is a terminal state (since we can't revert to Permissioned)?
About the token owner authentication part. What we really want to establish is, (origin, token_id) ->
origin
is signed by the token_id
or not, approaches so far:
ensure_is_token_issuer
implementation should be inside the content pallet or inside the token pallet. In either case it is a valid approach:
let _ = ensure_signed(origin)?; // a must otherwise replay attacks?
let token_data = Self::ensure_token_exists(token_id)?;
Self::ensure_token_issuer(origin, token_data.opaque_id)?; // or T::Authenticator::ensure_token_issuer(origin, token_data.opaque_id)?
// operations..
Channel
record like token_id: Option<TokenId>
which can be set when channel_id
issues a CRT.TokenIssuerAuthenticator
inside the project_token
pallet Trait
configuration. Then having an actual implementor inside the content pallet. In this way ownership synching is fully delegated to the content palletThe above approach can be reused in any other pallet making use of the project token pallet
Addendum: I am more favorable for option 2
So you are saying that Permissionless state is a terminal state (since we can't revert to Permissioned)?
Yes
I didn' get if the actual ensure_is_token_issuer implementation should be inside the content pallet or inside the token pallet
It could not be inside CRT pallet, because that would not make the same implementation reusable across different types of owners, then we may as well just hard code owner types directly in TokenData
, it makes no difference. With what I am saying, you can take the exact same pallet code, and someone could use it in another runtime with totally diffeent ownership conscepts, or, if we want multiple instances of the same pallet (instance pallet) in our runtime, they could use different ownership representations.
So what I meant is that ensure_is_token_issuer
would live in content pallet, or in just some runtime level integration code which provides implementation of trait, outside any pallet.
An alternative to that is having:
This does not work as far as I see, it cannot capture that authentication for one type of owner requires different information to another. To authenticate curator group owned channel, the id of the worker must be provided. To authetnicate a member owned channel, no extra infromation is needed at all. This is a fundamental distinction.
So we decided that in order to avoid having to
we will go with the following:
ensure_is_token_issuer(origin, opaque_issuer_id: Vec<u8>)
where opaque_issuer_id
basically will for our runtime be passed, by user via extrinsic, an Codec encoded version of the following type that does not live in the token pallet
struct TokenOperator {
channel_id: ChannelId,
type: TokenOperatorActor
}
enum TokenOperatorActor {
Member,
Curator(CuratorId)
}
Names are horrible, I know, I'm rushed, idea is just to capture both id of channel and whomever is trying to do something as the operator of the channel, in one representation. With this, the ensure_is_token_issuer
can without iteration, or extra state, fully authenticate the caller as a legitimate token operator.
So it occurred to me: is it a good idea that $JOY account of each token lives outside of token pallet? If it is, how should project pallet interact with it? In particular when it comes to revenue splits, the initiation of a split requires that somehow the amount being paid out is not spendable, and that people cashing out their share of the split are able to transfer part of these unspendable funds during this split period. If the account lives outside of the token pallet, then the question becomes how to do this properly? At the very least issue_token
would require a parameter which provides the account id, or there would need to be a mapping from token id to channel in the content pallet again.
I don't see that in this design, am I missing it?
I didn't put it in the design.
When a revenue split is initiated, it is for a certain amount X (user input) of $JOY, no more than the current balance of the channel, Y. The owner specifies some account during split initiation, where the owner share of X is diverted, that is X*(1 - S/100)
We can have something like split_account
s (internal to the modules) like we have for channel accounts and issue_split
will need to know the channel_account
from which a transfer X
amount of JOY to the split_account
.
Every split claim will consists: balance_pallet::transfer(spit_account, member_account, X*S/100)
.
With that we should have also an function that lives externally like:
funds_account_for_admin(origin, opaque_id: Vec<u8>) -> AccountId
that it is a variant of the function you suggested which retrieves the internal channel account.
So we decided after a quick call
X
$JOY (split amount) when a split is initiated by debiting the external account for the token. The external account in the case of the content directory would be the channel account.finalize_split
which will move any unclaimed funds back to the external source account.Perhaps we should just make all issuer actions into non-extrinsics, so pallet methods? This would
ensure_signed
only.In fact we are already assuming some of this stuff is possible by what we are doing here => https://github.com/Joystream/joystream/issues/2362#issuecomment-1060918599
Is there any downside?
We decided to go with summary in last post.
I think it should be possible to turn off AMM again, it should be possible only by the issuer and require that it has no bonded $JOY at the moment. Probably useful to allow closing it and selling sufficient $CRT to buy back all $JOY, so at to deplete AMM and satisfy this constraint in one step.
vested amount at block block
for linear + cliff vesting schedule:
vested_amount(block) = cliff + amount_per_block * (block - vesting_start_block)
with vesting_start_block <= block <= vesting_end_block
.
Not exactly, the cliff refers to the duration from start to the point in time at which the quantity of tokens you have access to actually starts to increase, before this, nothing is happening as time passes. So this is the formula
V(b) = 0
if b < start + cliff
V(b) = max{allocation, base_liquidity + (b - [start + cliff])*vesting_rate }
else
where
b: BlockNumber >= 0
is the time counter in blocks.start: BlockNumber >= 0
is the block at which vesting began.cliff: BlockNumber >= 0
the length of the cliff from the start
.base_liquidity: Balance >= 0
is the instant liquidity on first block after cliff.vesting_rate: Balance > 0
the number of additional units of token unlocked per block after cliff but before completion.allocation: Balance > 0
is the number of token units that was assigned.alternatively viewed graphically like
Is it possible to have multiple vesting schedules ongoing for a token? I.e. given token_id
there can be at minimum 0 and at most
n = 1
vesting schedules at any given time?
Is it possible to have multiple vesting schedules ongoing for a token? I.e. given token_id there can be at minimum 0 and at most n = 1 vesting schedules at any given time?
I think the issuer can self-impose a particular schedule when issuing, but then during a sale a new schedule can be imposed for the new amount. This basically implies that you need to be able to have multiple distinct schedules in play on the same account (or now member), because we are allowing multiple sales over time, and also the issuer could participate both in initial issuance and a later sale, which would require at least two simultanous schedules. We can put a limit, say a max number of schedules, and then allow a member to clean out an old schedule when it no longer applies, i.e. any b
where V(b) = allocation
.
So basically we go from
struct AccountData {
//unlikely to just be balance,
staked_for_revenue_split: StakingType
// include cliff block, and vesting rate, and balance schedule applies to
// **Is immutable:** does not get updated over time, only describes schedule that may have initially applied
vesting_schedule: VestingSchedule
// includes balance that can be transferred, and balance that is encumbered by vesting schedule
not_staked_balance: Balance
}
to
struct AccountData {
//unlikely to just be balance,
staked_for_revenue_split: StakingType
// include cliff block, and vesting rate, and balance schedule applies to
// each schedule is immutable, and can get cleanout out when expired through a dedicated extrinsic
// an length of Vec must be hard capped.
vesting_schedules: Vec<VestingSchedule>
// includes balance that can be transferred, and balance that is encumbered by vesting schedule
not_staked_balance: Balance
}
While discussing it with @ignazio-bovo I was considering an introduction of a separate AccountId => Vec<VestingSchedule>
map in the storage and then allow users to claim the vested tokens through a separate extrinsic in order to limit the amount of computation (including storage read, calculations etc.) done every time we need to retrieve token holder's usable balance. Do you think that would be a good approach @bedeho ?
in order to limit the amount of computation
First off, the only computational cost that really is relevant is db-io, realistically, separating this out into a different map from the core account data would only increase that, perhaps I am missing something? There is no extra db-io to what you will have to do anyway when you are doing any sort of transfer, and thus will need to load the AccountData
instance anyway. Realistically vesting_schedules
could even be really large before it would have any effect, easily many dusins of instances before the size of the storage object itself started having any weight impact.
Just to level set, the main storage representation we are using now, given that we will not use raw accounts, is TokenIdxMemberId=> AccountData
.
This is just the idea I've had based on the fact that similar data structures (like balances.locks
or balances.reserves
) are usually separate maps in Substrate. I'm actually not sure at which point it becomes an advantage to use a separate map vs large single storage object, but my hunch was that since we'll load AccountData
very often and we'll need vesting_schedules
only during claim
, it may be more efficient to separate those.
That beeing said, if we expect the amount of vesting_schedules
per member to always be very small (and we can guarantee it in some way), maybe that's a premature optimalization.
based on the fact that similar data structures (like balances.locks or balances.reserves) are usually separate maps in Substrate.
Yes, perhaps because they are general purpose and cannot make assumptions about what sort of staking or locking people will want to use in their chain? In our case we have a specific use case, not a general fungible token system to be used for generalised staking of various kinds. If we look at our concrete locks and reservations, we also end up having 2-3 locks simultaneously at most.
very often and we'll need vesting_schedules only during claim, it may be more efficient to separate those.
Do we need a claim
? our vesting constrain is basically like a lock with a temporal dimension, you don't need to do anything to claim or realize the tokens, its just an encumbrance that will decline in significance on its own.
That beeing said, if we expect the amount of vesting_schedules per member to always be very small (and we can guarantee it in some way), maybe that's a premature optimalization.
I mean in terms of number of schedules, then I think yes, at least for normal users. Like how many different simultaneous schedules do you need as one member on one token? At most 2 perhaps?
Notes
creator
= anyone that is the owner of a channel, has the right to issue a own $CRTPallet components
Supporting library
(token core implementation): I am in favor of using assets: Dependencies:
This will provide us with a bunch of primitives such as :
burn(token_id, account, amount)
heremint(token_id, account, amount)
heretransfer(token_id, from, to, amount)
hereCRT token core
crt_token::burn
function that ensure this constraint is respected whenever balance of freshly burned accounts drop below theexistential_amount
threshold.crt_token::{stake, unstake}
functions, that I suppose can be build on top offreeze, thaw
[freeze](https://docs.rs/pallet-assets/latest/src/pallet_assets/lib.rs.html#190), thawIdle
,Sell
,AMM
. Allowed transitions: Idle -> AMM, Idle -> Sale, Sale -> AMMTransfer
this encapsules how transferring CRT works, What I am thinking is to build on top of
transfer
primitive in order to change behavior according to the selected Policy (Permissioned, Permissionless). The main goal of this section is to have acrt_token::transfer(token_id, from, to, amount)
that:amount
oftoken_id
is transferred fromfrom
toto
.from < existential_deposit
thenbalance(from)
is burned andfrom
account is removed. There should be also support for multi output payments, by allowingtransfer
(or a variant of) to accept a list[(to, amount)]
Minting & Patronage
What I am thinking here is to build on top of
mint
primitive in order to:amount
into specifiedaccount
withmint(token_id, amount, account)
inflationary_amount = amount * PATRONAGE_RATE/100
to the creator account It is creator's responsibility to cash out such amount, by calling appropriate extrinsic Once completed we should have thecrt_token::mint(token_id,account, amount)
, whose purpose is twofold: mint amount + credit creator with patronage. There should be an extrinsic allowing thecreator
to reduce the patronage rateSale
Requires:
crt_token::{mint, burn}
First method in order to bootstrap liquidity for $CRT. A sale period defined by(start, duration)
is issued which can be updated any time prior. Optional whitelist containing member with respective cap amounts that they can receive from the sale. This member whitelist can be used as starting whitelist if the initial transfer policy for $CRT isPermissioned
.creator
$CRT/$JOY for the sale. Proceeds (liquidity) go intocreator
JOY account, while tokens "sold" arecrt_token::mint
ed into the buyer $CRT account. A percentage ofJOY
is burned on top of each sale. Council control this percentage parameterRevenue split
Requires:
crt_token::{stake, unstake}
The creator, holdsY
$JOY in its account by issuing a Sale announces that he intends to distribute percentageS
of amountX
(in JOY). any token holder interested mustcrt_token::stake
V/Q * 100
% (percentage of total $CRT issuance) in order to claimS/100 * X * V/Q
$JOY at the end of the split period. After the payout has been received in the specified JOY account the $CRT amount can becrt_token::unstake
d.AMM
Terminal state for $CRT and alternative to a Sale for bootstrapping liquidty. Requires:
crt_token::{mint, burn}
crt_token::mint
s $CRT into the buyer's account.crt_token::burn
s $CRT from the seller's accountTimeline
Stage 0:
Stage 1:
Each of these components can be implemented concurrently and independently (more or less)
Stage 2:
This will build on Stage1 being reviewed and tested. The following components can be build concurrently & independently
Questions
Open problems / Comments