diff --git a/Cargo.lock b/Cargo.lock index 626570f3..bbe1a3ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -923,6 +923,7 @@ name = "defuse-poa-factory" version = "0.1.0" dependencies = [ "defuse-admin-utils", + "defuse-controller", "defuse-near-utils", "defuse-poa-token", "near-contract-standards", @@ -935,6 +936,7 @@ name = "defuse-poa-token" version = "0.1.0" dependencies = [ "defuse-admin-utils", + "defuse-controller", "defuse-near-utils", "near-contract-standards", "near-plugins", @@ -958,6 +960,7 @@ dependencies = [ "bnum", "defuse", "defuse-poa-factory", + "defuse-poa-token", "hex-literal", "impl-tools", "near-contract-standards", diff --git a/Makefile.toml b/Makefile.toml index d441f35e..c58b5dbe 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -5,12 +5,13 @@ skip_core_tasks = true [env] TARGET_DIR = "${PWD}/res" POA_TOKEN_WASM = "${TARGET_DIR}/defuse_poa_token.wasm" +POA_TOKEN_WITH_DEPOSIT_DIR = "${TARGET_DIR}/poa-token-with-deposit" [tasks.default] alias = "build" [tasks.clippy] -dependencies = ["add-cache-dir-tag", "build-poa-token"] +dependencies = ["add-cache-dir-tag"] command = "cargo" args = ["clippy", "--workspace", "--all-targets", "--no-deps"] @@ -34,7 +35,7 @@ args = [ ] [tasks.build-poa-factory] -dependencies = ["add-cache-dir-tag", "build-poa-token"] +dependencies = ["add-cache-dir-tag", "build-poa-token", "build-poa-token-with-deposits"] command = "cargo" args = [ "near", @@ -65,6 +66,22 @@ args = [ "--no-embed-abi", ] +[tasks.build-poa-token-with-deposits] +dependencies = ["add-cache-dir-tag"] +command = "cargo" +args = [ + "near", + "build", + "non-reproducible-wasm", + "--manifest-path", + "./poa-token/Cargo.toml", + "--features", + "contract,deposits", + "--out-dir", + "${POA_TOKEN_WITH_DEPOSIT_DIR}", + "--no-embed-abi", +] + [tasks.test] alias = "tests" diff --git a/near-utils/src/lock.rs b/near-utils/src/lock.rs index d815ad67..2adf096e 100644 --- a/near-utils/src/lock.rs +++ b/near-utils/src/lock.rs @@ -106,6 +106,11 @@ impl Lock { self.locked = false; &mut self.value } + + #[inline] + pub fn ignore_lock(&self) -> &T { + &self.value + } } impl From for Lock { diff --git a/near-utils/src/panic.rs b/near-utils/src/panic.rs index fcfd1d24..413777da 100644 --- a/near-utils/src/panic.rs +++ b/near-utils/src/panic.rs @@ -44,6 +44,18 @@ impl UnwrapOrPanic for Option { } } +pub trait UnwrapOrPanicCtx { + fn unwrap_or_panic_ctx(self, ctx: &str) -> T; +} + +impl UnwrapOrPanicCtx for Option { + #[inline] + #[track_caller] + fn unwrap_or_panic_ctx(self, ctx: &str) -> T { + self.unwrap_or_else(|| env::panic_str(ctx)) + } +} + impl UnwrapOrPanic for Result where E: FunctionError, diff --git a/poa-factory/Cargo.toml b/poa-factory/Cargo.toml index 61e94531..bfe98df8 100644 --- a/poa-factory/Cargo.toml +++ b/poa-factory/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] defuse-admin-utils.workspace = true +defuse-controller.workspace = true defuse-near-utils.workspace = true defuse-poa-token.workspace = true diff --git a/poa-factory/src/contract.rs b/poa-factory/src/contract.rs index 342dd59d..4fa2bf43 100644 --- a/poa-factory/src/contract.rs +++ b/poa-factory/src/contract.rs @@ -2,7 +2,8 @@ use core::iter; use std::collections::{HashMap, HashSet}; use defuse_admin_utils::full_access_keys::FullAccessKeys; -use defuse_near_utils::{CURRENT_ACCOUNT_ID, UnwrapOrPanicError, gas_left}; +use defuse_controller::ControllerUpgradable; +use defuse_near_utils::{CURRENT_ACCOUNT_ID, UnwrapOrPanicError, gas_left, method_name}; use defuse_poa_token::ext_poa_fungible_token; use near_contract_standards::fungible_token::{core::ext_ft_core, metadata::FungibleTokenMetadata}; use near_plugins::{ @@ -21,6 +22,9 @@ use near_sdk::{ use crate::PoaFactory; +const STATE_MIGRATE_FUNCTION: &str = method_name!(Contract::state_migrate); +const STATE_MIGRATE_DEFAULT_GAS: Gas = Gas::from_tgas(5); + #[cfg(not(clippy))] const POA_TOKEN_WASM: &[u8] = include_bytes!(std::env!( "POA_TOKEN_WASM", @@ -29,12 +33,14 @@ const POA_TOKEN_WASM: &[u8] = include_bytes!(std::env!( #[cfg(clippy)] const POA_TOKEN_WASM: &[u8] = b""; -const POA_TOKEN_INIT_BALANCE: NearToken = NearToken::from_near(3); +const POA_TOKEN_INIT_BALANCE: NearToken = NearToken::from_near(5); const POA_TOKEN_NEW_GAS: Gas = Gas::from_tgas(10); const POA_TOKEN_FT_DEPOSIT_GAS: Gas = Gas::from_tgas(10); /// Copied from `near_contract_standards::fungible_token::core_impl::GAS_FOR_FT_TRANSFER_CALL` const POA_TOKEN_FT_TRANSFER_CALL_MIN_GAS: Gas = Gas::from_tgas(30); +const POA_TOKEN_UPRADE_GAS: Gas = Gas::from_tgas(10); + #[derive(AccessControlRole, Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[near(serializers = [json])] pub enum Role { @@ -92,6 +98,39 @@ impl Contract { ); contract } + + #[pause] + #[access_control_any(roles(Role::DAO, Role::TokenDeployer))] + #[payable] + pub fn upgrade_token(&mut self, token: String, self_public_key: PublicKey) -> Promise { + // FIXME: Check what conditions for atomicity, safety and repeatability are needed + Promise::new(Self::token_id(token)) + .function_call( + "add_full_access_key".to_string(), + serde_json::to_vec(&json!({ + "public_key": self_public_key, + })) + .unwrap_or_panic_display(), + NearToken::from_near(0), + POA_TOKEN_UPRADE_GAS, + ) + .deploy_contract(POA_TOKEN_WASM.to_vec()) + .function_call( + "upgrade_to_versioned".to_string(), + serde_json::to_vec(&json!({})).unwrap_or_panic_display(), + NearToken::from_yoctonear(1), // FIXME: amount + POA_TOKEN_UPRADE_GAS, + ) + .function_call( + "delete_key".to_string(), + serde_json::to_vec(&json!({ + "public_key": self_public_key, + })) + .unwrap_or_panic_display(), + NearToken::from_near(0), + POA_TOKEN_UPRADE_GAS, + ) + } } #[near] @@ -225,6 +264,30 @@ impl FullAccessKeys for Contract { } } +#[near] +impl ControllerUpgradable for Contract { + #[access_control_any(roles(Role::DAO, Role::TokenDeployer))] + #[payable] + fn upgrade( + &mut self, + #[serializer(borsh)] code: Vec, + #[serializer(borsh)] state_migration_gas: Option, + ) -> Promise { + assert_one_yocto(); + Promise::new(CURRENT_ACCOUNT_ID.clone()) + .deploy_contract(code) + .function_call( + STATE_MIGRATE_FUNCTION.into(), + Vec::new(), + NearToken::from_yoctonear(0), + state_migration_gas.unwrap_or(STATE_MIGRATE_DEFAULT_GAS), + ) + } + + #[private] + fn state_migrate(&mut self) {} +} + #[derive(BorshSerialize, BorshStorageKey)] #[borsh(crate = "::near_sdk::borsh")] enum Prefix { diff --git a/poa-factory/src/lib.rs b/poa-factory/src/lib.rs index 0fe59eb3..c725c5c4 100644 --- a/poa-factory/src/lib.rs +++ b/poa-factory/src/lib.rs @@ -4,12 +4,13 @@ pub mod contract; use std::collections::HashMap; use defuse_admin_utils::full_access_keys::FullAccessKeys; +use defuse_controller::ControllerUpgradable; use near_contract_standards::fungible_token::metadata::FungibleTokenMetadata; use near_plugins::AccessControllable; use near_sdk::{AccountId, Promise, ext_contract, json_types::U128}; #[ext_contract(ext_poa_factory)] -pub trait PoaFactory: AccessControllable + FullAccessKeys { +pub trait PoaFactory: AccessControllable + FullAccessKeys + ControllerUpgradable { /// Deploys new token to `token.`. /// Requires to attach enough Ⓝ to cover storage costs. fn deploy_token(&mut self, token: String, metadata: Option) -> Promise; diff --git a/poa-token/Cargo.toml b/poa-token/Cargo.toml index 68b53c66..eef15d36 100644 --- a/poa-token/Cargo.toml +++ b/poa-token/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] defuse-admin-utils.workspace = true +defuse-controller.workspace = true defuse-near-utils.workspace = true near-contract-standards.workspace = true @@ -19,3 +20,4 @@ near-sdk = { workspace = true, features = ["unstable"] } [features] contract = [] +deposits = [] diff --git a/poa-token/src/contract.rs b/poa-token/src/contract.rs index 5e1eb88c..dd0cba55 100644 --- a/poa-token/src/contract.rs +++ b/poa-token/src/contract.rs @@ -1,46 +1,140 @@ +use crate::{CanWrapToken, PoaFungibleToken, UNWRAP_PREFIX, WITHDRAW_MEMO_PREFIX}; use defuse_admin_utils::full_access_keys::FullAccessKeys; -use defuse_near_utils::{CURRENT_ACCOUNT_ID, PREDECESSOR_ACCOUNT_ID}; +use defuse_controller::ControllerUpgradable; +use defuse_near_utils::{ + CURRENT_ACCOUNT_ID, Lock, PREDECESSOR_ACCOUNT_ID, UnwrapOrPanic, UnwrapOrPanicCtx, + UnwrapOrPanicError, method_name, +}; use near_contract_standards::{ fungible_token::{ FungibleToken, FungibleTokenCore, FungibleTokenResolver, + core::ext_ft_core, events::{FtBurn, FtMint}, - metadata::{FT_METADATA_SPEC, FungibleTokenMetadata, FungibleTokenMetadataProvider}, + metadata::{ + FT_METADATA_SPEC, FungibleTokenMetadata, FungibleTokenMetadataProvider, ext_ft_metadata, + }, }, storage_management::{StorageBalance, StorageBalanceBounds, StorageManagement}, }; -use near_plugins::{Ownable, events::AsEvent, only, ownable::OwnershipTransferred}; +use near_plugins::Ownable; +use near_plugins::{events::AsEvent, only, ownable::OwnershipTransferred}; use near_sdk::{ - AccountId, BorshStorageKey, NearToken, PanicOnDefault, Promise, PromiseOrValue, PublicKey, - assert_one_yocto, borsh::BorshSerialize, env, json_types::U128, near, require, store::Lazy, + AccountId, BorshStorageKey, Gas, NearToken, PanicOnDefault, Promise, PromiseOrValue, PublicKey, + assert_one_yocto, + borsh::{BorshDeserialize, BorshSerialize}, + env::{self}, + json_types::U128, + near, require, serde_json, + store::Lazy, }; -use crate::{PoaFungibleToken, WITHDRAW_MEMO_PREFIX}; +const STATE_MIGRATE_FUNCTION: &str = method_name!(Contract::state_migrate); +const STATE_MIGRATE_DEFAULT_GAS: Gas = Gas::from_tgas(5); + +const FT_RESOLVE_UNWRAP_GAS: Gas = Gas::from_tgas(10); +const DO_WRAP_TOKEN_GAS: Gas = Gas::from_tgas(10); +const BALANCE_OF_GAS: Gas = Gas::from_tgas(10); +const METADATA_GET_TOKEN_GAS: Gas = Gas::from_tgas(40); +const METADATA_SET_TOKEN_GAS: Gas = Gas::from_tgas(50); + +#[derive(BorshSerialize, BorshDeserialize)] +#[borsh(crate = "::near_sdk::borsh")] +pub struct LegacyPoATokenContract { + token: FungibleToken, + metadata: Lazy, +} + +#[near] +pub struct WrappableTokenState { + token: FungibleToken, + metadata: Lazy, + wrapped_token: Lazy>, +} + +#[near] +enum ContractState { + WrappableToken(Lock), +} #[near(contract_state)] #[derive(Ownable, PanicOnDefault)] pub struct Contract { - token: FungibleToken, - metadata: Lazy, + inner: ContractState, +} + +impl Contract { + fn is_locked(&self) -> bool { + match &self.inner { + ContractState::WrappableToken(lock) => lock.is_locked(), + } + } + + fn token(&self) -> &FungibleToken { + match &self.inner { + ContractState::WrappableToken(lock) => &lock.ignore_lock().token, + } + } + + fn token_mut(&mut self) -> &mut FungibleToken { + match &mut self.inner { + ContractState::WrappableToken(lock) => { + &mut lock + .as_unlocked_mut() + .unwrap_or_panic_ctx("Locked for token mut") + .token + } + } + } + + fn metadata(&self) -> &Lazy { + match &self.inner { + ContractState::WrappableToken(lock) => &lock.ignore_lock().metadata, + } + } + + fn metadata_mut(&mut self) -> &mut Lazy { + match &mut self.inner { + ContractState::WrappableToken(lock) => { + &mut lock + .as_unlocked_mut() + .unwrap_or_panic_ctx("Locked for token ref") + .metadata + } + } + } + + fn wrapped_token(&self) -> Option<&AccountId> { + match &self.inner { + ContractState::WrappableToken(lock) => (*lock.ignore_lock().wrapped_token).as_ref(), + } + } } #[near] impl Contract { + #[must_use] #[init] pub fn new(owner_id: Option, metadata: Option) -> Self { let metadata = metadata.unwrap_or_else(|| FungibleTokenMetadata { spec: FT_METADATA_SPEC.to_string(), - name: Default::default(), - symbol: Default::default(), - icon: Default::default(), - reference: Default::default(), - reference_hash: Default::default(), + name: String::default(), + symbol: String::default(), + icon: Option::default(), + reference: Option::default(), + reference_hash: Option::default(), decimals: Default::default(), }); metadata.assert_valid(); let contract = Self { - token: FungibleToken::new(Prefix::FungibleToken), - metadata: Lazy::new(Prefix::Metadata, metadata), + inner: ContractState::WrappableToken(Lock::new( + WrappableTokenState { + token: FungibleToken::new(Prefix::FungibleToken), + metadata: Lazy::new(Prefix::Metadata, metadata), + wrapped_token: Lazy::new(Prefix::WrappedToken, None), + }, + false, + )), }; let owner = owner_id.unwrap_or_else(|| PREDECESSOR_ACCOUNT_ID.clone()); @@ -54,8 +148,337 @@ impl Contract { new_owner: Some(owner), } .emit(); + contract } + + #[must_use] + #[private] + #[init(ignore_state)] + pub fn upgrade_to_versioned() -> Self { + let old_state: LegacyPoATokenContract = + env::state_read().expect("Deserializing old state failed"); + + Self { + inner: ContractState::WrappableToken(Lock::new( + WrappableTokenState { + token: old_state.token, + metadata: old_state.metadata, + wrapped_token: Lazy::new(Prefix::WrappedToken, None), + }, + false, + )), + } + } + + // Note that we make this permission'ed because it can be disastrous if an attacker can change the number of decimals of an external contract, + // and then sync the metadata (update our decimals), which will break all off-chain (and possibly on-chain) applications. + #[only(self, owner)] + #[payable] + pub fn force_sync_wrapped_token_metadata(&mut self) -> Promise { + let caller_id = env::predecessor_account_id(); + + require!( + env::attached_deposit() >= NearToken::from_yoctonear(1), + "Requires attached deposit of exactly 1 yoctoNEAR or more" + ); + + let Some(wrapped_token) = self.wrapped_token().cloned() else { + env::panic_str("This function is restricted to wrapped tokens") + }; + + ext_ft_metadata::ext(wrapped_token) + .with_static_gas(METADATA_GET_TOKEN_GAS) + .ft_metadata() + .then( + Self::ext(CURRENT_ACCOUNT_ID.clone()) + .with_static_gas(METADATA_SET_TOKEN_GAS) + .with_attached_deposit(env::attached_deposit()) + .do_sync_wrapped_metadata(caller_id), + ) + } + + #[private] + #[payable] + pub fn do_sync_wrapped_metadata(&mut self, caller_id: AccountId) -> PromiseOrValue<()> { + let near_sdk::PromiseResult::Successful(metadata_bytes) = env::promise_result(0) else { + env::panic_str( + "Setting metadata failed due to the promise failing at ft_metadata() call.", + ) + }; + + let incoming_metadata = serde_json::from_slice::(&metadata_bytes) + .map_err(|e| { + format!("JSON: failed to parse Promise output as FungibleTokenMetadata: {e}") + }) + .unwrap_or_panic(); + + let initial_storage_usage = env::storage_usage(); + + let to_set_metadata = FungibleTokenMetadata { + spec: FT_METADATA_SPEC.to_string(), + name: format!("Wrapped {}", incoming_metadata.name), + symbol: format!("w{}", incoming_metadata.symbol), + ..incoming_metadata + }; + + to_set_metadata.assert_valid(); + + match &mut self.inner { + ContractState::WrappableToken(lock) => { + let metadata = &mut lock + .as_unlocked_mut() + .unwrap_or_panic_ctx("Smart contract seems to be locked") + .metadata; + metadata.set(to_set_metadata); + metadata.flush(); + } + } + + let end_storage_usage = env::storage_usage(); + + // Note that we use saturating sub here to prevent abuse. We do not refund Near for freed storage + let storage_increase_byte_count = end_storage_usage.saturating_sub(initial_storage_usage); + + let storage_increase_cost = env::storage_byte_cost() + .checked_mul(u128::from(storage_increase_byte_count)) + .ok_or("Storage cost calculation overflow") + .unwrap_or_panic(); + + let refund = env::attached_deposit() + .checked_sub(storage_increase_cost) + .ok_or_else(|| { + format!( + "Insufficient attached deposit {}yN, required {}yN", + env::attached_deposit().as_yoctonear(), + storage_increase_cost.as_yoctonear(), + ) + }) + .unwrap_or_panic(); + + if refund > NearToken::from_yoctonear(0) { + Promise::new(caller_id).transfer(refund).into() + } else { + PromiseOrValue::Value(()) + } + } + + #[only(self, owner)] + #[payable] + pub fn set_wrapped_token_account_id(&mut self, token_account_id: AccountId) -> Promise { + if !self.is_locked() { + env::panic_str( + "The contract must be locked first before setting the wrapped token target", + ) + } + + let caller_id = env::predecessor_account_id(); + + require!( + self.wrapped_token().is_none(), + "Wrapped token is already set" + ); + + ext_ft_core::ext(token_account_id.clone()) + .with_static_gas(BALANCE_OF_GAS) + .ft_balance_of(CURRENT_ACCOUNT_ID.clone()) + .then( + Self::ext(CURRENT_ACCOUNT_ID.clone()) + .with_attached_deposit(env::attached_deposit()) + .with_static_gas(DO_WRAP_TOKEN_GAS) + .do_set_wrapped_token_account_id(token_account_id, caller_id), + ) + } + + #[private] + #[payable] + pub fn do_set_wrapped_token_account_id( + &mut self, + token_account_id: AccountId, + caller_id: AccountId, + ) -> PromiseOrValue<()> { + require!( + self.wrapped_token().is_none(), + "Wrapped token is already set" + ); + + let parsed_balance = match env::promise_result(0) { + near_sdk::PromiseResult::Successful(balance_bytes) => { + if let Ok(balance) = serde_json::from_slice::(&balance_bytes) { + balance + } else { + env::panic_str(&format!( + "Setting token id {token_account_id} for contract {} failed due to bad balance bytes", + &*CURRENT_ACCOUNT_ID + )) + } + } + near_sdk::PromiseResult::Failed => env::panic_str( + "Setting token id {token_account_id} for contract {} failed due to failed promise", + ), + }; + + let self_total_supply = self.ft_total_supply(); + require!( + parsed_balance >= self_total_supply, + format!( + "Migration required that the wrapped token have sufficient balance to cover for the balance in this contract: {} < {}", + parsed_balance.0, self_total_supply.0 + ) + ); + + let initial_storage_usage = env::storage_usage(); + + match &mut self.inner { + ContractState::WrappableToken(lock) => { + let wrapped_token = &mut lock + .as_locked_mut() + .unwrap_or_panic_ctx("Invariant broken. The contract is expected to be locked when updating the wrapped token id") + .wrapped_token; + + wrapped_token.set(Some(token_account_id)); + wrapped_token.flush(); + } + } + + let end_storage_usage = env::storage_usage(); + + let storage_increase_byte_count = end_storage_usage.saturating_sub(initial_storage_usage); + + let storage_increase_cost = env::storage_byte_cost() + .checked_mul(u128::from(storage_increase_byte_count)) + .ok_or("Storage cost calculation overflow") + .unwrap_or_panic(); + + let refund = env::attached_deposit() + .checked_sub(storage_increase_cost) + .ok_or_else(|| { + format!( + "Insufficient attached deposit {}yN, required {}yN", + env::attached_deposit().as_yoctonear(), + storage_increase_cost.as_yoctonear(), + ) + }) + .unwrap_or_panic(); + + if refund > NearToken::from_yoctonear(0) { + Promise::new(caller_id).transfer(refund).into() + } else { + PromiseOrValue::Value(()) + } + } + + /// Returns the amount of tokens that were used/unwrapped after requesting an unwrap + #[private] + pub fn ft_resolve_unwrap( + &mut self, + sender_id: &AccountId, + amount: U128, + is_call: bool, + ) -> U128 { + let used = match env::promise_result(0) { + near_sdk::PromiseResult::Successful(value) => { + if is_call { + // `ft_transfer_call` returns successfully transferred amount + if let Ok(unused_amount) = near_sdk::serde_json::from_slice::(&value) { + std::cmp::min(amount, unused_amount).0 + } else { + amount.0 + } + } else if value.is_empty() { + // `ft_transfer` returns empty result on success + amount.0 + } else { + 0 + } + } + near_sdk::PromiseResult::Failed => { + if is_call { + // do not refund on failed `ft_transfer_call` due to + // NEP-141 vulnerability: `ft_resolve_transfer` fails to + // read result of `ft_on_transfer` due to insufficient gas + amount.0 + } else { + 0 + } + } + }; + + let to_refund = amount.0.saturating_sub(used); + + if to_refund > 0 { + self.token_mut().internal_deposit(sender_id, to_refund); + FtMint { + owner_id: sender_id, + amount, + memo: Some("refund for unwrap"), + } + .emit(); + } + + used.into() + } + + pub fn lock_contract(&mut self) { + match &mut self.inner { + ContractState::WrappableToken(lock) => { + lock.lock() + .unwrap_or_panic_ctx("Contract is already locked"); + } + } + } + + pub fn unlock_contract(&mut self) { + match &mut self.inner { + ContractState::WrappableToken(lock) => { + lock.unlock() + .unwrap_or_panic_ctx("Contract is already unlocked"); + } + } + } + + pub fn is_contract_locked(&self) -> bool { + self.is_locked() + } +} + +impl Contract { + fn unwrap_and_transfer( + &mut self, + receiver_id: AccountId, + amount: U128, + memo: Option, + msg: Option, + ) -> PromiseOrValue { + let Some(wrapped_token_id) = self.wrapped_token().cloned() else { + env::panic_str("Unwrapping is only for wrapped tokens"); + }; + + let sender_id = &*PREDECESSOR_ACCOUNT_ID; + + self.ft_withdraw(sender_id, amount, None); + + if let Some(inner_msg) = msg { + ext_ft_core::ext(wrapped_token_id.clone()) + .with_attached_deposit(NearToken::from_yoctonear(1)) + .ft_transfer_call(receiver_id, amount, memo, inner_msg) + .then( + Contract::ext(CURRENT_ACCOUNT_ID.clone()) + .with_static_gas(FT_RESOLVE_UNWRAP_GAS) + .ft_resolve_unwrap(sender_id, amount, true), + ) + .into() + } else { + ext_ft_core::ext(wrapped_token_id.clone()) + .with_attached_deposit(NearToken::from_yoctonear(1)) + .ft_transfer(receiver_id, amount, memo) + .then( + Self::ext(CURRENT_ACCOUNT_ID.clone()) + .ft_resolve_unwrap(sender_id, amount, false), + ) + .into() + } + } } #[near] @@ -65,14 +488,22 @@ impl PoaFungibleToken for Contract { fn set_metadata(&mut self, metadata: FungibleTokenMetadata) { assert_one_yocto(); metadata.assert_valid(); - self.metadata.set(metadata); + self.metadata_mut().set(metadata); } #[only(self, owner)] #[payable] fn ft_deposit(&mut self, owner_id: AccountId, amount: U128, memo: Option) { - self.token.storage_deposit(Some(owner_id.clone()), None); - self.token.internal_deposit(&owner_id, amount.into()); + require!( + self.wrapped_token().is_none(), + "This PoA token was migrated to OmniBridge. No deposits are possible.", + ); + + self.token_mut() + .storage_deposit(Some(owner_id.clone()), None); + + self.token_mut().internal_deposit(&owner_id, amount.into()); + FtMint { owner_id: &owner_id, amount, @@ -82,6 +513,13 @@ impl PoaFungibleToken for Contract { } } +#[near] +impl CanWrapToken for Contract { + fn wrapped_token(&self) -> Option<&AccountId> { + self.wrapped_token() + } +} + #[near] impl FungibleTokenCore for Contract { #[payable] @@ -92,11 +530,16 @@ impl FungibleTokenCore for Contract { if receiver_id == *CURRENT_ACCOUNT_ID && memo .as_deref() - .map_or(false, |memo| memo.starts_with(WITHDRAW_MEMO_PREFIX)) + .is_some_and(|memo| memo.starts_with(WITHDRAW_MEMO_PREFIX)) { - self.ft_withdraw(&PREDECESSOR_ACCOUNT_ID, amount, memo); + require!( + self.wrapped_token().is_none(), + "This PoA token was migrated to OmniBridge" + ); + + self.ft_withdraw(&PREDECESSOR_ACCOUNT_ID, amount, memo.as_deref()); } else { - self.token.ft_transfer(receiver_id, amount, memo) + self.token_mut().ft_transfer(receiver_id, amount, memo); } } @@ -108,15 +551,57 @@ impl FungibleTokenCore for Contract { memo: Option, msg: String, ) -> PromiseOrValue { - self.token.ft_transfer_call(receiver_id, amount, memo, msg) + assert_one_yocto(); + + if receiver_id != *CURRENT_ACCOUNT_ID { + return self + .token_mut() + .ft_transfer_call(receiver_id, amount, memo, msg); + } + + if self.wrapped_token().is_none() { + return self + .token_mut() + .ft_transfer_call(receiver_id, amount, memo, msg); + }; + + let Some(rest) = msg.strip_prefix(UNWRAP_PREFIX) else { + // In the case when our custom conditions were not met, + // we should keep backwards compatibility with NEP-141 standard, + // so that other protocols can interact with this token as a + // regular Fungible Token. + // + // We could have let the remaining Promises (i.e. ft_on_transfer() + // and ft_resolve_transfer()) go though, but we make a shortcut + // and save gas, since we know what is going to happen anyway. + // + // This is the expected behavior from NEP-141 token standard + // in both cases: `deposits` feature enabled and not + return PromiseOrValue::Value(U128(0)); + }; + + if let Some((receiver_id_from_msg, msg)) = rest.split_once(':') { + let receiver_id_from_msg = receiver_id_from_msg + .parse::() + .map_err(|e| format!("Failed to parse account id `{receiver_id_from_msg}`: {e}")) + .unwrap_or_panic_display(); + + self.unwrap_and_transfer(receiver_id_from_msg, amount, memo, Some(msg.to_string())) + } else { + let receiver_id_from_msg: AccountId = rest + .parse() + .map_err(|e| format!("Failed to parse account id `{rest}``: {e}")) + .unwrap_or_panic_display(); + self.unwrap_and_transfer(receiver_id_from_msg, amount, memo, None) + } } fn ft_total_supply(&self) -> U128 { - self.token.ft_total_supply() + self.token().ft_total_supply() } fn ft_balance_of(&self, account_id: AccountId) -> U128 { - self.token.ft_balance_of(account_id) + self.token().ft_balance_of(account_id) } } @@ -129,11 +614,45 @@ impl FungibleTokenResolver for Contract { receiver_id: AccountId, amount: U128, ) -> U128 { - self.token + self.token_mut() .ft_resolve_transfer(sender_id, receiver_id, amount) } } +#[cfg(feature = "deposits")] +#[near] +impl near_contract_standards::fungible_token::receiver::FungibleTokenReceiver for Contract { + fn ft_on_transfer( + &mut self, + sender_id: AccountId, + amount: U128, + msg: String, + ) -> PromiseOrValue { + require!( + self.wrapped_token().is_some(), + "This function is supposed to be used only with a token" + ); + + require!( + self.wrapped_token() + .as_ref() + .is_some_and(|t| &*PREDECESSOR_ACCOUNT_ID == *t), + "Only the wrapped token can be the caller of this function", + ); + + let recipient = if msg.is_empty() { + sender_id + } else { + use defuse_near_utils::UnwrapOrPanicError; + msg.parse().unwrap_or_panic_display() + }; + + self.token_mut().internal_deposit(&recipient, amount.0); + + PromiseOrValue::Value(0.into()) + } +} + #[near] impl StorageManagement for Contract { #[payable] @@ -142,44 +661,46 @@ impl StorageManagement for Contract { account_id: Option, registration_only: Option, ) -> StorageBalance { - self.token.storage_deposit(account_id, registration_only) + self.token_mut() + .storage_deposit(account_id, registration_only) } #[payable] fn storage_withdraw(&mut self, amount: Option) -> StorageBalance { - self.token.storage_withdraw(amount) + self.token_mut().storage_withdraw(amount) } #[payable] fn storage_unregister(&mut self, force: Option) -> bool { - self.token.storage_unregister(force) + self.token_mut().storage_unregister(force) } fn storage_balance_bounds(&self) -> StorageBalanceBounds { - self.token.storage_balance_bounds() + self.token().storage_balance_bounds() } fn storage_balance_of(&self, account_id: AccountId) -> Option { - self.token.storage_balance_of(account_id) + self.token().storage_balance_of(account_id) } } #[near] impl FungibleTokenMetadataProvider for Contract { fn ft_metadata(&self) -> FungibleTokenMetadata { - self.metadata.clone() + self.metadata().as_ref().clone() } } impl Contract { - fn ft_withdraw(&mut self, account_id: &AccountId, amount: U128, memo: Option) { + fn ft_withdraw(&mut self, account_id: &AccountId, amount: U128, memo: Option<&str>) { assert_one_yocto(); require!(amount.0 > 0, "zero amount"); - self.token.internal_withdraw(account_id, amount.into()); + self.token_mut() + .internal_withdraw(account_id, amount.into()); FtBurn { owner_id: account_id, amount, - memo: memo.as_deref(), + memo, } .emit(); } @@ -198,9 +719,34 @@ impl FullAccessKeys for Contract { } } +#[near] +impl ControllerUpgradable for Contract { + #[only(self, owner)] + #[payable] + fn upgrade( + &mut self, + #[serializer(borsh)] code: Vec, + #[serializer(borsh)] state_migration_gas: Option, + ) -> Promise { + assert_one_yocto(); + Promise::new(CURRENT_ACCOUNT_ID.clone()) + .deploy_contract(code) + .function_call( + STATE_MIGRATE_FUNCTION.into(), + Vec::new(), + NearToken::from_yoctonear(0), + state_migration_gas.unwrap_or(STATE_MIGRATE_DEFAULT_GAS), + ) + } + + #[private] + fn state_migrate(&mut self) {} +} + #[derive(BorshSerialize, BorshStorageKey)] #[borsh(crate = "::near_sdk::borsh")] enum Prefix { FungibleToken, Metadata, + WrappedToken, } diff --git a/poa-token/src/lib.rs b/poa-token/src/lib.rs index bbc05215..324eefdf 100644 --- a/poa-token/src/lib.rs +++ b/poa-token/src/lib.rs @@ -1,7 +1,8 @@ #[cfg(feature = "contract")] -mod contract; +pub mod contract; use defuse_admin_utils::full_access_keys::FullAccessKeys; +use defuse_controller::ControllerUpgradable; use near_contract_standards::{ fungible_token::{ FungibleTokenCore, FungibleTokenResolver, @@ -24,6 +25,8 @@ pub trait PoaFungibleToken: + StorageManagement + Ownable + FullAccessKeys + + CanWrapToken + + ControllerUpgradable { /// Sets metadata. /// NOTE: MUST attach 1 yⓃ for security purposes. @@ -35,8 +38,15 @@ pub trait PoaFungibleToken: fn ft_deposit(&mut self, owner_id: AccountId, amount: U128, memo: Option); } +pub trait CanWrapToken { + /// If this `PoA` token wraps an Omni-bridge token, returns Some(id) of the token it wraps. Otherwise, None. + fn wrapped_token(&self) -> Option<&AccountId>; +} + pub const WITHDRAW_MEMO_PREFIX: &str = "WITHDRAW_TO:"; +pub const UNWRAP_PREFIX: &str = "UNWRAP_TO:"; + pub fn withdraw_to(address: impl AsRef) -> String { format!("{WITHDRAW_MEMO_PREFIX}{}", address.as_ref()) } diff --git a/test-utils/src/random.rs b/test-utils/src/random.rs index aa1e419f..6e2f2769 100644 --- a/test-utils/src/random.rs +++ b/test-utils/src/random.rs @@ -56,6 +56,13 @@ impl randomness::distributions::Distribution for randomness::distributions } } +pub fn make_random_string(rng: &mut impl Rng, size: usize) -> String { + rng.sample_iter(&randomness::distributions::Alphanumeric) + .take(size) + .map(char::from) + .collect() +} + #[derive(Debug, Clone)] pub struct TestRng(rand_chacha::ChaChaRng); diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 9ecaaa26..5c9a7fa6 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -9,6 +9,7 @@ workspace = true [dev-dependencies] defuse = { workspace = true, features = ["contract"] } defuse-poa-factory = { workspace = true, features = ["contract"] } +defuse-poa-token = { workspace = true, features = ["contract"] } anyhow.workspace = true bnum = { workspace = true, features = ["rand"] } diff --git a/tests/old-artifacts/README.md b/tests/old-artifacts/README.md new file mode 100644 index 00000000..b7e8c6d9 --- /dev/null +++ b/tests/old-artifacts/README.md @@ -0,0 +1,3 @@ +### Old artifacts + +In this directory, we place the old smart contracts that we use for testing things like state upgrades. diff --git a/tests/old-artifacts/unversioned-poa/defuse_poa_factory.wasm b/tests/old-artifacts/unversioned-poa/defuse_poa_factory.wasm new file mode 100644 index 00000000..66f2b620 Binary files /dev/null and b/tests/old-artifacts/unversioned-poa/defuse_poa_factory.wasm differ diff --git a/tests/old-artifacts/unversioned-poa/defuse_poa_factory_abi.json b/tests/old-artifacts/unversioned-poa/defuse_poa_factory_abi.json new file mode 100644 index 00000000..4376d5e9 --- /dev/null +++ b/tests/old-artifacts/unversioned-poa/defuse_poa_factory_abi.json @@ -0,0 +1,976 @@ +{ + "schema_version": "0.4.0", + "metadata": { + "name": "defuse-poa-factory", + "version": "0.1.0", + "build": { + "compiler": "rustc 1.84.1", + "builder": "cargo-near cargo-near-build 0.4.4" + }, + "wasm_hash": "5efF2hdUm6EDPHtfFfzUWVgZ8878yX9SSaXy8cSH1HZu" + }, + "body": { + "functions": [ + { + "name": "acl_add_admin", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "role", + "type_schema": { + "type": "string" + } + }, + { + "name": "account_id", + "type_schema": { + "$ref": "#/definitions/AccountId" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": [ + "boolean", + "null" + ] + } + } + }, + { + "name": "acl_add_super_admin", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "account_id", + "type_schema": { + "$ref": "#/definitions/AccountId" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": [ + "boolean", + "null" + ] + } + } + }, + { + "name": "acl_get_admins", + "kind": "view", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "role", + "type_schema": { + "type": "string" + } + }, + { + "name": "skip", + "type_schema": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + { + "name": "limit", + "type_schema": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "array", + "items": { + "$ref": "#/definitions/AccountId" + } + } + } + }, + { + "name": "acl_get_grantees", + "kind": "view", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "role", + "type_schema": { + "type": "string" + } + }, + { + "name": "skip", + "type_schema": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + { + "name": "limit", + "type_schema": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "array", + "items": { + "$ref": "#/definitions/AccountId" + } + } + } + }, + { + "name": "acl_get_permissioned_accounts", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/PermissionedAccounts" + } + } + }, + { + "name": "acl_get_super_admins", + "kind": "view", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "skip", + "type_schema": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + { + "name": "limit", + "type_schema": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "array", + "items": { + "$ref": "#/definitions/AccountId" + } + } + } + }, + { + "name": "acl_grant_role", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "role", + "type_schema": { + "type": "string" + } + }, + { + "name": "account_id", + "type_schema": { + "$ref": "#/definitions/AccountId" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": [ + "boolean", + "null" + ] + } + } + }, + { + "name": "acl_has_any_role", + "kind": "view", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "roles", + "type_schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "account_id", + "type_schema": { + "$ref": "#/definitions/AccountId" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "boolean" + } + } + }, + { + "name": "acl_has_role", + "kind": "view", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "role", + "type_schema": { + "type": "string" + } + }, + { + "name": "account_id", + "type_schema": { + "$ref": "#/definitions/AccountId" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "boolean" + } + } + }, + { + "name": "acl_init_super_admin", + "kind": "call", + "modifiers": [ + "private" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "account_id", + "type_schema": { + "$ref": "#/definitions/AccountId" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "boolean" + } + } + }, + { + "name": "acl_is_admin", + "kind": "view", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "role", + "type_schema": { + "type": "string" + } + }, + { + "name": "account_id", + "type_schema": { + "$ref": "#/definitions/AccountId" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "boolean" + } + } + }, + { + "name": "acl_is_super_admin", + "kind": "view", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "account_id", + "type_schema": { + "$ref": "#/definitions/AccountId" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "boolean" + } + } + }, + { + "name": "acl_renounce_admin", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "role", + "type_schema": { + "type": "string" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "boolean" + } + } + }, + { + "name": "acl_renounce_role", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "role", + "type_schema": { + "type": "string" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "boolean" + } + } + }, + { + "name": "acl_revoke_admin", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "role", + "type_schema": { + "type": "string" + } + }, + { + "name": "account_id", + "type_schema": { + "$ref": "#/definitions/AccountId" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": [ + "boolean", + "null" + ] + } + } + }, + { + "name": "acl_revoke_role", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "role", + "type_schema": { + "type": "string" + } + }, + { + "name": "account_id", + "type_schema": { + "$ref": "#/definitions/AccountId" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": [ + "boolean", + "null" + ] + } + } + }, + { + "name": "acl_revoke_super_admin", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "account_id", + "type_schema": { + "$ref": "#/definitions/AccountId" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": [ + "boolean", + "null" + ] + } + } + }, + { + "name": "acl_role_variants", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + { + "name": "acl_storage_prefix", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + } + } + } + }, + { + "name": "acl_transfer_super_admin", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "account_id", + "type_schema": { + "$ref": "#/definitions/AccountId" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": [ + "boolean", + "null" + ] + } + } + }, + { + "name": "add_full_access_key", + "kind": "call", + "modifiers": [ + "payable" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "public_key", + "type_schema": { + "type": "string" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/Promise" + } + } + }, + { + "name": "contract_source_metadata", + "kind": "view" + }, + { + "name": "delete_key", + "kind": "call", + "modifiers": [ + "payable" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "public_key", + "type_schema": { + "type": "string" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/Promise" + } + } + }, + { + "name": "deploy_token", + "kind": "call", + "modifiers": [ + "payable" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "token", + "type_schema": { + "type": "string" + } + }, + { + "name": "metadata", + "type_schema": { + "anyOf": [ + { + "$ref": "#/definitions/FungibleTokenMetadata" + }, + { + "type": "null" + } + ] + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/Promise" + } + } + }, + { + "name": "ft_deposit", + "kind": "call", + "modifiers": [ + "payable" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "token", + "type_schema": { + "type": "string" + } + }, + { + "name": "owner_id", + "type_schema": { + "$ref": "#/definitions/AccountId" + } + }, + { + "name": "amount", + "type_schema": { + "type": "string" + } + }, + { + "name": "msg", + "type_schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "memo", + "type_schema": { + "type": [ + "string", + "null" + ] + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/Promise" + } + } + }, + { + "name": "new", + "kind": "call", + "modifiers": [ + "init" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "super_admins", + "type_schema": { + "type": "array", + "items": { + "$ref": "#/definitions/AccountId" + }, + "uniqueItems": true + } + }, + { + "name": "admins", + "type_schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/AccountId" + }, + "uniqueItems": true + } + } + }, + { + "name": "grantees", + "type_schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/AccountId" + }, + "uniqueItems": true + } + } + } + ] + } + }, + { + "name": "pa_all_paused", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "uniqueItems": true + } + } + }, + { + "name": "pa_is_paused", + "kind": "view", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "key", + "type_schema": { + "type": "string" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "boolean" + } + } + }, + { + "name": "pa_pause_feature", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "key", + "type_schema": { + "type": "string" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "boolean" + } + } + }, + { + "name": "pa_storage_key", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + } + } + } + }, + { + "name": "pa_unpause_feature", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "key", + "type_schema": { + "type": "string" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "boolean" + } + } + }, + { + "name": "set_metadata", + "kind": "call", + "modifiers": [ + "payable" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "token", + "type_schema": { + "type": "string" + } + }, + { + "name": "metadata", + "type_schema": { + "$ref": "#/definitions/FungibleTokenMetadata" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/Promise" + } + } + }, + { + "name": "tokens", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/AccountId" + } + } + } + } + ], + "root_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "String", + "type": "string", + "definitions": { + "AccountId": { + "description": "NEAR Account Identifier.\n\nThis is a unique, syntactically valid, human-readable account identifier on the NEAR network.\n\n[See the crate-level docs for information about validation.](index.html#account-id-rules)\n\nAlso see [Error kind precedence](AccountId#error-kind-precedence).\n\n## Examples\n\n``` use near_account_id::AccountId;\n\nlet alice: AccountId = \"alice.near\".parse().unwrap();\n\nassert!(\"ƒelicia.near\".parse::().is_err()); // (ƒ is not f) ```", + "type": "string" + }, + "Base64VecU8": { + "description": "Helper class to serialize/deserialize `Vec` to base64 string.", + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + } + }, + "FungibleTokenMetadata": { + "type": "object", + "required": [ + "decimals", + "name", + "spec", + "symbol" + ], + "properties": { + "decimals": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "icon": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "reference": { + "type": [ + "string", + "null" + ] + }, + "reference_hash": { + "anyOf": [ + { + "$ref": "#/definitions/Base64VecU8" + }, + { + "type": "null" + } + ] + }, + "spec": { + "type": "string" + }, + "symbol": { + "type": "string" + } + } + }, + "PermissionedAccounts": { + "description": "Collects super admin accounts and accounts that have been granted permissions defined by `AccessControlRole`.\n\n# Data structure\n\nAssume `AccessControlRole` is derived for the following enum, which is then passed as `role` attribute to `AccessControllable`.\n\n```rust pub enum Role { PauseManager, UnpauseManager, } ```\n\nThen the returned data has the following structure:\n\n```ignore PermissionedAccounts { super_admins: vec![\"acc1.near\", \"acc2.near\"], roles: HashMap::from([ (\"PauseManager\", PermissionedAccountsPerRole { admins: vec![\"acc3.near\", \"acc4.near\"], grantees: vec![\"acc5.near\", \"acc6.near\"], }), (\"UnpauseManager\", PermissionedAccountsPerRole { admins: vec![\"acc7.near\", \"acc8.near\"], grantees: vec![\"acc9.near\", \"acc10.near\"], }), ]) } ```\n\n# Uniqueness and ordering\n\nAccount ids returned in vectors are unique but not ordered.", + "type": "object", + "required": [ + "roles", + "super_admins" + ], + "properties": { + "roles": { + "description": "The admins and grantees of all roles.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/PermissionedAccountsPerRole" + } + }, + "super_admins": { + "description": "The accounts that have super admin permissions.", + "type": "array", + "items": { + "$ref": "#/definitions/AccountId" + } + } + } + }, + "PermissionedAccountsPerRole": { + "description": "Collects all admins and grantees of a role.\n\n# Uniqueness and ordering\n\nAccount ids returned in vectors are unique but not ordered.", + "type": "object", + "required": [ + "admins", + "grantees" + ], + "properties": { + "admins": { + "description": "The accounts that have admin permissions for the role.", + "type": "array", + "items": { + "$ref": "#/definitions/AccountId" + } + }, + "grantees": { + "description": "The accounts that have been granted the role.", + "type": "array", + "items": { + "$ref": "#/definitions/AccountId" + } + } + } + }, + "Promise": true + } + } + } +} \ No newline at end of file diff --git a/tests/old-artifacts/unversioned-poa/defuse_poa_token.wasm b/tests/old-artifacts/unversioned-poa/defuse_poa_token.wasm new file mode 100644 index 00000000..a0d82816 Binary files /dev/null and b/tests/old-artifacts/unversioned-poa/defuse_poa_token.wasm differ diff --git a/tests/old-artifacts/unversioned-poa/defuse_poa_token_abi.json b/tests/old-artifacts/unversioned-poa/defuse_poa_token_abi.json new file mode 100644 index 00000000..09fd6831 --- /dev/null +++ b/tests/old-artifacts/unversioned-poa/defuse_poa_token_abi.json @@ -0,0 +1,605 @@ +{ + "schema_version": "0.4.0", + "metadata": { + "name": "defuse-poa-token", + "version": "0.1.0", + "build": { + "compiler": "rustc 1.84.1", + "builder": "cargo-near cargo-near-build 0.4.4" + }, + "wasm_hash": "7uwxzTqR7kDGArt9qjyp4N9KLZ8Gk1aMND1eabGRjDpP" + }, + "body": { + "functions": [ + { + "name": "add_full_access_key", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "public_key", + "type_schema": { + "type": "string" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/Promise" + } + } + }, + { + "name": "contract_source_metadata", + "kind": "view" + }, + { + "name": "delete_key", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "public_key", + "type_schema": { + "type": "string" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/Promise" + } + } + }, + { + "name": "ft_balance_of", + "kind": "view", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "account_id", + "type_schema": { + "$ref": "#/definitions/AccountId" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "string" + } + } + }, + { + "name": "ft_deposit", + "kind": "call", + "modifiers": [ + "payable" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "owner_id", + "type_schema": { + "$ref": "#/definitions/AccountId" + } + }, + { + "name": "amount", + "type_schema": { + "type": "string" + } + }, + { + "name": "memo", + "type_schema": { + "type": [ + "string", + "null" + ] + } + } + ] + } + }, + { + "name": "ft_metadata", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/FungibleTokenMetadata" + } + } + }, + { + "name": "ft_resolve_transfer", + "kind": "call", + "modifiers": [ + "private" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "sender_id", + "type_schema": { + "$ref": "#/definitions/AccountId" + } + }, + { + "name": "receiver_id", + "type_schema": { + "$ref": "#/definitions/AccountId" + } + }, + { + "name": "amount", + "type_schema": { + "type": "string" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "string" + } + } + }, + { + "name": "ft_total_supply", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "type": "string" + } + } + }, + { + "name": "ft_transfer", + "kind": "call", + "modifiers": [ + "payable" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "receiver_id", + "type_schema": { + "$ref": "#/definitions/AccountId" + } + }, + { + "name": "amount", + "type_schema": { + "type": "string" + } + }, + { + "name": "memo", + "type_schema": { + "type": [ + "string", + "null" + ] + } + } + ] + } + }, + { + "name": "ft_transfer_call", + "kind": "call", + "modifiers": [ + "payable" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "receiver_id", + "type_schema": { + "$ref": "#/definitions/AccountId" + } + }, + { + "name": "amount", + "type_schema": { + "type": "string" + } + }, + { + "name": "memo", + "type_schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "msg", + "type_schema": { + "type": "string" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/PromiseOrValueString" + } + } + }, + { + "name": "new", + "kind": "call", + "modifiers": [ + "init" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "owner_id", + "type_schema": { + "anyOf": [ + { + "$ref": "#/definitions/AccountId" + }, + { + "type": "null" + } + ] + } + }, + { + "name": "metadata", + "type_schema": { + "anyOf": [ + { + "$ref": "#/definitions/FungibleTokenMetadata" + }, + { + "type": "null" + } + ] + } + } + ] + } + }, + { + "name": "owner_get", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "anyOf": [ + { + "$ref": "#/definitions/AccountId" + }, + { + "type": "null" + } + ] + } + } + }, + { + "name": "owner_is", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "type": "boolean" + } + } + }, + { + "name": "owner_set", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "owner", + "type_schema": { + "anyOf": [ + { + "$ref": "#/definitions/AccountId" + }, + { + "type": "null" + } + ] + } + } + ] + } + }, + { + "name": "owner_storage_key", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + } + } + } + }, + { + "name": "set_metadata", + "kind": "call", + "modifiers": [ + "payable" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "metadata", + "type_schema": { + "$ref": "#/definitions/FungibleTokenMetadata" + } + } + ] + } + }, + { + "name": "storage_balance_bounds", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/StorageBalanceBounds" + } + } + }, + { + "name": "storage_balance_of", + "kind": "view", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "account_id", + "type_schema": { + "$ref": "#/definitions/AccountId" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "anyOf": [ + { + "$ref": "#/definitions/StorageBalance" + }, + { + "type": "null" + } + ] + } + } + }, + { + "name": "storage_deposit", + "kind": "call", + "modifiers": [ + "payable" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "account_id", + "type_schema": { + "anyOf": [ + { + "$ref": "#/definitions/AccountId" + }, + { + "type": "null" + } + ] + } + }, + { + "name": "registration_only", + "type_schema": { + "type": [ + "boolean", + "null" + ] + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/StorageBalance" + } + } + }, + { + "name": "storage_unregister", + "kind": "call", + "modifiers": [ + "payable" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "force", + "type_schema": { + "type": [ + "boolean", + "null" + ] + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "boolean" + } + } + }, + { + "name": "storage_withdraw", + "kind": "call", + "modifiers": [ + "payable" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "amount", + "type_schema": { + "type": [ + "string", + "null" + ] + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/StorageBalance" + } + } + } + ], + "root_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "String", + "type": "string", + "definitions": { + "AccountId": { + "description": "NEAR Account Identifier.\n\nThis is a unique, syntactically valid, human-readable account identifier on the NEAR network.\n\n[See the crate-level docs for information about validation.](index.html#account-id-rules)\n\nAlso see [Error kind precedence](AccountId#error-kind-precedence).\n\n## Examples\n\n``` use near_account_id::AccountId;\n\nlet alice: AccountId = \"alice.near\".parse().unwrap();\n\nassert!(\"ƒelicia.near\".parse::().is_err()); // (ƒ is not f) ```", + "type": "string" + }, + "Base64VecU8": { + "description": "Helper class to serialize/deserialize `Vec` to base64 string.", + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + } + }, + "FungibleTokenMetadata": { + "type": "object", + "required": [ + "decimals", + "name", + "spec", + "symbol" + ], + "properties": { + "decimals": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "icon": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "reference": { + "type": [ + "string", + "null" + ] + }, + "reference_hash": { + "anyOf": [ + { + "$ref": "#/definitions/Base64VecU8" + }, + { + "type": "null" + } + ] + }, + "spec": { + "type": "string" + }, + "symbol": { + "type": "string" + } + } + }, + "Promise": true, + "PromiseOrValueString": { + "type": "string" + }, + "StorageBalance": { + "type": "object", + "required": [ + "available", + "total" + ], + "properties": { + "available": { + "type": "string" + }, + "total": { + "type": "string" + } + } + }, + "StorageBalanceBounds": { + "type": "object", + "required": [ + "min" + ], + "properties": { + "max": { + "type": [ + "string", + "null" + ] + }, + "min": { + "type": "string" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/src/tests/defuse/env.rs b/tests/src/tests/defuse/env.rs index 293d4bd6..7f74f6fc 100644 --- a/tests/src/tests/defuse/env.rs +++ b/tests/src/tests/defuse/env.rs @@ -16,7 +16,7 @@ use near_sdk::{AccountId, NearToken}; use near_workspaces::{Account, Contract}; use crate::{ - tests::poa::factory::PoAFactoryExt, + tests::poa::factory::factory_env::PoAFactoryExt, utils::{Sandbox, ft::FtExt, wnear::WNearExt}, }; @@ -111,7 +111,7 @@ impl Env { .unwrap(); } - pub async fn near_balance(&mut self, account_id: &AccountId) -> NearToken { + pub async fn near_balance(&self, account_id: &AccountId) -> NearToken { self.sandbox .worker() .view_account(account_id) @@ -190,11 +190,6 @@ impl EnvBuilder { self } - // pub fn staging_duration(mut self, staging_duration: Duration) -> Self { - // self.staging_duration = Some(staging_duration); - // self - // } - pub async fn build(mut self) -> Env { let sandbox = Sandbox::new().await.unwrap(); let root = sandbox.root_account().clone(); diff --git a/tests/src/tests/defuse/tokens/nep141.rs b/tests/src/tests/defuse/tokens/nep141.rs index 644d00e7..dc00ec05 100644 --- a/tests/src/tests/defuse/tokens/nep141.rs +++ b/tests/src/tests/defuse/tokens/nep141.rs @@ -16,7 +16,7 @@ use serde_json::json; use crate::{ tests::{ defuse::{DefuseSigner, env::Env}, - poa::factory::PoAFactoryExt, + poa::factory::factory_env::PoAFactoryExt, }, utils::{acl::AclExt, ft::FtExt, mt::MtExt}, }; diff --git a/tests/src/tests/poa/factory/basic.rs b/tests/src/tests/poa/factory/basic.rs new file mode 100644 index 00000000..3cc40773 --- /dev/null +++ b/tests/src/tests/poa/factory/basic.rs @@ -0,0 +1,82 @@ +use defuse_poa_factory::contract::Role; + +use crate::{ + tests::poa::factory::factory_env::PoAFactoryExt, + utils::{Sandbox, ft::FtExt}, +}; + +#[tokio::test] +async fn test_deploy_mint() { + let sandbox = Sandbox::new().await.unwrap(); + let root = sandbox.root_account(); + let user = sandbox.create_account("user1").await; + + let poa_factory = root + .deploy_poa_factory( + "poa-factory", + [root.id().clone()], + [ + (Role::TokenDeployer, [root.id().clone()]), + (Role::TokenDepositer, [root.id().clone()]), + ], + [ + (Role::TokenDeployer, [root.id().clone()]), + (Role::TokenDepositer, [root.id().clone()]), + ], + ) + .await + .unwrap(); + + user.poa_factory_deploy_token(poa_factory.id(), "ft1", None) + .await + .unwrap_err() + .to_string(); + + assert!( + root.poa_factory_deploy_token(poa_factory.id(), "ft1.abc", None) + .await + .unwrap_err() + .to_string() + .contains("invalid token name") + ); + + let ft1 = root + .poa_factory_deploy_token(poa_factory.id(), "ft1", None) + .await + .unwrap(); + + assert!( + root.poa_factory_deploy_token(poa_factory.id(), "ft1", None) + .await + .unwrap_err() + .to_string() + .contains("token exists") + ); + + assert_eq!( + sandbox.ft_token_balance_of(&ft1, user.id()).await.unwrap(), + 0 + ); + + sandbox + .ft_storage_deposit_many(&ft1, &[root.id(), user.id()]) + .await + .unwrap(); + + assert!( + user.poa_factory_ft_deposit(poa_factory.id(), "ft1", user.id(), 1000, None, None) + .await + .unwrap_err() + .to_string() + .contains("Requires one of these roles") + ); + + root.poa_factory_ft_deposit(poa_factory.id(), "ft1", user.id(), 1000, None, None) + .await + .unwrap(); + + assert_eq!( + sandbox.ft_token_balance_of(&ft1, user.id()).await.unwrap(), + 1000 + ); +} diff --git a/tests/src/tests/poa/factory.rs b/tests/src/tests/poa/factory/factory_env.rs similarity index 76% rename from tests/src/tests/poa/factory.rs rename to tests/src/tests/poa/factory/factory_env.rs index ad449c28..6196eb40 100644 --- a/tests/src/tests/poa/factory.rs +++ b/tests/src/tests/poa/factory/factory_env.rs @@ -11,7 +11,8 @@ use serde_json::json; use crate::utils::{account::AccountExt, read_wasm}; -static POA_FACTORY_WASM: LazyLock> = LazyLock::new(|| read_wasm("defuse_poa_factory")); +static CURRENT_POA_FACTORY_WASM: LazyLock> = + LazyLock::new(|| read_wasm("defuse_poa_factory")); pub trait PoAFactoryExt { async fn deploy_poa_factory( @@ -26,12 +27,14 @@ pub trait PoAFactoryExt { fn token_id(token: &str, factory: &AccountId) -> AccountId { format!("{token}.{factory}").parse().unwrap() } + async fn poa_factory_deploy_token( &self, factory: &AccountId, token: &str, metadata: impl Into>, ) -> anyhow::Result; + async fn poa_deploy_token( &self, token: &str, @@ -47,6 +50,7 @@ pub trait PoAFactoryExt { msg: Option, memo: Option, ) -> anyhow::Result<()>; + async fn poa_ft_deposit( &self, token: &str, @@ -55,6 +59,7 @@ pub trait PoAFactoryExt { msg: Option, memo: Option, ) -> anyhow::Result<()>; + async fn poa_factory_tokens( &self, poa_factory: &AccountId, @@ -69,7 +74,9 @@ impl PoAFactoryExt for near_workspaces::Account { admins: impl IntoIterator)>, grantees: impl IntoIterator)>, ) -> anyhow::Result { - let contract = self.deploy_contract(name, &POA_FACTORY_WASM).await?; + let contract = self + .deploy_contract(name, &CURRENT_POA_FACTORY_WASM) + .await?; self.transfer_near(contract.id(), NearToken::from_near(100)) .await? .into_result()?; @@ -104,7 +111,7 @@ impl PoAFactoryExt for near_workspaces::Account { "token": token, "metadata": metadata.into(), })) - .deposit(NearToken::from_near(4)) + .deposit(NearToken::from_near(10)) .max_gas() .transact() .await? @@ -235,73 +242,3 @@ impl PoAFactoryExt for near_workspaces::Contract { self.as_account().poa_factory_tokens(poa_factory).await } } - -#[cfg(test)] -mod tests { - use super::*; - - use crate::utils::{Sandbox, ft::FtExt}; - - #[tokio::test] - async fn test_deploy_mint() { - let sandbox = Sandbox::new().await.unwrap(); - let root = sandbox.root_account(); - let user = sandbox.create_account("user1").await; - - let poa_factory = root - .deploy_poa_factory( - "poa-factory", - [root.id().clone()], - [ - (Role::TokenDeployer, [root.id().clone()]), - (Role::TokenDepositer, [root.id().clone()]), - ], - [ - (Role::TokenDeployer, [root.id().clone()]), - (Role::TokenDepositer, [root.id().clone()]), - ], - ) - .await - .unwrap(); - - user.poa_factory_deploy_token(poa_factory.id(), "ft1", None) - .await - .unwrap_err(); - - root.poa_factory_deploy_token(poa_factory.id(), "ft1.abc", None) - .await - .unwrap_err(); - - let ft1 = root - .poa_factory_deploy_token(poa_factory.id(), "ft1", None) - .await - .unwrap(); - - root.poa_factory_deploy_token(poa_factory.id(), "ft1", None) - .await - .unwrap_err(); - - assert_eq!( - sandbox.ft_token_balance_of(&ft1, user.id()).await.unwrap(), - 0 - ); - - sandbox - .ft_storage_deposit_many(&ft1, &[root.id(), user.id()]) - .await - .unwrap(); - - user.poa_factory_ft_deposit(poa_factory.id(), "ft1", user.id(), 1000, None, None) - .await - .unwrap_err(); - - root.poa_factory_ft_deposit(poa_factory.id(), "ft1", user.id(), 1000, None, None) - .await - .unwrap(); - - assert_eq!( - sandbox.ft_token_balance_of(&ft1, user.id()).await.unwrap(), - 1000 - ); - } -} diff --git a/tests/src/tests/poa/factory/mod.rs b/tests/src/tests/poa/factory/mod.rs new file mode 100644 index 00000000..71164c05 --- /dev/null +++ b/tests/src/tests/poa/factory/mod.rs @@ -0,0 +1,3 @@ +pub mod factory_env; + +mod basic; diff --git a/tests/src/tests/poa/mod.rs b/tests/src/tests/poa/mod.rs index a106d20e..c7b8384e 100644 --- a/tests/src/tests/poa/mod.rs +++ b/tests/src/tests/poa/mod.rs @@ -1 +1,2 @@ pub mod factory; +pub mod token; diff --git a/tests/src/tests/poa/token/mod.rs b/tests/src/tests/poa/token/mod.rs new file mode 100644 index 00000000..859a5fe8 --- /dev/null +++ b/tests/src/tests/poa/token/mod.rs @@ -0,0 +1,4 @@ +mod token_env; +mod transfer; +mod transfer_call; +mod upgrade; diff --git a/tests/src/tests/poa/token/token_env.rs b/tests/src/tests/poa/token/token_env.rs new file mode 100644 index 00000000..86994c19 --- /dev/null +++ b/tests/src/tests/poa/token/token_env.rs @@ -0,0 +1,228 @@ +use crate::utils::{account::AccountExt, test_logs::TestLog}; +use near_contract_standards::fungible_token::metadata::FungibleTokenMetadata; +use near_sdk::{AccountId, AccountIdRef, NearToken, json_types::U128}; +use near_workspaces::Contract; +use serde_json::json; + +pub const MIN_FT_STORAGE_DEPOSIT_VALUE: NearToken = + NearToken::from_yoctonear(1_250_000_000_000_000_000_000); + +#[cfg(not(clippy))] +pub const POA_TOKEN_WASM: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../res/poa-token-with-deposit/defuse_poa_token.wasm" +)); +#[cfg(clippy)] +pub const POA_TOKEN_WASM: &[u8] = b""; + +pub trait PoATokenExt { + async fn deploy_poa_token( + &self, + id: &str, + owner_id: Option<&AccountIdRef>, + metadata: Option, + ) -> anyhow::Result; +} + +impl PoATokenExt for near_workspaces::Account { + async fn deploy_poa_token( + &self, + id: &str, + owner_id: Option<&AccountIdRef>, + metadata: Option, + ) -> anyhow::Result { + let contract = self.deploy_contract(id, POA_TOKEN_WASM).await?; + let mut json_args = serde_json::Map::new(); + if let Some(oid) = owner_id { + json_args.insert("owner_id".to_string(), serde_json::to_value(oid).unwrap()); + } + if let Some(md) = metadata { + json_args.insert("metadata".to_string(), serde_json::to_value(md).unwrap()); + } + + contract + .call("new") + .args_json(json_args) + .max_gas() + .transact() + .await? + .into_result()?; + Ok(PoATokenContract::new(contract)) + } +} + +pub struct PoATokenContract { + contract: Contract, +} + +impl PoATokenContract { + fn new(contract: Contract) -> Self { + Self { contract } + } + + pub fn inner(&self) -> &Contract { + &self.contract + } + + pub fn id(&self) -> &AccountId { + self.contract.id() + } + + pub async fn poa_wrapped_token(&self) -> anyhow::Result> { + self.contract + .call("wrapped_token") + .view() + .await? + .json() + .map_err(Into::into) + } + + pub async fn poa_ft_metadata(&self) -> anyhow::Result { + self.contract + .call("ft_metadata") + .view() + .await? + .json() + .map_err(Into::into) + } + + pub async fn poa_is_contract_locked_for_wrapping(&self) -> anyhow::Result { + self.contract + .view("is_contract_locked") + .await? + .json() + .map_err(Into::into) + } +} + +pub trait PoATokenContractCaller { + async fn poa_ft_deposit( + &self, + contract: &PoATokenContract, + owner_id: &AccountIdRef, + amount: U128, + memo: Option, + ) -> anyhow::Result<()>; + + async fn poa_set_wrapped_token_account_id( + &self, + contract: &PoATokenContract, + token_account_id: &AccountIdRef, + attached_deposit: NearToken, + ) -> anyhow::Result; + + async fn poa_force_sync_wrapped_token_metadata( + &self, + contract: &PoATokenContract, + attached_deposit: NearToken, + ) -> anyhow::Result; + + async fn poa_lock_contract_for_wrapping(&self, contract: &AccountId) + -> anyhow::Result; + + async fn poa_unlock_contract_for_wrapping( + &self, + contract: &AccountId, + ) -> anyhow::Result; +} + +impl PoATokenContractCaller for near_workspaces::Account { + async fn poa_ft_deposit( + &self, + contract: &PoATokenContract, + owner_id: &AccountIdRef, + amount: U128, + memo: Option, + ) -> anyhow::Result<()> { + let mut json_args = json!( + { + "owner_id": owner_id, + "amount": amount, + } + ); + + if let Some(m) = memo { + json_args + .as_object_mut() + .unwrap() + .insert("memo".to_string(), m.into()); + } + + self.call(contract.contract.id(), "ft_deposit") + .args_json(json_args) + .deposit(NearToken::from_millinear(100)) + .max_gas() + .transact() + .await? + .into_result()?; + + Ok(()) + } + + async fn poa_set_wrapped_token_account_id( + &self, + contract: &PoATokenContract, + token_account_id: &AccountIdRef, + attached_deposit: NearToken, + ) -> anyhow::Result { + let logs = self + .call(contract.id(), "set_wrapped_token_account_id") + .args_json(json!( + { + "token_account_id": token_account_id, + } + )) + .max_gas() + .deposit(attached_deposit) + .transact() + .await? + .into_result() + .map(Into::into)?; + + Ok(logs) + } + + async fn poa_force_sync_wrapped_token_metadata( + &self, + contract: &PoATokenContract, + attached_deposit: NearToken, + ) -> anyhow::Result { + let outcome = self + .call(contract.id(), "force_sync_wrapped_token_metadata") + .max_gas() + .deposit(attached_deposit) + .transact() + .await? + .into_result()?; + + Ok(outcome.into()) + } + + async fn poa_lock_contract_for_wrapping( + &self, + contract_id: &AccountId, + ) -> anyhow::Result { + let outcome = self + .call(contract_id, "lock_contract") + .max_gas() + .transact() + .await? + .into_result()?; + + Ok(outcome.into()) + } + + async fn poa_unlock_contract_for_wrapping( + &self, + contract: &AccountId, + ) -> anyhow::Result { + let outcome = self + .call(contract, "unlock_contract") + .max_gas() + .transact() + .await? + .into_result()?; + + Ok(outcome.into()) + } +} diff --git a/tests/src/tests/poa/token/transfer.rs b/tests/src/tests/poa/token/transfer.rs new file mode 100644 index 00000000..a8c55e7d --- /dev/null +++ b/tests/src/tests/poa/token/transfer.rs @@ -0,0 +1,586 @@ +use super::token_env::{PoATokenContract, PoATokenContractCaller, PoATokenExt}; +use crate::{ + tests::poa::token::token_env::MIN_FT_STORAGE_DEPOSIT_VALUE, + utils::{Sandbox, ft::FtExt, storage_management::StorageManagementExt, wnear::WNearExt}, +}; +use defuse_poa_token::WITHDRAW_MEMO_PREFIX; +use near_contract_standards::fungible_token::metadata::FungibleTokenMetadata; +use near_sdk::{AccountId, NearToken}; +use near_workspaces::Account; + +struct TransferFixture { + sandbox: Sandbox, + poa_contract_owner: Account, + root: Account, + user1: Account, + user2: Account, + poa_token_contract: PoATokenContract, +} + +impl TransferFixture { + async fn near_balance(&self, account_id: &AccountId) -> NearToken { + self.sandbox + .worker() + .view_account(account_id) + .await + .unwrap() + .balance + } + + async fn new() -> Self { + let sandbox = Sandbox::new().await.unwrap(); + let root = sandbox.root_account().clone(); + let poa_contract_owner = sandbox.create_account("owner").await; + let user1 = sandbox.create_account("user1").await; + let user2 = sandbox.create_account("user2").await; + let poa_token_contract: PoATokenContract = root + .deploy_poa_token("poa_token", Some(poa_contract_owner.id()), None) + .await + .unwrap(); + + // Storage deposit for involved users, to deposit tokens into his account + { + root.storage_deposit( + poa_token_contract.id(), + Some(user1.id()), + MIN_FT_STORAGE_DEPOSIT_VALUE, + ) + .await + .unwrap(); + root.storage_deposit( + poa_token_contract.id(), + Some(user2.id()), + MIN_FT_STORAGE_DEPOSIT_VALUE, + ) + .await + .unwrap(); + } + + Self { + sandbox, + poa_contract_owner, + root, + user1, + user2, + poa_token_contract, + } + } +} + +/// Tests ft_transfer, ft_deposit, balances and withdrawals with and without wrapping +#[tokio::test] +async fn simple_transfer() { + let fixture = TransferFixture::new().await; + + // fund user1 with deposit + { + assert_eq!( + fixture + .poa_token_contract + .inner() + .ft_balance_of(fixture.user1.id()) + .await + .unwrap(), + 0 + ); + + fixture + .poa_contract_owner + .poa_ft_deposit( + &fixture.poa_token_contract, + fixture.user1.id(), + 100_000.into(), + None, + ) + .await + .unwrap(); + + assert_eq!( + fixture + .poa_token_contract + .inner() + .ft_balance_of(fixture.user1.id()) + .await + .unwrap(), + 100_000 + ); + } + + // transfer from user1 to user2 + { + assert_eq!( + fixture + .poa_token_contract + .inner() + .ft_balance_of(fixture.user2.id()) + .await + .unwrap(), + 0 + ); + + let logs = fixture + .user1 + .ft_transfer( + fixture.poa_token_contract.id(), + fixture.user2.id(), + 40_000, + None, + ) + .await + .unwrap(); + + assert_eq!( + fixture + .poa_token_contract + .inner() + .ft_balance_of(fixture.user2.id()) + .await + .unwrap(), + 40_000 + ); + + assert!(!logs.logs().iter().any(|s| s.contains("ft_burn"))); + } + + // Burning tokens by using the special case and transferring to the smart contract address + { + assert_eq!( + fixture + .poa_token_contract + .inner() + .ft_balance_of(fixture.user2.id()) + .await + .unwrap(), + 40_000 + ); + + let total_supply_before_burn = fixture + .poa_token_contract + .inner() + .ft_total_supply(fixture.poa_token_contract.inner().id()) + .await + .unwrap(); + + let logs = fixture + .user2 + .ft_transfer( + fixture.poa_token_contract.id(), + fixture.poa_token_contract.id(), + 10_000, + Some(WITHDRAW_MEMO_PREFIX.to_owned()), + ) + .await + .unwrap(); + + // Assert that a burn event was emitted + assert!(logs.logs().iter().any(|s| s.contains("ft_burn"))); + assert!(logs.logs().iter().any(|s| { + s.replace(' ', "") + .contains(&"\"amount\":\"10000\"".to_string()) + })); + + let total_supply_after_burn = fixture + .poa_token_contract + .inner() + .ft_total_supply(fixture.poa_token_contract.id()) + .await + .unwrap(); + + // Supply went down by the burned amount + assert_eq!(total_supply_after_burn + 10000, total_supply_before_burn); + + assert_eq!( + fixture + .poa_token_contract + .inner() + .ft_balance_of(fixture.user2.id()) + .await + .unwrap(), + 30_000 + ); + } + + // Deploy wrapped near + let wnear_contract = fixture.sandbox.deploy_wrap_near("wnear").await.unwrap(); + + { + // No token wraps in PoA so far + assert!( + fixture + .poa_token_contract + .poa_wrapped_token() + .await + .unwrap() + .is_none() + ); + + // Attempt to deploy with the a non-owner + assert!( + fixture + .user1 + .poa_set_wrapped_token_account_id( + &fixture.poa_token_contract, + wnear_contract.id(), + NearToken::from_near(1) + ) + .await + .unwrap_err() + .to_string() + .contains("Method is private") + ); + + // Without locking, setting token wrapper won't work + assert!( + fixture + .poa_contract_owner + .poa_set_wrapped_token_account_id( + &fixture.poa_token_contract, + wnear_contract.id(), + NearToken::from_near(1) + ) + .await + .unwrap_err() + .to_string() + .contains("The contract must be locked first") + ); + + // Prepare the conditions for successful wrapping, but test a failed case + { + fixture + .poa_contract_owner + .poa_lock_contract_for_wrapping(fixture.poa_token_contract.id()) + .await + .unwrap(); + + // This will fail because the target contract we're wrapping, wnear, has no balance for the PoA contract. + assert!( + fixture + .poa_contract_owner + .poa_set_wrapped_token_account_id( + &fixture.poa_token_contract, + wnear_contract.id(), + NearToken::from_near(1) + ) + .await + .unwrap_err() + .to_string() + .contains("sufficient balance to cover") + ); + + fixture + .poa_contract_owner + .poa_unlock_contract_for_wrapping(fixture.poa_token_contract.id()) + .await + .unwrap(); + } + + // Fund wnear + fixture + .root + .near_deposit(wnear_contract.id(), NearToken::from_near(10)) + .await + .unwrap(); + + fixture + .root + .storage_deposit( + wnear_contract.id(), + Some(fixture.poa_token_contract.id()), + MIN_FT_STORAGE_DEPOSIT_VALUE, + ) + .await + .unwrap(); + + fixture + .root + .ft_transfer( + wnear_contract.id(), + fixture.poa_token_contract.id(), + 100_000, + None, + ) + .await + .unwrap(); + + { + fixture + .poa_contract_owner + .poa_lock_contract_for_wrapping(fixture.poa_token_contract.id()) + .await + .unwrap(); + + // This fails because the attached deposit is not enough to cover for storage + assert!( + fixture + .poa_contract_owner + .poa_set_wrapped_token_account_id( + &fixture.poa_token_contract, + wnear_contract.id(), + NearToken::from_yoctonear(1), + ) + .await + .unwrap_err() + .to_string() + .contains("Insufficient attached deposit") + ); + + // This succeeds, now + fixture + .poa_contract_owner + .poa_set_wrapped_token_account_id( + &fixture.poa_token_contract, + wnear_contract.id(), + NearToken::from_near(1), + ) + .await + .unwrap(); + + fixture + .poa_contract_owner + .poa_unlock_contract_for_wrapping(fixture.poa_token_contract.id()) + .await + .unwrap(); + } + + assert_eq!( + fixture + .poa_token_contract + .poa_wrapped_token() + .await + .unwrap() + .as_ref(), + Some(wnear_contract.id()) + ); + } + + // transfer from user1 to user2 should still work, even though it's wrapped + { + assert_eq!( + fixture + .poa_token_contract + .inner() + .ft_balance_of(fixture.user2.id()) + .await + .unwrap(), + 30_000 + ); + + let logs = fixture + .user1 + .ft_transfer( + fixture.poa_token_contract.id(), + fixture.user2.id(), + 5_000, + None, + ) + .await + .unwrap(); + + assert_eq!( + fixture + .poa_token_contract + .inner() + .ft_balance_of(fixture.user2.id()) + .await + .unwrap(), + 35_000 + ); + + assert!(!logs.logs().iter().any(|s| s.contains("ft_burn"))); + } + + // Burning tokens by using the special case and transferring to the smart contract address + { + assert!( + fixture + .user2 + .ft_transfer( + fixture.poa_token_contract.id(), + fixture.poa_token_contract.id(), + 10_000, + Some(WITHDRAW_MEMO_PREFIX.to_owned()), + ) + .await + .unwrap_err() + .to_string() + .contains("PoA token was migrated to OmniBridge") + ); + } + + // Deposit after wrapping should fail + { + assert!( + fixture + .poa_contract_owner + .poa_ft_deposit( + &fixture.poa_token_contract, + fixture.user1.id(), + 10_000.into(), + None, + ) + .await + .unwrap_err() + .to_string() + .contains("This PoA token was migrated to OmniBridge. No deposits are possible") + ); + } +} + +#[tokio::test] +async fn metadata_sync() { + let fixture = TransferFixture::new().await; + + // Unauthorized user attempts syncing + assert!( + fixture + .user1 + .poa_force_sync_wrapped_token_metadata( + &fixture.poa_token_contract, + NearToken::from_near(1) + ) + .await + .unwrap_err() + .to_string() + .contains("Method is private") + ); + + // Cannot sync metadata before wrapping + assert!( + fixture + .poa_contract_owner + .poa_force_sync_wrapped_token_metadata( + &fixture.poa_token_contract, + NearToken::from_near(1) + ) + .await + .unwrap_err() + .to_string() + .contains("This function is restricted to wrapped tokens") + ); + + // Deploy wrapped near + let wnear_contract = fixture.sandbox.deploy_wrap_near("wnear").await.unwrap(); + + // Wrap the PoA token + { + // No token wraps in PoA so far + assert!( + fixture + .poa_token_contract + .poa_wrapped_token() + .await + .unwrap() + .is_none() + ); + + // Fund wnear + fixture + .root + .near_deposit(wnear_contract.id(), NearToken::from_near(10)) + .await + .unwrap(); + + fixture + .root + .storage_deposit( + wnear_contract.id(), + Some(fixture.poa_token_contract.id()), + MIN_FT_STORAGE_DEPOSIT_VALUE, + ) + .await + .unwrap(); + + { + fixture + .poa_contract_owner + .poa_lock_contract_for_wrapping(fixture.poa_token_contract.id()) + .await + .unwrap(); + + fixture + .poa_contract_owner + .poa_set_wrapped_token_account_id( + &fixture.poa_token_contract, + wnear_contract.id(), + NearToken::from_near(1), + ) + .await + .unwrap(); + + fixture + .poa_contract_owner + .poa_unlock_contract_for_wrapping(fixture.poa_token_contract.id()) + .await + .unwrap(); + } + + assert_eq!( + fixture + .poa_token_contract + .poa_wrapped_token() + .await + .unwrap() + .as_ref(), + Some(wnear_contract.id()) + ); + } + + // Attempting to update metadata but with only 1 yocto, which is not enough for storage deposit, because we're adding more info + assert!( + fixture + .poa_contract_owner + .poa_force_sync_wrapped_token_metadata( + &fixture.poa_token_contract, + NearToken::from_yoctonear(1) + ) + .await + .unwrap_err() + .to_string() + .contains("Insufficient attached deposit") + ); + + let balance_before = fixture.near_balance(fixture.poa_token_contract.id()).await; + + // syncing metadata should work now with enough sufficient deposit + fixture + .poa_contract_owner + .poa_force_sync_wrapped_token_metadata(&fixture.poa_token_contract, NearToken::from_near(1)) + .await + .unwrap(); + + let balance_after = fixture.near_balance(fixture.poa_token_contract.id()).await; + + // Updating the metadata shouldn't consume more than 10 millinear + assert!( + balance_after + > balance_before + .checked_sub(NearToken::from_millinear(10)) + .unwrap() + ); + + // Check the metadata against the wrapped token + let source_metadata: FungibleTokenMetadata = wnear_contract + .call("ft_metadata") + .view() + .await + .unwrap() + .json() + .unwrap(); + + let new_metadata = fixture.poa_token_contract.poa_ft_metadata().await.unwrap(); + assert_eq!(new_metadata.symbol, format!("w{}", source_metadata.symbol)); + assert_eq!( + new_metadata.name, + format!("Wrapped {}", source_metadata.name), + ); + + // Attempting to redo synchronization is OK + fixture + .poa_contract_owner + .poa_force_sync_wrapped_token_metadata( + &fixture.poa_token_contract, + NearToken::from_yoctonear(1), + ) // only 1 yocto because we're not changing anything + .await + .unwrap(); +} diff --git a/tests/src/tests/poa/token/transfer_call.rs b/tests/src/tests/poa/token/transfer_call.rs new file mode 100644 index 00000000..6d7424ca --- /dev/null +++ b/tests/src/tests/poa/token/transfer_call.rs @@ -0,0 +1,1050 @@ +use super::token_env::{ + MIN_FT_STORAGE_DEPOSIT_VALUE, PoATokenContract, PoATokenContractCaller, PoATokenExt, +}; +use crate::utils::{Sandbox, ft::FtExt, storage_management::StorageManagementExt}; +use defuse_poa_token::UNWRAP_PREFIX; +use near_sdk::NearToken; +use near_workspaces::Account; +use rstest::rstest; +use test_utils::random::{Seed, make_random_string, make_seedable_rng, random_seed}; + +struct TransferCallFixture { + #[allow(dead_code)] + sandbox: Sandbox, + #[allow(dead_code)] + root: Account, + user1: Account, + user2: Account, + + // L3.near: L3 (Omni, wraps L2, deposits are enabled) + // | + // v + // L1_2.near: L2_1 (Omni, wraps L1, deposits are enabled) + // | + // v + // PoA Token (L1) + // ^ + // | + // L2_2.near: L2_2 (Omni, wraps L1, deposits are enabled) + + // l1 -> Level 1 -> Doesn't wrap anything + // L1 + poa_l1_contract_owner: Account, + poa_l1_token_contract: PoATokenContract, + + // l2_1 -> Level 2 -> Wraps L1 + // L2_1 + poa_l2_1_contract_owner: Account, + poa_l2_1_token_contract: PoATokenContract, + + // l2_2 -> Level 2 -> Wraps L1 + // L2_2 + poa_l2_2_contract_owner: Account, + poa_l2_2_token_contract: PoATokenContract, + + // l3 -> Level 3 -> Wraps L2_1 + // L3 + poa_l3_contract_owner: Account, + poa_l3_token_contract: PoATokenContract, +} + +impl TransferCallFixture { + async fn new() -> Self { + let sandbox = Sandbox::new().await.unwrap(); + let root = sandbox.root_account().clone(); + let user1 = sandbox.create_account("user1").await; + let user2 = sandbox.create_account("user2").await; + let poa_l1_contract_owner = sandbox.create_account("owner").await; + let poa_l1_token_contract: PoATokenContract = root + .deploy_poa_token("poa_token", Some(poa_l1_contract_owner.id()), None) + .await + .unwrap(); + + let poa_l2_1_contract_owner = sandbox.create_account("owner2_1").await; + let poa_l2_1_token_contract: PoATokenContract = root + .deploy_poa_token("poa_token2_1", Some(poa_l2_1_contract_owner.id()), None) + .await + .unwrap(); + + let poa_l2_2_contract_owner = sandbox.create_account("owner2_2").await; + let poa_l2_2_token_contract: PoATokenContract = root + .deploy_poa_token("poa_token2_2", Some(poa_l2_2_contract_owner.id()), None) + .await + .unwrap(); + + let poa_l3_contract_owner = sandbox.create_account("owner3").await; + let poa_l3_token_contract: PoATokenContract = root + .deploy_poa_token("poa_token3", Some(poa_l3_contract_owner.id()), None) + .await + .unwrap(); + + // Storage deposit for involved users, to deposit tokens into his account + { + root.storage_deposit( + poa_l1_token_contract.id(), + Some(user1.id()), + MIN_FT_STORAGE_DEPOSIT_VALUE, + ) + .await + .unwrap(); + root.storage_deposit( + poa_l1_token_contract.id(), + Some(user2.id()), + MIN_FT_STORAGE_DEPOSIT_VALUE, + ) + .await + .unwrap(); + root.storage_deposit( + poa_l1_token_contract.id(), + Some(poa_l2_1_token_contract.id()), + MIN_FT_STORAGE_DEPOSIT_VALUE, + ) + .await + .unwrap(); + + root.storage_deposit( + poa_l2_1_token_contract.id(), + Some(user1.id()), + MIN_FT_STORAGE_DEPOSIT_VALUE, + ) + .await + .unwrap(); + root.storage_deposit( + poa_l2_1_token_contract.id(), + Some(user2.id()), + MIN_FT_STORAGE_DEPOSIT_VALUE, + ) + .await + .unwrap(); + root.storage_deposit( + poa_l2_1_token_contract.id(), + Some(poa_l3_token_contract.id()), + MIN_FT_STORAGE_DEPOSIT_VALUE, + ) + .await + .unwrap(); + + root.storage_deposit( + poa_l3_token_contract.id(), + Some(user1.id()), + MIN_FT_STORAGE_DEPOSIT_VALUE, + ) + .await + .unwrap(); + root.storage_deposit( + poa_l3_token_contract.id(), + Some(user2.id()), + MIN_FT_STORAGE_DEPOSIT_VALUE, + ) + .await + .unwrap(); + + root.storage_deposit( + poa_l2_1_token_contract.id(), + Some(poa_l2_2_token_contract.id()), + MIN_FT_STORAGE_DEPOSIT_VALUE, + ) + .await + .unwrap(); + root.storage_deposit( + poa_l2_2_token_contract.id(), + Some(poa_l2_1_token_contract.id()), + MIN_FT_STORAGE_DEPOSIT_VALUE, + ) + .await + .unwrap(); + } + + Self { + sandbox, + root, + user1, + user2, + poa_l1_contract_owner, + poa_l1_token_contract, + poa_l2_1_contract_owner, + poa_l2_1_token_contract, + poa_l2_2_contract_owner, + poa_l2_2_token_contract, + poa_l3_contract_owner, + poa_l3_token_contract, + } + } +} + +#[tokio::test] +#[rstest] +#[trace] +async fn transfer_and_call(random_seed: Seed) { + let mut rng = make_seedable_rng(random_seed); + + let fixture = TransferCallFixture::new().await; + + // fund user1 with deposit + { + fixture + .poa_l1_contract_owner + .poa_ft_deposit( + &fixture.poa_l1_token_contract, + fixture.user1.id(), + 100_000.into(), + None, + ) + .await + .unwrap(); + + assert_eq!( + fixture + .poa_l1_token_contract + .inner() + .ft_balance_of(fixture.user1.id()) + .await + .unwrap(), + 100_000 + ); + } + + // fund user1 with deposit + { + fixture + .poa_l2_1_contract_owner + .poa_ft_deposit( + &fixture.poa_l2_1_token_contract, + fixture.user1.id(), + 10_000.into(), + None, + ) + .await + .unwrap(); + + assert_eq!( + fixture + .poa_l2_1_token_contract + .inner() + .ft_balance_of(fixture.user1.id()) + .await + .unwrap(), + 10_000 + ); + + fixture + .poa_l1_contract_owner + .poa_ft_deposit( + &fixture.poa_l1_token_contract, + fixture.poa_l2_1_token_contract.id(), + 10_000.into(), + None, + ) + .await + .unwrap(); + + fixture + .poa_l2_2_contract_owner + .poa_ft_deposit( + &fixture.poa_l2_2_token_contract, + fixture.user1.id(), + 5_000.into(), + None, + ) + .await + .unwrap(); + + fixture + .poa_l1_contract_owner + .poa_ft_deposit( + &fixture.poa_l1_token_contract, + fixture.poa_l2_2_token_contract.id(), + 5_000.into(), + None, + ) + .await + .unwrap(); + } + + // Make the L2_1 PoA token a wrap of the L1 contract + { + // No token wraps in PoA so far + assert!( + fixture + .poa_l2_1_token_contract + .poa_wrapped_token() + .await + .unwrap() + .is_none() + ); + + { + fixture + .poa_l2_1_contract_owner + .poa_lock_contract_for_wrapping(fixture.poa_l2_1_token_contract.id()) + .await + .unwrap(); + + fixture + .poa_l2_1_contract_owner + .poa_set_wrapped_token_account_id( + &fixture.poa_l2_1_token_contract, + fixture.poa_l1_token_contract.id(), + NearToken::from_near(1), + ) + .await + .unwrap(); + + fixture + .poa_l2_1_contract_owner + .poa_unlock_contract_for_wrapping(fixture.poa_l2_1_token_contract.id()) + .await + .unwrap(); + } + + assert_eq!( + fixture + .poa_l2_1_token_contract + .poa_wrapped_token() + .await + .unwrap() + .as_ref(), + Some(fixture.poa_l1_token_contract.id()) + ); + } + + // Make the L2_2 PoA token a wrap of the L1 contract + { + // No token wraps in PoA so far + assert!( + fixture + .poa_l2_2_token_contract + .poa_wrapped_token() + .await + .unwrap() + .is_none() + ); + + { + fixture + .poa_l2_2_contract_owner + .poa_lock_contract_for_wrapping(fixture.poa_l2_2_token_contract.id()) + .await + .unwrap(); + + fixture + .poa_l2_2_contract_owner + .poa_set_wrapped_token_account_id( + &fixture.poa_l2_2_token_contract, + fixture.poa_l1_token_contract.id(), + NearToken::from_near(1), + ) + .await + .unwrap(); + + fixture + .poa_l2_2_contract_owner + .poa_unlock_contract_for_wrapping(fixture.poa_l2_2_token_contract.id()) + .await + .unwrap(); + } + + assert_eq!( + fixture + .poa_l2_2_token_contract + .poa_wrapped_token() + .await + .unwrap() + .as_ref(), + Some(fixture.poa_l1_token_contract.id()) + ); + } + + // Make the L3 PoA token a wrap of L2 + { + // No token wraps in PoA so far + assert!( + fixture + .poa_l3_token_contract + .poa_wrapped_token() + .await + .unwrap() + .is_none() + ); + + { + assert!( + !fixture + .poa_l3_token_contract + .poa_is_contract_locked_for_wrapping() + .await + .unwrap() + ); + + fixture + .poa_l3_contract_owner + .poa_lock_contract_for_wrapping(fixture.poa_l3_token_contract.id()) + .await + .unwrap(); + + assert!( + fixture + .poa_l3_token_contract + .poa_is_contract_locked_for_wrapping() + .await + .unwrap() + ); + + fixture + .poa_l3_contract_owner + .poa_set_wrapped_token_account_id( + &fixture.poa_l3_token_contract, + fixture.poa_l2_1_token_contract.id(), + NearToken::from_near(1), + ) + .await + .unwrap(); + + fixture + .poa_l3_contract_owner + .poa_unlock_contract_for_wrapping(fixture.poa_l3_token_contract.id()) + .await + .unwrap(); + + assert!( + !fixture + .poa_l3_token_contract + .poa_is_contract_locked_for_wrapping() + .await + .unwrap() + ); + } + + assert_eq!( + fixture + .poa_l3_token_contract + .poa_wrapped_token() + .await + .unwrap() + .as_ref(), + Some(fixture.poa_l2_1_token_contract.id()) + ); + } + + // Testing ft_on_transfer + // Transferring to another account/contract (on L1 poa token contract, which is unwrapped) does a simple ft_transfer_call in the inner token + // `msg` is empty. The sender should receive the balance (based on ft_on_transfer in the L2 contract). + { + // Balance before + assert_eq!( + fixture + .poa_l1_token_contract + .inner() + .ft_balance_of(fixture.poa_l2_1_token_contract.id()) + .await + .unwrap(), + 10_000 + ); + + // Transfer + fixture + .user1 + .ft_transfer_call( + fixture.poa_l1_token_contract.id(), + fixture.poa_l2_1_token_contract.id(), + 10_000, + None, + "", + ) + .await + .unwrap(); + + // Balance after + assert_eq!( + fixture + .poa_l1_token_contract + .inner() + .ft_balance_of(fixture.poa_l2_1_token_contract.id()) + .await + .unwrap(), + 20_000 + ); + } + + // Testing ft_on_transfer + // Transferring to another account/contract (on L1 poa token contract, which is unwrapped) to L2 contract, does a simple ft_transfer_call in the inner token + // `msg` has user2 id. They should receive that balance in the L2 contract (based on ft_on_transfer in the L2 contract). + { + // Balance before (L2 contract's balance in L1's) + assert_eq!( + fixture + .poa_l1_token_contract + .inner() + .ft_balance_of(fixture.poa_l2_1_token_contract.id()) + .await + .unwrap(), + 20_000 + ); + + // Balance before (user2 balance in L2 contract) + assert_eq!( + fixture + .poa_l2_1_token_contract + .inner() + .ft_balance_of(fixture.user2.id()) + .await + .unwrap(), + 0 + ); + + // Transfer + fixture + .user1 + .ft_transfer_call( + fixture.poa_l1_token_contract.id(), + fixture.poa_l2_1_token_contract.id(), + 5_000, + None, + fixture.user2.id().as_ref(), + ) + .await + .unwrap(); + + // Balance after (L2 contract's balance in L1's) + assert_eq!( + fixture + .poa_l1_token_contract + .inner() + .ft_balance_of(fixture.poa_l2_1_token_contract.id()) + .await + .unwrap(), + 25_000 + ); + + // Balance after (user2 balance in L2 contract) + assert_eq!( + fixture + .poa_l2_1_token_contract + .inner() + .ft_balance_of(fixture.user2.id()) + .await + .unwrap(), + 5_000 + ); + } + + // Testing ft_transfer_call + // On a contract with a wrapped token (L2 contract), if the receiver is NOT the contract account id, it will still use the inner token's transfer function + // which will call ft_on_transfer on the L3 poa token contract with the same message, giving the funds to user2, the sender, because it's an empty message + { + // Balance before + assert_eq!( + fixture + .poa_l3_token_contract + .inner() + .ft_balance_of(fixture.user2.id()) + .await + .unwrap(), + 0 + ); + + // Transfer + fixture + .user2 + .ft_transfer_call( + fixture.poa_l2_1_token_contract.id(), + fixture.poa_l3_token_contract.id(), + 200, + None, + "", + ) + .await + .unwrap(); + + // Balance after + assert_eq!( + fixture + .poa_l3_token_contract + .inner() + .ft_balance_of(fixture.user2.id()) + .await + .unwrap(), + 200 + ); + } + + // Testing ft_transfer_call + // On a contract with a wrapped token (L2 contract), if the receiver is NOT the contract account id, it will still use the inner token's transfer function + // which will call ft_on_transfer on the L3 poa token contract with the same message, giving the funds to user1, because user1 is specified there + { + // Balance before + assert_eq!( + fixture + .poa_l3_token_contract + .inner() + .ft_balance_of(fixture.user1.id()) + .await + .unwrap(), + 0 + ); + + // Transfer + fixture + .user2 + .ft_transfer_call( + fixture.poa_l2_1_token_contract.id(), + fixture.poa_l3_token_contract.id(), + 300, + None, + fixture.user1.id().as_ref(), + ) + .await + .unwrap(); + + // Balance after + assert_eq!( + fixture + .poa_l3_token_contract + .inner() + .ft_balance_of(fixture.user1.id()) + .await + .unwrap(), + 300 + ); + } + + // Testing ft_transfer_call + // Using a random message will lead to a NO-OP + { + let msg = make_random_string(&mut rng, 30); + + // Balance before + assert_eq!( + fixture + .poa_l2_1_token_contract + .inner() + .ft_balance_of(fixture.user1.id()) + .await + .unwrap(), + 20000 + ); + + assert_eq!( + fixture + .poa_l2_1_token_contract + .inner() + .ft_balance_of(fixture.user2.id()) + .await + .unwrap(), + 4500 + ); + + // Transfer + fixture + .user2 + .ft_transfer_call( + fixture.poa_l2_1_token_contract.id(), + fixture.user1.id(), + 500, + None, + &msg, + ) + .await + .unwrap(); + + // Balance after + assert_eq!( + fixture + .poa_l2_1_token_contract + .inner() + .ft_balance_of(fixture.user1.id()) + .await + .unwrap(), + 20000 + ); + + assert_eq!( + fixture + .poa_l2_1_token_contract + .inner() + .ft_balance_of(fixture.user2.id()) + .await + .unwrap(), + 4500 + ); + } + + // Testing ft_transfer_call + // Using the contract's address as destination + a message with the unwrap prefix + an invalid address will panic + { + // Transfer + fixture + .user2 + .ft_transfer_call( + fixture.poa_l2_1_token_contract.id(), + fixture.poa_l2_1_token_contract.id(), + 500, + None, + &format!("{UNWRAP_PREFIX}HELLO_WORLD"), + ) + .await + .unwrap_err() + .to_string() + .contains("Invalid account id provided in msg"); + } + + // Testing ft_transfer_call + // Using the contract's address as destination + a message with the unwrap prefix + a valid address in the form UNWRAP_TO:receiver.near + { + let msg = format!("{UNWRAP_PREFIX}{}", fixture.user2.id()); + + // Balance before + assert_eq!( + fixture + .poa_l2_1_token_contract + .inner() + .ft_balance_of(fixture.user1.id()) + .await + .unwrap(), + 20000 + ); + + assert_eq!( + fixture + .poa_l2_1_token_contract + .inner() + .ft_balance_of(fixture.user2.id()) + .await + .unwrap(), + 4500 + ); + + // user2 balance in L1 contract + assert_eq!( + fixture + .poa_l1_token_contract + .inner() + .ft_balance_of(fixture.user2.id()) + .await + .unwrap(), + 0 + ); + + // Transfer + fixture + .user1 + .ft_transfer_call( + fixture.poa_l2_1_token_contract.id(), + fixture.poa_l2_1_token_contract.id(), + 500, + None, + &msg, + ) + .await + .unwrap(); + + // Balance after + assert_eq!( + fixture + .poa_l2_1_token_contract + .inner() + .ft_balance_of(fixture.user1.id()) + .await + .unwrap(), + 19500 + ); + + assert_eq!( + fixture + .poa_l2_1_token_contract + .inner() + .ft_balance_of(fixture.user2.id()) + .await + .unwrap(), + 4500 + ); + + // Balance of user2 in L1's contract + assert_eq!( + fixture + .poa_l1_token_contract + .inner() + .ft_balance_of(fixture.user2.id()) + .await + .unwrap(), + 500 + ); + } + + // Testing ft_transfer_call + // Using the contract's address as destination + a message with the unwrap prefix + a valid address in the form UNWRAP_TO:L2_1.near:UNWRAP_TO:user2 + // Call is done by user1. + // This will unwrap from L3 into L2, which in turn will unwrap into L1 with a simple ft_transfer + { + let msg = format!( + "{UNWRAP_PREFIX}{}:{UNWRAP_PREFIX}{}", + fixture.poa_l2_1_token_contract.id(), + fixture.user2.id() + ); + + // Balance before + assert_eq!( + fixture + .poa_l3_token_contract + .inner() + .ft_balance_of(fixture.user1.id()) + .await + .unwrap(), + 300 + ); + + // Balance in L1, which will be receiving the tokens after unwrapping twice + assert_eq!( + fixture + .poa_l1_token_contract + .inner() + .ft_balance_of(fixture.user2.id()) + .await + .unwrap(), + 500 + ); + + // Transfer + fixture + .user1 + .ft_transfer_call( + fixture.poa_l3_token_contract.id(), + fixture.poa_l3_token_contract.id(), + 120, + None, + &msg, + ) + .await + .unwrap(); + + // Balance after + assert_eq!( + fixture + .poa_l3_token_contract + .inner() + .ft_balance_of(fixture.user1.id()) + .await + .unwrap(), + 180 + ); + + // Balance of user1 in L1 contract, which we unwrapped to + assert_eq!( + fixture + .poa_l1_token_contract + .inner() + .ft_balance_of(fixture.user2.id()) + .await + .unwrap(), + 620 + ); + } + + // Testing ft_transfer_call + // Using the contract's address as destination + a message with the unwrap prefix + a valid address in the form UNWRAP_TO:L2_2.near:user1 + // Call is done by user1. + // This will unwrap from L3 into L2, which in turn will unwrap into L1 with a simple ft_transfer + // + // user1: L2_1.near::ft_transfer_call({ + // "receiver_id": "L2_1.near", + // "amount": "1234", + // "memo": null, + // "msg": "UNWRAP_TO:L2_2.near:user1.near" + // }) + // result: L2_2.near::ft_balance_of("user1.near") == "1234" + { + let msg = format!( + "{UNWRAP_PREFIX}{}:{}", + fixture.poa_l2_2_token_contract.id(), + fixture.user1.id() + ); + + // Balance before + assert_eq!( + fixture + .poa_l2_1_token_contract + .inner() + .ft_balance_of(fixture.user1.id()) + .await + .unwrap(), + 19500 + ); + + assert_eq!( + fixture + .poa_l2_2_token_contract + .inner() + .ft_balance_of(fixture.user1.id()) + .await + .unwrap(), + 5000 + ); + + // Transfer + fixture + .user1 + .ft_transfer_call( + fixture.poa_l2_1_token_contract.id(), + fixture.poa_l2_1_token_contract.id(), + 500, + None, + &msg, + ) + .await + .unwrap(); + + // Balance after + assert_eq!( + fixture + .poa_l2_1_token_contract + .inner() + .ft_balance_of(fixture.user1.id()) + .await + .unwrap(), + 19000 + ); + + assert_eq!( + fixture + .poa_l2_2_token_contract + .inner() + .ft_balance_of(fixture.user1.id()) + .await + .unwrap(), + 5500 + ); + } + + // user1: L2_2.near::ft_transfer_call({ + // "receiver_id": "L2_2.near", + // "amount": "1234", + // "memo": "abcd", + // "msg": "UNWRAP_TO:L2_1.near:" + // }) + // result: L2_1.near::ft_balance_of("L2_2.near") == "1234" + // + // Testing ft_transfer_call + { + let msg = format!("{UNWRAP_PREFIX}{}:", fixture.poa_l2_1_token_contract.id()); + + // Balance before + assert_eq!( + fixture + .poa_l2_2_token_contract + .inner() + .ft_balance_of(fixture.user1.id()) + .await + .unwrap(), + 5500 + ); + + assert_eq!( + fixture + .poa_l2_1_token_contract + .inner() + .ft_balance_of(fixture.poa_l2_2_token_contract.id()) + .await + .unwrap(), + 0 + ); + + fixture + .user1 + .ft_transfer_call( + fixture.poa_l2_2_token_contract.id(), + fixture.poa_l2_2_token_contract.id(), + 1000, + None, + &msg, + ) + .await + .unwrap(); + + assert_eq!( + fixture + .poa_l2_2_token_contract + .inner() + .ft_balance_of(fixture.user1.id()) + .await + .unwrap(), + 4500 + ); + + assert_eq!( + fixture + .poa_l2_1_token_contract + .inner() + .ft_balance_of(fixture.poa_l2_2_token_contract.id()) + .await + .unwrap(), + 1000 + ); + } + + // user1: L2_2.near::ft_transfer_call({ + // "receiver_id": "L2_2.near", + // "amount": "1234", + // "memo": "abcd", + // "msg": "UNWRAP_TO:user1.near" + // }) + // result: L1::ft_balance_of("user1.near") == "1234" + // + // Testing ft_transfer_call + { + let msg = format!("{UNWRAP_PREFIX}{}", fixture.user1.id()); + + // Balance before + assert_eq!( + fixture + .poa_l2_1_token_contract + .inner() + .ft_balance_of(fixture.user1.id()) + .await + .unwrap(), + 19000 + ); + + assert_eq!( + fixture + .poa_l1_token_contract + .inner() + .ft_balance_of(fixture.user1.id()) + .await + .unwrap(), + 85000 + ); + + fixture + .user1 + .ft_transfer_call( + fixture.poa_l2_1_token_contract.id(), + fixture.poa_l2_1_token_contract.id(), + 100, + None, + &msg, + ) + .await + .unwrap(); + + // Balance after + assert_eq!( + fixture + .poa_l2_1_token_contract + .inner() + .ft_balance_of(fixture.user1.id()) + .await + .unwrap(), + 18900 + ); + + assert_eq!( + fixture + .poa_l1_token_contract + .inner() + .ft_balance_of(fixture.user1.id()) + .await + .unwrap(), + 85100 + ); + } +} diff --git a/tests/src/tests/poa/token/upgrade.rs b/tests/src/tests/poa/token/upgrade.rs new file mode 100644 index 00000000..5f4f5da1 --- /dev/null +++ b/tests/src/tests/poa/token/upgrade.rs @@ -0,0 +1,96 @@ +use near_contract_standards::fungible_token::metadata::FungibleTokenMetadata; +use near_sdk::{AccountId, AccountIdRef}; +use near_workspaces::{Account, Contract}; + +use crate::utils::{Sandbox, account::AccountExt}; + +use super::token_env::POA_TOKEN_WASM; + +const UNVERSIONED_POA_CONTRACT_WASM_BYTES: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/old-artifacts/unversioned-poa/defuse_poa_token.wasm" +)); + +struct UpgradeFixture { + sandbox: Sandbox, + root: Account, +} + +impl UpgradeFixture { + async fn new() -> Self { + let sandbox = Sandbox::new().await.unwrap(); + let root = sandbox.root_account().clone(); + + Self { sandbox, root } + } + + async fn deploy_unversioned_poa_token( + &self, + id: &str, + owner_id: Option<&AccountIdRef>, + metadata: Option, + ) -> anyhow::Result { + let contract = self + .root + .deploy_contract(id, UNVERSIONED_POA_CONTRACT_WASM_BYTES) + .await?; + + let mut json_args = serde_json::Map::new(); + if let Some(oid) = owner_id { + json_args.insert("owner_id".to_string(), serde_json::to_value(oid).unwrap()); + } + if let Some(md) = metadata { + json_args.insert("metadata".to_string(), serde_json::to_value(md).unwrap()); + } + + contract + .call("new") + .args_json(json_args) + .max_gas() + .transact() + .await? + .into_result()?; + Ok(contract) + } + + async fn upgrade_to_new_poa_token( + &self, + id_to_deploy_at: &Account, + ) -> anyhow::Result { + let contract = id_to_deploy_at.deploy(POA_TOKEN_WASM).await?.unwrap(); + + contract + .call("upgrade_to_versioned") + .args_json(serde_json::Map::new()) + .max_gas() + .transact() + .await? + .into_result()?; + Ok(contract) + } +} + +#[tokio::test] +async fn upgrade_to_versioned() { + let fixture = UpgradeFixture::new().await; + let poa_contract_owner = fixture.sandbox.create_account("owner").await; + let unversioned_poa_contract = fixture + .deploy_unversioned_poa_token("old-poa-token", Some(poa_contract_owner.id()), None) + .await + .unwrap(); + + let new_contract = fixture + .upgrade_to_new_poa_token(unversioned_poa_contract.as_account()) + .await + .unwrap(); + + let wrapped_token: Option = new_contract + .call("wrapped_token") + .view() + .await + .unwrap() + .json() + .unwrap(); + + assert!(wrapped_token.is_none()); +} diff --git a/tests/src/utils/ft.rs b/tests/src/utils/ft.rs index 92212c0c..f6af99bd 100644 --- a/tests/src/utils/ft.rs +++ b/tests/src/utils/ft.rs @@ -4,8 +4,8 @@ use near_workspaces::types::NearToken; use near_workspaces::{Account, AccountId, Contract}; use serde_json::json; -use super::account::AccountExt; use super::storage_management::StorageManagementExt; +use super::test_logs::TestLog; pub const FT_STORAGE_DEPOSIT: NearToken = NearToken::from_yoctonear(2_350_000_000_000_000_000_000); const TOTAL_SUPPLY: u128 = 1_000_000_000; @@ -16,14 +16,14 @@ const FUNGIBLE_TOKEN_WASM: &[u8] = include_bytes!(concat!( )); pub trait FtExt: StorageManagementExt { - async fn deploy_vanilla_ft_token(&self, token_name: &str) -> anyhow::Result; - async fn ft_token_balance_of( &self, token_id: &AccountId, account_id: &AccountId, ) -> anyhow::Result; + async fn ft_total_supply(&self, token_id: &AccountId) -> anyhow::Result; + async fn ft_balance_of(&self, account_id: &AccountId) -> anyhow::Result; async fn ft_transfer( @@ -32,7 +32,7 @@ pub trait FtExt: StorageManagementExt { receiver_id: &AccountId, amount: u128, memo: Option, - ) -> anyhow::Result<()>; + ) -> anyhow::Result; async fn ft_transfer_call( &self, @@ -59,30 +59,6 @@ pub trait FtExt: StorageManagementExt { } impl FtExt for Account { - async fn deploy_vanilla_ft_token(&self, token_name: &str) -> anyhow::Result { - let contract = self - .deploy_contract(token_name, FUNGIBLE_TOKEN_WASM) - .await?; - contract - .call("new") - .args_json(json!({ - "owner_id": self.id(), - "total_supply": TOTAL_SUPPLY.to_string(), - "metadata": { - "spec": "ft-1.0.0", - "name": format!("Token {}", token_name), - "symbol": "TKN", - "decimals": 18 - } - })) - .max_gas() - .transact() - .await? - .into_result()?; - - Ok(contract) - } - async fn ft_token_balance_of( &self, token_id: &AccountId, @@ -98,6 +74,14 @@ impl FtExt for Account { .map_err(Into::into) } + async fn ft_total_supply(&self, token_id: &AccountId) -> anyhow::Result { + self.view(token_id, "ft_total_supply") + .await? + .json::() + .map(|v| v.0) + .map_err(Into::into) + } + async fn ft_balance_of(&self, account_id: &AccountId) -> anyhow::Result { self.ft_token_balance_of(self.id(), account_id).await } @@ -108,8 +92,9 @@ impl FtExt for Account { receiver_id: &AccountId, amount: u128, memo: Option, - ) -> anyhow::Result<()> { - self.call(token_id, "ft_transfer") + ) -> anyhow::Result { + let outcome = self + .call(token_id, "ft_transfer") .args_json(json!({ "receiver_id": receiver_id, "amount": U128(amount), @@ -120,7 +105,8 @@ impl FtExt for Account { .transact() .await? .into_result()?; - Ok(()) + + Ok(outcome.into()) } async fn ft_transfer_call( @@ -145,10 +131,11 @@ impl FtExt for Account { .into_result() .inspect(|outcome| { println!( - "ft_transfer_call: total_gas_burnt: {}, logs: {:#?}", + "ft_transfer_call: total_gas_burnt: {}", outcome.total_gas_burnt, - outcome.logs() ); + let test_log: TestLog = outcome.clone().into(); + println!("Inner logs: {test_log:#?}"); })? .json::() .map(|v| v.0) @@ -168,10 +155,6 @@ impl FtExt for Account { } impl FtExt for Contract { - async fn deploy_vanilla_ft_token(&self, token_name: &str) -> anyhow::Result { - self.as_account().deploy_vanilla_ft_token(token_name).await - } - async fn ft_token_balance_of( &self, token_id: &AccountId, @@ -186,13 +169,17 @@ impl FtExt for Contract { self.as_account().ft_balance_of(account_id).await } + async fn ft_total_supply(&self, token_id: &AccountId) -> anyhow::Result { + self.as_account().ft_total_supply(token_id).await + } + async fn ft_transfer( &self, token_id: &AccountId, receiver_id: &AccountId, amount: u128, memo: Option, - ) -> anyhow::Result<()> { + ) -> anyhow::Result { self.as_account() .ft_transfer(token_id, receiver_id, amount, memo) .await diff --git a/tests/src/utils/mod.rs b/tests/src/utils/mod.rs index 7a92bf61..fa1ce5af 100644 --- a/tests/src/utils/mod.rs +++ b/tests/src/utils/mod.rs @@ -9,6 +9,7 @@ pub mod native; pub mod nft; mod sandbox; pub mod storage_management; +pub mod test_logs; pub mod wnear; pub use sandbox::*; diff --git a/tests/src/utils/sandbox.rs b/tests/src/utils/sandbox.rs index 03ee7665..83c57f8c 100644 --- a/tests/src/utils/sandbox.rs +++ b/tests/src/utils/sandbox.rs @@ -9,6 +9,7 @@ pub fn read_wasm(name: impl AsRef) -> Vec { .with_extension("wasm"); fs::read(filename).unwrap() } + pub struct Sandbox { worker: Worker, root_account: Account, diff --git a/tests/src/utils/test_logs.rs b/tests/src/utils/test_logs.rs new file mode 100644 index 00000000..e3f5c16a --- /dev/null +++ b/tests/src/utils/test_logs.rs @@ -0,0 +1,36 @@ +use near_workspaces::result::ExecutionResult; + +#[allow(dead_code)] +#[derive(Debug)] +pub struct TestLog { + logs: Vec, + receipt_failure_errors: Vec, +} + +impl From> for TestLog { + fn from(outcome: ExecutionResult) -> Self { + Self { + logs: outcome.logs().into_iter().map(str::to_string).collect(), + receipt_failure_errors: outcome + .receipt_outcomes() + .iter() + .map(|s| { + if let Err(e) = (*s).clone().into_result() { + match e.into_inner() { + Ok(o) => format!("OK: {o}"), + Err(e) => format!("Err: {e}"), + } + } else { + String::new() + } + }) + .collect::>(), + } + } +} + +impl TestLog { + pub fn logs(&self) -> &[String] { + &self.logs + } +}