diff --git a/.gitignore b/.gitignore index adeafd8f..3131fe1a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,4 @@ Cargo.lock **/*.rs.bk # IDE specific user-config -.vscode/ \ No newline at end of file +.vscode/ diff --git a/Cargo.toml b/Cargo.toml index 558f9b0a..a58b3dca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,4 +2,7 @@ members = [ "fvm_dispatch", + "fil_token", + "testing/fil_token_integration", + "testing/fil_token_integration/actors/wfil_token_actor", ] \ No newline at end of file diff --git a/fil_token/Cargo.toml b/fil_token/Cargo.toml new file mode 100644 index 00000000..ea05d265 --- /dev/null +++ b/fil_token/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "fil_token" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0.56" +cid = { version = "0.8.3", default-features = false, features = ["serde-codec"] } +fvm_ipld_blockstore = "0.1.1" +fvm_ipld_hamt = "0.5.1" +fvm_ipld_amt = { version = "0.4.2", features = ["go-interop"] } +fvm_ipld_encoding = "0.2.2" +fvm_sdk = { version = "1.0.0" } +fvm_shared = { version = "0.8.0" } +serde = { version = "1.0.136", features = ["derive"] } +serde_tuple = { version = "0.5.0" } +thiserror = { version = "1.0.31" } \ No newline at end of file diff --git a/fil_token/src/blockstore.rs b/fil_token/src/blockstore.rs new file mode 100644 index 00000000..c492af22 --- /dev/null +++ b/fil_token/src/blockstore.rs @@ -0,0 +1,72 @@ +//! Blockstore implementation is borrowed from https://github.com/filecoin-project/builtin-actors/blob/6df845dcdf9872beb6e871205eb34dcc8f7550b5/runtime/src/runtime/actor_blockstore.rs +//! This impl will likely be made redundant if low-level SDKs export blockstore implementations +use std::convert::TryFrom; + +use anyhow::{anyhow, Result}; +use cid::multihash::Code; +use cid::Cid; +use fvm_ipld_blockstore::Block; +use fvm_sdk::ipld; + +/// A blockstore that delegates to IPLD syscalls. +#[derive(Default, Debug, Copy, Clone)] +pub struct Blockstore; + +impl fvm_ipld_blockstore::Blockstore for Blockstore { + fn get(&self, cid: &Cid) -> Result>> { + // If this fails, the _CID_ is invalid. I.e., we have a bug. + ipld::get(cid) + .map(Some) + .map_err(|e| anyhow!("get failed with {:?} on CID '{}'", e, cid)) + } + + fn put_keyed(&self, k: &Cid, block: &[u8]) -> Result<()> { + let code = Code::try_from(k.hash().code()).map_err(|e| anyhow!(e.to_string()))?; + let k2 = self.put(code, &Block::new(k.codec(), block))?; + if k != &k2 { + return Err(anyhow!("put block with cid {} but has cid {}", k, k2)); + } + Ok(()) + } + + fn put(&self, code: Code, block: &Block) -> Result + where + D: AsRef<[u8]>, + { + // TODO: Don't hard-code the size. Unfortunately, there's no good way to get it from the + // codec at the moment. + const SIZE: u32 = 32; + let k = ipld::put(code.into(), SIZE, block.codec, block.data.as_ref()) + .map_err(|e| anyhow!("put failed with {:?}", e))?; + Ok(k) + } +} + +// TODO: put this somewhere more appropriate when a tests folder exists +/// An in-memory blockstore impl taken from filecoin-project/ref-fvm +#[derive(Debug, Default, Clone)] +pub struct MemoryBlockstore { + blocks: RefCell>>, +} + +use std::{cell::RefCell, collections::HashMap}; +impl MemoryBlockstore { + pub fn new() -> Self { + Self::default() + } +} + +impl fvm_ipld_blockstore::Blockstore for MemoryBlockstore { + fn has(&self, k: &Cid) -> Result { + Ok(self.blocks.borrow().contains_key(k)) + } + + fn get(&self, k: &Cid) -> Result>> { + Ok(self.blocks.borrow().get(k).cloned()) + } + + fn put_keyed(&self, k: &Cid, block: &[u8]) -> Result<()> { + self.blocks.borrow_mut().insert(*k, block.into()); + Ok(()) + } +} diff --git a/fil_token/src/lib.rs b/fil_token/src/lib.rs new file mode 100644 index 00000000..a6309255 --- /dev/null +++ b/fil_token/src/lib.rs @@ -0,0 +1,5 @@ +pub mod blockstore; +pub mod token; + +#[cfg(test)] +mod tests {} diff --git a/fil_token/src/token/errors.rs b/fil_token/src/token/errors.rs new file mode 100644 index 00000000..c74b6f09 --- /dev/null +++ b/fil_token/src/token/errors.rs @@ -0,0 +1,71 @@ +use std::{error::Error, fmt::Display}; + +use fvm_ipld_hamt::Error as HamtError; +use fvm_shared::address::Address; + +#[derive(Debug)] +pub enum RuntimeError { + AddrNotFound(Address), +} + +impl Display for RuntimeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RuntimeError::AddrNotFound(_) => write!(f, "Address not found"), + } + } +} + +impl Error for RuntimeError {} + +#[derive(Debug)] +pub enum StateError {} + +impl Display for StateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "State error") + } +} + +impl Error for StateError {} + +#[derive(Debug)] +pub enum ActorError { + AddrNotFound(Address), + Arithmetic(String), + IpldState(StateError), + IpldHamt(HamtError), + RuntimeError(RuntimeError), +} + +impl Display for ActorError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ActorError::AddrNotFound(e) => write!(f, "{}", e), + ActorError::Arithmetic(e) => write!(f, "{}", e), + ActorError::IpldState(e) => write!(f, "{}", e), + ActorError::IpldHamt(e) => write!(f, "{}", e), + ActorError::RuntimeError(e) => write!(f, "{}", e), + } + } +} + +impl Error for ActorError {} + +impl From for ActorError { + fn from(e: StateError) -> Self { + Self::IpldState(e) + } +} + +impl From for ActorError { + fn from(e: HamtError) -> Self { + Self::IpldHamt(e) + } +} + +impl From for ActorError { + fn from(e: RuntimeError) -> Self { + ActorError::RuntimeError(e) + } +} diff --git a/fil_token/src/token/mod.rs b/fil_token/src/token/mod.rs new file mode 100644 index 00000000..22e8ed69 --- /dev/null +++ b/fil_token/src/token/mod.rs @@ -0,0 +1,253 @@ +pub mod errors; +pub mod receiver; +mod state; +mod types; +use self::state::TokenState; +pub use self::types::*; + +use anyhow::bail; +use anyhow::Ok; +use anyhow::Result; +use cid::Cid; +use fvm_ipld_blockstore::Blockstore as IpldStore; +use fvm_shared::bigint::Zero; +use fvm_shared::econ::TokenAmount; +use fvm_shared::ActorID; + +/// Library functions that implement core FRC-??? standards +/// +/// Holds injectable services to access/interface with IPLD/FVM layer. +pub struct TokenHelper +where + BS: IpldStore + Clone, +{ + /// Injected blockstore + bs: BS, + /// Root of the token state tree + token_state: Cid, +} + +impl TokenHelper +where + BS: IpldStore + Clone, +{ + /// Instantiate a token helper with access to a blockstore and runtime + pub fn new(bs: BS, token_state: Cid) -> Self { + Self { bs, token_state } + } + + /// Constructs the token state tree and saves it at a CID + pub fn init_state(&self) -> Result { + let init_state = TokenState::new(&self.bs)?; + init_state.save(&self.bs) + } + + /// Helper function that loads the root of the state tree related to token-accounting + fn load_state(&self) -> Result { + TokenState::load(&self.bs, &self.token_state) + } + + /// Mints the specified value of tokens into an account + /// + /// If the total supply or account balance overflows, this method returns an error. The mint + /// amount must be non-negative or the method returns an error. + pub fn mint(&self, initial_holder: ActorID, value: TokenAmount) -> Result<()> { + if value.lt(&TokenAmount::zero()) { + bail!("value of mint was negative {}", value); + } + + // Increase the balance of the actor and increase total supply + let mut state = self.load_state()?; + state.increase_balance(&self.bs, initial_holder, &value)?; + state.increase_supply(&value)?; + + // Commit the state atomically if supply and balance increased + state.save(&self.bs)?; + + Ok(()) + } + + /// Gets the total number of tokens in existence + /// + /// This equals the sum of `balance_of` called on all addresses. This equals sum of all + /// successful `mint` calls minus the sum of all successful `burn`/`burn_from` calls + pub fn total_supply(&self) -> TokenAmount { + let state = self.load_state().unwrap(); + state.supply + } + + /// Returns the balance associated with a particular address + /// + /// Accounts that have never received transfers implicitly have a zero-balance + pub fn balance_of(&self, holder: ActorID) -> Result { + // Load the HAMT holding balances + let state = self.load_state()?; + state.get_balance(&self.bs, holder) + } + + /// Gets the allowance between owner and spender + /// + /// The allowance is the amount that the spender can transfer or burn out of the owner's account + /// via the `transfer_from` and `burn_from` methods. + pub fn allowance(&self, owner: ActorID, spender: ActorID) -> Result { + let state = self.load_state()?; + let allowance = state.get_allowance_between(&self.bs, owner, spender)?; + Ok(allowance) + } + + /// Increase the allowance that a spender controls of the owner's balance by the requested delta + /// + /// Returns an error if requested delta is negative or there are errors in (de)sereliazation of + /// state. Else returns the new allowance. + pub fn increase_allowance( + &self, + owner: ActorID, + spender: ActorID, + delta: TokenAmount, + ) -> Result { + if delta.lt(&TokenAmount::zero()) { + bail!("value of delta was negative {}", delta); + } + + let mut state = self.load_state()?; + let new_amount = state.increase_allowance(&self.bs, owner, spender, &delta)?; + state.save(&self.bs)?; + + Ok(new_amount) + } + + /// Decrease the allowance that a spender controls of the owner's balance by the requested delta + /// + /// If the resulting allowance would be negative, the allowance between owner and spender is set + /// to zero. If resulting allowance is zero, the entry is removed from the state map. Returns an + /// error if either the spender or owner address is unresolvable. Returns an error if requested + /// delta is negative. Else returns the new allowance + pub fn decrease_allowance( + &self, + owner: ActorID, + spender: ActorID, + delta: TokenAmount, + ) -> Result { + if delta.lt(&TokenAmount::zero()) { + bail!("value of delta was negative {}", delta); + } + + let mut state = self.load_state()?; + let new_allowance = state.decrease_allowance(&self.bs, owner, spender, &delta)?; + state.save(&self.bs)?; + + Ok(new_allowance) + } + + /// Sets the allowance between owner and spender to 0 + pub fn revoke_allowance(&self, owner: ActorID, spender: ActorID) -> Result<()> { + let mut state = self.load_state()?; + state.revoke_allowance(&self.bs, owner, spender)?; + state.save(&self.bs)?; + Ok(()) + } + + /// Burns an amount of token from the specified address, decreasing total token supply + /// + /// ## For all burn operations + /// - The requested value MUST be non-negative + /// - The requested value MUST NOT exceed the target's balance + /// + /// Upon successful burn + /// - The target's balance MUST decrease by the requested value + /// - The total_supply MUST decrease by the requested value + /// + /// ## Operator equals target address + /// If the operator is the targeted address, they are implicitly approved to burn an unlimited + /// amount of tokens (up to their balance) + /// + /// ## Operator burning on behalf of target address + /// If the operator is burning on behalf of the target token holder the following preconditions + /// must be met on top of the general burn conditions: + /// - The operator MUST have an allowance not less than the requested value + /// In addition to the general postconditions: + /// - The target-operator allowance MUST decrease by the requested value + /// + /// If the burn operation would result in a negative balance for the targeted address, the burn + /// is discarded and this method returns an error + pub fn burn( + &self, + spender: ActorID, + owner: ActorID, + value: TokenAmount, + ) -> Result { + if value.lt(&TokenAmount::zero()) { + bail!("Cannot burn a negative amount"); + } + + let mut state = self.load_state()?; + + if spender != owner { + // attempt to use allowance and return early if not enough + state.attempt_use_allowance(&self.bs, spender, owner, &value)?; + } + // attempt to burn the requested amount + let new_amount = state.decrease_balance(&self.bs, owner, &value)?; + + // if both succeeded, atomically commit the transaction + state.save(&self.bs)?; + Ok(new_amount) + } + + /// Transfers an amount from one actor to another + /// + /// ## For all transfer operations + /// + /// - The requested value MUST be non-negative + /// - The requested value MUST NOT exceed the sender's balance + /// - The receiver actor MUST implement a method called `tokens_received`, corresponding to the + /// interface specified for FRC-XXX token receivers + /// - The receiver's `tokens_received` hook MUST NOT abort + /// + /// Upon successful transfer: + /// - The senders's balance MUST decrease by the requested value + /// - The receiver's balance MUST increase by the requested value + /// + /// ## Operator equals target address + /// If the operator is the 'from' address, they are implicitly approved to transfer an unlimited + /// amount of tokens (up to their balance) + /// + /// ## Operator transferring on behalf of target address + /// If the operator is transferring on behalf of the target token holder the following preconditions + /// must be met on top of the general burn conditions: + /// - The operator MUST have an allowance not less than the requested value + /// In addition to the general postconditions: + /// - The from-operator allowance MUST decrease by the requested value + pub fn transfer( + &self, + operator: ActorID, + from: ActorID, + to: ActorID, + value: TokenAmount, + ) -> Result<()> { + if value.lt(&TokenAmount::zero()) { + bail!("Cannot transfer a negative amount"); + } + + let mut state = self.load_state()?; + + if operator != from { + // attempt to use allowance and return early if not enough + state.attempt_use_allowance(&self.bs, operator, from, &value)?; + } + + // call the receiver hook + // FIXME: use fvm_dispatch to make a standard runtime call to the receiver + // - ensure the hook did not abort + + // attempt to debit from the sender + state.decrease_balance(&self.bs, from, &value)?; + // attempt to credit the receiver + state.increase_balance(&self.bs, to, &value)?; + + // if all succeeded, atomically commit the transaction + state.save(&self.bs)?; + + Ok(()) + } +} diff --git a/fil_token/src/token/receiver.rs b/fil_token/src/token/receiver.rs new file mode 100644 index 00000000..3c1a8424 --- /dev/null +++ b/fil_token/src/token/receiver.rs @@ -0,0 +1,7 @@ +use anyhow::Result; +use fvm_shared::{address::Address, econ::TokenAmount}; + +/// Standard interface for a contract that wishes to receive tokens +pub trait TokenReceiver { + fn token_received(from: Address, amount: TokenAmount, data: &[u8]) -> Result; +} diff --git a/fil_token/src/token/state.rs b/fil_token/src/token/state.rs new file mode 100644 index 00000000..18b6d64d --- /dev/null +++ b/fil_token/src/token/state.rs @@ -0,0 +1,532 @@ +use anyhow::anyhow; +use anyhow::bail; +use anyhow::Result; +use cid::multihash::Code; +use cid::Cid; + +use fvm_ipld_blockstore::Block; +use fvm_ipld_blockstore::Blockstore as IpldStore; +use fvm_ipld_encoding::ser; +use fvm_ipld_encoding::tuple::*; +use fvm_ipld_encoding::Cbor; +use fvm_ipld_encoding::CborStore; +use fvm_ipld_encoding::DAG_CBOR; +use fvm_ipld_hamt::Hamt; +use fvm_shared::bigint::bigint_ser; +use fvm_shared::bigint::bigint_ser::BigIntDe; +use fvm_shared::bigint::Zero; +use fvm_shared::econ::TokenAmount; +use fvm_shared::ActorID; + +use crate::blockstore::Blockstore; + +const HAMT_BIT_WIDTH: u32 = 5; + +/// Token state IPLD structure +#[derive(Serialize_tuple, Deserialize_tuple, PartialEq, Clone, Debug)] +pub struct TokenState { + /// Total supply of token + #[serde(with = "bigint_ser")] + pub supply: TokenAmount, + + /// Map of balances as a Hamt + pub balances: Cid, + /// Map> as a Hamt. Allowances are stored balances[owner][spender] + pub allowances: Cid, +} + +/// An abstraction over the IPLD layer to get and modify token state without dealing with HAMTs etc. +/// +/// This is a simple wrapper of state and in general does not account for token protocol level +/// checks such as ensuring necessary approvals are enforced during transfers. This is left for the +/// caller to handle. However, some invariants such as enforcing non-negative balances, allowances +/// and total supply are enforced. Furthermore, this layer returns errors if any of the underlying +/// arithmetic overflows. +/// +/// Some methods on TokenState require the caller to pass in a blockstore implementing the Clone +/// trait. It is assumed that when cloning the blockstore implementation does a "shallow-clone" +/// of the blockstore and provides access to the same underlying data. +impl TokenState { + /// Create a new token state-tree, without committing it to a blockstore + pub fn new(store: &BS) -> Result { + let empty_balance_map = Hamt::<_, ()>::new_with_bit_width(store, HAMT_BIT_WIDTH).flush()?; + let empty_allowances_map = + Hamt::<_, ()>::new_with_bit_width(store, HAMT_BIT_WIDTH).flush()?; + + Ok(Self { + supply: Default::default(), + balances: empty_balance_map, + allowances: empty_allowances_map, + }) + } + + /// Loads a fresh copy of the state from a blockstore from a given cid + pub fn load(bs: &BS, cid: &Cid) -> Result { + // Load the actor state from the state tree. + match bs.get_cbor::(&cid) { + Ok(Some(state)) => Ok(state), + Ok(None) => Err(anyhow!("No state at this cid {:?}", cid)), + Err(err) => Err(anyhow!("failed to get state: {}", err)), + } + } + + /// Saves the current state to the blockstore, returning the cid + /// TODO: Potentially should replaced with more targeted saving of different branches of the state tree for + /// efficiency + /// - possibly keep track of "dirty" branches that need to be flushed and put into the blockstore + /// on save + /// - hack for now is that each method that touches a hamt, will itself commit the changes to the blockstore + pub fn save(&self, bs: &BS) -> Result { + let serialized = match fvm_ipld_encoding::to_vec(self) { + Ok(s) => s, + Err(err) => return Err(anyhow!("failed to serialize state: {:?}", err)), + }; + let block = Block { + codec: DAG_CBOR, + data: serialized, + }; + let cid = match bs.put(Code::Blake2b256, &block) { + Ok(cid) => cid, + Err(err) => return Err(anyhow!("failed to store initial state: {:}", err)), + }; + Ok(cid) + } + + /// Get the balance of an ActorID from the currently stored state + pub fn get_balance( + &self, + bs: &BS, + owner: ActorID, + ) -> Result { + let balances = self.get_balance_map(bs)?; + + let balance: TokenAmount; + match balances.get(&owner)? { + Some(amount) => balance = amount.0.clone(), + None => balance = TokenAmount::zero(), + } + + Ok(balance) + } + + /// Attempts to increase the balance of the specified account by the value + /// + /// Caller must ensure the requested amount is non-negative. + /// Returns an error if the balance overflows, else returns the new balance + pub fn increase_balance( + &mut self, + bs: &BS, + actor: ActorID, + value: &TokenAmount, + ) -> Result { + let mut balance_map = self.get_balance_map(bs)?; + let balance = balance_map.get(&actor)?; + + // calculate the new balance + let new_balance = match balance { + Some(existing_amount) => { + let existing_amount = existing_amount.clone().0; + let new_amount = existing_amount.checked_add(&value).ok_or_else(|| { + anyhow!( + "Overflow when adding {} to {}'s balance of {}", + value, + actor, + existing_amount + ) + })?; + + new_amount + } + None => value.clone(), + }; + + balance_map.set(actor, BigIntDe(new_balance.clone()))?; + self.balances = balance_map.flush()?; + let serialised = match fvm_ipld_encoding::to_vec(&balance_map) { + Ok(s) => s, + Err(err) => return Err(anyhow!("failed to serialize state: {:?}", err)), + }; + bs.put_keyed(&self.balances, &serialised)?; + Ok(new_balance) + } + + /// Attempts to decrease the balance of the specified account by the value + /// + /// Caller must ensure the requested amount is non-negative. + /// Returns an error if the balance overflows, or if resulting balance would be negative. + /// Else returns the new balance + pub fn decrease_balance( + &mut self, + bs: &BS, + actor: ActorID, + value: &TokenAmount, + ) -> Result { + let mut balance_map = self.get_balance_map(bs)?; + let balance = balance_map.get(&actor)?; + + if balance.is_none() { + bail!( + "Balance would be negative after subtracting {} from {}'s balance of {}", + value, + actor, + TokenAmount::zero() + ); + } + + let existing_amount = balance.unwrap().clone().0; + let new_amount = existing_amount.checked_sub(&value).ok_or_else(|| { + anyhow!( + "Overflow when subtracting {} from {}'s balance of {}", + value, + actor, + existing_amount + ) + })?; + + if new_amount.lt(&TokenAmount::zero()) { + bail!( + "Balance would be negative after subtracting {} from {}'s balance of {}", + value, + actor, + existing_amount + ); + } + + balance_map.set(actor, BigIntDe(new_amount.clone()))?; + self.balances = balance_map.flush()?; + + let serialised = match fvm_ipld_encoding::to_vec(&balance_map) { + Ok(s) => s, + Err(err) => return Err(anyhow!("failed to serialize state: {:?}", err)), + }; + bs.put_keyed(&self.balances, &serialised)?; + + Ok(new_amount) + } + + /// Retrieve the balance map as a HAMT + fn get_balance_map( + &self, + bs: &BS, + ) -> Result> { + match Hamt::::load(&self.balances, (*bs).clone()) { + Ok(map) => Ok(map), + Err(err) => return Err(anyhow!("Failed to load balances hamt: {:?}", err)), + } + } + + /// Increase the total supply by the specified value + /// + /// The requested amount must be non-negative. + /// Returns an error if the total supply overflows, else returns the new total supply + pub fn increase_supply(&mut self, value: &TokenAmount) -> Result { + let new_supply = self.supply.checked_add(&value).ok_or_else(|| { + anyhow!( + "Overflow when adding {} to the total_supply of {}", + value, + self.supply + ) + })?; + self.supply = new_supply.clone(); + Ok(new_supply) + } + + /// Get the allowance that an owner has approved for a spender + /// + /// If an existing allowance cannot be found, it is implicitly assumed to be zero + pub fn get_allowance_between( + &self, + bs: &BS, + owner: ActorID, + spender: ActorID, + ) -> Result { + let owner_allowances = self.get_owner_allowance_map(bs, owner)?; + match owner_allowances { + Some(hamt) => { + let maybe_allowance = hamt.get(&spender)?; + if let Some(allowance) = maybe_allowance { + return Ok(allowance.clone().0); + } + Ok(TokenAmount::zero()) + } + None => Ok(TokenAmount::zero()), + } + } + + /// Increase the allowance between owner and spender by the specified value + /// + /// Caller must ensure that value is non-negative. + pub fn increase_allowance( + &mut self, + bs: &BS, + owner: ActorID, + spender: ActorID, + value: &TokenAmount, + ) -> Result { + if value.is_zero() { + // This is a no-op as far as mutating state + return self.get_allowance_between(bs, owner, spender); + } + + let allowance_map = self.get_owner_allowance_map(bs, owner)?; + + // If allowance map exists, modify or insert the allowance + if let Some(mut hamt) = allowance_map { + let previous_allowance = hamt.get(&spender)?; + + // Calculate the new allowance + let new_allowance = match previous_allowance { + Some(prev_allowance) => prev_allowance.0.checked_add(&value).ok_or_else(|| { + anyhow!( + "Overflow when adding {} to {}'s allowance of {}", + value, + spender, + prev_allowance.0 + ) + })?, + None => value.clone(), + }; + + // TODO: should this be set as a BigIntSer rather than BigIntDe? + hamt.set(spender, BigIntDe(new_allowance.clone()))?; + + { + // TODO: helper functions for saving hamts?, this can probably be done more efficiently rather than + // getting the root allowance map again, by abstracting the nested hamt structure + let new_cid = hamt.flush()?; + let mut root_allowance_map = self.get_allowances_map(bs)?; + root_allowance_map.set(owner, new_cid)?; + let new_cid = root_allowance_map.flush(); + self.allowances = new_cid?; + } + + return Ok(new_allowance); + } + + // If allowance map does not exist, create it and insert the allowance + let mut owner_allowances = + Hamt::::new_with_bit_width((*bs).clone(), HAMT_BIT_WIDTH); + owner_allowances.set(spender, BigIntDe(value.clone()))?; + + { + // TODO: helper functions for saving hamts?, this can probably be done more efficiently rather than + // getting the root allowance map again, by abstracting the nested hamt structure + let mut root_allowance_map = self.get_allowances_map(bs)?; + root_allowance_map.set(owner, owner_allowances.flush()?)?; + self.allowances = root_allowance_map.flush()?; + } + + return Ok((*value).clone()); + } + + /// Decrease the allowance between owner and spender by the specified value. If the resulting + /// allowance is negative, it is set to zero. + /// + /// Caller must ensure that value is non-negative. + /// + /// If the allowance is decreased to zero, the entry is removed from the map. + /// If the map is empty, it is removed from the root map. + pub fn decrease_allowance( + &mut self, + bs: &BS, + owner: ActorID, + spender: ActorID, + value: &TokenAmount, + ) -> Result { + if value.is_zero() { + // This is a no-op as far as mutating state + return self.get_allowance_between(bs, owner, spender); + } + + let allowance_map = self.get_owner_allowance_map(bs, owner)?; + + // If allowance map exists, modify or insert the allowance + if let Some(mut hamt) = allowance_map { + let previous_allowance = hamt.get(&spender)?; + + // Calculate the new allowance, and max with zero + let new_allowance = match previous_allowance { + Some(prev_allowance) => prev_allowance.0.checked_sub(&value).ok_or_else(|| { + anyhow!( + "Overflow when adding {} to {}'s allowance of {}", + value, + spender, + prev_allowance.0 + ) + })?, + None => value.clone(), + } + .max(TokenAmount::zero()); + + // Update the Hamts + let mut root_allowance_map = self.get_allowances_map(bs)?; + + if new_allowance.is_zero() { + hamt.delete(&spender)?; + + if hamt.is_empty() { + root_allowance_map.delete(&owner)?; + } else { + root_allowance_map.set(owner, hamt.flush()?)?; + } + + self.allowances = root_allowance_map.flush()?; + return Ok(TokenAmount::zero()); + } + + // TODO: should this be set as a BigIntSer rather than BigIntDe? + hamt.set(spender, BigIntDe(new_allowance.clone()))?; + { + // TODO: helper functions for saving hamts?, this can probably be done more efficiently rather than + // getting the root allowance map again, by abstracting the nested hamt structure + root_allowance_map.set(owner, hamt.flush()?)?; + self.allowances = root_allowance_map.flush()?; + } + + return Ok(new_allowance); + } + + // If allowance map does not exist, decreasing is a no-op + Ok(TokenAmount::zero()) + } + + /// Revokes an approved allowance by removing the entry from the owner-spender map + /// + /// If that map becomes empty, it is removed from the root map. + pub fn revoke_allowance( + &mut self, + bs: &BS, + owner: ActorID, + spender: ActorID, + ) -> Result<()> { + let allowance_map = self.get_owner_allowance_map(bs, owner)?; + if let Some(mut map) = allowance_map { + map.delete(&spender)?; + if map.is_empty() { + let mut root_allowance_map = self.get_allowances_map(bs)?; + root_allowance_map.delete(&owner)?; + self.allowances = root_allowance_map.flush()?; + } else { + let new_cid = map.flush()?; + let mut root_allowance_map = self.get_allowances_map(bs)?; + root_allowance_map.set(owner, new_cid)?; + self.allowances = root_allowance_map.flush()?; + } + } + + Ok(()) + } + + /// Atomically checks if value is less than the allowance and deducts it if so + /// + /// Returns new allowance if successful, else returns an error and the allowance is unchanged + pub fn attempt_use_allowance( + &mut self, + bs: &BS, + spender: u64, + owner: u64, + value: &TokenAmount, + ) -> Result { + let current_allowance = self.get_allowance_between(bs, owner, spender)?; + + if value.is_zero() { + return Ok(current_allowance); + } + + let new_allowance = current_allowance.checked_sub(value).ok_or_else(|| { + anyhow!( + "Overflow when subtracting {} from {}'s allowance of {}", + value, + owner, + current_allowance + ) + })?; + + if new_allowance.lt(&TokenAmount::zero()) { + return Err(anyhow!( + "Attempted to use {} of {}'s tokens from {}'s allowance of {}", + value, + owner, + spender, + current_allowance + )); + } + + // TODO: helper function to set a new allowance and flush hamts + let owner_allowances = self.get_owner_allowance_map(bs, owner)?; + // to reach here, allowance must have been previously non zero; so safe to assume the map exists + let mut owner_allowances = owner_allowances.unwrap(); + owner_allowances.set(spender, BigIntDe(new_allowance.clone()))?; + let mut allowance_map = self.get_allowances_map(bs)?; + allowance_map.set(owner, owner_allowances.flush()?)?; + self.allowances = allowance_map.flush()?; + + Ok(new_allowance) + } + + /// Get the allowances map of a specific actor, resolving the CID link to a Hamt + /// + /// Ok(Some) if the owner has allocated allowances to other actors + /// Ok(None) if the owner has no current non-zero allowances to other actors + /// Err if operations on the underlying Hamt failed + fn get_owner_allowance_map( + &self, + bs: &BS, + owner: ActorID, + ) -> Result>> { + let allowances_map = self.get_allowances_map(bs)?; + let owner_allowances = match allowances_map.get(&owner)? { + Some(cid) => Some(Hamt::::load(cid, (*bs).clone())?), + None => None, + }; + Ok(owner_allowances) + } + + /// Get the global allowances map + /// + /// Gets a HAMT with CIDs linking to other HAMTs + fn get_allowances_map(&self, bs: &BS) -> Result> { + Hamt::::load(&self.allowances, (*bs).clone()) + .map_err(|e| anyhow!("Failed to load base allowances map {}", e)) + } +} + +impl Cbor for TokenState {} + +#[cfg(test)] +mod test { + use fvm_shared::{ + bigint::{BigInt, Zero}, + ActorID, + }; + + use crate::blockstore::MemoryBlockstore; + + use super::TokenState; + + #[test] + fn it_instantiates() { + let bs = MemoryBlockstore::new(); + let state = TokenState::new(&bs).unwrap(); + let cid = state.save(&bs).unwrap(); + let saved_state = TokenState::load(&bs, &cid).unwrap(); + assert_eq!(state, saved_state); + } + + #[test] + fn it_increases_balance_of_new_actor() { + let bs = MemoryBlockstore::new(); + let mut state = TokenState::new(&bs).unwrap(); + let actor: ActorID = 1; + + // Initially any actor has an implicit balance of 0 + assert_eq!(state.get_balance(&bs, actor).unwrap(), BigInt::zero()); + + let amount = BigInt::from(100); + state.increase_balance(&bs, actor, &amount).unwrap(); + let new_cid = state.save(&bs).unwrap(); + + let state = TokenState::load(&bs, &new_cid).unwrap(); + assert_eq!(state.get_balance(&bs, actor).unwrap(), amount); + } +} diff --git a/fil_token/src/token/types.rs b/fil_token/src/token/types.rs new file mode 100644 index 00000000..7d774ffc --- /dev/null +++ b/fil_token/src/token/types.rs @@ -0,0 +1,190 @@ +use anyhow::Result; + +use fvm_ipld_encoding::tuple::{Deserialize_tuple, Serialize_tuple}; +use fvm_ipld_encoding::{Cbor, RawBytes}; +use fvm_shared::address::Address; +use fvm_shared::bigint::bigint_ser; +use fvm_shared::econ::TokenAmount; +use fvm_shared::ActorID; + +/// A standard fungible token interface allowing for on-chain transactions that implements the +/// FRC-XXX standard. This represents the external interface exposed to other on-chain actors +/// +/// Token authors should implement this trait and link the methods to standard dispatch numbers in +/// their actor's `invoke` entrypoint. A standard helper (TODO) is provided to aid method dispatch. +/// +/// TODO: make non-pseudo code +/// ``` +/// //struct Token {} +/// //impl FrcXXXToken for Token { +/// // ... +/// //} +/// +/// //fn invoke(params: u32) -> u32 { +/// // let token = Token {}; +/// +/// // match sdk::message::method_number() { +/// // 1 => constructor(), +/// // 2 => token.name(), +/// // _ => abort!() +/// // // etc. +/// // } +/// //} +/// ``` +pub trait FrcXXXToken { + /// Returns the name of the token + fn name(&self) -> String; + + /// Returns the ticker symbol of the token + fn symbol(&self) -> String; + + /// Returns the total amount of the token in existence + fn total_supply(&self) -> TokenAmount; + + /// Gets the balance of a particular address (if it exists) + /// + /// This will method attempt to resolve addresses to ID-addresses + fn balance_of(&self, params: Address) -> Result; + + /// Atomically increase the amount that a spender can pull from the owner account + /// + /// The increase must be non-negative. Returns the new allowance between those two addresses if + /// successful + fn increase_allowance(&self, params: ChangeAllowanceParams) -> Result; + + /// Atomically decrease the amount that a spender can pull from an account + /// + /// The decrease must be non-negative. The resulting allowance is set to zero if the decrease is + /// more than the current allowance. Returns the new allowance between the two addresses if + /// successful + fn decrease_allowance(&self, params: ChangeAllowanceParams) -> Result; + + /// Set the allowance a spender has on the owner's account to zero + fn revoke_allowance(&self, params: RevokeAllowanceParams) -> Result; + + /// Get the allowance between two addresses + /// + /// The spender can burn or transfer the allowance amount out of the owner's address. If the + /// address of the owner cannot be resolved, this method returns an error. If the owner can be + /// resolved, but the spender address is not registered with an allowance, an implicit allowance + /// of 0 is returned + fn allowance(&self, params: GetAllowanceParams) -> Result; + + /// Burn tokens from the caller's account, decreasing the total supply + /// + /// When burning tokens: + /// - Any holder MUST be allowed to burn their own tokens + /// - The balance of the holder MUST decrease by the amount burned + /// - This method MUST revert if the burn amount is more than the holder's balance + fn burn(&self, params: BurnParams) -> Result; + + /// Burn tokens from the owner's account, decreasing the total supply + /// + /// When burning on behalf of the owner: + /// - The same rules on the holder apply as `burn` + /// - This method MUST revert if the burn amount is more than the allowance authorised by the + /// owner + fn burn_from(&self, params: BurnParams) -> Result; + + /// Transfer tokens from the caller to the receiver + /// + fn transfer(&self, params: TransferParams) -> Result; + + fn transfer_from(&self, params: TransferParams) -> Result; +} + +#[derive(Serialize_tuple, Deserialize_tuple)] +pub struct MintParams { + pub initial_holder: ActorID, + #[serde(with = "bigint_ser")] + pub value: TokenAmount, +} + +#[derive(Serialize_tuple, Deserialize_tuple)] +pub struct MintReturn { + pub successful: bool, + #[serde(with = "bigint_ser")] + pub newly_minted: TokenAmount, + #[serde(with = "bigint_ser")] + pub total_supply: TokenAmount, +} + +impl Cbor for MintParams {} +impl Cbor for MintReturn {} + +/// An amount to increase or decrease an allowance by +#[derive(Serialize_tuple, Deserialize_tuple)] +pub struct ChangeAllowanceParams { + pub owner: Address, + pub spender: Address, + #[serde(with = "bigint_ser")] + pub value: TokenAmount, +} + +/// Params to get allowance between to addresses +#[derive(Serialize_tuple, Deserialize_tuple)] +pub struct GetAllowanceParams { + pub owner: Address, + pub spender: Address, +} + +/// Instruction to revoke (set to 0) an allowance +#[derive(Serialize_tuple, Deserialize_tuple)] +pub struct RevokeAllowanceParams { + pub owner: Address, + pub spender: Address, +} + +/// The updated value after allowance is increased or decreased +#[derive(Serialize_tuple, Deserialize_tuple)] +pub struct AllowanceReturn { + pub owner: Address, + pub spender: Address, + #[serde(with = "bigint_ser")] + pub value: TokenAmount, +} + +impl Cbor for ChangeAllowanceParams {} +impl Cbor for GetAllowanceParams {} +impl Cbor for RevokeAllowanceParams {} +impl Cbor for AllowanceReturn {} + +/// Burns an amount of token from an address +#[derive(Serialize_tuple, Deserialize_tuple)] +pub struct BurnParams { + pub owner: Address, + #[serde(with = "bigint_ser")] + pub value: TokenAmount, + pub data: RawBytes, +} + +#[derive(Serialize_tuple, Deserialize_tuple)] +pub struct BurnReturn { + pub owner: Address, + #[serde(with = "bigint_ser")] + pub burnt: TokenAmount, + #[serde(with = "bigint_ser")] + pub remaining_balance: TokenAmount, +} + +impl Cbor for BurnParams {} +impl Cbor for BurnReturn {} + +#[derive(Serialize_tuple, Deserialize_tuple)] +pub struct TransferParams { + pub from: Address, + pub to: Address, + #[serde(with = "bigint_ser")] + pub value: TokenAmount, +} + +#[derive(Serialize_tuple, Deserialize_tuple)] +pub struct TransferReturn { + pub from: Address, + pub to: Address, + #[serde(with = "bigint_ser")] + pub value: TokenAmount, +} + +impl Cbor for TransferParams {} +impl Cbor for TransferReturn {} diff --git a/rust-toolchain b/rust-toolchain new file mode 100644 index 00000000..07ade694 --- /dev/null +++ b/rust-toolchain @@ -0,0 +1 @@ +nightly \ No newline at end of file diff --git a/testing/fil_token_integration/Cargo.toml b/testing/fil_token_integration/Cargo.toml new file mode 100644 index 00000000..1e62c3be --- /dev/null +++ b/testing/fil_token_integration/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "fil_token_integration_tests" +version = "0.1.0" +repository = "https://github.com/helix-collective/filecoin" +edition = "2021" + +[dependencies] +fil_token = { version = "0.1.0", path = "../../fil_token" } +fvm_integration_tests = { version = "0.1.0", git = "https://github.com/filecoin-project/ref-fvm" } +fvm_ipld_blockstore = { version = "0.1.1", git = "https://github.com/filecoin-project/ref-fvm" } +fvm_shared = { version = "0.8.0", git = "https://github.com/filecoin-project/ref-fvm" } \ No newline at end of file diff --git a/testing/fil_token_integration/actors/wfil_token_actor/Cargo.toml b/testing/fil_token_integration/actors/wfil_token_actor/Cargo.toml new file mode 100644 index 00000000..4b7f43ee --- /dev/null +++ b/testing/fil_token_integration/actors/wfil_token_actor/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "wfil_token_actor" +version = "0.1.0" +repository = "https://github.com/helix-collective/filecoin" +edition = "2021" + +[dependencies] +anyhow = { version = "1.0.56" } +fvm_ipld_encoding = { version = "0.2.2" } +fvm_sdk = { version = "1.0.0" } +fvm_shared = { version = "0.8.0" } +fil_token = { version = "0.1.0", path = "../../../../fil_token" } + +[build-dependencies] +wasm-builder = "3.0.1" diff --git a/testing/fil_token_integration/actors/wfil_token_actor/README.md b/testing/fil_token_integration/actors/wfil_token_actor/README.md new file mode 100644 index 00000000..27c2ff71 --- /dev/null +++ b/testing/fil_token_integration/actors/wfil_token_actor/README.md @@ -0,0 +1,35 @@ +# FIL Token Actor + +This creates a FIP-??? compliant fungible token that wraps the value of of +native FIL. + +Calling the `mint` (non-standard) method on this actor will increase the +caller's WFIL balance by the amount of FIL sent in the message. The WFIL can be +transferred between any FIP-??? compliant wallets (which have `token_received` +hook) by either direct transfer or delegated transfer. + +https://etherscan.io/tx/0x96a7155b44b77c173e7c534ae1ceca536ba2ce534012ff844cf8c1737bc54921 + +## Direct transfer flow + +Call `transfer(TransferParams)` to directly transfer tokens from the caller's +wallet to a receiving address. + +## Delegated transfer flow + +1. Call `increase_allowance(AllowanceParams)` to increase the "spenders" + allowance +2. The "spender" can then call this actor with + `transfer_from(TransferFromParams)` to + +## Transferring tokens to this actor + +Transferring WFIL to the address of this actor itself will result in the WFIL +being burnt and the corresponding FIL being credited back. This prevents the +case where tokens are unintentionally locked in to contracts that are unable to +receive them. This flow requires the actor to implement its own `token_received` +hook. + +However, also compliant with the token standard would be for this actor to omit +the `token_received` hook. In this case, transfers to the contract itself would +simply be rejected, which also prevents unintentional loss of tokens. diff --git a/testing/fil_token_integration/actors/wfil_token_actor/build.rs b/testing/fil_token_integration/actors/wfil_token_actor/build.rs new file mode 100644 index 00000000..0f2aa8a5 --- /dev/null +++ b/testing/fil_token_integration/actors/wfil_token_actor/build.rs @@ -0,0 +1,12 @@ +fn main() { + use wasm_builder::WasmBuilder; + WasmBuilder::new() + .with_current_project() + .import_memory() + .append_to_rust_flags("-Ctarget-feature=+crt-static") + .append_to_rust_flags("-Cpanic=abort") + .append_to_rust_flags("-Coverflow-checks=true") + .append_to_rust_flags("-Clto=true") + .append_to_rust_flags("-Copt-level=z") + .build() +} diff --git a/testing/fil_token_integration/actors/wfil_token_actor/src/lib.rs b/testing/fil_token_integration/actors/wfil_token_actor/src/lib.rs new file mode 100644 index 00000000..7816e202 --- /dev/null +++ b/testing/fil_token_integration/actors/wfil_token_actor/src/lib.rs @@ -0,0 +1,161 @@ +use anyhow::Result; +use fil_token::blockstore::Blockstore; +use fil_token::token::*; +use fvm_ipld_encoding::{RawBytes, DAG_CBOR}; +use fvm_sdk as sdk; +use fvm_shared::bigint::bigint_ser::BigIntSer; +use fvm_shared::econ::TokenAmount; +use sdk::NO_DATA_BLOCK_ID; + +struct WfilToken { + /// Default token helper impl + util: TokenHelper, +} + +/// Implement the token API +/// here addresses should be translated to actor id's etc. +impl FrcXXXToken for WfilToken { + fn name(&self) -> String { + String::from("Wrapped FIL") + } + + fn symbol(&self) -> String { + String::from("WFIL") + } + + fn total_supply(&self) -> TokenAmount { + self.util.total_supply() + } + + fn balance_of( + &self, + params: fvm_shared::address::Address, + ) -> Result { + todo!("reshape aprams") + } + + fn increase_allowance(&self, params: ChangeAllowanceParams) -> Result { + todo!("resolve address to actorid"); + } + + fn decrease_allowance(&self, params: ChangeAllowanceParams) -> Result { + todo!("add return") + } + + fn revoke_allowance(&self, params: RevokeAllowanceParams) -> Result { + todo!("add return") + } + + fn allowance(&self, params: GetAllowanceParams) -> Result { + todo!(); + } + + // TODO: change burn params + fn burn(&self, params: BurnParams) -> Result { + todo!(); + } + + fn transfer(&self, params: TransferParams) -> Result { + todo!() + } + + fn burn_from(&self, params: BurnParams) -> Result { + todo!(); + } + + fn transfer_from(&self, params: TransferParams) -> Result { + todo!() + } +} + +/// Placeholder invoke for testing +#[no_mangle] +pub fn invoke(params: u32) -> u32 { + // Conduct method dispatch. Handle input parameters and return data. + let method_num = sdk::message::method_number(); + + let root_cid = sdk::sself::root().unwrap(); + let token_actor = WfilToken { + util: TokenHelper::new(Blockstore {}, root_cid), + }; + + //TODO: this internal dispatch can be pushed as a library function into the fil_token crate + // - it should support a few different calling-conventions + // - it should also handle deserialization of raw_params into the expected IPLD types + let res = match method_num { + // Actor constructor + 1 => constructor(), + // Standard token interface + 2 => { + token_actor.name(); + // TODO: store and return CID + NO_DATA_BLOCK_ID + } + 3 => { + token_actor.symbol(); + // TODO: store and return CID + NO_DATA_BLOCK_ID + } + 4 => { + token_actor.total_supply(); + // TODO: store and return CID + NO_DATA_BLOCK_ID + } + 5 => { + // balance of + let params = sdk::message::params_raw(params).unwrap().1; + let params = RawBytes::new(params); + let params = params.deserialize().unwrap(); + let res = token_actor.balance_of(params).unwrap(); + let res = RawBytes::new(fvm_ipld_encoding::to_vec(&BigIntSer(&res)).unwrap()); + let cid = sdk::ipld::put_block(DAG_CBOR, res.bytes()).unwrap(); + cid + } + 6 => { + // increase allowance + NO_DATA_BLOCK_ID + } + 7 => { + // decrease allowance + NO_DATA_BLOCK_ID + } + 8 => { + // revoke_allowance + NO_DATA_BLOCK_ID + } + 9 => { + // allowance + NO_DATA_BLOCK_ID + } + 10 => { + // burn + NO_DATA_BLOCK_ID + } + 11 => { + // transfer_from + NO_DATA_BLOCK_ID + } + // Custom actor interface + 12 => { + let res = token_actor + .util + .mint(sdk::message::caller(), sdk::message::value_received()) + .unwrap(); + let res = RawBytes::new(fvm_ipld_encoding::to_vec(&res).unwrap()); + let cid = sdk::ipld::put_block(DAG_CBOR, res.bytes()).unwrap(); + cid + } + _ => { + sdk::vm::abort( + fvm_shared::error::ExitCode::USR_ILLEGAL_ARGUMENT.value(), + Some("Unknown method number"), + ); + } + }; + + res +} + +fn constructor() -> u32 { + 0_u32 +} diff --git a/testing/fil_token_integration/src/lib.rs b/testing/fil_token_integration/src/lib.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/testing/fil_token_integration/src/lib.rs @@ -0,0 +1 @@ + diff --git a/testing/fil_token_integration/tests/lib.rs b/testing/fil_token_integration/tests/lib.rs new file mode 100644 index 00000000..2cb23d44 --- /dev/null +++ b/testing/fil_token_integration/tests/lib.rs @@ -0,0 +1,59 @@ +use std::env; + +use fil_token::blockstore::Blockstore as ActorBlockstore; +use fvm_integration_tests::tester::{Account, Tester}; +use fvm_ipld_blockstore::MemoryBlockstore; +use fvm_shared::address::Address; +use fvm_shared::bigint::Zero; +use fvm_shared::econ::TokenAmount; +use fvm_shared::message::Message; +use fvm_shared::state::StateTreeVersion; +use fvm_shared::version::NetworkVersion; + +const WFIL_TOKEN_WASM_COMPILED_PATH: &str = + "../../target/debug/wbuild/wfil_token_actor/wfil_token_actor.compact.wasm"; + +#[test] +fn mint_tokens() { + let mut tester = Tester::new( + NetworkVersion::V15, + StateTreeVersion::V4, + MemoryBlockstore::default(), + ) + .unwrap(); + + let minter: [Account; 1] = tester.create_accounts().unwrap(); + + // // Get wasm bin + // let wasm_path = env::current_dir() + // .unwrap() + // .join(WFIL_TOKEN_WASM_COMPILED_PATH) + // .canonicalize() + // .unwrap(); + // let wasm_bin = std::fs::read(wasm_path).expect("Unable to read file"); + + // let actor_blockstore = ActorBlockstore::default(); + // let actor_state = TokenState::new(&actor_blockstore).unwrap(); + // let state_cid = tester.set_state(&actor_state).unwrap(); + + // let actor_address = Address::new_id(10000); + // tester.set_actor_from_bin(&wasm_bin, state_cid, actor_address, TokenAmount::zero()); + + // // Instantiate machine + // tester.instantiate_machine().unwrap(); + + // let message = Message { + // from: minter[0].1, + // to: actor_address, + // gas_limit: 10000000, + // method_num: 12, // 12 is Mint function + // value: TokenAmount::from(150), + // ..Message::default() + // }; + + // let res = tester + // .executor + // .unwrap() + // .execute_message(message, ApplyKind::Explicit, 100) + // .unwrap(); +}