From 9e41a70a15aa63a6002fa03e388a7071986764b9 Mon Sep 17 00:00:00 2001 From: Robert Zaremba Date: Mon, 30 Oct 2023 18:25:20 +0100 Subject: [PATCH 1/7] implement is_human_call_lock --- contracts/registry/src/events.rs | 10 ++++- contracts/registry/src/lib.rs | 71 ++++++++++++++++++++++++++++++- contracts/registry/src/storage.rs | 13 ++++++ 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/contracts/registry/src/events.rs b/contracts/registry/src/events.rs index cb72fe3..25b41fb 100644 --- a/contracts/registry/src/events.rs +++ b/contracts/registry/src/events.rs @@ -1,4 +1,4 @@ -use near_sdk::{serde::Serialize, AccountId}; +use near_sdk::{serde::Serialize, serde_json::json, AccountId}; use sbt::{EventPayload, NearEvent}; use crate::storage::AccountFlag; @@ -31,6 +31,14 @@ pub(crate) fn emit_iah_unflag_accounts(accounts: Vec) { }); } +/// `locked_until`: time in milliseconds until when the new account lock is established. +pub(crate) fn emit_transfer_lock(account: AccountId, locked_until: u64) { + emit_iah_event(EventPayload { + event: "transfer_lock", + data: json!({ "account": account, "locked_until": locked_until}), + }); +} + #[cfg(test)] mod tests { use near_sdk::test_utils; diff --git a/contracts/registry/src/lib.rs b/contracts/registry/src/lib.rs index 4594b37..7f8d59d 100644 --- a/contracts/registry/src/lib.rs +++ b/contracts/registry/src/lib.rs @@ -28,6 +28,10 @@ pub struct Contract { /// store ongoing soul transfers by "old owner" pub(crate) ongoing_soul_tx: LookupMap, + /// map accounts -> unix timestamp in milliseconds until when any transfer is blocked + /// for the given account. + pub(crate) transfer_lock: LookupMap, + /// registry of banned accounts created through `Nep393Event::Ban` (eg: soul transfer). pub(crate) banlist: UnorderedSet, /// Map of accounts that are marked by a committee to have a special status (eg: blacklist, @@ -74,6 +78,7 @@ impl Contract { authority, sbt_issuers: UnorderedMap::new(StorageKey::SbtIssuers), issuer_id_map: LookupMap::new(StorageKey::SbtIssuersRev), + transfer_lock: LookupMap::new(StorageKey::TransferLock), banlist: UnorderedSet::new(StorageKey::Banlist), supply_by_owner: LookupMap::new(StorageKey::SupplyByOwner), supply_by_class: LookupMap::new(StorageKey::SupplyByClass), @@ -316,7 +321,7 @@ impl Contract { (token_counter as u32, completed) } - /// Checks if the `predecessor_account_id` is human. If yes, then calls: + /// Checks if the `predecessor_account_id` is a human. If yes, then calls: /// /// ctr.function({caller: predecessor_account_id(), /// iah_proof: SBTs, @@ -344,6 +349,70 @@ impl Contract { ) } + /// Apps should use this function to ask a user to lock his account for soul transfer. + /// This is useful when a dapp relays on user account ID (rather set of potential SBTs) + /// being a unique human over a period of time (there is no soul transfer in between). + /// Example use cases: voting, staking, games. + /// Dapps should make it clear that they extend user lock for a given amount of time. + /// Parameters are similar to `is_human_call`: + /// * `ctr` and `function`: the contract function we will call if and only if the caller + /// has a valid humanity proof. + /// + /// ctr.function({caller: predecessor_account_id(), + /// duration: u64, + /// iah_proof: SBTs, + /// locked_until, + /// payload: payload}) + /// + /// Note the additional arguments provided to the recipient function call, that are not + /// present in `is_human_call`: + /// - `locked_until`: time in milliseconds until when the account is locked for soul + /// transfers. It may be bigger than `now + lock_duration` (this is a case when there + /// is already an existing lock with a longer duration). + /// - `iah_proof` will be set to an empty list if `with_proof=false`. + /// * `payload`: must be a JSON string, and it will be passed through the default interface. + /// * `lock_duration`: duration in milliseconds to extend the predecessor account lock for + /// soul transfers. + /// * `with_proof`: when false - doesn't send iah_proof (SBTs) to the contract call. + /// Panics if the predecessor is not a human. + #[payable] + pub fn is_human_call_lock( + &mut self, + ctr: AccountId, + function: String, + payload: String, + lock_duration: u64, + with_proof: bool, + ) -> Promise { + let caller = env::predecessor_account_id(); + let proof = self._is_human(&caller); + require!(!proof.is_empty(), "caller not a human"); + + let now = env::block_timestamp_ms(); + let mut lock = self.transfer_lock.get(&caller).unwrap_or(now); + if lock_duration > 0 { + if lock < now + lock_duration { + lock = now + lock_duration; + self.transfer_lock.insert(&caller, &lock); + events::emit_transfer_lock(caller.clone(), lock) + } + } + + let args = IsHumanLockCallbackArgs { + caller, + locked_until: lock, + iah_proof: if with_proof { Some(proof) } else { None }, + payload: &RawValue::from_string(payload).unwrap(), + }; + + Promise::new(ctr).function_call( + function, + serde_json::to_vec(&args).unwrap(), + env::attached_deposit(), + env::prepaid_gas() - IS_HUMAN_GAS, + ) + } + // NOTE: we are using IssuerTokenId to return Issuer and ClassId. This works as expected // and doesn't create API conflict because this is a crate private function. The reason we // do it is to avoid another struct creation and save the bytes. diff --git a/contracts/registry/src/storage.rs b/contracts/registry/src/storage.rs index 60b177c..5bee92b 100644 --- a/contracts/registry/src/storage.rs +++ b/contracts/registry/src/storage.rs @@ -22,6 +22,7 @@ pub enum StorageKey { OngoingSoultTx, Flagged, AdminsFlagged, + TransferLock, } #[derive(BorshSerialize, BorshDeserialize, BorshStorageKey, Serialize, Deserialize, PartialEq)] @@ -70,6 +71,18 @@ pub struct IsHumanCallbackArgs<'a> { pub payload: &'a RawValue, } +/// `is_human_call_lock` wrapper for passing the payload args to the callback. +#[derive(Serialize)] +#[cfg_attr(not(target_arch = "wasm32"), derive(Debug,))] +#[serde(crate = "near_sdk::serde")] +pub struct IsHumanLockCallbackArgs<'a> { + pub caller: AccountId, + /// time in milliseconds, + pub locked_until: u64, + pub iah_proof: Option, + pub payload: &'a RawValue, +} + #[cfg(test)] mod tests { use super::*; From 8d2516bedc3470805c0a472708d2739fed4774ca Mon Sep 17 00:00:00 2001 From: Robert Zaremba Date: Mon, 30 Oct 2023 18:29:06 +0100 Subject: [PATCH 2/7] add migration --- contracts/registry/src/migrate.rs | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/contracts/registry/src/migrate.rs b/contracts/registry/src/migrate.rs index fb7c60f..bf98522 100644 --- a/contracts/registry/src/migrate.rs +++ b/contracts/registry/src/migrate.rs @@ -1,13 +1,15 @@ use crate::*; -// registry/v1.3.0 +// registry/v1.6.0 #[derive(BorshDeserialize, PanicOnDefault)] pub struct OldState { pub authority: AccountId, pub sbt_issuers: UnorderedMap, pub issuer_id_map: LookupMap, // reverse index - pub(crate) banlist: UnorderedSet, pub(crate) ongoing_soul_tx: LookupMap, + pub(crate) banlist: UnorderedSet, + pub(crate) flagged: LookupMap, + pub(crate) authorized_flaggers: LazyOption>, pub(crate) supply_by_owner: LookupMap<(AccountId, IssuerId), u64>, pub(crate) supply_by_class: LookupMap<(IssuerId, ClassId), u64>, pub(crate) supply_by_issuer: LookupMap, @@ -22,18 +24,17 @@ pub struct OldState { impl Contract { #[private] #[init(ignore_state)] - #[allow(dead_code)] // no migration for 1.5.0 - /* pub */ - fn migrate(authorized_flaggers: Vec) -> Self { + // #[allow(dead_code)] + pub fn migrate() -> Self { let old_state: OldState = env::state_read().expect("failed"); // new field in the smart contract : - // + flagged: LookupMap - // + authorized_flaggers: LazyOption> + // + transfer_lock: LookupMap, Self { authority: old_state.authority.clone(), sbt_issuers: old_state.sbt_issuers, issuer_id_map: old_state.issuer_id_map, + transfer_lock: LookupMap::new(StorageKey::TransferLock), banlist: old_state.banlist, supply_by_owner: old_state.supply_by_owner, supply_by_class: old_state.supply_by_class, @@ -44,11 +45,8 @@ impl Contract { next_issuer_id: old_state.next_issuer_id, ongoing_soul_tx: old_state.ongoing_soul_tx, iah_sbts: old_state.iah_sbts, - flagged: LookupMap::new(StorageKey::Flagged), - authorized_flaggers: LazyOption::new( - StorageKey::AdminsFlagged, - Some(&authorized_flaggers), - ), + flagged: old_state.flagged, + authorized_flaggers: old_state.authorized_flaggers, } } } From cd990de5a8471dedf42532346328f53c8a3b1999 Mon Sep 17 00:00:00 2001 From: Robert Zaremba Date: Mon, 30 Oct 2023 19:00:19 +0100 Subject: [PATCH 3/7] docs --- contracts/registry/CHANGELOG.md | 6 +++++- contracts/registry/README.md | 23 +++++++++++++++++++---- contracts/registry/src/lib.rs | 13 ++++++------- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/contracts/registry/CHANGELOG.md b/contracts/registry/CHANGELOG.md index 70824c8..7c8ac54 100644 --- a/contracts/registry/CHANGELOG.md +++ b/contracts/registry/CHANGELOG.md @@ -23,9 +23,13 @@ Change log entries are to be added to the Unreleased section. Example entry: - Added `authorized_flaggers` query. - Added `admin_add_authorized_flagger` method. +- added `is_human_call_lock` method: allows dapp to lock ane account for soul transfers and call a recipient contract when the predecessor has a proof of personhood. ### Breaking Changes +- New contract field: `transfer_lock`. +- `sbt_soul_transfer` will fail if an account has an active transfer lock. + ### Bug Fixes ## v1.6.0 (2023-10-08) @@ -48,7 +52,7 @@ Change log entries are to be added to the Unreleased section. Example entry: ### Breaking Changes -- Recommended `cost.mint_deposit` is decreased by 0.001 miliNEAR (in total). +- Recommended `cost.mint_deposit` is decreased by 0.001 milliNEAR (in total). - `soul_transfer` conflict resolution is updated to panic. - Default `registry.sbt_soul_transfer` limit is decreased from 25 to 20. diff --git a/contracts/registry/README.md b/contracts/registry/README.md index 1a7f3f6..205f2e6 100644 --- a/contracts/registry/README.md +++ b/contracts/registry/README.md @@ -33,7 +33,12 @@ The IAH Registry supports the following extra queries, which are not part of the See the function docs for more complete documentation. - `sbt_mint_iah(token_spec: Vec<(AccountId, Vec)>) -> Vec` is a wrapper around `sbt_mint` and `is_human`. It mints SBTs only when all recipients are humans. -- `is_human_call(ctr: AccountId, function: String, payload: JSONString)` checks if the predecessor account (_caller_) account is human (using `is_human` method). If it's not, then it panics and returns the deposit. Otherwise it makes a cross contract call passing the deposit: + +- `sbt_burn(issuer: AccountId, tokens: Vec, memo: Option)` - every holder can burn some of his tokens. + +- `sbt_burn_all()` - method to burn all caller tokens (from all issuers). To efficiently burn all tokens, the method must be called repeatedly until true is returned. + +- `is_human_call(ctr: AccountId, function: String, payload: JSONString)` checks if the predecessor account (_caller_) account is human (using `is_human` method). If it's not, then it panics and returns the deposit. Otherwise it makes a cross contract call passing the provided deposit: ```python ctr.function(caller=predecessor_account_id, @@ -44,11 +49,19 @@ See the function docs for more complete documentation. Classical example will registering an action (for poll participation), only when a user is a human. Instead of `Poll --is_human--> Registry -> Poll`, we can simplify and do `Registry.is_human_call --> Poll`. - See the function documentation for more details and [integration test](https://github.com/near-ndc/i-am-human/blob/780e8cf8326fd0a7976c48afbbafd4553cc7b639/contracts/human_checker/tests/workspaces.rs#L131) for usage.o + See the function documentation for more details and [integration test](https://github.com/near-ndc/i-am-human/blob/780e8cf8326fd0a7976c48afbbafd4553cc7b639/contracts/human_checker/tests/workspaces.rs#L131) for usage. -- `sbt_burn(issuer: AccountId, tokens: Vec, memo: Option)` - every holder can burn some of his tokens. +- `is_human_call_lock(ctr: AccountId, function: String, lock_duration: u64, with_proof: bool)` checks if the predecessor account (_caller_) account is human (using `is_human` method). If it's not, then it panics and returns the deposit. Otherwise it will extend the _account soul transfer lock_ (blocking account ability to execute soul transfers) and make a cross contract call passing the provided deposit: -- `sbt_burn_all()` - method to burn all caller tokens (from all issuers). To efficiently burn all tokens, the method must be called repeatedly until true is returned. + ```python + ctr.function(caller=predecessor_account_id, + locked_until: time_in_ms_until_when_the_account_is_locked, + iah_proof=tokens_prooving_caller_humanity, + payload) + ``` + + Classical example will be a voting: we need to assure that an account won't migrate to other one using a soul transfer, and vote from two different accounts. Alternative would be to records humanity proof (SBTs) - this approach + may be more difficult to implement, especially if we are going to supply more humanity proofs. ### Admin functions @@ -61,3 +74,5 @@ See the function docs for more complete documentation. The registry enables atomic `soul_transfers`. It Transfers all SBT tokens from one account to another account. Additionally, it attempts to transfer the associated account flags. For example, if the 'from' account is blacklisted and initiates a soul transfer, the recipient account will also be flagged as blacklisted. If a conflict arises between the caller's and recipient's flags, the transfer will fail. + +Soul transfer is blocked, if there is an active soul transfer lock. The lock may be requested by dapps, that relay on unique personhood linked to an account over a period of time (for example: voting, games). diff --git a/contracts/registry/src/lib.rs b/contracts/registry/src/lib.rs index 7f8d59d..56c9b6a 100644 --- a/contracts/registry/src/lib.rs +++ b/contracts/registry/src/lib.rs @@ -31,7 +31,6 @@ pub struct Contract { /// map accounts -> unix timestamp in milliseconds until when any transfer is blocked /// for the given account. pub(crate) transfer_lock: LookupMap, - /// registry of banned accounts created through `Nep393Event::Ban` (eg: soul transfer). pub(crate) banlist: UnorderedSet, /// Map of accounts that are marked by a committee to have a special status (eg: blacklist, @@ -321,7 +320,8 @@ impl Contract { (token_counter as u32, completed) } - /// Checks if the `predecessor_account_id` is a human. If yes, then calls: + /// Checks if the `predecessor_account_id` is a human. If yes, then calls, passing the + /// provided deposit: /// /// ctr.function({caller: predecessor_account_id(), /// iah_proof: SBTs, @@ -355,13 +355,12 @@ impl Contract { /// Example use cases: voting, staking, games. /// Dapps should make it clear that they extend user lock for a given amount of time. /// Parameters are similar to `is_human_call`: - /// * `ctr` and `function`: the contract function we will call if and only if the caller - /// has a valid humanity proof. + /// * `ctr` and `function`: the contract function we will call, passing the provided deposit, + /// if and only if the caller has a valid humanity proof. /// /// ctr.function({caller: predecessor_account_id(), - /// duration: u64, + /// locked_until: time_in_ms_until_when_the_account_is_locked, /// iah_proof: SBTs, - /// locked_until, /// payload: payload}) /// /// Note the additional arguments provided to the recipient function call, that are not @@ -372,7 +371,7 @@ impl Contract { /// - `iah_proof` will be set to an empty list if `with_proof=false`. /// * `payload`: must be a JSON string, and it will be passed through the default interface. /// * `lock_duration`: duration in milliseconds to extend the predecessor account lock for - /// soul transfers. + /// soul transfers. Can be zero, if no lock is needed. /// * `with_proof`: when false - doesn't send iah_proof (SBTs) to the contract call. /// Panics if the predecessor is not a human. #[payable] From 7f31895c26ad39fe340afb98531dd86f43f8ecac Mon Sep 17 00:00:00 2001 From: Robert Zaremba Date: Mon, 30 Oct 2023 21:41:12 +0100 Subject: [PATCH 4/7] Update contracts/registry/CHANGELOG.md Co-authored-by: sczembor <43810037+sczembor@users.noreply.github.com> --- contracts/registry/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/registry/CHANGELOG.md b/contracts/registry/CHANGELOG.md index 7c8ac54..6adb6e6 100644 --- a/contracts/registry/CHANGELOG.md +++ b/contracts/registry/CHANGELOG.md @@ -23,7 +23,7 @@ Change log entries are to be added to the Unreleased section. Example entry: - Added `authorized_flaggers` query. - Added `admin_add_authorized_flagger` method. -- added `is_human_call_lock` method: allows dapp to lock ane account for soul transfers and call a recipient contract when the predecessor has a proof of personhood. +- added `is_human_call_lock` method: allows dapp to lock an account for soul transfers and call a recipient contract when the predecessor has a proof of personhood. ### Breaking Changes From b456575e0dc617da73152d2a7e1c6fbbdbe51611 Mon Sep 17 00:00:00 2001 From: Robert Zaremba Date: Mon, 30 Oct 2023 23:01:16 +0100 Subject: [PATCH 5/7] unit tests --- contracts/registry/src/lib.rs | 232 +++++++++++++++++++++++++--------- 1 file changed, 173 insertions(+), 59 deletions(-) diff --git a/contracts/registry/src/lib.rs b/contracts/registry/src/lib.rs index 56c9b6a..02dc110 100644 --- a/contracts/registry/src/lib.rs +++ b/contracts/registry/src/lib.rs @@ -7,8 +7,10 @@ use near_sdk::{env, near_bindgen, require, serde_json, AccountId, Gas, PanicOnDe use sbt::*; +use crate::errors::*; use crate::storage::*; +pub mod errors; pub mod events; pub mod migrate; pub mod registry; @@ -205,11 +207,12 @@ impl Contract { /// See https://github.com/near/NEPs/pull/393 for more details and rationality about /// soul transfer. #[payable] + #[handle_result] pub fn sbt_soul_transfer( &mut self, recipient: AccountId, #[allow(unused_variables)] memo: Option, - ) -> (u32, bool) { + ) -> Result<(u32, bool), SoulTransferErr> { // TODO: test what is the max safe amount of updates self._sbt_soul_transfer(recipient, 20) } @@ -229,8 +232,17 @@ impl Contract { // execution of the sbt_soul_transfer in this function to parametrize `max_updates` in // order to facilitate tests. - pub(crate) fn _sbt_soul_transfer(&mut self, recipient: AccountId, limit: usize) -> (u32, bool) { + #[handle_result] + pub(crate) fn _sbt_soul_transfer( + &mut self, + recipient: AccountId, + limit: usize, + ) -> Result<(u32, bool), SoulTransferErr> { let owner = env::predecessor_account_id(); + let transfer_lock = self.transfer_lock.get(&owner).unwrap_or(0); + if transfer_lock >= env::block_timestamp_ms() { + return Err(SoulTransferErr::TransferLocked); + } let (resumed, start) = self.transfer_continuation(&owner, &recipient, true); if !resumed { @@ -317,7 +329,7 @@ impl Contract { ); } - (token_counter as u32, completed) + Ok((token_counter as u32, completed)) } /// Checks if the `predecessor_account_id` is a human. If yes, then calls, passing the @@ -331,22 +343,30 @@ impl Contract { /// hence it will be JSON deserialized when using SDK. /// Panics if the predecessor is not a human. #[payable] - pub fn is_human_call(&mut self, ctr: AccountId, function: String, payload: String) -> Promise { + #[handle_result] + pub fn is_human_call( + &mut self, + ctr: AccountId, + function: String, + payload: String, + ) -> Result { let caller = env::predecessor_account_id(); let iah_proof = self._is_human(&caller); - require!(!iah_proof.is_empty(), "caller not a human"); + if iah_proof.is_empty() { + return Err(IsHumanCallErr::NotHuman); + } let args = IsHumanCallbackArgs { caller, iah_proof, payload: &RawValue::from_string(payload).unwrap(), }; - Promise::new(ctr).function_call( + Ok(Promise::new(ctr).function_call( function, serde_json::to_vec(&args).unwrap(), env::attached_deposit(), env::prepaid_gas() - IS_HUMAN_GAS, - ) + )) } /// Apps should use this function to ask a user to lock his account for soul transfer. @@ -373,8 +393,10 @@ impl Contract { /// * `lock_duration`: duration in milliseconds to extend the predecessor account lock for /// soul transfers. Can be zero, if no lock is needed. /// * `with_proof`: when false - doesn't send iah_proof (SBTs) to the contract call. + /// Emits transfer_lock if the transfer_lock is extended. /// Panics if the predecessor is not a human. #[payable] + #[handle_result] pub fn is_human_call_lock( &mut self, ctr: AccountId, @@ -382,10 +404,12 @@ impl Contract { payload: String, lock_duration: u64, with_proof: bool, - ) -> Promise { + ) -> Result { let caller = env::predecessor_account_id(); let proof = self._is_human(&caller); - require!(!proof.is_empty(), "caller not a human"); + if proof.is_empty() { + return Err(IsHumanCallErr::NotHuman); + } let now = env::block_timestamp_ms(); let mut lock = self.transfer_lock.get(&caller).unwrap_or(now); @@ -404,12 +428,12 @@ impl Contract { payload: &RawValue::from_string(payload).unwrap(), }; - Promise::new(ctr).function_call( + Ok(Promise::new(ctr).function_call( function, serde_json::to_vec(&args).unwrap(), env::attached_deposit(), env::prepaid_gas() - IS_HUMAN_GAS, - ) + )) } // NOTE: we are using IssuerTokenId to return Issuer and ClassId. This works as expected @@ -1072,14 +1096,14 @@ mod tests { Gas::ONE_TERA.mul(300) } - const MILI_SECOND: u64 = 1_000_000; // milisecond in ns + const MSECOND: u64 = 1_000_000; // milisecond in ns const START: u64 = 10; const MINT_DEPOSIT: Balance = 9 * MILI_NEAR; fn setup(predecessor: &AccountId, deposit: Balance) -> (VMContext, Contract) { let mut ctx = VMContextBuilder::new() .predecessor_account_id(admin()) - .block_timestamp(START * MILI_SECOND) // multiplying by mili seconds for easier testing + .block_timestamp(START * MSECOND) // multiplying by mili seconds for easier testing .is_view(false) .build(); if deposit > 0 { @@ -1452,8 +1476,7 @@ mod tests { // make soul transfer ctx.predecessor_account_id = alice(); testing_env!(ctx); - let ret = ctr.sbt_soul_transfer(alice2(), None); - assert_eq!((3, true), ret); + assert_eq!(ctr.sbt_soul_transfer(alice2(), None).unwrap(), (3, true)); let log1 = mk_log_str("ban", &format!(r#"["{}"]"#, alice())); let log2 = mk_log_str( @@ -1502,11 +1525,9 @@ mod tests { // make soul transfer ctx.predecessor_account_id = alice(); testing_env!(ctx); - let mut result = ctr._sbt_soul_transfer(alice2(), 3); - assert_eq!((3, false), result); + assert_eq!(ctr._sbt_soul_transfer(alice2(), 3).unwrap(), (3, false)); assert!(test_utils::get_logs().len() == 1); - result = ctr._sbt_soul_transfer(alice2(), 3); - assert_eq!((1, true), result); + assert_eq!(ctr._sbt_soul_transfer(alice2(), 3).unwrap(), (1, true)); assert!(test_utils::get_logs().len() == 2); let log_soul_transfer = mk_log_str( @@ -1529,7 +1550,7 @@ mod tests { testing_env!(ctx); assert!(!ctr.is_banned(alice())); assert!(!ctr.is_banned(alice2())); - ctr.sbt_soul_transfer(alice2(), None); + ctr.sbt_soul_transfer(alice2(), None).unwrap(); assert!(ctr.is_banned(alice())); assert!(!ctr.is_banned(alice2())); // assert ban even is being emited after the caller with zero tokens has invoked the soul_transfer @@ -1583,10 +1604,10 @@ mod tests { testing_env!(ctx.clone()); let limit: u32 = 20; //anything above this limit will fail due to exceeding maximum gas usage per call - let mut result = ctr._sbt_soul_transfer(alice2(), limit as usize); + let mut result = ctr._sbt_soul_transfer(alice2(), limit as usize).unwrap(); while !result.1 { testing_env!(ctx.clone()); // reset gas - result = ctr._sbt_soul_transfer(alice2(), limit as usize); + result = ctr._sbt_soul_transfer(alice2(), limit as usize).unwrap(); } // check all the balances afterwards @@ -1609,7 +1630,7 @@ mod tests { ctx.prepaid_gas = max_gas(); testing_env!(ctx); let limit: u32 = 30; - ctr._sbt_soul_transfer(alice2(), limit as usize); + ctr._sbt_soul_transfer(alice2(), limit as usize).unwrap(); } #[test] @@ -1629,7 +1650,7 @@ mod tests { ctx.prepaid_gas = max_gas(); testing_env!(ctx); let limit: u32 = 30; - ctr._sbt_soul_transfer(alice2(), limit as usize); + ctr._sbt_soul_transfer(alice2(), limit as usize).unwrap(); } #[test] @@ -1653,30 +1674,40 @@ mod tests { ctx.prepaid_gas = max_gas(); testing_env!(ctx.clone()); - let limit: u32 = 10; - let mut result = ctr._sbt_soul_transfer(alice2(), limit as usize); - assert_eq!((limit, false), result); + let limit: usize = 10; + assert_eq!( + ctr._sbt_soul_transfer(alice2(), limit).unwrap(), + (limit as u32, false) + ); ctx.prepaid_gas = max_gas(); testing_env!(ctx.clone()); - result = ctr._sbt_soul_transfer(alice2(), limit as usize); - assert_eq!((limit, false), result); + assert_eq!( + ctr._sbt_soul_transfer(alice2(), limit).unwrap(), + (limit as u32, false) + ); ctx.prepaid_gas = max_gas(); testing_env!(ctx.clone()); - result = ctr._sbt_soul_transfer(alice2(), limit as usize); - assert_eq!((limit, false), result); + assert_eq!( + ctr._sbt_soul_transfer(alice2(), limit as usize).unwrap(), + (limit as u32, false) + ); ctx.prepaid_gas = max_gas(); testing_env!(ctx.clone()); - result = ctr._sbt_soul_transfer(alice2(), limit as usize); - assert_eq!((limit, false), result); + assert_eq!( + ctr._sbt_soul_transfer(alice2(), limit as usize).unwrap(), + (limit as u32, false) + ); // resumed transfer but no more tokens to transfer ctx.prepaid_gas = max_gas(); testing_env!(ctx); - result = ctr._sbt_soul_transfer(alice2(), limit as usize); - assert_eq!((0, true), result); + assert_eq!( + ctr._sbt_soul_transfer(alice2(), limit as usize).unwrap(), + (0, true) + ); assert_eq!(ctr.sbt_supply_by_owner(alice(), issuer1(), None), 0); assert_eq!(ctr.sbt_supply_by_owner(alice(), issuer2(), None), 0); @@ -1971,7 +2002,7 @@ mod tests { let m2_1 = mk_metadata(2, Some(START + 11)); let m3_1 = mk_metadata(3, Some(START + 21)); - let current_timestamp = ctx.block_timestamp / MILI_SECOND; // convert nano to mili seconds + let current_timestamp = ctx.block_timestamp / MSECOND; // convert nano to mili seconds let m1_1_revoked = mk_metadata(1, Some(current_timestamp)); let m2_1_revoked = mk_metadata(2, Some(current_timestamp)); @@ -2107,7 +2138,7 @@ mod tests { ctx.predecessor_account_id = alice(); testing_env!(ctx); - ctr.sbt_soul_transfer(alice2(), None); + ctr.sbt_soul_transfer(alice2(), None).unwrap(); assert!(ctr.is_banned(alice())); assert!(!ctr.is_banned(alice2())); @@ -2240,7 +2271,7 @@ mod tests { ctx.predecessor_account_id = alice(); testing_env!(ctx); - ctr.sbt_soul_transfer(alice2(), None); + ctr.sbt_soul_transfer(alice2(), None).unwrap(); } #[test] @@ -2257,7 +2288,7 @@ mod tests { ctx.predecessor_account_id = alice(); testing_env!(ctx); - ctr.sbt_soul_transfer(alice2(), None); + ctr.sbt_soul_transfer(alice2(), None).unwrap(); } #[test] @@ -2269,9 +2300,7 @@ mod tests { ctx.predecessor_account_id = alice(); testing_env!(ctx.clone()); - // soul transfer - let result: (u32, bool) = ctr.sbt_soul_transfer(alice2(), None); - assert!(!result.1); + assert!(!ctr.sbt_soul_transfer(alice2(), None).unwrap().1); // assert the from account is banned after the first soul transfer execution assert!(ctr.is_banned(alice())); @@ -2279,9 +2308,8 @@ mod tests { ctx.prepaid_gas = max_gas(); testing_env!(ctx); - ctr.sbt_soul_transfer(alice2(), None); - let result: (u32, bool) = ctr.sbt_soul_transfer(alice2(), None); - assert!(result.1); + ctr.sbt_soul_transfer(alice2(), None).unwrap(); + assert!(ctr.sbt_soul_transfer(alice2(), None).unwrap().1); // assert it stays banned after the soul transfer has been completed assert!(ctr.is_banned(alice())); @@ -2320,7 +2348,7 @@ mod tests { #[test] fn sbt_tokens_by_owner_non_expired() { let (mut ctx, mut ctr) = setup(&issuer1(), 4 * MINT_DEPOSIT); - ctx.block_timestamp = START * MILI_SECOND; // 11 seconds + ctx.block_timestamp = START * MSECOND; // 11 seconds testing_env!(ctx.clone()); let m1_1 = mk_metadata(1, Some(START)); @@ -2342,7 +2370,7 @@ mod tests { assert_eq!(res.len(), 4); // fast forward so the first two sbts are expired - ctx.block_timestamp = (START + 50) * MILI_SECOND; + ctx.block_timestamp = (START + 50) * MSECOND; testing_env!(ctx); let res = ctr.sbt_tokens_by_owner(alice(), None, None, None, Some(true)); @@ -2423,7 +2451,7 @@ mod tests { assert_eq!(test_utils::get_logs()[0], log_revoke[0]); // fast forward - ctx.block_timestamp = (START + 50) * MILI_SECOND; + ctx.block_timestamp = (START + 50) * MSECOND; testing_env!(ctx); // make sure the balances are updated correctly @@ -2550,13 +2578,13 @@ mod tests { testing_env!(ctx.clone()); let res = ctr.sbt_revoke_by_owner(alice(), false); assert!(!res); - ctx.block_timestamp = (START + 1) * MILI_SECOND; + ctx.block_timestamp = (START + 1) * MSECOND; testing_env!(ctx.clone()); let res = ctr.sbt_revoke_by_owner(alice(), false); assert!(res); - ctx.block_timestamp = (START + 5) * MILI_SECOND; + ctx.block_timestamp = (START + 5) * MSECOND; testing_env!(ctx); // make sure the balances are updated correctly @@ -2622,7 +2650,7 @@ mod tests { assert_eq!(ctr.is_human(bob()), vec![]); // step forward, so the tokens will expire - ctx.block_timestamp = (START + 1) * MILI_SECOND; + ctx.block_timestamp = (START + 1) * MSECOND; testing_env!(ctx); assert_eq!(ctr.is_human(alice()), vec![]); assert_eq!(ctr.is_human(bob()), vec![]); @@ -2763,7 +2791,7 @@ mod tests { assert_eq!(ctr.is_human(alice()), vec![(fractal_mainnet(), vec![1, 3])]); // step forward, so token class==3 will expire - ctx.block_timestamp = (START + 1) * MILI_SECOND; + ctx.block_timestamp = (START + 1) * MSECOND; testing_env!(ctx); assert_eq!(ctr.is_human(alice()), vec![]); } @@ -3038,19 +3066,22 @@ mod tests { AccountId::new_unchecked("registry.i-am-human.near".to_string()), "function_name".to_string(), "{}".to_string(), - ); + ) + .unwrap(); } #[test] - #[should_panic(expected = "caller not a human")] fn is_human_call_fail() { let (_, mut ctr) = setup(&alice(), MINT_DEPOSIT); - ctr.is_human_call( + match ctr.is_human_call( AccountId::new_unchecked("registry.i-am-human.near".to_string()), "function_name".to_string(), "{}".to_string(), - ); + ) { + Err(err) => assert_eq!(err, IsHumanCallErr::NotHuman), + Ok(_) => panic!("expecting Err(IsHumanCallErr::NotHuman)"), + }; } #[test] @@ -3191,7 +3222,7 @@ mod tests { // make soul transfer ctx.predecessor_account_id = alice(); testing_env!(ctx.clone()); - ctr.sbt_soul_transfer(alice2(), None); + ctr.sbt_soul_transfer(alice2(), None).unwrap(); assert_eq!( ctr.flagged.get(&alice()), @@ -3212,7 +3243,7 @@ mod tests { // transferring from blacklisted to verified account should fail ctx.predecessor_account_id = alice2(); testing_env!(ctx); - ctr.sbt_soul_transfer(bob(), None); + ctr.sbt_soul_transfer(bob(), None).unwrap(); } #[test] @@ -3227,6 +3258,89 @@ mod tests { ctx.predecessor_account_id = alice(); testing_env!(ctx); - ctr.sbt_soul_transfer(alice2(), None); + ctr.sbt_soul_transfer(alice2(), None).unwrap(); + } + + #[test] + fn is_human_call_lock() { + let (mut ctx, mut ctr) = setup(&fractal_mainnet(), MINT_DEPOSIT); + ctx.prepaid_gas = ctx.prepaid_gas * 10; // add more gas + + let m1_1 = mk_metadata(1, None); + ctr.sbt_mint(vec![(alice(), vec![m1_1.clone()])]); + ctr.sbt_mint(vec![(bob(), vec![m1_1.clone()])]); + + let fun = || "call_me".to_owned(); + let payload = || "{}".to_owned(); + let lock_duration = 5000; // in ms + + // + // Should fail on not a human + ctx.predecessor_account_id = carol(); + testing_env!(ctx.clone()); + match ctr.is_human_call_lock(bob(), fun(), payload(), lock_duration, false) { + Err(err) => assert_eq!(err, IsHumanCallErr::NotHuman), + Ok(_) => panic!("expects Err(IsHumanCallErr::NotHuman)"), + }; + + // + // Test transfer lock + ctx.predecessor_account_id = alice(); + testing_env!(ctx.clone()); + ctr.is_human_call_lock(bob(), fun(), payload(), lock_duration, false) + .unwrap(); + assert_eq!( + ctr.sbt_soul_transfer(alice2(), None), + Err(SoulTransferErr::TransferLocked) + ); + // at the lock_duration we should still fail + ctx.block_timestamp += lock_duration * MSECOND; + testing_env!(ctx.clone()); + assert_eq!( + ctr.sbt_soul_transfer(alice2(), None), + Err(SoulTransferErr::TransferLocked) + ); + // add one more millisecond, now it transfer should work. + ctx.block_timestamp += MSECOND; + testing_env!(ctx.clone()); + assert_eq!(ctr.sbt_soul_transfer(alice2(), None), Ok((1, true))); + + // + // Test 2: is_human_call_lock should extend the lock + ctx.predecessor_account_id = bob(); + testing_env!(ctx.clone()); + ctr.is_human_call_lock(bob(), fun(), payload(), lock_duration, false) + .unwrap(); + // call again -> should extend the lock to the max + testing_env!(ctx.clone()); // reset gas + ctr.is_human_call_lock(alice(), fun(), payload(), lock_duration * 3, false) + .unwrap(); + + // try to call after the initial lock, but before the extended lock + ctx.block_timestamp += (2 * lock_duration + 1) * MSECOND; + testing_env!(ctx.clone()); + assert_eq!( + ctr.sbt_soul_transfer(carol(), None), + Err(SoulTransferErr::TransferLocked) + ); + + // move forward, now it should work + ctx.block_timestamp += lock_duration * MSECOND; + testing_env!(ctx.clone()); + assert_eq!(ctr.sbt_soul_transfer(carol(), None), Ok((1, true))); + + // + // Test 3: is_human_call_lock should extend the lock only if it's bigger than the previous one + // use carol to transfer dylan + ctx.predecessor_account_id = carol(); + testing_env!(ctx.clone()); + ctr.is_human_call_lock(bob(), fun(), payload(), lock_duration, false) + .unwrap(); + testing_env!(ctx.clone()); // reset gas + ctr.is_human_call_lock(bob(), fun(), payload(), lock_duration / 2, false) + .unwrap(); + ctx.block_timestamp += (lock_duration + 1) * MSECOND; + testing_env!(ctx.clone()); + assert_eq!(ctr.sbt_soul_transfer(dan(), None), Ok((1, true))); } } From bbb91eda444aa8c75cda275d74e2122dbf150162 Mon Sep 17 00:00:00 2001 From: Robert Zaremba Date: Mon, 30 Oct 2023 23:06:51 +0100 Subject: [PATCH 6/7] add errors.rs --- contracts/registry/src/errors.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 contracts/registry/src/errors.rs diff --git a/contracts/registry/src/errors.rs b/contracts/registry/src/errors.rs new file mode 100644 index 0000000..8e2f9cb --- /dev/null +++ b/contracts/registry/src/errors.rs @@ -0,0 +1,30 @@ +use near_sdk::env::panic_str; +use near_sdk::FunctionError; + +#[cfg_attr(not(target_arch = "wasm32"), derive(PartialEq, Debug))] +pub enum IsHumanCallErr { + NotHuman, +} + +impl FunctionError for IsHumanCallErr { + fn panic(&self) -> ! { + match self { + IsHumanCallErr::NotHuman => panic_str("caller not a human"), + } + } +} + +#[cfg_attr(not(target_arch = "wasm32"), derive(PartialEq, Debug))] +pub enum SoulTransferErr { + TransferLocked, +} + +impl FunctionError for SoulTransferErr { + fn panic(&self) -> ! { + match self { + SoulTransferErr::TransferLocked => { + panic_str("soul transfer not possible: owner has an transfer lock") + } + } + } +} From 9d4ec1b862cea63adc76df2865fcb23514c3311c Mon Sep 17 00:00:00 2001 From: Robert Zaremba Date: Tue, 31 Oct 2023 09:57:59 +0100 Subject: [PATCH 7/7] Apply suggestions from code review Co-authored-by: sczembor <43810037+sczembor@users.noreply.github.com> --- contracts/registry/CHANGELOG.md | 2 +- contracts/registry/src/errors.rs | 4 ++-- contracts/registry/src/lib.rs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/registry/CHANGELOG.md b/contracts/registry/CHANGELOG.md index 6adb6e6..2224acb 100644 --- a/contracts/registry/CHANGELOG.md +++ b/contracts/registry/CHANGELOG.md @@ -23,7 +23,7 @@ Change log entries are to be added to the Unreleased section. Example entry: - Added `authorized_flaggers` query. - Added `admin_add_authorized_flagger` method. -- added `is_human_call_lock` method: allows dapp to lock an account for soul transfers and call a recipient contract when the predecessor has a proof of personhood. +- added `is_human_call_lock` method: allows dapp to lock an account for soul transfers and calls a recipient contract when the predecessor has a proof of personhood. ### Breaking Changes diff --git a/contracts/registry/src/errors.rs b/contracts/registry/src/errors.rs index 8e2f9cb..c9d51fd 100644 --- a/contracts/registry/src/errors.rs +++ b/contracts/registry/src/errors.rs @@ -9,7 +9,7 @@ pub enum IsHumanCallErr { impl FunctionError for IsHumanCallErr { fn panic(&self) -> ! { match self { - IsHumanCallErr::NotHuman => panic_str("caller not a human"), + IsHumanCallErr::NotHuman => panic_str("caller is not a human"), } } } @@ -23,7 +23,7 @@ impl FunctionError for SoulTransferErr { fn panic(&self) -> ! { match self { SoulTransferErr::TransferLocked => { - panic_str("soul transfer not possible: owner has an transfer lock") + panic_str("soul transfer not possible: owner has a transfer lock") } } } diff --git a/contracts/registry/src/lib.rs b/contracts/registry/src/lib.rs index 02dc110..ae25093 100644 --- a/contracts/registry/src/lib.rs +++ b/contracts/registry/src/lib.rs @@ -30,7 +30,7 @@ pub struct Contract { /// store ongoing soul transfers by "old owner" pub(crate) ongoing_soul_tx: LookupMap, - /// map accounts -> unix timestamp in milliseconds until when any transfer is blocked + /// map accounts -> unix timestamp in milliseconds until when any soul transfer is blocked /// for the given account. pub(crate) transfer_lock: LookupMap, /// registry of banned accounts created through `Nep393Event::Ban` (eg: soul transfer). @@ -385,7 +385,7 @@ impl Contract { /// /// Note the additional arguments provided to the recipient function call, that are not /// present in `is_human_call`: - /// - `locked_until`: time in milliseconds until when the account is locked for soul + /// - `locked_until`: time in milliseconds (duration) until when the account is locked for soul /// transfers. It may be bigger than `now + lock_duration` (this is a case when there /// is already an existing lock with a longer duration). /// - `iah_proof` will be set to an empty list if `with_proof=false`.