From 6ad62caed41c62a8c8400084e9add700f4c4fe17 Mon Sep 17 00:00:00 2001 From: Jacob Lindahl Date: Sun, 29 Oct 2023 23:17:05 -0500 Subject: [PATCH] v2.0.0 (#133) * basic generic upgrade function * adding Upgrade and UpgradeHook traits * update to stable near-sdk-rs * broken upgrade workspaces-tests * fix: remove migrate argument * Added basic failure tests for upgrade * added owner check for upgrade tests * not owner failure test * removing JS scripts * feat: adds more derives to events * cleaned up workspaces tests for upgrade * removed unnecessary near_bindgen * fixed macro version issue * fix: some comments * chore: conciseness * fix: repo url in Cargo.toml * fixing naming conventions * chore: adds build task to workspaces tests * feat: #[derive(Nep297)] allows enums * feat: #[event] supports enums * fix: Owner uses borsh storage keys * fix: upgrade workspaces version to 0.6 * fix: removes generic requirement from Event trait * chore: convert events back to enum shape with owned values * fix: pin Rust version to 1.64; fixes #83 * Use dtolnay/rust-toolchain (#87) * fix: use dtolnay/rust-toolchain for GH Action * fix: rust doc warning from README.md * Add safety invariants to owner module docs (#75) * Add safety invariants to owner module docs * Add usage and safety guide for owner component * Add module docs for pause and rbac * Add module docs for migrate and resolve comments * Update links * Resolve comments * Fix cargo docs warning * Resolve comments * chore: minor corrections & formatting Co-authored-by: Jacob * chore: adds .DS_Store to .gitignore * fix: better doc links in Slot * Default storage keys enum (#90) * feat: default storage keys enum * fix: doc links * Link items in docs and other improvements (#91) * Link items and doc improvements * Add rbac macro attr doc and resolve comments * Upgrade macro (#84) * adding upgrade macro * fix linting issues * removing generic parameters * fixing lint issues * feat: new upgrade macro * fix: tests for new upgrade macro * fix: use Box to decrease enum size * chore: better documentation for new upgrade modules * fix: macro documentation * wip: transfer to mac * chore: workspace tests for upgrade * upgrade multisig testing * feat: better naming for raw functions + args structs * chore: rename "none" hook body flag to "empty" * chore: serialized upgrade uses PostUpgrade struct * Upgrade macros safety docs (#96) * chore: simplify api Co-authored-by: Jacob Co-authored-by: Ishan Bhanuka * feat: unstable feature flag and upgrade near_sdk -> 4.1.0 * fix: Eq derive now that Base64VecU8: Eq * fix: remove near-sdk default features usage (#98) * fix: remove near-sdk default features usage * commit to re-trigger CI * explicitly increase sdk version on test to ensure it gets used internally * chore: Cow -> String (#100) * Makes `Rbac` iterable (#99) * wip: how to implement iterator * fix: unused imports * feat: possible rbac iter implementation * feat: iterable rbac works * chore: finish docs & tests * chore: upgrade Rust to 1.65 * chore: upgrade GH Actions Rust to 1.65 * feat: update version to 0.7.0 * chore: optimize has_role (#102) * chore: update version to 0.7.1 * Fuller Iterator implementation for Rbac iterator (#105) * feat: fuller Iterator implementation for Rbac iterator * feat: mut and non-mut versions of with_members_of, more Iter guarantees * Disallows Event name collisions (#108) * feat: disallow event name collisions, tests * chore: clippy fix * chore: add name change notification * chore: versions to 0.7.2 * Change package name to `near-sdk-contract-tools` (#109) * chore: change name * chore: change repository details * fix: inaccurate documentation * chore: upgrade rust, workspaces, adds approval keys test * fix: clippy: needless borrow * feat: easier network specification in tests * feat: create account test * chore: create account test, base64 for Vec in native tx actions * feat: delete account native transaction action test * feat: stake action test * feat: utils tests * chore: unused imports * fix: check additional fees sum * chore: add rename warning to docs * feat: finally, a decent prefix_key signature * BREAKING: Internal Traits (#111) * feat: internal trait for Rbac * feat: Pause internal trait and separate default implementation * fix: remove duplicate empty impl in Pause macro * feat: Owner internal trait * feat: Nep141 internal trait * feat: ApprovalManager internal trait * fix: grammar * better test naming * fix: double underscore warning (#113) * feat: adds kudelski audit final doc (#114) * chore: mention audit in README.md * chore: version bump to v1.0.0 * fix: exclude documents folder from publishing * chore: version bump to v1.0.1 * fix: skip upgrade_jsonbase64 test on ghactions (#119) * fix: warnings * Non-Fungible Token Standards (#69) * feat: nep171 wip * feat: nep171 events are owned; some experiments for allowing approval extension * feat: some ideas for implementing approval extension (wip) * fix: crate name change * feat: nep171 macro progress * full nep171 implementation * chore: simplify nft_resolve_transfer * chore: finished nep171 macro * chore: cleanup, tests, renaming * chore: documentation comments * chore: workspaces tests & multiple token ids * feat: nep171 multiple token id transfer * chore: more tests + small fixes * fix: warnings * feat: predicate api improvements * feat: remove predicate, introduce check function * feat: nft events version 1.2.0 update * Nep177: NFT Metadata (#124) * feat: nep177 * chore: more function implementation * feat: nep177 macro * feat: non_fungible_token macro * feat: documentation comments * feat: switch to using #[serde(flatten)] for token metadata * feat: nep178 * chore: upgrade tests & organize libs * feat: nep178 macro integration * feat: nep178 testing * chore: docs * chore: more tests and examples in docs * chore: lots of docs * feat: switch hook state to associated type * feat: nep181 internals done, improved hooks * feat: nep181 macro, sanity test * chore: qol nft module * feat: docs * feat: further enumeration tests * feat: final (?) tests for enumeration * chore: update readme to mention nep-171 and related * Fast Account IDs (#123) * feat: fast account ID * fix: cleanup, extract to fn, switch to Rc * Escrow (#121) * feat: escrowable capabilities * feat: add getter for locked state * docs(escrow): add some documentation * test(escrow): add integration test for macro * test(escrow): add workspace test for macro * test(escrow): add integration test for escrow macro * fix: some clippy lints, also optional macro props * feat(escrow): optionally implement events allows types that implement `serde::Serialize` to emit events --------- Co-authored-by: Jacob Lindahl * chore: more tests, more const in fast account id * chore: cleanup md * feat: nep-178 hooks improved * v1.1.0 * fix: gh actions tests should work again * BREAKING: Fungible Token Improvements (better hooks + dynamic metadata) (#127) * feat: better hooks for nep141 * chore: move resolve code to macro * chore: use Result instead of panicking in nep141 functions * chore: update nep148 to use dynamic metadata * feat: extension hooks * chore: upgrade to near-workspaces@0.8.0 * chore: use pretty_assertions and fix gas issue * chore: clean tests * fix: update ghactions rust version to 1.72 --------- Co-authored-by: Jacob Lindahl * NEP-145: Storage Management (#126) * storage management wip * more internal methods * wip: macro impl * feat: finish + basic impl nep145 * chore: sanity test and docs * fix: change method names + initial test skeleton * fix: only resolved half the merge lmao * chore: storage management tests * chore: run out of storage test * BREAKING: Refactor Fungible Token (#128) * feat: better hooks for nep141 * chore: move resolve code to macro * chore: use Result instead of panicking in nep141 functions * chore: update nep148 to use dynamic metadata * feat: extension hooks * chore: upgrade to near-workspaces@0.8.0 * chore: use pretty_assertions and fix gas issue * chore: clean tests * fix: update ghactions rust version to 1.72 * feat: nep145 convenience method for storage accounting * fix: error message typo * feat: new hooks * feat: removes State associated type variable * fix: readme sample * feat: adds all_hooks + PausableHook * feat: storage force unregister hook * feat: FungibleToken derive macro includes NEP-145 * chore: fmt + docs * chore: split out hooks to separate modules --------- Co-authored-by: Jacob Lindahl * chore: NEARFoundation -> near repo update (#130) * BREAKING: Upgrade all components to new hooks API (#129) * feat: upgrade nep-171 to new hooks * feat: "our most powerful hooks api yet" * chore: 178 hooks and some reorg * chore: move 178 actions to own module * chore: nep178 extract error to separate module * chore: nep141 transfers don't require owned values * Normalize & update dependencies (#131) * fix: remove external serde* deps; use near_sdk-provided serde* instead * chore: use workspace dependencies for everything * chore: upgrade strum deps * chore: loosen version requirements * chore: remove unused dependencies * feat: nextest * chore: use nextest in GH actions * fix: gh actions syntax * feat: use caching in ghaction * Integrate NEP-145 with token standards (#132) * feat: nep145 full integration with nep141 * feat: some renames; full nep145<>nep171 integration * fmt * fix: failing test & conciseness * chore: better readme * v2.0.0 --------- Co-authored-by: nearken Co-authored-by: nearken <108417131+nearken@users.noreply.github.com> Co-authored-by: Ishan Bhanuka Co-authored-by: Austin Abell Co-authored-by: Don <37594653+dndll@users.noreply.github.com> Co-authored-by: Jacob --- .github/workflows/rust.yml | 35 +- Cargo.toml | 52 +- README.md | 155 +++- macros/Cargo.toml | 27 +- macros/src/lib.rs | 51 +- macros/src/standard/fungible_token.rs | 82 ++- macros/src/standard/mod.rs | 1 + macros/src/standard/nep141.rs | 182 +++-- macros/src/standard/nep145.rs | 165 +++++ macros/src/standard/nep148.rs | 61 +- macros/src/standard/nep171.rs | 74 +- macros/src/standard/nep178.rs | 81 ++- macros/src/standard/non_fungible_token.rs | 79 +- macros/src/upgrade.rs | 24 +- rust-toolchain.toml | 2 +- rustfmt.toml | 1 + src/approval/mod.rs | 9 +- src/approval/native_transaction_action.rs | 4 +- src/approval/simple_multisig.rs | 7 +- src/hook.rs | 56 ++ src/lib.rs | 55 +- src/migrate.rs | 2 +- src/pause.rs | 41 +- src/rbac.rs | 2 +- src/standard/mod.rs | 1 + src/standard/nep141.rs | 538 -------------- src/standard/nep141/error.rs | 81 +++ src/standard/nep141/event.rs | 121 ++++ src/standard/nep141/ext.rs | 56 ++ src/standard/nep141/hooks.rs | 37 + src/standard/nep141/mod.rs | 336 +++++++++ src/standard/nep145/error.rs | 141 ++++ src/standard/nep145/ext.rs | 52 ++ src/standard/nep145/hooks.rs | 87 +++ src/standard/nep145/mod.rs | 398 +++++++++++ src/standard/nep148.rs | 163 +++-- src/standard/nep171/action.rs | 55 ++ src/standard/nep171/error.rs | 38 + src/standard/nep171/event.rs | 30 +- src/standard/nep171/hooks.rs | 41 ++ src/standard/nep171/mod.rs | 542 +++----------- src/standard/nep177.rs | 31 +- src/standard/nep178.rs | 675 ------------------ src/standard/nep178/action.rs | 47 ++ src/standard/nep178/error.rs | 79 ++ src/standard/nep178/ext.rs | 45 ++ src/standard/nep178/mod.rs | 356 +++++++++ src/standard/nep181.rs | 52 +- src/standard/nep297.rs | 15 +- src/utils.rs | 1 - tests/macros/event.rs | 8 +- tests/macros/mod.rs | 86 ++- tests/macros/standard/fungible_token.rs | 135 ++-- tests/macros/standard/mod.rs | 1 + tests/macros/standard/nep141.rs | 68 +- tests/macros/standard/nep145.rs | 118 +++ tests/macros/standard/nep148.rs | 29 +- tests/macros/standard/nep171/hooks.rs | 24 +- .../standard/nep171/manual_integration.rs | 17 +- tests/macros/standard/nep171/mod.rs | 115 ++- tests/macros/standard/nep171/no_hooks.rs | 20 +- .../standard/nep171/non_fungible_token.rs | 15 +- workspaces-tests-utils/Cargo.toml | 3 +- workspaces-tests-utils/src/lib.rs | 18 +- workspaces-tests/Cargo.toml | 19 +- workspaces-tests/Makefile.toml | 18 +- workspaces-tests/src/bin/fungible_token.rs | 48 +- .../src/bin/non_fungible_token_full.rs | 77 +- .../src/bin/non_fungible_token_nep171.rs | 27 +- workspaces-tests/tests/counter_multisig.rs | 5 +- workspaces-tests/tests/escrow.rs | 5 +- workspaces-tests/tests/fungible_token.rs | 220 ++++-- workspaces-tests/tests/native_multisig.rs | 34 +- workspaces-tests/tests/non_fungible_token.rs | 147 ++-- workspaces-tests/tests/rbac.rs | 5 +- workspaces-tests/tests/simple_multisig.rs | 5 +- workspaces-tests/tests/storage_fee.rs | 6 +- workspaces-tests/tests/upgrade.rs | 5 +- workspaces-tests/tests/upgrade_multisig.rs | 5 +- 79 files changed, 4036 insertions(+), 2513 deletions(-) create mode 100644 macros/src/standard/nep145.rs create mode 100644 src/hook.rs delete mode 100644 src/standard/nep141.rs create mode 100644 src/standard/nep141/error.rs create mode 100644 src/standard/nep141/event.rs create mode 100644 src/standard/nep141/ext.rs create mode 100644 src/standard/nep141/hooks.rs create mode 100644 src/standard/nep141/mod.rs create mode 100644 src/standard/nep145/error.rs create mode 100644 src/standard/nep145/ext.rs create mode 100644 src/standard/nep145/hooks.rs create mode 100644 src/standard/nep145/mod.rs create mode 100644 src/standard/nep171/action.rs create mode 100644 src/standard/nep171/hooks.rs delete mode 100644 src/standard/nep178.rs create mode 100644 src/standard/nep178/action.rs create mode 100644 src/standard/nep178/error.rs create mode 100644 src/standard/nep178/ext.rs create mode 100644 src/standard/nep178/mod.rs create mode 100644 tests/macros/standard/nep145.rs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 52293d4..331d944 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -14,10 +14,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@master with: - toolchain: 1.69 + toolchain: 1.72 components: rustfmt - name: Check formatting run: > @@ -27,34 +27,45 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@master with: - toolchain: 1.69 + toolchain: 1.72 components: clippy - name: Run linter run: cargo clippy -- -D warnings test: runs-on: ubuntu-latest + env: + CARGO_TERM_COLOR: always steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@master with: - toolchain: 1.69 + toolchain: 1.72 + - uses: taiki-e/install-action@v2 + with: + tool: nextest + - uses: Swatinem/rust-cache@v2 - name: Run unit and integration tests - run: cargo test --workspace --exclude workspaces-tests + run: cargo nextest run --workspace --exclude workspaces-tests + - name: Run doctests + run: cargo test --doc workspaces-test: runs-on: ubuntu-latest + env: + CARGO_TERM_COLOR: always steps: - uses: actions/checkout@v3 - uses: dtolnay/rust-toolchain@master with: - toolchain: 1.69 + toolchain: 1.72 targets: wasm32-unknown-unknown + - uses: taiki-e/install-action@v2 + with: + tool: cargo-make,nextest + - uses: Swatinem/rust-cache@v2 - name: Run workspaces tests - run: > - cd workspaces-tests; - cargo install cargo-make; - cargo make test; + run: cd workspaces-tests && cargo make nextest diff --git a/Cargo.toml b/Cargo.toml index de8b266..8232d4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,29 +1,50 @@ [package] -authors = ["Jacob Lindahl "] +authors.workspace = true categories = ["wasm", "cryptography::cryptocurrencies"] description = """ Helpful functions and macros for developing smart contracts on NEAR Protocol. """ documentation = "https://docs.rs/near-sdk-contract-tools" -edition = "2021" +edition.workspace = true exclude = ["documents/"] -license = "GPL-3.0" +license.workspace = true name = "near-sdk-contract-tools" -repository = "https://github.com/NEARFoundation/near-sdk-contract-tools" -version = "1.1.0" +repository.workspace = true +version.workspace = true + +[workspace] +members = [".", "macros", "workspaces-tests", "workspaces-tests-utils"] + +[workspace.package] +authors = ["Jacob Lindahl "] +edition = "2021" +license = "GPL-3.0" +repository = "https://github.com/near/near-sdk-contract-tools" +version = "2.0.0" [workspace.dependencies] +# normal dependencies near-sdk = { version = "4.1.1", default-features = false } -near-sdk-contract-tools-macros = { version = "=1.1.0", path = "./macros" } -serde = "1.0.144" -serde_json = "1.0.85" -thiserror = "1.0.35" +near-sdk-contract-tools-macros = { version = "=2.0.0", path = "./macros" } +thiserror = "1" + +darling = "0.20" +heck = "0.4" +proc-macro2 = "1" +quote = "1.0" +strum = "0.25" +strum_macros = "0.25" +syn = "2.0" + +# dev-dependencies +near-crypto = "0.15" +near-workspaces = "0.8" +pretty_assertions = "1" +tokio = "1" [dependencies] near-sdk.workspace = true near-sdk-contract-tools-macros.workspace = true -serde.workspace = true -serde_json.workspace = true thiserror.workspace = true [dev-dependencies] @@ -39,5 +60,10 @@ unstable = ["near-sdk/unstable"] all-features = true rustdoc-args = ["--cfg", "docsrs"] -[workspace] -members = [".", "macros", "workspaces-tests", "workspaces-tests-utils"] +[profile.release] +codegen-units = 1 +debug = false +lto = true +opt-level = "z" +overflow-checks = true +panic = "abort" diff --git a/README.md b/README.md index 3b3e28f..d0e3732 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,87 @@ # near-sdk-contract-tools -> Helpful functions and macros for developing smart contracts on NEAR Protocol. +## NFT + +```diff +use near_sdk::{ + borsh::{self, BorshDeserialize, BorshSerialize}, + near_bindgen, PanicOnDefault, +}; ++ use near_sdk_contract_tools::nft::*; + +#[derive(BorshSerialize, BorshDeserialize, PanicOnDefault)] ++ #[derive(NonFungibleToken)] +#[near_bindgen] +pub struct MyNftContract {} + +#[near_bindgen] +impl MyNftContract { + #[init] + pub fn new() -> Self { + let mut contract = Self {}; + ++ contract.set_contract_metadata(ContractMetadata::new( ++ "My NFT".to_string(), ++ "MNFT".to_string(), ++ None, ++ )); + + contract + } +} +``` + +## FT + +```diff +use near_sdk::{ + borsh::{self, BorshDeserialize, BorshSerialize}, + near_bindgen, PanicOnDefault, +}; ++ use near_sdk_contract_tools::ft::*; + +#[derive(BorshSerialize, BorshDeserialize, PanicOnDefault)] ++ #[derive(FungibleToken)] +#[near_bindgen] +pub struct MyFtContract {} + +#[near_bindgen] +impl MyFtContract { + #[init] + pub fn new() -> Self { + let mut contract = Self {}; + ++ contract.set_metadata(&FungibleTokenMetadata::new( ++ "My Fungible Token".into(), ++ "MYFT".into(), ++ 24, ++ )); + + contract + } +} +``` + +## What is it? This package is a collection of common tools and patterns in NEAR smart contract development: -- Storage fee management -- Owner pattern + derive macro -- Pause pattern + derive macro -- Role-based access control +- Storage fee management. +- Escrow pattern and derive macro. +- Owner pattern and derive macro. +- Pause pattern and derive macro. +- Role-based access control. - Derive macros for NEP standards: - - [NEP-297](https://nomicon.io/Standards/EventsFormat) (events) - - [NEP-141](https://nomicon.io/Standards/Tokens/FungibleToken/Core) (fungible token), extension [NEP-148](https://nomicon.io/Standards/Tokens/FungibleToken/Metadata) - - [NEP-171](https://nomicon.io/Standards/NonFungibleToken/NonFungibleToken) (non-fungible token), extensions [NEP-177](https://nomicon.io/Standards/Tokens/NonFungibleToken/Metadata), [NEP-178](https://nomicon.io/Standards/Tokens/NonFungibleToken/ApprovalManagement), [NEP-181](https://nomicon.io/Standards/Tokens/NonFungibleToken/Enumeration) + - [NEP-141][nep141] (fungible token), extension [NEP-148][nep148]. + - [NEP-145][nep145] (storage management), and integrations for the fungible token and non-fungible token standards. + - [NEP-171][nep171] (non-fungible token), extensions [NEP-177][nep177], [NEP-178][nep178], [NEP-181][nep181]. + - [NEP-297][nep297] (events). Not to be confused with [`near-contract-standards`](https://crates.io/crates/near-contract-standards), which contains official implementations of standardized NEPs. This crate is intended to be a complement to `near-contract-standards`. You can think of this collection of common tools and patterns (mostly in the form of [derive macros](https://doc.rust-lang.org/reference/procedural-macros.html#derive-macros)) as a sort of [OpenZeppelin](https://docs.openzeppelin.com/contracts/4.x/) for NEAR. -**WARNING:** This is still early software, and there may be breaking changes between versions. I'll try my best to keep the docs & changelogs up-to-date. Don't hesitate to create an issue if find anything wrong. +Pro tip: Use the [contract wizard](https://near.org/contractwizard.near/widget/ContractWizardUI) to generate starter code for your next project. ## Installation @@ -96,22 +160,30 @@ e.emit(); ### Fungible Token -To create a contract that is compatible with the NEP-141 and NEP-148 standards, that emits standard-compliant (NEP-141, NEP-297) events. +To create a contract that is compatible with the [NEP-141][nep141], [NEP-145][nep145], and [NEP-148][nep148] standards, that emits standard-compliant ([NEP-297][nep297]) events. ```rust -use near_sdk_contract_tools::FungibleToken; +use near_sdk_contract_tools::ft::*; use near_sdk::near_bindgen; #[derive(FungibleToken)] -#[fungible_token( - name = "My Fungible Token", - symbol = "MYFT", - decimals = 18, - no_hooks -)] #[near_bindgen] -struct FungibleToken { - // ... +struct MyFt {} + +#[near_bindgen] +impl MyFt { + #[init] + pub fn new() -> Self { + let mut contract = Self {}; + + contract.set_metadata(&FungibleTokenMetadata::new( + "My Fungible Token".to_string(), + "MYFT".to_string(), + 24, + )); + + contract + } } ``` @@ -119,7 +191,7 @@ Standalone macros for each individual standard also exist. ### Non-fungible Token -Use the `NonFungibleToken` derive macro to implement NEP-171, NEP-177, NEP-178, and NEP-181, with NEP-297 events. +Use the `NonFungibleToken` derive macro to implement [NEP-145][nep145], [NEP-171][nep171], [NEP-177][nep177], [NEP-178][nep178], and [NEP-181][nep181], with [NEP-297][nep297] events. ```rust use near_sdk::{ @@ -130,7 +202,6 @@ use near_sdk::{ use near_sdk_contract_tools::nft::*; #[derive(BorshSerialize, BorshDeserialize, PanicOnDefault, NonFungibleToken)] -#[non_fungible_token(no_core_hooks, no_approval_hooks)] #[near_bindgen] pub struct MyNft {} ``` @@ -141,26 +212,22 @@ One may wish to combine the features of multiple macros in one contract. All of ```rust use near_sdk_contract_tools::{ - pause::Pause, - standard::nep141::{Nep141Hook, Nep141Transfer}, - FungibleToken, Pause, + ft::*, + pause::{*, hooks::PausableHook}, + Pause, +}; +use near_sdk::{ + borsh::{self, BorshSerialize, BorshDeserialize}, + PanicOnDefault, + near_bindgen, }; -use near_sdk::near_bindgen; -#[derive(FungibleToken, Pause)] -#[fungible_token(name = "Pausable Fungible Token", symbol = "PFT", decimals = 18)] +#[derive(BorshSerialize, BorshDeserialize, PanicOnDefault, FungibleToken, Pause)] +#[fungible_token(all_hooks = "PausableHook")] #[near_bindgen] struct Contract {} - -impl Nep141Hook for Contract { - fn before_transfer(&mut self, _transfer: &Nep141Transfer) { - Contract::require_unpaused(); - } -} ``` -Note: Hooks can be disabled using `#[nep141(no_hooks)]` or `#[fungible_token(no_hooks)]`. - ### Custom Crates If you are a library developer, have modified a crate that one of the `near-sdk-contract-tools` macros uses (like `serde` or `near-sdk`), or are otherwise using a crate under a different name, you can specify crate names in macros like so: @@ -221,20 +288,21 @@ Run `git config core.hooksPath hooks/` to set up commit hooks. Install `cargo-make` if it is not installed already: ```text -cargo install cargo-make +cargo install cargo-make cargo-nextest ``` Run tests: ```text -cargo test +cargo nextest run +cargo test --doc cd workspaces-tests -cargo make test +cargo make nextest ``` ## Audit -This library has been [audited](./documents/NEAR%20Contract%20Tools%20-%20Final%20-%2005.05.2023.pdf) by [Kudelski Security](https://www.kudelskisecurity.com/). +Version 1.0.0 of this library has been [audited](./documents/NEAR%20Contract%20Tools%20-%20Final%20-%2005.05.2023.pdf) by [Kudelski Security](https://www.kudelskisecurity.com/). (May 2023) ## Authors @@ -243,3 +311,12 @@ This library has been [audited](./documents/NEAR%20Contract%20Tools%20-%20Final% --- (Formerly known as [`near-contract-tools`](https://crates.io/crates/near-contract-tools).) + +[nep145]: https://nomicon.io/Standards/StorageManagement +[nep141]: https://nomicon.io/Standards/Tokens/FungibleToken/Core +[nep148]: https://nomicon.io/Standards/Tokens/FungibleToken/Metadata +[nep171]: https://nomicon.io/Standards/NonFungibleToken/NonFungibleToken +[nep177]: https://nomicon.io/Standards/Tokens/NonFungibleToken/Metadata +[nep178]: https://nomicon.io/Standards/Tokens/NonFungibleToken/ApprovalManagement +[nep181]: https://nomicon.io/Standards/Tokens/NonFungibleToken/Enumeration +[nep297]: https://nomicon.io/Standards/EventsFormat diff --git a/macros/Cargo.toml b/macros/Cargo.toml index 51d542e..20fd1fb 100644 --- a/macros/Cargo.toml +++ b/macros/Cargo.toml @@ -1,25 +1,24 @@ [package] -authors = ["Jacob Lindahl "] +authors.workspace = true categories = ["wasm"] description = """ Macros for `near-sdk-contract-tools` """ -edition = "2021" -license = "GPL-3.0" +documentation = "https://docs.rs/near-sdk-contract-tools-macros" +edition.workspace = true +license.workspace = true name = "near-sdk-contract-tools-macros" -version = "1.1.0" +repository.workspace = true +version.workspace = true [dependencies] -base64 = "0.13.0" -darling = "0.14.1" -heck = "0.4.0" -once_cell = "1.16.0" -proc-macro2 = "1.0.43" -quote = "1.0.21" -regex = "1.6.0" -strum = "0.24.1" -strum_macros = "0.24.3" -syn = "1.0.99" +darling.workspace = true +heck.workspace = true +proc-macro2.workspace = true +quote.workspace = true +strum.workspace = true +strum_macros.workspace = true +syn.workspace = true [lib] proc-macro = true diff --git a/macros/src/lib.rs b/macros/src/lib.rs index cfff60a..e4a86d6 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -1,8 +1,8 @@ //! Macros for near-sdk-contract-tools. -use darling::{FromDeriveInput, FromMeta}; +use darling::{ast::NestedMeta, FromDeriveInput, FromMeta}; use proc_macro::TokenStream; -use syn::{parse_macro_input, AttributeArgs, DeriveInput, Item}; +use syn::{parse_macro_input, DeriveInput, Item}; mod approval; mod escrow; @@ -27,7 +27,11 @@ fn default_near_sdk() -> syn::Path { } fn default_serde() -> syn::Path { - syn::parse_str("::serde").unwrap() + syn::parse_str("::near_sdk::serde").unwrap() +} + +fn unitify(ty: Option) -> syn::Type { + ty.unwrap_or_else(|| syn::parse_quote! { () }) } fn make_derive( @@ -120,28 +124,34 @@ pub fn derive_nep141(input: TokenStream) -> TokenStream { make_derive(input, standard::nep141::expand) } -/// Adds NEP-148 fungible token metadata functionality to a contract. Metadata -/// is hardcoded into the contract code, and is therefore not stored in storage. +/// Adds NEP-145 fungible token core functionality to a contract. Exposes +/// `storage_*` functions to the public blockchain, implements internal +/// controller functionality. /// -/// Specify metadata using the `#[nep148(...)]` attribute. +/// The storage key prefix for the fields can be optionally specified (default: +/// `"~$145"`) using `#[nep145(storage_key = "")]`. +#[proc_macro_derive(Nep145, attributes(nep145))] +pub fn derive_nep145(input: TokenStream) -> TokenStream { + make_derive(input, standard::nep145::expand) +} + +/// Adds NEP-148 fungible token metadata functionality to a contract. Metadata +/// must be initialized during contract creation using `Nep148Controller::set_metadata`. /// -/// Fields: -/// - `name` -/// - `symbol` -/// - `decimals` -/// - `spec` (optional) -/// - `icon` (optional) -/// - `reference` (optional) -/// - `reference_hash` (optional) +/// The storage key prefix for the fields can be optionally specified (default: +/// `"~$148"`) using `#[nep148(storage_key = "")]`. #[proc_macro_derive(Nep148, attributes(nep148))] pub fn derive_nep148(input: TokenStream) -> TokenStream { make_derive(input, standard::nep148::expand) } -/// Implements NEP-141 and NEP-148 functionality, like -/// `#[derive(Nep141, Nep148)]`. +/// Implements NEP-141, NEP-145, and NEP-148 functionality, like +/// `#[derive(Nep141, Nep145, Nep148)]`. This is the recommended way to +/// implement a fungible token, as it also ensures that all of the standards +/// integrate with each other correctly. /// -/// Attributes are the union of those for the constituent derive macros. +/// Attributes are generally the union of those from the constituent derive +/// macros. /// Specify attributes with `#[fungible_token(...)]`. #[proc_macro_derive(FungibleToken, attributes(fungible_token))] pub fn derive_fungible_token(input: TokenStream) -> TokenStream { @@ -238,7 +248,12 @@ pub fn derive_simple_multisig(input: TokenStream) -> TokenStream { /// See documentation on the [`derive@Nep297`] derive macro for more details. #[proc_macro_attribute] pub fn event(attr: TokenStream, item: TokenStream) -> TokenStream { - let attr = parse_macro_input!(attr as AttributeArgs); + let attr = match NestedMeta::parse_meta_list(attr.into()) { + Ok(v) => v, + Err(e) => { + return TokenStream::from(darling::Error::from(e).write_errors()); + } + }; let item = parse_macro_input!(item as Item); standard::event::EventAttributeMeta::from_list(&attr) diff --git a/macros/src/standard/fungible_token.rs b/macros/src/standard/fungible_token.rs index 866a0af..f4c53da 100644 --- a/macros/src/standard/fungible_token.rs +++ b/macros/src/standard/fungible_token.rs @@ -1,25 +1,26 @@ -use darling::{util::Flag, FromDeriveInput}; +use darling::FromDeriveInput; use proc_macro2::TokenStream; use quote::quote; -use syn::Expr; +use syn::{Expr, Type}; -use super::{nep141, nep148}; +use super::{nep141, nep145, nep148}; #[derive(Debug, FromDeriveInput)] #[darling(attributes(fungible_token), supports(struct_named))] pub struct FungibleTokenMeta { // NEP-141 fields - pub storage_key: Option, - pub no_hooks: Flag, + pub core_storage_key: Option, + pub all_hooks: Option, + pub mint_hook: Option, + pub transfer_hook: Option, + pub burn_hook: Option, // NEP-148 fields - pub spec: Option, - pub name: String, - pub symbol: String, - pub icon: Option, - pub reference: Option, - pub reference_hash: Option, - pub decimals: u8, + pub metadata_storage_key: Option, + + // NEP-145 fields + pub storage_management_storage_key: Option, + pub force_unregister_hook: Option, // darling pub generics: syn::Generics, @@ -34,16 +35,16 @@ pub struct FungibleTokenMeta { pub fn expand(meta: FungibleTokenMeta) -> Result { let FungibleTokenMeta { - storage_key, - no_hooks, + core_storage_key, + all_hooks, + mint_hook, + transfer_hook, + burn_hook, + + metadata_storage_key, - spec, - name, - symbol, - icon, - reference, - reference_hash, - decimals, + storage_management_storage_key, + force_unregister_hook, generics, ident, @@ -52,9 +53,20 @@ pub fn expand(meta: FungibleTokenMeta) -> Result { near_sdk, } = meta; + let all_hooks_or_unit = all_hooks + .clone() + .unwrap_or_else(|| syn::parse_quote! { () }); + let force_unregister_hook_or_unit = + force_unregister_hook.unwrap_or_else(|| syn::parse_quote! { () }); + let expand_nep141 = nep141::expand(nep141::Nep141Meta { - storage_key, - no_hooks, + storage_key: core_storage_key, + all_hooks: Some( + syn::parse_quote! { (#all_hooks_or_unit, #me::standard::nep145::hooks::Nep141StorageAccountingHook) }, + ), + mint_hook, + transfer_hook, + burn_hook, generics: generics.clone(), ident: ident.clone(), @@ -63,15 +75,21 @@ pub fn expand(meta: FungibleTokenMeta) -> Result { near_sdk: near_sdk.clone(), }); - let expand_nep148 = nep148::expand(nep148::Nep148Meta { - spec, - name, - symbol, - icon, - reference, - reference_hash, - decimals, + let expand_nep145 = nep145::expand(nep145::Nep145Meta { + storage_key: storage_management_storage_key, + all_hooks, + force_unregister_hook: Some( + syn::parse_quote! { (#force_unregister_hook_or_unit, #me::standard::nep141::hooks::BurnNep141OnForceUnregisterHook) }, + ), + generics: generics.clone(), + ident: ident.clone(), + me: me.clone(), + near_sdk: near_sdk.clone(), + }); + + let expand_nep148 = nep148::expand(nep148::Nep148Meta { + storage_key: metadata_storage_key, generics, ident, @@ -82,10 +100,12 @@ pub fn expand(meta: FungibleTokenMeta) -> Result { let mut e = darling::Error::accumulator(); let nep141 = e.handle(expand_nep141); + let nep145 = e.handle(expand_nep145); let nep148 = e.handle(expand_nep148); e.finish_with(quote! { #nep141 + #nep145 #nep148 }) } diff --git a/macros/src/standard/mod.rs b/macros/src/standard/mod.rs index 4a95ca5..3b09f6c 100644 --- a/macros/src/standard/mod.rs +++ b/macros/src/standard/mod.rs @@ -3,6 +3,7 @@ pub mod fungible_token; pub mod non_fungible_token; pub mod nep141; +pub mod nep145; pub mod nep148; pub mod nep171; pub mod nep177; diff --git a/macros/src/standard/nep141.rs b/macros/src/standard/nep141.rs index 2e6c962..f839354 100644 --- a/macros/src/standard/nep141.rs +++ b/macros/src/standard/nep141.rs @@ -1,15 +1,16 @@ -use std::ops::Not; - -use darling::{util::Flag, FromDeriveInput}; +use darling::FromDeriveInput; use proc_macro2::TokenStream; use quote::quote; -use syn::Expr; +use syn::{Expr, Type}; #[derive(Debug, FromDeriveInput)] #[darling(attributes(nep141), supports(struct_named))] pub struct Nep141Meta { pub storage_key: Option, - pub no_hooks: Flag, + pub all_hooks: Option, + pub mint_hook: Option, + pub transfer_hook: Option, + pub burn_hook: Option, pub generics: syn::Generics, pub ident: syn::Ident, @@ -23,7 +24,10 @@ pub struct Nep141Meta { pub fn expand(meta: Nep141Meta) -> Result { let Nep141Meta { storage_key, - no_hooks, + all_hooks, + mint_hook, + transfer_hook, + burn_hook, generics, ident, @@ -41,20 +45,26 @@ pub fn expand(meta: Nep141Meta) -> Result { } }); - let before_transfer = no_hooks.is_present().not().then(|| { - quote! { - let hook_state = >::before_transfer(self, &transfer); - } - }); + let mint_hook = mint_hook + .map(|h| quote! { #h }) + .unwrap_or_else(|| quote! { () }); + let transfer_hook = transfer_hook + .map(|h| quote! { #h }) + .unwrap_or_else(|| quote! { () }); + let burn_hook = burn_hook + .map(|h| quote! { #h }) + .unwrap_or_else(|| quote! { () }); - let after_transfer = no_hooks.is_present().not().then(|| { - quote! { - >::after_transfer(self, &transfer, hook_state); - } - }); + let default_hook = all_hooks + .map(|h| quote! { #h }) + .unwrap_or_else(|| quote! { () }); Ok(quote! { impl #imp #me::standard::nep141::Nep141ControllerInternal for #ident #ty #wher { + type MintHook = (#mint_hook, #default_hook); + type TransferHook = (#transfer_hook, #default_hook); + type BurnHook = (#burn_hook, #default_hook); + #root } @@ -67,36 +77,23 @@ pub fn expand(meta: Nep141Meta) -> Result { amount: #near_sdk::json_types::U128, memo: Option, ) { - use #me::{ - standard::{ - nep141::{Nep141Controller, event}, - nep297::Event, - }, - }; + use #me::standard::nep141::*; #near_sdk::assert_one_yocto(); let sender_id = #near_sdk::env::predecessor_account_id(); let amount: u128 = amount.into(); - let transfer = #me::standard::nep141::Nep141Transfer { - sender_id: sender_id.clone(), - receiver_id: receiver_id.clone(), + let transfer = Nep141Transfer { + sender_id: &sender_id, + receiver_id: &receiver_id, amount, - memo: memo.clone(), + memo: memo.as_deref(), msg: None, + revert: false, }; - #before_transfer - - Nep141Controller::transfer( - self, - sender_id.clone(), - receiver_id.clone(), - amount, - memo, - ); - - #after_transfer + Nep141Controller::transfer(self, &transfer) + .unwrap_or_else(|e| #near_sdk::env::panic_str(&e.to_string())); } #[payable] @@ -107,41 +104,57 @@ pub fn expand(meta: Nep141Meta) -> Result { memo: Option, msg: String, ) -> #near_sdk::Promise { + use #me::standard::nep141::*; + + let prepaid_gas = #near_sdk::env::prepaid_gas(); + + #near_sdk::require!( + prepaid_gas >= GAS_FOR_FT_TRANSFER_CALL, + MORE_GAS_FAIL_MESSAGE, + ); + #near_sdk::assert_one_yocto(); let sender_id = #near_sdk::env::predecessor_account_id(); let amount: u128 = amount.into(); - let transfer = #me::standard::nep141::Nep141Transfer { - sender_id: sender_id.clone(), - receiver_id: receiver_id.clone(), + let transfer = Nep141Transfer { + sender_id: &sender_id, + receiver_id: &receiver_id, amount, - memo: memo.clone(), - msg: None, + memo: memo.as_deref(), + msg: Some(&msg), + revert: false, }; - #before_transfer - - let r = #me::standard::nep141::Nep141Controller::transfer_call( - self, - sender_id.clone(), - receiver_id.clone(), - amount, - memo, - msg.clone(), - #near_sdk::env::prepaid_gas(), - ); - - #after_transfer - - r + Nep141Controller::transfer(self, &transfer) + .unwrap_or_else(|e| #near_sdk::env::panic_str(&e.to_string())); + + let receiver_gas = prepaid_gas + .0 + .checked_sub(GAS_FOR_FT_TRANSFER_CALL.0) // TODO: Double-check this math. Should this be GAS_FOR_RESOLVE_TRANSFER? If not, this checked_sub call is superfluous given the require!() at the top of this function. + .unwrap_or_else(|| #near_sdk::env::panic_str("Prepaid gas overflow")); + + // Initiating receiver's call and the callback + ext_nep141_receiver::ext(transfer.receiver_id.clone()) + .with_static_gas(receiver_gas.into()) + .ft_on_transfer(transfer.sender_id.clone(), transfer.amount.into(), msg.clone()) + .then( + ext_nep141_resolver::ext(#near_sdk::env::current_account_id()) + .with_static_gas(GAS_FOR_RESOLVE_TRANSFER) + .ft_resolve_transfer( + transfer.sender_id.clone(), + transfer.receiver_id.clone(), + transfer.amount.into(), + ), + ) } fn ft_total_supply(&self) -> #near_sdk::json_types::U128 { - ::total_supply().into() + #me::standard::nep141::Nep141Controller::total_supply(self).into() } fn ft_balance_of(&self, account_id: #near_sdk::AccountId) -> #near_sdk::json_types::U128 { - ::balance_of(&account_id).into() + #me::standard::nep141::Nep141Controller::balance_of(self, &account_id).into() } } @@ -154,12 +167,51 @@ pub fn expand(meta: Nep141Meta) -> Result { receiver_id: #near_sdk::AccountId, amount: #near_sdk::json_types::U128, ) -> #near_sdk::json_types::U128 { - #me::standard::nep141::Nep141Controller::resolve_transfer( - self, - sender_id, - receiver_id, - amount.into(), - ).into() + use #near_sdk::{env, PromiseResult, serde_json, json_types::U128}; + use #me::standard::nep141::*; + + let amount = amount.0; + + let ft_on_transfer_promise_result = env::promise_result(0); + + let unused_amount = match ft_on_transfer_promise_result { + PromiseResult::NotReady => env::abort(), + PromiseResult::Successful(value) => { + if let Ok(U128(unused_amount)) = serde_json::from_slice::(&value) { + std::cmp::min(amount, unused_amount) + } else { + amount + } + } + PromiseResult::Failed => amount, + }; + + let refunded_amount = if unused_amount > 0 { + let receiver_balance = Nep141Controller::balance_of(self, &receiver_id); + if receiver_balance > 0 { + let refund_amount = std::cmp::min(receiver_balance, unused_amount); + let transfer = Nep141Transfer { + sender_id: &receiver_id, + receiver_id: &sender_id, + amount: refund_amount, + memo: None, + msg: None, + revert: true, + }; + + Nep141Controller::transfer(self, &transfer) + .unwrap_or_else(|e| env::panic_str(&e.to_string())); + + refund_amount + } else { + 0 + } + } else { + 0 + }; + + // Used amount + U128(amount - refunded_amount) } } }) diff --git a/macros/src/standard/nep145.rs b/macros/src/standard/nep145.rs new file mode 100644 index 0000000..ca4ca68 --- /dev/null +++ b/macros/src/standard/nep145.rs @@ -0,0 +1,165 @@ +use darling::FromDeriveInput; +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Expr, Type}; + +#[derive(Debug, FromDeriveInput)] +#[darling(attributes(nep145), supports(struct_named))] +pub struct Nep145Meta { + pub storage_key: Option, + pub all_hooks: Option, + pub force_unregister_hook: Option, + pub generics: syn::Generics, + pub ident: syn::Ident, + + // crates + #[darling(rename = "crate", default = "crate::default_crate_name")] + pub me: syn::Path, + #[darling(default = "crate::default_near_sdk")] + pub near_sdk: syn::Path, +} + +pub fn expand(meta: Nep145Meta) -> Result { + let Nep145Meta { + storage_key, + all_hooks, + force_unregister_hook, + generics, + ident, + + me, + near_sdk, + } = meta; + + let (imp, ty, wher) = generics.split_for_impl(); + + let root = storage_key.map(|storage_key| { + quote! { + fn root() -> #me::slot::Slot<()> { + #me::slot::Slot::root(#storage_key) + } + } + }); + + let all_hooks = all_hooks + .map(|h| quote! { #h }) + .unwrap_or_else(|| quote! { () }); + let force_unregister_hook = force_unregister_hook + .map(|h| quote! { #h }) + .unwrap_or_else(|| quote! { () }); + + Ok(quote! { + impl #imp #me::standard::nep145::Nep145ControllerInternal for #ident #ty #wher { + type ForceUnregisterHook = (#force_unregister_hook, #all_hooks); + + #root + } + + #[#near_sdk::near_bindgen] + impl #imp #me::standard::nep145::Nep145 for #ident #ty #wher { + #[payable] + fn storage_deposit( + &mut self, + account_id: Option<#near_sdk::AccountId>, + registration_only: Option, + ) -> #me::standard::nep145::StorageBalance { + use #me::standard::nep145::*; + use #near_sdk::{env, json_types::U128, Promise}; + + let bounds = Nep145Controller::get_storage_balance_bounds(self); + + let attached = env::attached_deposit(); + let amount = if registration_only.unwrap_or(false) { + bounds.min.0 + } else if let Some(U128(max)) = bounds.max { + u128::min(max, attached) + } else { + attached + }; + let refund = attached.checked_sub(amount).unwrap_or_else(|| { + env::panic_str(&format!( + "Attached deposit {} is less than required {}", + attached, amount, + )) + }); + let predecessor = env::predecessor_account_id(); + + let storage_balance = Nep145Controller::deposit_to_storage_account( + self, + &account_id.unwrap_or_else(|| predecessor.clone()), + U128(amount), + ) + .unwrap_or_else(|e| env::panic_str(&format!("Storage deposit error: {}", e))); + + if refund > 0 { + Promise::new(predecessor).transfer(amount); + } + + storage_balance + } + + #[payable] + fn storage_withdraw(&mut self, amount: Option<#near_sdk::json_types::U128>) -> #me::standard::nep145::StorageBalance { + use #me::standard::nep145::*; + use #near_sdk::{env, json_types::U128, Promise}; + + near_sdk::assert_one_yocto(); + + let predecessor = env::predecessor_account_id(); + + let balance = Nep145Controller::get_storage_balance(self, &predecessor) + .unwrap_or_else(|e| env::panic_str(&e.to_string())); + + let amount = amount.unwrap_or(balance.available); + + if amount.0 == 0 { + return balance; + } + + let new_balance = Nep145Controller::withdraw_from_storage_account(self, &predecessor, amount) + .unwrap_or_else(|e| env::panic_str(&format!("Storage withdraw error: {}", e))); + + Promise::new(predecessor).transfer(amount.0); + + new_balance + } + + fn storage_unregister(&mut self, force: Option) -> bool { + use #me::standard::nep145::*; + use #near_sdk::{env, Promise}; + + near_sdk::assert_one_yocto(); + + let predecessor = env::predecessor_account_id(); + + let refund = if force.unwrap_or(false) { + match Nep145Controller::force_unregister_storage_account(self, &predecessor) { + Ok(refund) => refund, + Err(error::StorageForceUnregisterError::AccountNotRegistered(_)) => return false, + } + } else { + match Nep145Controller::unregister_storage_account(self, &predecessor) { + Ok(refund) => refund, + Err(error::StorageUnregisterError::UnregisterWithLockedBalance(e)) => { + env::panic_str(&format!( + "Attempt to unregister from storage with locked balance: {}", e + )); + } + Err(error::StorageUnregisterError::AccountNotRegistered(_)) => return false, + } + }; + + Promise::new(predecessor).transfer(refund.0); + true + } + + fn storage_balance_of(&self, account_id: #near_sdk::AccountId) -> Option<#me::standard::nep145::StorageBalance> { + #me::standard::nep145::Nep145Controller::get_storage_balance(self, &account_id).ok() + } + + fn storage_balance_bounds(&self) -> #me::standard::nep145::StorageBalanceBounds { + #me::standard::nep145::Nep145Controller::get_storage_balance_bounds(self) + } + } + }) +} diff --git a/macros/src/standard/nep148.rs b/macros/src/standard/nep148.rs index 5580317..227f3bd 100644 --- a/macros/src/standard/nep148.rs +++ b/macros/src/standard/nep148.rs @@ -1,18 +1,12 @@ -use darling::{FromDeriveInput, ToTokens}; +use darling::FromDeriveInput; use proc_macro2::TokenStream; use quote::quote; +use syn::Expr; #[derive(Debug, FromDeriveInput)] #[darling(attributes(nep148), supports(struct_named))] pub struct Nep148Meta { - pub spec: Option, - pub name: String, - pub symbol: String, - pub icon: Option, - pub reference: Option, - pub reference_hash: Option, - pub decimals: u8, - + pub storage_key: Option, pub generics: syn::Generics, pub ident: syn::Ident, @@ -23,64 +17,35 @@ pub struct Nep148Meta { pub near_sdk: syn::Path, } -fn optionize(t: Option) -> TokenStream -where - T: ToTokens, -{ - t.map_or_else(|| quote! { None }, |v| quote! { Some(#v) }) -} - pub fn expand(meta: Nep148Meta) -> Result { let Nep148Meta { + storage_key, generics, ident, - // fields - spec, - name, - symbol, - icon, - reference, - reference_hash, - decimals, me, near_sdk, } = meta; - let spec = spec.map(|s| s.to_token_stream()).unwrap_or_else(|| { + let root = storage_key.map(|storage_key| { quote! { - #me::standard::nep148::FT_METADATA_SPEC + fn root() -> #me::slot::Slot<()> { + #me::slot::Slot::root(#storage_key) + } } }); - let icon = optionize(icon); - let reference = optionize(reference); - - // TODO: Download reference field at compile time and calculate reference_hash automatically - let reference_hash = optionize(reference_hash.map(|s| { - let v = format!("{:?}", base64::decode(s).unwrap()) - .parse::() - .unwrap(); - - quote! { #near_sdk::json_types::Base64VecU8::from(#v.to_vec()) } - })); - let (imp, ty, wher) = generics.split_for_impl(); Ok(quote! { - use #me::standard::nep148::Nep148; + impl #imp #me::standard::nep148::Nep148ControllerInternal for #ident #ty #wher { + #root + } + #[#near_sdk::near_bindgen] impl #imp #me::standard::nep148::Nep148 for #ident #ty #wher { fn ft_metadata(&self) -> #me::standard::nep148::FungibleTokenMetadata { - #me::standard::nep148::FungibleTokenMetadata { - spec: #spec.into(), - name: #name.into(), - symbol: #symbol.into(), - icon: #icon.map(|s: &str| s.into()), - reference: #reference.map(|s: &str| s.into()), - reference_hash: #reference_hash, - decimals: #decimals, - } + #me::standard::nep148::Nep148Controller::get_metadata(self) } } }) diff --git a/macros/src/standard/nep171.rs b/macros/src/standard/nep171.rs index 5220ec5..1757410 100644 --- a/macros/src/standard/nep171.rs +++ b/macros/src/standard/nep171.rs @@ -1,18 +1,20 @@ -use std::ops::Not; - -use darling::{util::Flag, FromDeriveInput}; +use darling::FromDeriveInput; use proc_macro2::TokenStream; use quote::quote; -use syn::Expr; +use syn::{parse_quote, Expr, Type}; + +use crate::unitify; #[derive(Debug, FromDeriveInput)] #[darling(attributes(nep171), supports(struct_named))] pub struct Nep171Meta { pub storage_key: Option, - pub no_hooks: Flag, - pub extension_hooks: Option, - pub check_external_transfer: Option, - pub token_data: Option, + pub all_hooks: Option, + pub mint_hook: Option, + pub transfer_hook: Option, + pub burn_hook: Option, + pub check_external_transfer: Option, + pub token_data: Option, pub generics: syn::Generics, pub ident: syn::Ident, @@ -27,8 +29,10 @@ pub struct Nep171Meta { pub fn expand(meta: Nep171Meta) -> Result { let Nep171Meta { storage_key, - no_hooks, - extension_hooks, + all_hooks, + mint_hook, + transfer_hook, + burn_hook, check_external_transfer, token_data, @@ -41,17 +45,11 @@ pub fn expand(meta: Nep171Meta) -> Result { let (imp, ty, wher) = generics.split_for_impl(); - let token_data = token_data - .map(|token_data| quote! { #token_data }) - .unwrap_or_else(|| { - quote! { () } - }); + let token_data = unitify(token_data); - let check_external_transfer = check_external_transfer - .map(|check_external_transfer| quote! { #check_external_transfer }) - .unwrap_or_else(|| { - quote! { #me::standard::nep171::DefaultCheckExternalTransfer } - }); + let check_external_transfer = check_external_transfer.unwrap_or_else(|| { + parse_quote! { #me::standard::nep171::DefaultCheckExternalTransfer } + }); let root = storage_key.map(|storage_key| { quote! { @@ -61,31 +59,17 @@ pub fn expand(meta: Nep171Meta) -> Result { } }); - let extension_hooks_type = extension_hooks - .map(|extension_hooks| quote! { #extension_hooks }) - .unwrap_or_else(|| { - quote! { () } - }); - - let self_hooks_type = no_hooks - .is_present() - .not() - .then(|| { - quote! { - Self - } - }) - .unwrap_or_else(|| { - quote! { - () - } - }); - - let hooks_type = quote! { (#self_hooks_type, #extension_hooks_type) }; + let all_hooks = unitify(all_hooks); + let mint_hook = unitify(mint_hook); + let transfer_hook = unitify(transfer_hook); + let burn_hook = unitify(burn_hook); Ok(quote! { impl #imp #me::standard::nep171::Nep171ControllerInternal for #ident #ty #wher { - type Hook = #hooks_type; + type MintHook = (#mint_hook, #all_hooks); + type TransferHook = (#transfer_hook, #all_hooks); + type BurnHook = (#burn_hook, #all_hooks); + type CheckExternalTransfer = #check_external_transfer; type LoadTokenMetadata = #token_data; @@ -122,7 +106,7 @@ pub fn expand(meta: Nep171Meta) -> Result { if should_revert { let token_ids = [token_id]; - let transfer = Nep171Transfer { + let transfer = action::Nep171Transfer { token_id: &token_ids[0], authorization: Nep171TransferAuthorization::Owner, sender_id: &receiver_id, @@ -158,7 +142,7 @@ pub fn expand(meta: Nep171Meta) -> Result { let token_ids = [token_id]; - let transfer = Nep171Transfer { + let transfer = action::Nep171Transfer { token_id: &token_ids[0], authorization: approval_id.map(Nep171TransferAuthorization::ApprovalId).unwrap_or(Nep171TransferAuthorization::Owner), sender_id: &sender_id, @@ -194,7 +178,7 @@ pub fn expand(meta: Nep171Meta) -> Result { let token_ids = [token_id]; - let transfer = Nep171Transfer { + let transfer = action::Nep171Transfer { token_id: &token_ids[0], authorization: approval_id.map(Nep171TransferAuthorization::ApprovalId).unwrap_or(Nep171TransferAuthorization::Owner), sender_id: &sender_id, diff --git a/macros/src/standard/nep178.rs b/macros/src/standard/nep178.rs index eff6eb3..6369f48 100644 --- a/macros/src/standard/nep178.rs +++ b/macros/src/standard/nep178.rs @@ -1,13 +1,18 @@ -use darling::{util::Flag, FromDeriveInput}; +use darling::FromDeriveInput; use proc_macro2::TokenStream; use quote::quote; -use syn::Expr; +use syn::{Expr, Type}; + +use crate::unitify; #[derive(Debug, FromDeriveInput)] #[darling(attributes(nep178), supports(struct_named))] pub struct Nep178Meta { pub storage_key: Option, - pub no_hooks: Flag, + pub all_hooks: Option, + pub approve_hook: Option, + pub revoke_hook: Option, + pub revoke_all_hook: Option, pub generics: syn::Generics, pub ident: syn::Ident, @@ -22,7 +27,10 @@ pub struct Nep178Meta { pub fn expand(meta: Nep178Meta) -> Result { let Nep178Meta { storage_key, - no_hooks, + all_hooks, + approve_hook, + revoke_hook, + revoke_all_hook, generics, ident, @@ -33,11 +41,6 @@ pub fn expand(meta: Nep178Meta) -> Result { let (imp, ty, wher) = generics.split_for_impl(); - let hook = no_hooks - .is_present() - .then(|| quote! { () }) - .unwrap_or_else(|| quote! { Self }); - let root = storage_key.map(|storage_key| { quote! { fn root() -> #me::slot::Slot<()> { @@ -46,9 +49,16 @@ pub fn expand(meta: Nep178Meta) -> Result { } }); + let all_hooks = unitify(all_hooks); + let approve_hook = unitify(approve_hook); + let revoke_hook = unitify(revoke_hook); + let revoke_all_hook = unitify(revoke_all_hook); + Ok(quote! { impl #imp #me::standard::nep178::Nep178ControllerInternal for #ident #ty #wher { - type Hook = #hook; + type ApproveHook = (#approve_hook, #all_hooks); + type RevokeHook = (#revoke_hook, #all_hooks); + type RevokeAllHook = (#revoke_all_hook, #all_hooks); #root } @@ -62,20 +72,23 @@ pub fn expand(meta: Nep178Meta) -> Result { account_id: #near_sdk::AccountId, msg: Option, ) -> #near_sdk::PromiseOrValue<()> { + use #me::standard::nep178::*; + #me::utils::assert_nonzero_deposit(); let predecessor = #near_sdk::env::predecessor_account_id(); - let approval_id = #me::standard::nep178::Nep178Controller::approve( - self, - &token_id, - &predecessor, - &account_id, - ) - .unwrap_or_else(|e| #near_sdk::env::panic_str(&e.to_string())); + let action = action::Nep178Approve { + token_id: &token_id, + current_owner_id: &predecessor, + account_id: &account_id, + }; + + let approval_id = Nep178Controller::approve(self, &action) + .unwrap_or_else(|e| #near_sdk::env::panic_str(&e.to_string())); msg.map_or(#near_sdk::PromiseOrValue::Value(()), |msg| { - #me::standard::nep178::ext_nep178_receiver::ext(account_id) + ext_nep178_receiver::ext(account_id) .nft_on_approve(token_id, predecessor, approval_id, msg) .into() }) @@ -87,31 +100,37 @@ pub fn expand(meta: Nep178Meta) -> Result { token_id: #me::standard::nep171::TokenId, account_id: #near_sdk::AccountId, ) { + use #me::standard::nep178::*; + #near_sdk::assert_one_yocto(); let predecessor = #near_sdk::env::predecessor_account_id(); - #me::standard::nep178::Nep178Controller::revoke( - self, - &token_id, - &predecessor, - &account_id, - ) - .unwrap_or_else(|e| #near_sdk::env::panic_str(&e.to_string())); + let action = action::Nep178Revoke { + token_id: &token_id, + current_owner_id: &predecessor, + account_id: &account_id, + }; + + Nep178Controller::revoke(self, &action) + .unwrap_or_else(|e| #near_sdk::env::panic_str(&e.to_string())); } #[payable] fn nft_revoke_all(&mut self, token_id: #me::standard::nep171::TokenId) { + use #me::standard::nep178::*; + #near_sdk::assert_one_yocto(); let predecessor = #near_sdk::env::predecessor_account_id(); - #me::standard::nep178::Nep178Controller::revoke_all( - self, - &token_id, - &predecessor, - ) - .unwrap_or_else(|e| #near_sdk::env::panic_str(&e.to_string())); + let action = action::Nep178RevokeAll { + token_id: &token_id, + current_owner_id: &predecessor, + }; + + Nep178Controller::revoke_all(self, &action) + .unwrap_or_else(|e| #near_sdk::env::panic_str(&e.to_string())); } fn nft_is_approved( diff --git a/macros/src/standard/non_fungible_token.rs b/macros/src/standard/non_fungible_token.rs index 552ab0d..b5d0f28 100644 --- a/macros/src/standard/non_fungible_token.rs +++ b/macros/src/standard/non_fungible_token.rs @@ -1,23 +1,35 @@ -use darling::{util::Flag, FromDeriveInput}; +use darling::FromDeriveInput; use proc_macro2::TokenStream; use quote::quote; -use syn::Expr; +use syn::{parse_quote, Expr, Type}; -use super::{nep171, nep177, nep178, nep181}; +use crate::unitify; + +use super::{nep145, nep171, nep177, nep178, nep181}; #[derive(Debug, FromDeriveInput)] #[darling(attributes(non_fungible_token), supports(struct_named))] pub struct NonFungibleTokenMeta { + pub all_hooks: Option, + + // NEP-145 fields + pub storage_management_storage_key: Option, + pub force_unregister_hook: Option, + // NEP-171 fields pub core_storage_key: Option, - pub no_core_hooks: Flag, + pub mint_hook: Option, + pub transfer_hook: Option, + pub burn_hook: Option, // NEP-177 fields pub metadata_storage_key: Option, // NEP-178 fields pub approval_storage_key: Option, - pub no_approval_hooks: Flag, + pub approve_hook: Option, + pub revoke_hook: Option, + pub revoke_all_hook: Option, // NEP-181 fields pub enumeration_storage_key: Option, @@ -35,13 +47,22 @@ pub struct NonFungibleTokenMeta { pub fn expand(meta: NonFungibleTokenMeta) -> Result { let NonFungibleTokenMeta { - core_storage_key: storage_key, - no_core_hooks: no_hooks, + all_hooks, + + storage_management_storage_key, + force_unregister_hook, + + core_storage_key, + mint_hook, + transfer_hook, + burn_hook, metadata_storage_key, approval_storage_key, - no_approval_hooks, + approve_hook, + revoke_hook, + revoke_all_hook, enumeration_storage_key, @@ -52,12 +73,36 @@ pub fn expand(meta: NonFungibleTokenMeta) -> Result near_sdk, } = meta; - let expand_nep171 = nep171::expand(nep171::Nep171Meta { - storage_key, - no_hooks, - extension_hooks: Some( - syn::parse_quote! { (#me::standard::nep178::TokenApprovals, #me::standard::nep181::TokenEnumeration) }, + let all_hooks_inner = unitify(all_hooks.clone()); + let force_unregister_hook = unitify(force_unregister_hook); + + let expand_nep145 = nep145::expand(nep145::Nep145Meta { + storage_key: storage_management_storage_key, + all_hooks: Some(all_hooks_inner.clone()), + force_unregister_hook: Some( + parse_quote! { (#force_unregister_hook, #me::standard::nep171::hooks::BurnNep171OnForceUnregisterHook) }, ), + generics: generics.clone(), + ident: ident.clone(), + me: me.clone(), + near_sdk: near_sdk.clone(), + }); + + let expand_nep171 = nep171::expand(nep171::Nep171Meta { + storage_key: core_storage_key, + all_hooks: Some(parse_quote! { ( + #all_hooks_inner, + ( + #me::standard::nep145::hooks::Nep171StorageAccountingHook, + ( + #me::standard::nep178::TokenApprovals, + #me::standard::nep181::TokenEnumeration, + ), + ), + ) }), + mint_hook, + transfer_hook, + burn_hook, check_external_transfer: Some(syn::parse_quote! { #me::standard::nep178::TokenApprovals }), token_data: Some( @@ -83,7 +128,11 @@ pub fn expand(meta: NonFungibleTokenMeta) -> Result let expand_nep178 = nep178::expand(nep178::Nep178Meta { storage_key: approval_storage_key, - no_hooks: no_approval_hooks, + all_hooks, + approve_hook, + revoke_hook, + revoke_all_hook, + generics: generics.clone(), ident: ident.clone(), me: me.clone(), @@ -100,12 +149,14 @@ pub fn expand(meta: NonFungibleTokenMeta) -> Result let mut e = darling::Error::accumulator(); + let nep145 = e.handle(expand_nep145); let nep171 = e.handle(expand_nep171); let nep177 = e.handle(expand_nep177); let nep178 = e.handle(expand_nep178); let nep181 = e.handle(expand_nep181); e.finish_with(quote! { + #nep145 #nep171 #nep177 #nep178 diff --git a/macros/src/upgrade.rs b/macros/src/upgrade.rs index e08a319..0fed6c4 100644 --- a/macros/src/upgrade.rs +++ b/macros/src/upgrade.rs @@ -1,8 +1,6 @@ use darling::{FromDeriveInput, FromMeta}; -use once_cell::sync::OnceCell; use proc_macro2::TokenStream; use quote::quote; -use regex::Regex; use syn::Expr; #[derive(Debug, Clone)] @@ -19,23 +17,21 @@ impl FromMeta for HookBody { } fn from_string(value: &str) -> darling::Result { - static REGEX: OnceCell = OnceCell::new(); - if value == "empty" { Ok(HookBody::Empty) } else if value == "owner" { Ok(HookBody::Owner) + } else if let Some(b) = value + .strip_prefix("role(") + .and_then(|s| s.strip_suffix(')')) + .and_then(|s| syn::parse_str::(s).ok()) + .map(|e| HookBody::Role(Box::new(e))) + { + Ok(b) } else { - let r = REGEX.get_or_init(|| Regex::new(r"^role\((.+)\)$").unwrap()); - r.captures(value) - .and_then(|c| c.get(1)) - .and_then(|s| syn::parse_str::(s.as_str()).ok()) - .map(|e| HookBody::Role(Box::new(e))) - .ok_or_else(|| { - darling::Error::custom(format!( - r#"Invalid value "{value}", expected "empty", "owner", or "role(...)""#, - )) - }) + Err(darling::Error::custom(format!( + r#"Invalid value "{value}", expected "empty", "owner", or "role(...)""#, + ))) } } } diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 5eca3a9..7e8f0a9 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,2 @@ [toolchain] -channel = "1.69" # https://github.com/near/nearcore/issues/9143 +channel = "1.72" diff --git a/rustfmt.toml b/rustfmt.toml index b6f799d..7c8c985 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1 +1,2 @@ tab_spaces = 4 +max_width = 100 diff --git a/src/approval/mod.rs b/src/approval/mod.rs index e4045b4..95fe2c4 100644 --- a/src/approval/mod.rs +++ b/src/approval/mod.rs @@ -2,9 +2,10 @@ use near_sdk::{ borsh::{self, BorshDeserialize, BorshSerialize}, - env, require, AccountId, BorshStorageKey, + env, require, + serde::{Deserialize, Serialize}, + AccountId, BorshStorageKey, }; -use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::{slot::Slot, DefaultStorageKey}; @@ -64,6 +65,7 @@ pub trait ApprovalConfiguration { /// An action request is composed of an action that will be executed when the /// associated approval state is satisfied #[derive(Serialize, Deserialize, BorshSerialize, BorshDeserialize, Debug)] +#[serde(crate = "near_sdk::serde")] pub struct ActionRequest { /// The action that will be executed when the approval state is /// fulfilled @@ -336,11 +338,11 @@ mod tests { use near_sdk::{ borsh::{self, BorshDeserialize, BorshSerialize}, near_bindgen, + serde::Serialize, test_utils::VMContextBuilder, testing_env, AccountId, BorshStorageKey, }; use near_sdk_contract_tools_macros::Rbac; - use serde::Serialize; use crate::{rbac::Rbac, slot::Slot}; @@ -405,6 +407,7 @@ mod tests { } #[derive(BorshSerialize, BorshDeserialize, Serialize, Default, Debug)] + #[serde(crate = "near_sdk::serde")] struct MultisigApprovalState { pub approved_by: Vec, } diff --git a/src/approval/native_transaction_action.rs b/src/approval/native_transaction_action.rs index 3058115..179ab9d 100644 --- a/src/approval/native_transaction_action.rs +++ b/src/approval/native_transaction_action.rs @@ -5,14 +5,15 @@ use near_sdk::{ borsh::{self, BorshDeserialize, BorshSerialize}, json_types::{Base64VecU8, U128, U64}, + serde::{Deserialize, Serialize}, AccountId, Gas, Promise, }; -use serde::{Deserialize, Serialize}; /// Every native NEAR action can be mapped to a Promise action. /// NOTE: The native ADD_KEY action is split into two: one for adding a /// full-access key, one for a function call access key. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[serde(crate = "near_sdk::serde")] pub enum PromiseAction { /// Native CREATE_ACCOUNT action CreateAccount, @@ -79,6 +80,7 @@ pub enum PromiseAction { /// A native protocol-level transaction that (de)serializes into many different /// formats #[derive(BorshSerialize, BorshDeserialize, Serialize, Deserialize, Eq, PartialEq, Clone, Debug)] +#[serde(crate = "near_sdk::serde")] pub struct NativeTransactionAction { /// Receiver of the transaction pub receiver_id: AccountId, diff --git a/src/approval/simple_multisig.rs b/src/approval/simple_multisig.rs index cdd0463..75c5b9b 100644 --- a/src/approval/simple_multisig.rs +++ b/src/approval/simple_multisig.rs @@ -5,9 +5,10 @@ use std::marker::PhantomData; use near_sdk::{ borsh::{self, BorshDeserialize, BorshSerialize}, - env, AccountId, + env, + serde::{Deserialize, Serialize}, + AccountId, }; -use serde::{Deserialize, Serialize}; use thiserror::Error; use super::{ActionRequest, ApprovalConfiguration}; @@ -24,6 +25,7 @@ pub trait AccountAuthorizer { /// M (threshold) of N approval scheme #[derive(BorshSerialize, BorshDeserialize, Serialize, Deserialize, Clone, Debug)] +#[serde(crate = "near_sdk::serde")] pub struct Configuration { /// How many approvals are required? pub threshold: u8, @@ -61,6 +63,7 @@ impl Configuration { /// Approval state for simple multisig #[derive(BorshSerialize, BorshDeserialize, Serialize, Deserialize, Debug)] +#[serde(crate = "near_sdk::serde")] pub struct ApprovalState { /// List of accounts that have approved an action thus far pub approved_by: Vec, diff --git a/src/hook.rs b/src/hook.rs new file mode 100644 index 0000000..8a54f3f --- /dev/null +++ b/src/hook.rs @@ -0,0 +1,56 @@ +//! # Hooks +//! +//! Hooks are a way to wrap (inject code before and after) component functions. +//! +//! Most of the time, hooks are used to implement cross-cutting concerns, such as +//! logging, accounting, or integration with other components. +//! +//! ## Example +//! +//! ``` +//! use near_sdk::{env, log, near_bindgen}; +//! use near_sdk_contract_tools::{hook::Hook, standard::nep141::*, Nep141}; +//! +//! pub struct MyTransferHook; +//! +//! impl Hook> for MyTransferHook { +//! fn hook(contract: &mut MyContract, transfer: &Nep141Transfer<'_>, f: impl FnOnce(&mut MyContract) -> R) -> R { +//! // Log, check preconditions, save state, etc. +//! log!("NEP-141 transfer from {} to {} of {} tokens", transfer.sender_id, transfer.receiver_id, transfer.amount); +//! +//! let storage_usage_before = env::storage_usage(); +//! +//! let r = f(contract); // execute wrapped function +//! +//! let storage_usage_after = env::storage_usage(); +//! log!("Storage delta: {}", storage_usage_after - storage_usage_before); +//! +//! r +//! } +//! } +//! +//! #[derive(Nep141)] +//! #[nep141(transfer_hook = "MyTransferHook")] +//! #[near_bindgen] +//! struct MyContract {} +//! ``` + +/// Generic hook trait for injecting code before and after component functions. +pub trait Hook { + /// Execute a function with hooks. + fn hook(contract: &mut C, _args: &A, f: impl FnOnce(&mut C) -> R) -> R { + f(contract) + } +} + +impl Hook for () {} + +impl Hook for (T, U) +where + T: Hook, + U: Hook, +{ + fn hook(contract: &mut C, args: &A, f: impl FnOnce(&mut C) -> R) -> R { + T::hook(contract, args, |contract| U::hook(contract, args, f)) + } +} diff --git a/src/lib.rs b/src/lib.rs index f1f34d7..4935ca4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,10 @@ pub enum DefaultStorageKey { ApprovalManager, /// Default storage key for [`standard::nep141::Nep141ControllerInternal::root`]. Nep141, + /// Default storage key for [`standard::nep145::Nep145ControllerInternal::root`] + Nep145, + /// Default storage key for [`standard::nep148::Nep148ControllerInternal::root`]. + Nep148, /// Default storage key for [`standard::nep171::Nep171ControllerInternal::root`]. Nep171, /// Default storage key for [`standard::nep177::Nep177ControllerInternal::root`]. @@ -25,7 +29,7 @@ pub enum DefaultStorageKey { Pause, /// Default storage key for [`rbac::RbacInternal::root`]. Rbac, - /// Default storage key for [`escrow::Escrow::root`] + /// Default storage key for [`escrow::EscrowInternal::root`] Escrow, } @@ -34,6 +38,8 @@ impl IntoStorageKey for DefaultStorageKey { match self { DefaultStorageKey::ApprovalManager => b"~am".to_vec(), DefaultStorageKey::Nep141 => b"~$141".to_vec(), + DefaultStorageKey::Nep145 => b"~$145".to_vec(), + DefaultStorageKey::Nep148 => b"~$148".to_vec(), DefaultStorageKey::Nep171 => b"~$171".to_vec(), DefaultStorageKey::Nep177 => b"~$177".to_vec(), DefaultStorageKey::Nep178 => b"~$178".to_vec(), @@ -51,6 +57,7 @@ pub mod standard; pub mod approval; pub mod escrow; pub mod fast_account_id; +pub mod hook; pub mod migrate; pub mod owner; pub mod pause; @@ -62,7 +69,51 @@ pub mod utils; /// Re-exports of the NFT standard traits. pub mod nft { pub use crate::{ - standard::{nep171::*, nep177::*, nep178::*, nep181::*}, + standard::{ + nep145::{ + self, ext_nep145, Nep145, Nep145Controller, Nep145ControllerInternal, + StorageBalance, StorageBalanceBounds, + }, + nep171::{ + self, action::*, ext_nep171, ext_nep171_receiver, ext_nep171_resolver, Nep171, + Nep171Controller, Nep171ControllerInternal, Nep171Receiver, Nep171Resolver, Token, + TokenId, + }, + nep177::{ + self, ext_nep177, ContractMetadata, Nep177, Nep177Controller, + Nep177ControllerInternal, TokenMetadata, + }, + nep178::{ + self, action::*, ext_nep178, ext_nep178_receiver, ApprovalId, Nep178, + Nep178Controller, Nep178ControllerInternal, Nep178Receiver, TokenApprovals, + }, + nep181::{ + self, ext_nep181, Nep181, Nep181Controller, Nep181ControllerInternal, + TokenEnumeration, + }, + }, Nep171, Nep177, Nep178, Nep181, NonFungibleToken, }; } + +/// Re-exports of the FT standard traits. +pub mod ft { + pub use crate::{ + standard::{ + nep141::{ + self, ext_nep141, ext_nep141_receiver, ext_nep141_resolver, Nep141, Nep141Burn, + Nep141Controller, Nep141ControllerInternal, Nep141Mint, Nep141Receiver, + Nep141Resolver, Nep141Transfer, + }, + nep145::{ + self, ext_nep145, Nep145, Nep145Controller, Nep145ControllerInternal, + StorageBalance, StorageBalanceBounds, + }, + nep148::{ + self, ext_nep148, FungibleTokenMetadata, Nep148, Nep148Controller, + Nep148ControllerInternal, + }, + }, + FungibleToken, Nep141, Nep145, Nep148, + }; +} diff --git a/src/migrate.rs b/src/migrate.rs index a05fccf..7b98e63 100644 --- a/src/migrate.rs +++ b/src/migrate.rs @@ -10,7 +10,7 @@ //! //! Note: [`MigrateHook`] must be implemented by the user and is not derived //! by default. It must convert data in the old schema to the new schema without -//! failing. For a complete example checkout [upgrade_new.rs](https://github.com/NEARFoundation/near-sdk-contract-tools/blob/develop/workspaces-tests/src/bin/upgrade_new.rs) +//! failing. For a complete example checkout [upgrade_new.rs](https://github.com/near/near-sdk-contract-tools/blob/develop/workspaces-tests/src/bin/upgrade_new.rs) //! in workspace-tests. //! //! # Safety diff --git a/src/pause.rs b/src/pause.rs index b0aa657..c4b4fc0 100644 --- a/src/pause.rs +++ b/src/pause.rs @@ -21,10 +21,9 @@ //! * (ERR) Only a "paused" contract can call `unpause`. //! * (ERR) [`Pause::require_paused`] may only be called when the contract is paused. //! * (ERR) [`Pause::require_unpaused`] may only be called when the contract is unpaused. -#![allow(missing_docs)] // #[ext_contract(...)] does not play nicely with clippy use crate::{slot::Slot, standard::nep297::Event, DefaultStorageKey}; -use near_sdk::{ext_contract, require}; +use near_sdk::require; use near_sdk_contract_tools_macros::event; const UNPAUSED_FAIL_MESSAGE: &str = "Disallowed while contract is unpaused"; @@ -144,9 +143,37 @@ impl Pause for T { } } -/// External (public) methods for [`Pause`] -#[ext_contract(ext_pause)] -pub trait PauseExternal { - /// Returns `true` if the contract is paused, `false` otherwise - fn paus_is_paused(&self) -> bool; +mod ext { + #![allow(missing_docs)] // #[ext_contract(...)] does not play nicely with clippy + + use near_sdk::ext_contract; + + /// External (public) methods for [`Pause`] + #[ext_contract(ext_pause)] + pub trait PauseExternal { + /// Returns `true` if the contract is paused, `false` otherwise + fn paus_is_paused(&self) -> bool; + } +} +pub use ext::*; + +pub mod hooks { + //! Hooks to integrate [`Pause`] with other components. + + use crate::hook::Hook; + + use super::Pause; + + /// Ensures that a contract is unpaused before calling a method. + pub struct PausableHook; + + impl Hook for PausableHook + where + C: Pause, + { + fn hook(contract: &mut C, _args: &A, f: impl FnOnce(&mut C) -> R) -> R { + C::require_unpaused(); + f(contract) + } + } } diff --git a/src/rbac.rs b/src/rbac.rs index ab66708..c7605c3 100644 --- a/src/rbac.rs +++ b/src/rbac.rs @@ -10,7 +10,7 @@ //! //! This [derive macro](near_sdk_contract_tools_macros::Rbac) derives //! a default implementation for RBAC. For a complete example check out -//! [`counter_multisig.rs`](https://github.com/NEARFoundation/near-sdk-contract-tools/blob/develop/workspaces-tests/src/bin/counter_multisig.rs) +//! [`counter_multisig.rs`](https://github.com/near/near-sdk-contract-tools/blob/develop/workspaces-tests/src/bin/counter_multisig.rs) //! in workspace-tests directory. //! //! # Safety diff --git a/src/standard/mod.rs b/src/standard/mod.rs index faaac74..f8c3281 100644 --- a/src/standard/mod.rs +++ b/src/standard/mod.rs @@ -1,6 +1,7 @@ //! Implementations of NEP standards. pub mod nep141; +pub mod nep145; pub mod nep148; pub mod nep171; pub mod nep177; diff --git a/src/standard/nep141.rs b/src/standard/nep141.rs deleted file mode 100644 index a59f0ce..0000000 --- a/src/standard/nep141.rs +++ /dev/null @@ -1,538 +0,0 @@ -//! NEP-141 fungible token core implementation -//! -#![allow(missing_docs)] // ext_contract doesn't play nice with #![warn(missing_docs)] - -use near_sdk::{ - borsh::{self, BorshDeserialize, BorshSerialize}, - env, ext_contract, - json_types::U128, - require, AccountId, BorshStorageKey, Gas, Promise, PromiseOrValue, PromiseResult, -}; -use near_sdk_contract_tools_macros::event; -use serde::{Deserialize, Serialize}; - -use crate::{slot::Slot, standard::nep297::*, DefaultStorageKey}; - -/// Gas value required for ft_resolve_transfer calls -pub const GAS_FOR_RESOLVE_TRANSFER: Gas = Gas(5_000_000_000_000); -/// Gas value required for ft_transfer_call calls (includes gas for ) -pub const GAS_FOR_FT_TRANSFER_CALL: Gas = Gas(25_000_000_000_000 + GAS_FOR_RESOLVE_TRANSFER.0); - -const MORE_GAS_FAIL_MESSAGE: &str = "More gas is required"; - -/// NEP-141 standard events for minting, burning, and transferring tokens -#[event( - crate = "crate", - macros = "crate", - serde = "serde", - standard = "nep141", - version = "1.0.0" -)] -#[derive(Debug, Clone)] -pub enum Nep141Event { - /// Token mint event. Emitted when tokens are created and total_supply is - /// increased. - FtMint(Vec), - - /// Token transfer event. Emitted when tokens are transferred between two - /// accounts. No change to total_supply. - FtTransfer(Vec), - - /// Token burn event. Emitted when tokens are burned (removed from supply). - /// Decrease in total_supply. - FtBurn(Vec), -} - -pub mod event { - use near_sdk::{json_types::U128, AccountId}; - use serde::Serialize; - - /// Individual mint metadata - #[derive(Serialize, Debug, Clone)] - pub struct FtMintData { - /// Address to which new tokens were minted - pub owner_id: AccountId, - /// Amount of minted tokens - pub amount: U128, - /// Optional note - #[serde(skip_serializing_if = "Option::is_none")] - pub memo: Option, - } - - /// Individual transfer metadata - #[derive(Serialize, Debug, Clone)] - pub struct FtTransferData { - /// Account ID of the sender - pub old_owner_id: AccountId, - /// Account ID of the receiver - pub new_owner_id: AccountId, - /// Amount of transferred tokens - pub amount: U128, - /// Optional note - #[serde(skip_serializing_if = "Option::is_none")] - pub memo: Option, - } - - /// Individual burn metadata - #[derive(Serialize, Debug, Clone)] - pub struct FtBurnData { - /// Account ID from which tokens were burned - pub owner_id: AccountId, - /// Amount of burned tokens - pub amount: U128, - /// Optional note - #[serde(skip_serializing_if = "Option::is_none")] - pub memo: Option, - } - - #[cfg(test)] - mod tests { - - use super::{super::Nep141Event, *}; - use crate::standard::nep297::Event; - - #[test] - fn mint() { - assert_eq!( - Nep141Event::FtMint(vec![FtMintData { - owner_id: "foundation.near".parse().unwrap(), - amount: 500u128.into(), - memo: None, - }]) - .to_event_string(), - r#"EVENT_JSON:{"standard":"nep141","version":"1.0.0","event":"ft_mint","data":[{"owner_id":"foundation.near","amount":"500"}]}"#, - ); - } - - #[test] - fn transfer() { - assert_eq!( - Nep141Event::FtTransfer(vec![ - FtTransferData { - old_owner_id: "from.near".parse().unwrap(), - new_owner_id: "to.near".parse().unwrap(), - amount: 42u128.into(), - memo: Some("hi hello bonjour".to_string()), - }, - FtTransferData { - old_owner_id: "user1.near".parse().unwrap(), - new_owner_id: "user2.near".parse().unwrap(), - amount: 7500u128.into(), - memo: None - }, - ]) - .to_event_string(), - r#"EVENT_JSON:{"standard":"nep141","version":"1.0.0","event":"ft_transfer","data":[{"old_owner_id":"from.near","new_owner_id":"to.near","amount":"42","memo":"hi hello bonjour"},{"old_owner_id":"user1.near","new_owner_id":"user2.near","amount":"7500"}]}"#, - ); - } - - #[test] - fn burn() { - assert_eq!( - Nep141Event::FtBurn(vec![FtBurnData { - owner_id: "foundation.near".parse().unwrap(), - amount: 100u128.into(), - memo: None, - }]) - .to_event_string(), - r#"EVENT_JSON:{"standard":"nep141","version":"1.0.0","event":"ft_burn","data":[{"owner_id":"foundation.near","amount":"100"}]}"#, - ); - } - } -} - -#[derive(BorshSerialize, BorshStorageKey)] -enum StorageKey { - TotalSupply, - Account(AccountId), -} - -/// Contracts may implement this trait to inject code into NEP-141 functions. -/// -/// `T` is an optional value for passing state between different lifecycle -/// hooks. This may be useful for charging callers for storage usage, for -/// example. -pub trait Nep141Hook { - /// Executed before a token transfer is conducted - /// - /// May return an optional state value which will be passed along to the - /// following `after_transfer`. - fn before_transfer(&mut self, _transfer: &Nep141Transfer) -> T { - Default::default() - } - - /// Executed after a token transfer is conducted - /// - /// Receives the state value returned by `before_transfer`. - fn after_transfer(&mut self, _transfer: &Nep141Transfer, _state: T) {} -} - -/// Transfer metadata generic over both types of transfer (`ft_transfer` and -/// `ft_transfer_call`). -#[derive(Serialize, Deserialize, BorshSerialize, BorshDeserialize, PartialEq, Eq, Clone, Debug)] -pub struct Nep141Transfer { - /// Sender's account ID - pub sender_id: AccountId, - /// Receiver's account ID - pub receiver_id: AccountId, - /// Transferred amount - pub amount: u128, - /// Optional memo string - pub memo: Option, - /// Message passed to contract located at `receiver_id` - pub msg: Option, -} - -impl Nep141Transfer { - /// Returns `true` if this transfer comes from a `ft_transfer_call` - /// call, `false` otherwise - pub fn is_transfer_call(&self) -> bool { - self.msg.is_some() - } -} - -/// Internal functions for [`Nep141Controller`]. Using these methods may result in unexpected behavior. -pub trait Nep141ControllerInternal { - /// Root storage slot - fn root() -> Slot<()> { - Slot::new(DefaultStorageKey::Nep141) - } - - /// Slot for account data - fn slot_account(account_id: &AccountId) -> Slot { - Self::root().field(StorageKey::Account(account_id.clone())) - } - - /// Slot for storing total supply - fn slot_total_supply() -> Slot { - Self::root().field(StorageKey::TotalSupply) - } -} - -/// Non-public implementations of functions for managing a fungible token. -pub trait Nep141Controller { - /// Get the balance of an account. Returns 0 if the account does not exist. - fn balance_of(account_id: &AccountId) -> u128; - - /// Get the total circulating supply of the token. - fn total_supply() -> u128; - - /// Removes tokens from an account and decreases total supply. No event - /// emission. - /// - /// # Panics - /// - /// Panics if the current balance of `account_id` is less than `amount` or - /// if `total_supply` is less than `amount`. - fn withdraw_unchecked(&mut self, account_id: &AccountId, amount: u128); - - /// Increases the token balance of an account. Updates total supply. No - /// event emission, - /// - /// # Panics - /// - /// Panics if the balance of `account_id` plus `amount` >= `u128::MAX`, or - /// if the total supply plus `amount` >= `u128::MAX`. - fn deposit_unchecked(&mut self, account_id: &AccountId, amount: u128); - - /// Decreases the balance of `sender_account_id` by `amount` and increases - /// the balance of `receiver_account_id` by the same. No change to total - /// supply. No event emission. - /// - /// # Panics - /// - /// Panics if the balance of `sender_account_id` < `amount` or if the - /// balance of `receiver_account_id` plus `amount` >= `u128::MAX`. - fn transfer_unchecked( - &mut self, - sender_account_id: &AccountId, - receiver_account_id: &AccountId, - amount: u128, - ); - - /// Performs an NEP-141 token transfer, with event emission. - /// - /// # Panics - /// - /// See: `Nep141Controller::transfer_unchecked` - fn transfer( - &mut self, - sender_account_id: AccountId, - receiver_account_id: AccountId, - amount: u128, - memo: Option, - ); - - /// Performs an NEP-141 token mint, with event emission. - /// - /// # Panics - /// - /// See: `Nep141Controller::deposit_unchecked` - fn mint(&mut self, account_id: AccountId, amount: u128, memo: Option); - - /// Performs an NEP-141 token burn, with event emission. - /// - /// # Panics - /// - /// See: `Nep141Controller::withdraw_unchecked` - fn burn(&mut self, account_id: AccountId, amount: u128, memo: Option); - - /// Performs an NEP-141 token transfer call, with event emission. - /// - /// # Panics - /// - /// Panics if `gas_allowance` < `GAS_FOR_FT_TRANSFER_CALL`. - /// - /// See also: `Nep141Controller::transfer` - fn transfer_call( - &mut self, - sender_account_id: AccountId, - receiver_account_id: AccountId, - amount: u128, - memo: Option, - msg: String, - gas_allowance: Gas, - ) -> Promise; - - /// Resolves an NEP-141 `ft_transfer_call` promise chain. - fn resolve_transfer( - &mut self, - sender_id: AccountId, - receiver_id: AccountId, - amount: u128, - ) -> u128; -} - -impl Nep141Controller for T { - fn balance_of(account_id: &AccountId) -> u128 { - Self::slot_account(account_id).read().unwrap_or(0) - } - - fn total_supply() -> u128 { - Self::slot_total_supply().read().unwrap_or(0) - } - - fn withdraw_unchecked(&mut self, account_id: &AccountId, amount: u128) { - if amount != 0 { - let balance = Self::balance_of(account_id); - if let Some(balance) = balance.checked_sub(amount) { - Self::slot_account(account_id).write(&balance); - } else { - env::panic_str("Balance underflow"); - } - - let total_supply = Self::total_supply(); - if let Some(total_supply) = total_supply.checked_sub(amount) { - Self::slot_total_supply().write(&total_supply); - } else { - env::panic_str("Total supply underflow"); - } - } - } - - fn deposit_unchecked(&mut self, account_id: &AccountId, amount: u128) { - if amount != 0 { - let balance = Self::balance_of(account_id); - if let Some(balance) = balance.checked_add(amount) { - Self::slot_account(account_id).write(&balance); - } else { - env::panic_str("Balance overflow"); - } - - let total_supply = Self::total_supply(); - if let Some(total_supply) = total_supply.checked_add(amount) { - Self::slot_total_supply().write(&total_supply); - } else { - env::panic_str("Total supply overflow"); - } - } - } - - fn transfer_unchecked( - &mut self, - sender_account_id: &AccountId, - receiver_account_id: &AccountId, - amount: u128, - ) { - let sender_balance = Self::balance_of(sender_account_id); - - if let Some(sender_balance) = sender_balance.checked_sub(amount) { - let receiver_balance = Self::balance_of(receiver_account_id); - if let Some(receiver_balance) = receiver_balance.checked_add(amount) { - Self::slot_account(sender_account_id).write(&sender_balance); - Self::slot_account(receiver_account_id).write(&receiver_balance); - } else { - env::panic_str("Receiver balance overflow"); - } - } else { - env::panic_str("Sender balance underflow"); - } - } - - fn transfer( - &mut self, - sender_account_id: AccountId, - receiver_account_id: AccountId, - amount: u128, - memo: Option, - ) { - self.transfer_unchecked(&sender_account_id, &receiver_account_id, amount); - - Nep141Event::FtTransfer(vec![event::FtTransferData { - old_owner_id: sender_account_id, - new_owner_id: receiver_account_id, - amount: amount.into(), - memo, - }]) - .emit(); - } - - fn mint(&mut self, account_id: AccountId, amount: u128, memo: Option) { - self.deposit_unchecked(&account_id, amount); - - Nep141Event::FtMint(vec![event::FtMintData { - owner_id: account_id, - amount: amount.into(), - memo, - }]) - .emit(); - } - - fn burn(&mut self, account_id: AccountId, amount: u128, memo: Option) { - self.withdraw_unchecked(&account_id, amount); - - Nep141Event::FtBurn(vec![event::FtBurnData { - owner_id: account_id, - amount: amount.into(), - memo, - }]) - .emit(); - } - - fn transfer_call( - &mut self, - sender_account_id: AccountId, - receiver_account_id: AccountId, - amount: u128, - memo: Option, - msg: String, - gas_allowance: Gas, - ) -> Promise { - require!( - gas_allowance >= GAS_FOR_FT_TRANSFER_CALL, - MORE_GAS_FAIL_MESSAGE, - ); - - self.transfer( - sender_account_id.clone(), - receiver_account_id.clone(), - amount, - memo, - ); - - let receiver_gas = gas_allowance - .0 - .checked_sub(GAS_FOR_FT_TRANSFER_CALL.0) // TODO: Double-check this math. Should this be GAS_FOR_RESOLVE_TRANSFER? If not, this checked_sub call is superfluous given the require!() at the top of this function. - .unwrap_or_else(|| env::panic_str("Prepaid gas overflow")); - - // Initiating receiver's call and the callback - ext_nep141_receiver::ext(receiver_account_id.clone()) - .with_static_gas(receiver_gas.into()) - .ft_on_transfer(sender_account_id.clone(), amount.into(), msg) - .then( - ext_nep141_resolver::ext(env::current_account_id()) - .with_static_gas(GAS_FOR_RESOLVE_TRANSFER) - .ft_resolve_transfer(sender_account_id, receiver_account_id, amount.into()), - ) - } - - fn resolve_transfer( - &mut self, - sender_id: AccountId, - receiver_id: AccountId, - amount: u128, - ) -> u128 { - let ft_on_transfer_promise_result = env::promise_result(0); - - let unused_amount = match ft_on_transfer_promise_result { - PromiseResult::NotReady => env::abort(), - PromiseResult::Successful(value) => { - if let Ok(U128(unused_amount)) = serde_json::from_slice::(&value) { - std::cmp::min(amount, unused_amount) - } else { - amount - } - } - PromiseResult::Failed => amount, - }; - - let refunded_amount = if unused_amount > 0 { - let receiver_balance = Self::balance_of(&receiver_id); - if receiver_balance > 0 { - let refund_amount = std::cmp::min(receiver_balance, unused_amount); - self.transfer(receiver_id, sender_id, refund_amount, None); - refund_amount - } else { - 0 - } - } else { - 0 - }; - - // Used amount - amount - refunded_amount - } -} - -/// A contract that may be the recipient of an `ft_transfer_call` function -/// call. -#[ext_contract(ext_nep141_receiver)] -pub trait Nep141Receiver { - /// Function that is called in an `ft_transfer_call` promise chain. - /// Returns the number of tokens "used", that is, those that will be kept - /// in the receiving contract's account. (The contract will attempt to - /// refund the difference from `amount` to the original sender.) - fn ft_on_transfer( - &mut self, - sender_id: AccountId, - amount: U128, - msg: String, - ) -> PromiseOrValue; -} - -/// Fungible token contract callback after `ft_transfer_call` execution. -#[ext_contract(ext_nep141_resolver)] -pub trait Nep141Resolver { - /// Callback, last in `ft_transfer_call` promise chain. Returns the amount - /// of tokens refunded to the original sender. - fn ft_resolve_transfer( - &mut self, - sender_id: AccountId, - receiver_id: AccountId, - amount: U128, - ) -> U128; -} - -/// Externally-accessible NEP-141-compatible fungible token interface. -#[ext_contract(ext_nep141)] -pub trait Nep141 { - /// Performs a token transfer - fn ft_transfer(&mut self, receiver_id: AccountId, amount: U128, memo: Option); - - /// Performs a token transfer, then initiates a promise chain that calls - /// `ft_on_transfer` on the receiving account, followed by - /// `ft_resolve_transfer` on the original token contract (this contract). - fn ft_transfer_call( - &mut self, - receiver_id: AccountId, - amount: U128, - memo: Option, - msg: String, - ) -> Promise; - - /// Returns the current total amount of tokens tracked by the contract - fn ft_total_supply(&self) -> U128; - - /// Returns the amount of tokens controlled by `account_id` - fn ft_balance_of(&self, account_id: AccountId) -> U128; -} diff --git a/src/standard/nep141/error.rs b/src/standard/nep141/error.rs new file mode 100644 index 0000000..397dd19 --- /dev/null +++ b/src/standard/nep141/error.rs @@ -0,0 +1,81 @@ +//! Error types for NEP-141 implementations. + +use near_sdk::AccountId; +use thiserror::Error; + +/// Errors that may occur when withdrawing (burning) tokens. +#[derive(Debug, Error)] +pub enum WithdrawError { + /// The account does not have enough balance to withdraw the given amount. + #[error(transparent)] + BalanceUnderflow(#[from] BalanceUnderflowError), + /// The total supply is less than the amount to be burned. + #[error(transparent)] + TotalSupplyUnderflow(#[from] TotalSupplyUnderflowError), +} + +/// An account does not have enough balance to withdraw the given amount. +#[derive(Debug, Error)] +#[error("The account {account_id} does not have enough balance to withdraw {amount} (current balance: {balance}).")] +pub struct BalanceUnderflowError { + /// The account ID. + pub account_id: AccountId, + /// The current balance of the account. + pub balance: u128, + /// The amount of the failed withdrawal attempt. + pub amount: u128, +} + +/// The total supply is less than the amount to be burned. +#[derive(Debug, Error)] +#[error("The total supply ({total_supply}) is less than the amount to be burned ({amount}).")] +pub struct TotalSupplyUnderflowError { + /// The total supply. + pub total_supply: u128, + /// The amount of the failed withdrawal attempt. + pub amount: u128, +} + +/// Errors that may occur when depositing (minting) tokens. +#[derive(Debug, Error)] +pub enum DepositError { + /// The balance of the receiver would overflow u128. + #[error(transparent)] + BalanceOverflow(#[from] BalanceOverflowError), + /// The total supply would overflow u128. + #[error(transparent)] + TotalSupplyOverflow(#[from] TotalSupplyOverflowError), +} + +/// The balance of the account would overflow u128. +#[derive(Debug, Error)] +#[error("The balance of {account_id} ({balance}) plus {amount} would overflow u128.")] +pub struct BalanceOverflowError { + /// The account ID. + pub account_id: AccountId, + /// The current balance of the account. + pub balance: u128, + /// The amount of the failed deposit attempt. + pub amount: u128, +} + +/// The total supply would overflow u128. +#[derive(Debug, Error)] +#[error("The total supply ({total_supply}) plus {amount} would overflow u128.")] +pub struct TotalSupplyOverflowError { + /// The total supply. + pub total_supply: u128, + /// The amount of the failed deposit attempt. + pub amount: u128, +} + +/// Errors that may occur when transferring tokens. +#[derive(Debug, Error)] +pub enum TransferError { + /// The balance of the receiver would overflow u128. + #[error("Balance of the receiver would overflow u128: {0}")] + ReceiverBalanceOverflow(#[from] BalanceOverflowError), + /// The balance of the sender is insufficient. + #[error("Balance of the sender is insufficient: {0}")] + SenderBalanceUnderflow(#[from] BalanceUnderflowError), +} diff --git a/src/standard/nep141/event.rs b/src/standard/nep141/event.rs new file mode 100644 index 0000000..7f6a8bb --- /dev/null +++ b/src/standard/nep141/event.rs @@ -0,0 +1,121 @@ +//! NEP-141 standard events for minting, burning, and transferring tokens. + +use near_sdk_contract_tools_macros::event; + +/// NEP-141 standard events for minting, burning, and transferring tokens. +#[event( + crate = "crate", + macros = "crate", + standard = "nep141", + version = "1.0.0" +)] +#[derive(Debug, Clone)] +pub enum Nep141Event { + /// Token mint event. Emitted when tokens are created and total_supply is + /// increased. + FtMint(Vec), + + /// Token transfer event. Emitted when tokens are transferred between two + /// accounts. No change to total_supply. + FtTransfer(Vec), + + /// Token burn event. Emitted when tokens are burned (removed from supply). + /// Decrease in total_supply. + FtBurn(Vec), +} +use near_sdk::{json_types::U128, serde::Serialize, AccountId}; + +/// Individual mint metadata +#[derive(Serialize, Debug, Clone)] +#[serde(crate = "near_sdk::serde")] +pub struct FtMintData { + /// Address to which new tokens were minted + pub owner_id: AccountId, + /// Amount of minted tokens + pub amount: U128, + /// Optional note + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option, +} + +/// Individual transfer metadata +#[derive(Serialize, Debug, Clone)] +#[serde(crate = "near_sdk::serde")] +pub struct FtTransferData { + /// Account ID of the sender + pub old_owner_id: AccountId, + /// Account ID of the receiver + pub new_owner_id: AccountId, + /// Amount of transferred tokens + pub amount: U128, + /// Optional note + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option, +} + +/// Individual burn metadata +#[derive(Serialize, Debug, Clone)] +#[serde(crate = "near_sdk::serde")] +pub struct FtBurnData { + /// Account ID from which tokens were burned + pub owner_id: AccountId, + /// Amount of burned tokens + pub amount: U128, + /// Optional note + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::standard::nep297::Event; + + #[test] + fn mint() { + assert_eq!( + Nep141Event::FtMint(vec![FtMintData { + owner_id: "foundation.near".parse().unwrap(), + amount: 500u128.into(), + memo: None, + }]) + .to_event_string(), + r#"EVENT_JSON:{"standard":"nep141","version":"1.0.0","event":"ft_mint","data":[{"owner_id":"foundation.near","amount":"500"}]}"#, + ); + } + + #[test] + fn transfer() { + assert_eq!( + Nep141Event::FtTransfer(vec![ + FtTransferData { + old_owner_id: "from.near".parse().unwrap(), + new_owner_id: "to.near".parse().unwrap(), + amount: 42u128.into(), + memo: Some("hi hello bonjour".to_string()), + }, + FtTransferData { + old_owner_id: "user1.near".parse().unwrap(), + new_owner_id: "user2.near".parse().unwrap(), + amount: 7500u128.into(), + memo: None + }, + ]) + .to_event_string(), + r#"EVENT_JSON:{"standard":"nep141","version":"1.0.0","event":"ft_transfer","data":[{"old_owner_id":"from.near","new_owner_id":"to.near","amount":"42","memo":"hi hello bonjour"},{"old_owner_id":"user1.near","new_owner_id":"user2.near","amount":"7500"}]}"#, + ); + } + + #[test] + fn burn() { + assert_eq!( + Nep141Event::FtBurn(vec![FtBurnData { + owner_id: "foundation.near".parse().unwrap(), + amount: 100u128.into(), + memo: None, + }]) + .to_event_string(), + r#"EVENT_JSON:{"standard":"nep141","version":"1.0.0","event":"ft_burn","data":[{"owner_id":"foundation.near","amount":"100"}]}"#, + ); + } +} diff --git a/src/standard/nep141/ext.rs b/src/standard/nep141/ext.rs new file mode 100644 index 0000000..329b232 --- /dev/null +++ b/src/standard/nep141/ext.rs @@ -0,0 +1,56 @@ +#![allow(missing_docs)] + +use near_sdk::{ext_contract, json_types::U128, AccountId, Promise, PromiseOrValue}; + +/// A contract that may be the recipient of an `ft_transfer_call` function +/// call. +#[ext_contract(ext_nep141_receiver)] +pub trait Nep141Receiver { + /// Function that is called in an `ft_transfer_call` promise chain. + /// Returns the number of tokens "used", that is, those that will be kept + /// in the receiving contract's account. (The contract will attempt to + /// refund the difference from `amount` to the original sender.) + fn ft_on_transfer( + &mut self, + sender_id: AccountId, + amount: U128, + msg: String, + ) -> PromiseOrValue; +} + +/// Fungible token contract callback after `ft_transfer_call` execution. +#[ext_contract(ext_nep141_resolver)] +pub trait Nep141Resolver { + /// Callback, last in `ft_transfer_call` promise chain. Returns the amount + /// of tokens refunded to the original sender. + fn ft_resolve_transfer( + &mut self, + sender_id: AccountId, + receiver_id: AccountId, + amount: U128, + ) -> U128; +} + +/// Externally-accessible NEP-141-compatible fungible token interface. +#[ext_contract(ext_nep141)] +pub trait Nep141 { + /// Performs a token transfer + fn ft_transfer(&mut self, receiver_id: AccountId, amount: U128, memo: Option); + + /// Performs a token transfer, then initiates a promise chain that calls + /// `ft_on_transfer` on the receiving account, followed by + /// `ft_resolve_transfer` on the original token contract (this contract). + fn ft_transfer_call( + &mut self, + receiver_id: AccountId, + amount: U128, + memo: Option, + msg: String, + ) -> Promise; + + /// Returns the current total amount of tokens tracked by the contract + fn ft_total_supply(&self) -> U128; + + /// Returns the amount of tokens controlled by `account_id` + fn ft_balance_of(&self, account_id: AccountId) -> U128; +} diff --git a/src/standard/nep141/hooks.rs b/src/standard/nep141/hooks.rs new file mode 100644 index 0000000..ffc7869 --- /dev/null +++ b/src/standard/nep141/hooks.rs @@ -0,0 +1,37 @@ +//! Hooks to integrate NEP-141 with other standards. + +use crate::{hook::Hook, standard::nep145::Nep145ForceUnregister}; + +use super::{Nep141Burn, Nep141Controller, Nep141ControllerInternal}; + +/// Hook that burns all tokens on NEP-145 force unregister. +pub struct BurnNep141OnForceUnregisterHook; + +impl Hook> + for BurnNep141OnForceUnregisterHook +{ + fn hook( + contract: &mut C, + args: &Nep145ForceUnregister<'_>, + f: impl FnOnce(&mut C) -> R, + ) -> R { + let r = f(contract); + + let balance = contract.balance_of(args.account_id); + contract + .burn(&Nep141Burn { + amount: balance, + owner_id: args.account_id, + memo: Some("storage forced unregistration"), + }) + .unwrap_or_else(|e| { + near_sdk::env::panic_str(&format!( + "Failed to burn tokens during forced unregistration: {e}", + )) + }); + + ::slot_account(args.account_id).remove(); + + r + } +} diff --git a/src/standard/nep141/mod.rs b/src/standard/nep141/mod.rs new file mode 100644 index 0000000..4614ff4 --- /dev/null +++ b/src/standard/nep141/mod.rs @@ -0,0 +1,336 @@ +//! NEP-141 fungible token core implementation +//! + +use near_sdk::{ + borsh::{self, BorshSerialize}, + serde::Serialize, + AccountId, BorshStorageKey, Gas, +}; + +use crate::{hook::Hook, slot::Slot, standard::nep297::*, DefaultStorageKey}; + +mod error; +pub use error::*; +mod event; +pub use event::*; +mod ext; +pub use ext::*; +pub mod hooks; + +/// Gas value required for ft_resolve_transfer calls +pub const GAS_FOR_RESOLVE_TRANSFER: Gas = Gas(5_000_000_000_000); +/// Gas value required for ft_transfer_call calls (includes gas for ) +pub const GAS_FOR_FT_TRANSFER_CALL: Gas = Gas(25_000_000_000_000 + GAS_FOR_RESOLVE_TRANSFER.0); +/// Error message for insufficient gas. +pub const MORE_GAS_FAIL_MESSAGE: &str = "More gas is required"; + +#[derive(BorshSerialize, BorshStorageKey)] +enum StorageKey { + TotalSupply, + Account(AccountId), +} + +/// Transfer metadata generic over both types of transfer (`ft_transfer` and +/// `ft_transfer_call`). +#[derive(Serialize, BorshSerialize, PartialEq, Eq, Clone, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct Nep141Transfer<'a> { + /// Sender's account ID. + pub sender_id: &'a AccountId, + /// Receiver's account ID. + pub receiver_id: &'a AccountId, + /// Transferred amount. + pub amount: u128, + /// Optional memo string. + pub memo: Option<&'a str>, + /// Message passed to contract located at `receiver_id`. + pub msg: Option<&'a str>, + /// Is this transfer a revert as a result of a [`Nep141::ft_transfer_call`] -> [`Nep141Receiver::ft_on_transfer`] call? + pub revert: bool, +} + +impl<'a> Nep141Transfer<'a> { + /// Returns `true` if this transfer comes from a `ft_transfer_call` + /// call, `false` otherwise. + pub fn is_transfer_call(&self) -> bool { + self.msg.is_some() + } +} + +/// Describes a mint operation. +#[derive(Clone, Debug, Serialize, BorshSerialize, PartialEq, Eq)] +#[serde(crate = "near_sdk::serde")] +pub struct Nep141Mint<'a> { + /// Amount to mint. + pub amount: u128, + /// Account ID to mint to. + pub receiver_id: &'a AccountId, + /// Optional memo string. + pub memo: Option<&'a str>, +} + +/// Describes a burn operation. +#[derive(Clone, Debug, Serialize, BorshSerialize, PartialEq, Eq)] +#[serde(crate = "near_sdk::serde")] +pub struct Nep141Burn<'a> { + /// Amount to burn. + pub amount: u128, + /// Account ID to burn from. + pub owner_id: &'a AccountId, + /// Optional memo string. + pub memo: Option<&'a str>, +} + +/// Internal functions for [`Nep141Controller`]. Using these methods may result in unexpected behavior. +pub trait Nep141ControllerInternal { + /// Hook for mint operations. + type MintHook: for<'a> Hook> + where + Self: Sized; + /// Hook for transfer operations. + type TransferHook: for<'a> Hook> + where + Self: Sized; + /// Hook for burn operations. + type BurnHook: for<'a> Hook> + where + Self: Sized; + + /// Root storage slot. + fn root() -> Slot<()> { + Slot::new(DefaultStorageKey::Nep141) + } + + /// Slot for account data. + fn slot_account(account_id: &AccountId) -> Slot { + Self::root().field(StorageKey::Account(account_id.clone())) + } + + /// Slot for storing total supply. + fn slot_total_supply() -> Slot { + Self::root().field(StorageKey::TotalSupply) + } +} + +/// Non-public implementations of functions for managing a fungible token. +pub trait Nep141Controller { + /// Hook for mint operations. + type MintHook: for<'a> Hook> + where + Self: Sized; + /// Hook for transfer operations. + type TransferHook: for<'a> Hook> + where + Self: Sized; + /// Hook for burn operations. + type BurnHook: for<'a> Hook> + where + Self: Sized; + + /// Get the balance of an account. Returns 0 if the account does not exist. + fn balance_of(&self, account_id: &AccountId) -> u128; + + /// Get the total circulating supply of the token. + fn total_supply(&self) -> u128; + + /// Removes tokens from an account and decreases total supply. No event + /// emission or hook invocation. + fn withdraw_unchecked( + &mut self, + account_id: &AccountId, + amount: u128, + ) -> Result<(), WithdrawError>; + + /// Increases the token balance of an account. Updates total supply. No + /// event emission or hook invocation. + fn deposit_unchecked( + &mut self, + account_id: &AccountId, + amount: u128, + ) -> Result<(), DepositError>; + + /// Decreases the balance of `sender_account_id` by `amount` and increases + /// the balance of `receiver_account_id` by the same. No change to total + /// supply. No event emission or hook invocation. + fn transfer_unchecked( + &mut self, + sender_account_id: &AccountId, + receiver_account_id: &AccountId, + amount: u128, + ) -> Result<(), TransferError>; + + /// Performs an NEP-141 token transfer, with event emission. Invokes + /// [`Nep141Controller::TransferHook`]. + fn transfer(&mut self, transfer: &Nep141Transfer<'_>) -> Result<(), TransferError>; + + /// Performs an NEP-141 token mint, with event emission. Invokes + /// [`Nep141Controller::MintHook`]. + fn mint(&mut self, mint: &Nep141Mint<'_>) -> Result<(), DepositError>; + + /// Performs an NEP-141 token burn, with event emission. Invokes + /// [`Nep141Controller::BurnHook`]. + fn burn(&mut self, burn: &Nep141Burn<'_>) -> Result<(), WithdrawError>; +} + +impl Nep141Controller for T { + type MintHook = T::MintHook; + type TransferHook = T::TransferHook; + type BurnHook = T::BurnHook; + + fn balance_of(&self, account_id: &AccountId) -> u128 { + Self::slot_account(account_id).read().unwrap_or(0) + } + + fn total_supply(&self) -> u128 { + Self::slot_total_supply().read().unwrap_or(0) + } + + fn withdraw_unchecked( + &mut self, + account_id: &AccountId, + amount: u128, + ) -> Result<(), WithdrawError> { + if amount != 0 { + let balance = self.balance_of(account_id); + if let Some(balance) = balance.checked_sub(amount) { + Self::slot_account(account_id).write(&balance); + } else { + return Err(BalanceUnderflowError { + account_id: account_id.clone(), + balance, + amount, + } + .into()); + } + + let total_supply = self.total_supply(); + if let Some(total_supply) = total_supply.checked_sub(amount) { + Self::slot_total_supply().write(&total_supply); + } else { + return Err(TotalSupplyUnderflowError { + total_supply, + amount, + } + .into()); + } + } + + Ok(()) + } + + fn deposit_unchecked( + &mut self, + account_id: &AccountId, + amount: u128, + ) -> Result<(), DepositError> { + if amount != 0 { + let balance = self.balance_of(account_id); + if let Some(balance) = balance.checked_add(amount) { + Self::slot_account(account_id).write(&balance); + } else { + return Err(BalanceOverflowError { + account_id: account_id.clone(), + balance, + amount, + } + .into()); + } + + let total_supply = self.total_supply(); + if let Some(total_supply) = total_supply.checked_add(amount) { + Self::slot_total_supply().write(&total_supply); + } else { + return Err(TotalSupplyOverflowError { + total_supply, + amount, + } + .into()); + } + } + + Ok(()) + } + + fn transfer_unchecked( + &mut self, + sender_account_id: &AccountId, + receiver_account_id: &AccountId, + amount: u128, + ) -> Result<(), TransferError> { + let sender_balance = self.balance_of(sender_account_id); + + if let Some(sender_balance) = sender_balance.checked_sub(amount) { + let receiver_balance = self.balance_of(receiver_account_id); + if let Some(receiver_balance) = receiver_balance.checked_add(amount) { + Self::slot_account(sender_account_id).write(&sender_balance); + Self::slot_account(receiver_account_id).write(&receiver_balance); + } else { + return Err(BalanceOverflowError { + account_id: receiver_account_id.clone(), + balance: receiver_balance, + amount, + } + .into()); + } + } else { + return Err(BalanceUnderflowError { + account_id: sender_account_id.clone(), + balance: sender_balance, + amount, + } + .into()); + } + + Ok(()) + } + + fn transfer(&mut self, transfer: &Nep141Transfer<'_>) -> Result<(), TransferError> { + Self::TransferHook::hook(self, transfer, |contract| { + contract.transfer_unchecked( + transfer.sender_id, + transfer.receiver_id, + transfer.amount, + )?; + + Nep141Event::FtTransfer(vec![FtTransferData { + old_owner_id: transfer.sender_id.clone(), + new_owner_id: transfer.receiver_id.clone(), + amount: transfer.amount.into(), + memo: transfer.memo.map(ToString::to_string), + }]) + .emit(); + + Ok(()) + }) + } + + fn mint(&mut self, mint: &Nep141Mint) -> Result<(), DepositError> { + Self::MintHook::hook(self, mint, |contract| { + contract.deposit_unchecked(mint.receiver_id, mint.amount)?; + + Nep141Event::FtMint(vec![FtMintData { + owner_id: mint.receiver_id.clone(), + amount: mint.amount.into(), + memo: mint.memo.map(ToString::to_string), + }]) + .emit(); + + Ok(()) + }) + } + + fn burn(&mut self, burn: &Nep141Burn) -> Result<(), WithdrawError> { + Self::BurnHook::hook(self, burn, |contract| { + contract.withdraw_unchecked(burn.owner_id, burn.amount)?; + + Nep141Event::FtBurn(vec![FtBurnData { + owner_id: burn.owner_id.clone(), + amount: burn.amount.into(), + memo: burn.memo.map(ToString::to_string), + }]) + .emit(); + + Ok(()) + }) + } +} diff --git a/src/standard/nep145/error.rs b/src/standard/nep145/error.rs new file mode 100644 index 0000000..f3f529e --- /dev/null +++ b/src/standard/nep145/error.rs @@ -0,0 +1,141 @@ +//! Error types for the NEP-145 standard. + +use near_sdk::{json_types::U128, AccountId}; +use thiserror::Error; + +/// Occurs when an account has insufficient storage balance to perform an operation. +#[derive(Debug, Error)] +#[error( + "Account {account_id} has insufficient balance: {} available, but attempted to lock {}", available.0, attempted_to_lock.0 +)] +pub struct InsufficientBalanceError { + /// The account that attempted to perform the operation. + pub account_id: AccountId, + + /// The amount of storage balance available to the account. + pub available: U128, + + /// The amount of storage balance the account attempted to lock. + pub attempted_to_lock: U128, +} + +/// Occurs when an account is not registered. +#[derive(Debug, Error)] +#[error("Account {0} is not registered")] +pub struct AccountNotRegisteredError(pub AccountId); + +/// Occurs when an account attempts to unlock more tokens than it has deposited. +#[derive(Debug, Error)] +#[error("Account {0} cannot unlock more tokens than it has deposited")] +pub struct ExcessiveUnlockError(pub AccountId); + +/// Occurs when an account attempts to withdraw more tokens than the contract +/// allows without unregistering. +#[derive(Debug, Error)] +#[error("Account {account_id} must cover the minimum balance {}", minimum_balance.0)] +pub struct MinimumBalanceUnderrunError { + /// The account that attempted to perform the operation. + pub account_id: AccountId, + + /// The minimum balance required to remain registered. + pub minimum_balance: U128, +} + +/// Occurs when an account attempts to deposit more tokens than the contract +/// allows. +#[derive(Debug, Error)] +#[error("Account {account_id} must not exceed the maximum balance {}", maximum_balance.0)] +pub struct MaximumBalanceOverrunError { + /// The account that attempted to perform the operation. + pub account_id: AccountId, + + /// The maximum balance allowed. + pub maximum_balance: U128, +} + +/// Occurs when an account attempts to unregister with a locked balance. +#[derive(Debug, Error)] +#[error("Account {account_id} cannot unregister with locked balance {} > 0", locked_balance.0)] +pub struct UnregisterWithLockedBalanceError { + /// The account that attempted to perform the operation. + pub account_id: AccountId, + + /// The amount of storage balance locked by the account. + pub locked_balance: U128, +} + +/// Errors that can occur when locking storage balance. +#[derive(Debug, Error)] +pub enum StorageLockError { + /// The account is not registered. + #[error(transparent)] + AccountNotRegistered(#[from] AccountNotRegisteredError), + /// The account has insufficient balance. + #[error(transparent)] + InsufficientBalance(#[from] InsufficientBalanceError), +} + +/// Errors that can occur when unlocking storage balance. +#[derive(Debug, Error)] +pub enum StorageUnlockError { + /// The account is not registered. + #[error(transparent)] + AccountNotRegistered(#[from] AccountNotRegisteredError), + /// The account tried to unlock more tokens than it has deposited. + #[error(transparent)] + ExcessiveUnlock(#[from] ExcessiveUnlockError), +} + +/// Errors that can occur when depositing storage balance. +#[derive(Debug, Error)] +pub enum StorageDepositError { + /// The deposit does not meet the minimum balance requirement. + #[error(transparent)] + MinimumBalanceUnderrun(#[from] MinimumBalanceUnderrunError), + /// The deposit exceeds the maximum balance limit. + #[error(transparent)] + MaximumBalanceOverrunError(#[from] MaximumBalanceOverrunError), +} + +/// Errors that can occur when withdrawing storage balance. +#[derive(Debug, Error)] +pub enum StorageWithdrawError { + /// The account is not registered. + #[error(transparent)] + AccountNotRegistered(#[from] AccountNotRegisteredError), + /// The withdrawal does not meet the minimum balance requirement. + #[error(transparent)] + MinimumBalanceUnderrun(#[from] MinimumBalanceUnderrunError), +} + +/// Errors that can occur when unregistering storage balance. +#[derive(Debug, Error)] +pub enum StorageUnregisterError { + /// The account is not registered. + #[error(transparent)] + AccountNotRegistered(#[from] AccountNotRegisteredError), + /// The account has a locked balance (is still using storage somewhere), + /// and cannot be unregistered. + #[error(transparent)] + UnregisterWithLockedBalance(#[from] UnregisterWithLockedBalanceError), +} + +/// Errors that can occur when force-unregistering storage balance. +#[derive(Debug, Error)] +pub enum StorageForceUnregisterError { + /// The account is not registered. + #[error(transparent)] + AccountNotRegistered(#[from] AccountNotRegisteredError), +} + +/// Errors that can occur when performing storage accounting. +#[derive(Debug, Error)] +pub enum StorageAccountingError { + /// Storage lock error. + #[error(transparent)] + StorageLock(#[from] StorageLockError), + + /// Storage unlock error. + #[error(transparent)] + StorageUnlock(#[from] StorageUnlockError), +} diff --git a/src/standard/nep145/ext.rs b/src/standard/nep145/ext.rs new file mode 100644 index 0000000..6183e66 --- /dev/null +++ b/src/standard/nep145/ext.rs @@ -0,0 +1,52 @@ +//! External interface for NEP-145. +#![allow(missing_docs)] // ext_contract doesn't play nice with #![warn(missing_docs)] + +use super::{StorageBalance, StorageBalanceBounds}; +use near_sdk::{ext_contract, json_types::U128, AccountId}; + +/// NEAR uses storage staking which means that a contract account must have +/// sufficient balance to cover all storage added over time. This standard +/// provides a uniform way to pass storage costs onto users. +/// +/// # Motivation +/// +/// It allows accounts and contracts to: +/// +/// - Check an account's storage balance. +/// - Determine the minimum storage needed to add account information such +/// that the account can interact as expected with a contract. +/// - Add storage balance for an account; either one's own or another. +/// - Withdraw some storage deposit by removing associated account data from +/// the contract and then making a call to remove unused deposit. +/// - Unregister an account to recover full storage balance. +#[ext_contract(ext_nep145)] +pub trait Nep145 { + /// Payable method that receives an attached deposit of NEAR for a given account. + /// + /// Returns the updated storage balance record for the given account. + fn storage_deposit( + &mut self, + account_id: Option, + registration_only: Option, + ) -> StorageBalance; + + /// Withdraw specified amount of available NEAR for predecessor account. + /// This method is safe to call, and does not remove data. + /// + /// Returns the updated storage balance record for the given account. + fn storage_withdraw(&mut self, amount: Option) -> StorageBalance; + + /// Unregister the predecessor account and withdraw all available NEAR. + /// + /// Returns `true` iff the account was successfully unregistered. + /// Returns `false` iff account was not registered before. + fn storage_unregister(&mut self, force: Option) -> bool; + + /// Returns the storage balance for the given account, or `None` if the account + /// is not registered. + fn storage_balance_of(&self, account_id: AccountId) -> Option; + + /// Returns minimum and maximum allowed balance amounts to interact with this + /// contract. See [`StorageBalanceBounds`] for more details. + fn storage_balance_bounds(&self) -> StorageBalanceBounds; +} diff --git a/src/standard/nep145/hooks.rs b/src/standard/nep145/hooks.rs new file mode 100644 index 0000000..9c5cddf --- /dev/null +++ b/src/standard/nep145/hooks.rs @@ -0,0 +1,87 @@ +//! Hooks to integrate NEP-145 with other components. + +use near_sdk::{env, AccountId}; + +use crate::{ + hook::Hook, + standard::{ + nep141::{Nep141Burn, Nep141Mint, Nep141Transfer}, + nep171::action::{Nep171Burn, Nep171Mint, Nep171Transfer}, + }, +}; + +use super::Nep145Controller; + +fn require_registration(contract: &impl Nep145Controller, account_id: &AccountId) { + contract + .get_storage_balance(account_id) + .unwrap_or_else(|e| env::panic_str(&e.to_string())); +} + +fn apply_storage_accounting_hook( + contract: &mut C, + account_id: &AccountId, + f: impl FnOnce(&mut C) -> R, +) -> R { + let storage_usage_start = env::storage_usage(); + require_registration(contract, account_id); + + let r = f(contract); + + contract + .storage_accounting(account_id, storage_usage_start) + .unwrap_or_else(|e| env::panic_str(&format!("Storage accounting error: {}", e))); + + r +} + +/// Hook to perform storage accounting before and after a storage write. +pub struct PredecessorStorageAccountingHook; + +impl Hook for PredecessorStorageAccountingHook { + fn hook(contract: &mut C, _args: &A, f: impl FnOnce(&mut C) -> R) -> R { + apply_storage_accounting_hook(contract, &env::predecessor_account_id(), f) + } +} + +/// NEP-141 support for NEP-145. +pub struct Nep141StorageAccountingHook; + +impl Hook> for Nep141StorageAccountingHook { + fn hook(contract: &mut C, action: &Nep141Mint<'_>, f: impl FnOnce(&mut C) -> R) -> R { + apply_storage_accounting_hook(contract, action.receiver_id, f) + } +} + +impl Hook> for Nep141StorageAccountingHook { + fn hook(contract: &mut C, action: &Nep141Transfer<'_>, f: impl FnOnce(&mut C) -> R) -> R { + apply_storage_accounting_hook(contract, action.receiver_id, f) + } +} + +impl Hook> for Nep141StorageAccountingHook { + fn hook(contract: &mut C, _action: &Nep141Burn<'_>, f: impl FnOnce(&mut C) -> R) -> R { + f(contract) + } +} + +/// NEP-171 support for NEP-145. +pub struct Nep171StorageAccountingHook; + +impl Hook> for Nep171StorageAccountingHook { + fn hook(contract: &mut C, action: &Nep171Mint<'_>, f: impl FnOnce(&mut C) -> R) -> R { + apply_storage_accounting_hook(contract, action.receiver_id, f) + } +} + +impl Hook> for Nep171StorageAccountingHook { + fn hook(contract: &mut C, action: &Nep171Transfer<'_>, f: impl FnOnce(&mut C) -> R) -> R { + apply_storage_accounting_hook(contract, action.receiver_id, f) + } +} + +impl Hook> for Nep171StorageAccountingHook { + fn hook(contract: &mut C, _action: &Nep171Burn<'_>, f: impl FnOnce(&mut C) -> R) -> R { + f(contract) + } +} diff --git a/src/standard/nep145/mod.rs b/src/standard/nep145/mod.rs new file mode 100644 index 0000000..4e7a117 --- /dev/null +++ b/src/standard/nep145/mod.rs @@ -0,0 +1,398 @@ +//! NEP-145 Storage Management +//! + +use std::cmp::Ordering; + +use near_sdk::{ + borsh::{self, BorshDeserialize, BorshSerialize}, + env, + json_types::U128, + serde::{Deserialize, Serialize}, + AccountId, BorshStorageKey, +}; + +use crate::{hook::Hook, slot::Slot, DefaultStorageKey}; + +pub mod error; +use error::*; +mod ext; +pub use ext::*; +pub mod hooks; + +const PANIC_MESSAGE_STORAGE_TOTAL_OVERFLOW: &str = "storage total balance overflow"; +const PANIC_MESSAGE_STORAGE_AVAILABLE_OVERFLOW: &str = "storage available balance overflow"; +const PANIC_MESSAGE_INCONSISTENT_STATE_AVAILABLE: &str = + "inconsistent state: available storage balance greater than total storage balance"; + +/// An account's storage balance. +#[derive(Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +#[serde(crate = "near_sdk::serde")] +pub struct StorageBalance { + /// The total amount of storage balance. + pub total: U128, + + /// The amount of storage balance that is available for use. + pub available: U128, +} + +impl Default for StorageBalance { + fn default() -> Self { + Self { + total: U128(0), + available: U128(0), + } + } +} + +/// Storage balance bounds. +#[derive(Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +#[serde(crate = "near_sdk::serde")] +pub struct StorageBalanceBounds { + /// The minimum storage balance. + pub min: U128, + + /// The maximum storage balance. + pub max: Option, +} + +impl Default for StorageBalanceBounds { + fn default() -> Self { + Self { + min: U128(0), + max: None, + } + } +} + +#[derive(BorshSerialize, BorshStorageKey)] +enum StorageKey<'a> { + BalanceBounds, + Account(&'a AccountId), +} + +/// Describes a force unregister action. +#[derive(Clone, Debug, Serialize, BorshSerialize, PartialEq, Eq)] +#[serde(crate = "near_sdk::serde")] +pub struct Nep145ForceUnregister<'a> { + /// The account to be unregistered. + pub account_id: &'a AccountId, + /// The account's balance at the time of unregistration. + pub balance: StorageBalance, +} + +/// NEP-145 Storage Management internal controller interface. +pub trait Nep145ControllerInternal { + /// NEP-145 lifecycle hook. + type ForceUnregisterHook: for<'a> Hook> + where + Self: Sized; + + /// Root storage slot. + fn root() -> Slot<()> { + Slot::new(DefaultStorageKey::Nep145) + } + + /// Storage slot for balance bounds. + fn slot_balance_bounds() -> Slot { + Slot::new(StorageKey::BalanceBounds) + } + + /// Storage slot for individual account balance. + fn slot_account(account_id: &AccountId) -> Slot { + Slot::new(StorageKey::Account(account_id)) + } +} + +/// NEP-145 Storage Management controller interface. These functions are not directly +/// exposed to the blockchain. +pub trait Nep145Controller { + /// NEP-145 lifecycle hook. + type ForceUnregisterHook: for<'a> Hook> + where + Self: Sized; + + /// Returns the storage balance of the given account. + fn get_storage_balance( + &self, + account_id: &AccountId, + ) -> Result; + + /// Locks the given amount of storage balance for the given account. + fn lock_storage( + &mut self, + account_id: &AccountId, + amount: U128, + ) -> Result; + + /// Unlocks the given amount of storage balance for the given account. + fn unlock_storage( + &mut self, + account_id: &AccountId, + amount: U128, + ) -> Result; + + /// Deposits the given amount of storage balance for the given account. + fn deposit_to_storage_account( + &mut self, + account_id: &AccountId, + amount: U128, + ) -> Result; + + /// Withdraws the given amount of storage balance for the given account. + fn withdraw_from_storage_account( + &mut self, + account_id: &AccountId, + amount: U128, + ) -> Result; + + /// Unregisters the given account, returning the amount of storage balance + /// that should be refunded. + fn unregister_storage_account( + &mut self, + account_id: &AccountId, + ) -> Result; + + /// Force unregisters the given account, returning the amount of storage balance + /// that should be refunded. + fn force_unregister_storage_account( + &mut self, + account_id: &AccountId, + ) -> Result; + + /// Returns the storage balance bounds for the contract. + fn get_storage_balance_bounds(&self) -> StorageBalanceBounds; + + /// Sets the storage balance bounds for the contract. + fn set_storage_balance_bounds(&mut self, bounds: &StorageBalanceBounds); + + /// Convenience method for performing storage accounting, to be used after + /// storage writes that are to be debited from the account's balance. + fn storage_accounting( + &mut self, + account_id: &AccountId, + storage_usage_start: u64, + ) -> Result<(), StorageAccountingError> { + let storage_usage_end = env::storage_usage(); + + match storage_usage_end.cmp(&storage_usage_start) { + Ordering::Equal => {} + Ordering::Greater => { + let storage_consumed = storage_usage_end - storage_usage_start; + let storage_fee = env::storage_byte_cost() * storage_consumed as u128; + + Nep145Controller::lock_storage(self, account_id, storage_fee.into())?; + } + Ordering::Less => { + let storage_released = storage_usage_start - storage_usage_end; + let storage_credit = env::storage_byte_cost() * storage_released as u128; + + Nep145Controller::unlock_storage(self, account_id, storage_credit.into())?; + } + }; + + Ok(()) + } +} + +impl Nep145Controller for T { + type ForceUnregisterHook = ::ForceUnregisterHook; + + fn get_storage_balance( + &self, + account_id: &AccountId, + ) -> Result { + Self::slot_account(account_id) + .read() + .ok_or_else(|| AccountNotRegisteredError(account_id.clone())) + } + + fn lock_storage( + &mut self, + account_id: &AccountId, + amount: U128, + ) -> Result { + let mut account_slot = Self::slot_account(account_id); + let mut balance = account_slot + .read() + .ok_or(AccountNotRegisteredError(account_id.clone()))?; + + balance.available = balance + .available + .0 + .checked_sub(amount.0) + .ok_or(InsufficientBalanceError { + account_id: account_id.clone(), + attempted_to_lock: amount, + available: balance.available, + })? + .into(); + + account_slot.write(&balance); + + Ok(balance) + } + + fn unlock_storage( + &mut self, + account_id: &AccountId, + amount: U128, + ) -> Result { + let mut account_slot = Self::slot_account(account_id); + + let mut balance = account_slot + .read() + .ok_or(AccountNotRegisteredError(account_id.clone()))?; + + balance.available = { + let new_available = balance + .available + .0 + .checked_add(amount.0) + .unwrap_or_else(|| env::panic_str(PANIC_MESSAGE_STORAGE_AVAILABLE_OVERFLOW)) + .into(); + + if new_available > balance.total { + return Err(ExcessiveUnlockError(account_id.clone()).into()); + } + + new_available + }; + + account_slot.write(&balance); + + Ok(balance) + } + + fn deposit_to_storage_account( + &mut self, + account_id: &AccountId, + amount: U128, + ) -> Result { + let mut account_slot = Self::slot_account(account_id); + + let mut balance = account_slot.read().unwrap_or_default(); + + balance.total.0 = { + let new_total = balance + .total + .0 + .checked_add(amount.0) + .unwrap_or_else(|| env::panic_str(PANIC_MESSAGE_STORAGE_TOTAL_OVERFLOW)); + + let bounds = self.get_storage_balance_bounds(); + + if new_total < bounds.min.0 { + return Err(MinimumBalanceUnderrunError { + account_id: account_id.clone(), + minimum_balance: bounds.min, + } + .into()); + } + + if let Some(maximum_balance) = bounds.max { + if new_total > maximum_balance.0 { + return Err(MaximumBalanceOverrunError { + account_id: account_id.clone(), + maximum_balance, + } + .into()); + } + } + + new_total + }; + + balance.available.0 += amount.0; + + account_slot.write(&balance); + + Ok(balance) + } + + fn withdraw_from_storage_account( + &mut self, + account_id: &AccountId, + amount: U128, + ) -> Result { + let mut account_slot = Self::slot_account(account_id); + + let mut balance = account_slot + .read() + .ok_or_else(|| AccountNotRegisteredError(account_id.clone()))?; + + balance.total.0 = { + let bounds = self.get_storage_balance_bounds(); + + balance + .total + .0 + .checked_sub(amount.0) + .filter(|&new_total| new_total >= bounds.min.0) + .ok_or(MinimumBalanceUnderrunError { + account_id: account_id.clone(), + minimum_balance: bounds.min, + })? + }; + + account_slot.write(&balance); + + Ok(balance) + } + + fn unregister_storage_account( + &mut self, + account_id: &AccountId, + ) -> Result { + let mut account_slot = Self::slot_account(account_id); + + let balance = account_slot + .read() + .ok_or_else(|| AccountNotRegisteredError(account_id.clone()))?; + + match balance.total.0.checked_sub(balance.available.0) { + Some(locked_balance) if locked_balance > 0 => { + return Err(UnregisterWithLockedBalanceError { + account_id: account_id.clone(), + locked_balance: U128(locked_balance), + } + .into()) + } + None => env::panic_str(PANIC_MESSAGE_INCONSISTENT_STATE_AVAILABLE), + _ => {} + } + + account_slot.remove(); + + Ok(balance.total) + } + + fn force_unregister_storage_account( + &mut self, + account_id: &AccountId, + ) -> Result { + let mut account_slot = Self::slot_account(account_id); + + let balance = account_slot + .read() + .ok_or_else(|| AccountNotRegisteredError(account_id.clone()))?; + + let action = Nep145ForceUnregister { + account_id, + balance, + }; + + Self::ForceUnregisterHook::hook(self, &action, |_| { + account_slot.remove(); + }); + + Ok(action.balance.available) + } + + fn get_storage_balance_bounds(&self) -> StorageBalanceBounds { + Self::slot_balance_bounds().read().unwrap_or_default() + } + + fn set_storage_balance_bounds(&mut self, bounds: &StorageBalanceBounds) { + Self::slot_balance_bounds().write(bounds); + } +} diff --git a/src/standard/nep148.rs b/src/standard/nep148.rs index 5f66cea..4b38c53 100644 --- a/src/standard/nep148.rs +++ b/src/standard/nep148.rs @@ -1,19 +1,26 @@ //! NEP-148 fungible token metadata implementation //! -#![allow(missing_docs)] // ext_contract doesn't play nice with #![warn(missing_docs)] use near_sdk::{ borsh::{self, BorshDeserialize, BorshSerialize}, - ext_contract, + env, json_types::Base64VecU8, + serde::{Deserialize, Serialize}, + BorshStorageKey, }; -use serde::{Deserialize, Serialize}; -/// Version of the NEP-148 metadata spec +use crate::{slot::Slot, DefaultStorageKey}; + +pub use ext::*; + +/// Version of the NEP-148 metadata spec. pub const FT_METADATA_SPEC: &str = "ft-1.0.0"; +/// Error message for unset metadata. +pub const ERR_METADATA_UNSET: &str = "NEP-148 metadata is not set"; /// NEP-148-compatible metadata struct #[derive(BorshDeserialize, BorshSerialize, Deserialize, Serialize, Eq, PartialEq, Clone, Debug)] +#[serde(crate = "near_sdk::serde")] pub struct FungibleTokenMetadata { /// Version of the NEP-148 spec pub spec: String, @@ -30,49 +37,121 @@ pub struct FungibleTokenMetadata { /// For tamper protection. pub reference_hash: Option, /// Cosmetic. Number of base-10 decimal places to shift the floating point. - /// 18 is a common value. + /// 24 is a common value. pub decimals: u8, } -/// Contract that supports the NEP-148 metadata standard -#[ext_contract(ext_nep148)] -pub trait Nep148 { +impl FungibleTokenMetadata { + /// Creates a new metadata struct. + pub fn new(name: String, symbol: String, decimals: u8) -> Self { + Self { + spec: FT_METADATA_SPEC.into(), + name, + symbol, + icon: None, + reference: None, + reference_hash: None, + decimals, + } + } + + /// Sets the spec field. + pub fn spec(mut self, spec: String) -> Self { + self.spec = spec; + self + } + + /// Sets the name field. + pub fn name(mut self, name: String) -> Self { + self.name = name; + self + } + + /// Sets the symbol field. + pub fn symbol(mut self, symbol: String) -> Self { + self.symbol = symbol; + self + } + + /// Sets the icon field. + pub fn icon(mut self, icon: String) -> Self { + self.icon = Some(icon); + self + } + + /// Sets the reference field. + pub fn reference(mut self, reference: String) -> Self { + self.reference = Some(reference); + self + } + + /// Sets the reference_hash field. + pub fn reference_hash(mut self, reference_hash: Base64VecU8) -> Self { + self.reference_hash = Some(reference_hash); + self + } + + /// Sets the decimals field. + pub fn decimals(mut self, decimals: u8) -> Self { + self.decimals = decimals; + self + } +} + +#[derive(BorshSerialize, BorshStorageKey)] +enum StorageKey { + Metadata, +} + +/// Internal functions for [`Nep148Controller`]. +pub trait Nep148ControllerInternal { + /// Returns the root storage slot for NEP-148. + fn root() -> Slot<()> { + Slot::new(DefaultStorageKey::Nep148) + } + + /// Returns the storage slot for NEP-148 metadata. + fn metadata() -> Slot { + Self::root().field(StorageKey::Metadata) + } +} + +/// Management functions for NEP-148. +pub trait Nep148Controller { /// Returns the metadata struct for this contract. - fn ft_metadata(&self) -> FungibleTokenMetadata; + /// + /// # Panics + /// + /// Panics if the metadata has not been set. + fn get_metadata(&self) -> FungibleTokenMetadata; + + /// Sets the metadata struct for this contract. + fn set_metadata(&mut self, metadata: &FungibleTokenMetadata); } -#[cfg(test)] -mod tests { - use crate::standard::nep148::FungibleTokenMetadata; - use near_sdk::borsh::BorshSerialize; - - #[test] - fn borsh_serialization_ignores_cow() { - let m1 = FungibleTokenMetadata { - spec: "spec".into(), - name: "name".into(), - symbol: "symbol".into(), - icon: Some("icon".into()), - reference: Some("reference".into()), - reference_hash: Some(b"reference_hash".to_vec().into()), - decimals: 18, - }; - - let m2 = FungibleTokenMetadata { - spec: "spec".to_owned(), - name: "name".to_owned(), - symbol: "symbol".to_owned(), - icon: Some("icon".to_owned()), - reference: Some("reference".to_owned()), - reference_hash: Some(b"reference_hash".to_vec().into()), - decimals: 18, - }; - - assert_eq!(m1, m2); - - let m1_serialized = m1.try_to_vec().unwrap(); - let m2_serialized = m2.try_to_vec().unwrap(); - - assert_eq!(m1_serialized, m2_serialized); +impl Nep148Controller for T { + fn get_metadata(&self) -> FungibleTokenMetadata { + Self::metadata() + .read() + .unwrap_or_else(|| env::panic_str(ERR_METADATA_UNSET)) + } + + fn set_metadata(&mut self, metadata: &FungibleTokenMetadata) { + Self::metadata().set(Some(metadata)); + } +} + +mod ext { + #![allow(missing_docs)] // ext_contract doesn't play well + + use near_sdk::ext_contract; + + use super::FungibleTokenMetadata; + + /// Contract that supports the NEP-148 metadata standard + #[ext_contract(ext_nep148)] + pub trait Nep148 { + /// Returns the metadata struct for this contract. + fn ft_metadata(&self) -> FungibleTokenMetadata; } } diff --git a/src/standard/nep171/action.rs b/src/standard/nep171/action.rs new file mode 100644 index 0000000..d302fff --- /dev/null +++ b/src/standard/nep171/action.rs @@ -0,0 +1,55 @@ +//! NEP-171 actions. +//! +//! Used when calling various functions on [`Nep171Controller`]. Also used when +//! implementing [`Hook`]s for the NEP-171 component. + +use super::*; +use near_sdk::{ + borsh::{self, BorshSerialize}, + serde::Serialize, +}; + +/// NEP-171 mint action. +#[derive(Clone, Debug, Serialize, BorshSerialize, PartialEq, Eq)] +#[serde(crate = "near_sdk::serde")] +pub struct Nep171Mint<'a> { + /// Token IDs to mint. + pub token_ids: &'a [TokenId], + /// Account ID of the receiver. + pub receiver_id: &'a AccountId, + /// Optional memo string. + pub memo: Option<&'a str>, +} + +/// NEP-171 burn action. +#[derive(Clone, Debug, Serialize, BorshSerialize, PartialEq, Eq)] +#[serde(crate = "near_sdk::serde")] +pub struct Nep171Burn<'a> { + /// Token IDs to burn. + pub token_ids: &'a [TokenId], + /// Account ID of the owner. + pub owner_id: &'a AccountId, + /// Optional memo string. + pub memo: Option<&'a str>, +} + +/// Transfer metadata generic over both types of transfer (`nft_transfer` and +/// `nft_transfer_call`). +#[derive(Serialize, BorshSerialize, PartialEq, Eq, Clone, Debug, Hash)] +#[serde(crate = "near_sdk::serde")] +pub struct Nep171Transfer<'a> { + /// Why is this sender allowed to perform this transfer? + pub authorization: Nep171TransferAuthorization, + /// Sending account ID. + pub sender_id: &'a AccountId, + /// Receiving account ID. + pub receiver_id: &'a AccountId, + /// Token ID. + pub token_id: &'a TokenId, + /// Optional memo string. + pub memo: Option<&'a str>, + /// Message passed to contract located at `receiver_id` in the case of `nft_transfer_call`. + pub msg: Option<&'a str>, + /// `true` if the transfer is a revert for a `nft_transfer_call`. + pub revert: bool, +} diff --git a/src/standard/nep171/error.rs b/src/standard/nep171/error.rs index f164617..08c609e 100644 --- a/src/standard/nep171/error.rs +++ b/src/standard/nep171/error.rs @@ -7,6 +7,44 @@ use crate::standard::nep178::ApprovalId; use super::TokenId; +/// Potential errors encountered when performing a burn operation. +#[derive(Error, Clone, Debug)] +pub enum Nep171BurnError { + /// The token could not be burned because it does not exist. + #[error(transparent)] + TokenDoesNotExist(#[from] TokenDoesNotExistError), + /// The token could not be burned because it is not owned by the expected owner. + #[error(transparent)] + TokenNotOwnedByExpectedOwner(#[from] TokenNotOwnedByExpectedOwnerError), +} + +/// Potential errors encountered when attempting to mint a new token. +#[derive(Error, Clone, Debug)] +pub enum Nep171MintError { + /// The token could not be minted because a token with the same ID already exists. + #[error(transparent)] + TokenAlreadyExists(#[from] TokenAlreadyExistsError), +} + +/// Potential errors encountered when performing a token transfer. +#[derive(Error, Clone, Debug)] +pub enum Nep171TransferError { + /// The token could not be transferred because it does not exist. + #[error(transparent)] + TokenDoesNotExist(#[from] TokenDoesNotExistError), + /// The token could not be transferred because the sender is not allowed to perform transfers of this token on behalf of its current owner. See: NEP-178. + /// + /// NOTE: If you only implement NEP-171, approval IDs will _not work_, and this error will always be returned whenever the sender is not the current owner. + #[error(transparent)] + SenderNotApproved(#[from] SenderNotApprovedError), + /// The token could not be transferred because the token is being sent to the account that currently owns it. Reflexive transfers are not allowed. + #[error(transparent)] + TokenReceiverIsCurrentOwner(#[from] TokenReceiverIsCurrentOwnerError), + /// The token could not be transferred because it is no longer owned by the expected owner. + #[error(transparent)] + TokenNotOwnedByExpectedOwner(#[from] TokenNotOwnedByExpectedOwnerError), +} + /// Occurs when trying to create a token ID that already exists. /// Overwriting pre-existing token IDs is not allowed. #[derive(Error, Clone, Debug)] diff --git a/src/standard/nep171/event.rs b/src/standard/nep171/event.rs index 74f012a..ee6cd35 100644 --- a/src/standard/nep171/event.rs +++ b/src/standard/nep171/event.rs @@ -1,10 +1,32 @@ //! Event log metadata & associated structures. -use near_sdk::AccountId; -use serde::Serialize; +use near_sdk::{serde::Serialize, AccountId}; +use near_sdk_contract_tools_macros::event; + +/// NEP-171 standard events. +#[event( + crate = "crate", + macros = "near_sdk_contract_tools_macros", + standard = "nep171", + version = "1.2.0" +)] +#[derive(Debug, Clone)] +pub enum Nep171Event { + /// Emitted when a token is newly minted. + NftMint(Vec), + /// Emitted when a token is transferred between two parties. + NftTransfer(Vec), + /// Emitted when a token is burned. + NftBurn(Vec), + /// Emitted when the metadata associated with an NFT contract is updated. + NftMetadataUpdate(Vec), + /// Emitted when the metadata associated with an NFT contract is updated. + ContractMetadataUpdate(Vec), +} /// Tokens minted to a single owner. #[derive(Serialize, Debug, Clone)] +#[serde(crate = "near_sdk::serde")] pub struct NftMintLog { /// To whom were the new tokens minted? pub owner_id: AccountId, @@ -17,6 +39,7 @@ pub struct NftMintLog { /// Tokens are transferred from one account to another. #[derive(Serialize, Debug, Clone)] +#[serde(crate = "near_sdk::serde")] pub struct NftTransferLog { /// NEP-178 authorized account ID. #[serde(skip_serializing_if = "Option::is_none")] @@ -34,6 +57,7 @@ pub struct NftTransferLog { /// Tokens are burned from a single holder. #[derive(Serialize, Debug, Clone)] +#[serde(crate = "near_sdk::serde")] pub struct NftBurnLog { /// What is the ID of the account from which the tokens were burned? pub owner_id: AccountId, @@ -49,6 +73,7 @@ pub struct NftBurnLog { /// Token metadata update. #[derive(Serialize, Debug, Clone)] +#[serde(crate = "near_sdk::serde")] pub struct NftMetadataUpdateLog { /// IDs of the updated tokens. pub token_ids: Vec, @@ -59,6 +84,7 @@ pub struct NftMetadataUpdateLog { /// Contract metadata update. #[derive(Serialize, Debug, Clone)] +#[serde(crate = "near_sdk::serde")] pub struct NftContractMetadataUpdateLog { /// Additional update information. #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/standard/nep171/hooks.rs b/src/standard/nep171/hooks.rs new file mode 100644 index 0000000..7b1164e --- /dev/null +++ b/src/standard/nep171/hooks.rs @@ -0,0 +1,41 @@ +//! Hooks to integrate NEP-171 with other components. + +use crate::{ + hook::Hook, + standard::{nep145::Nep145ForceUnregister, nep181::Nep181Controller}, +}; + +use super::{action::Nep171Burn, Nep171Controller}; + +/// Hook that burns all NEP-171 tokens held by an account when the account +/// performs an NEP-145 force unregister. +pub struct BurnNep171OnForceUnregisterHook; + +impl Hook> for BurnNep171OnForceUnregisterHook +where + C: Nep171Controller + Nep181Controller, +{ + fn hook( + contract: &mut C, + action: &Nep145ForceUnregister<'_>, + f: impl FnOnce(&mut C) -> R, + ) -> R { + let token_ids = contract.with_tokens_for_owner(action.account_id, |t| { + t.into_iter().cloned().collect::>() + }); + + contract + .burn(&Nep171Burn { + token_ids: &token_ids, + owner_id: action.account_id, + memo: Some("storage forced unregistration"), + }) + .unwrap_or_else(|e| { + near_sdk::env::panic_str(&format!( + "Failed to burn tokens during forced unregistration: {e}", + )) + }); + + f(contract) + } +} diff --git a/src/standard/nep171/mod.rs b/src/standard/nep171/mod.rs index 78172a7..21524e7 100644 --- a/src/standard/nep171/mod.rs +++ b/src/standard/nep171/mod.rs @@ -44,18 +44,20 @@ use near_sdk::{ serde::{Deserialize, Serialize}, AccountId, BorshStorageKey, Gas, }; -use near_sdk_contract_tools_macros::event; -use thiserror::Error; -use crate::{slot::Slot, DefaultStorageKey}; +use crate::{hook::Hook, slot::Slot, standard::nep297::Event, DefaultStorageKey}; -use super::nep297::Event; +pub mod action; +use action::*; pub mod error; +use error::*; pub mod event; +use event::*; // separate module with re-export because ext_contract doesn't play well with #![warn(missing_docs)] mod ext; pub use ext::*; +pub mod hooks; /// Minimum required gas for [`Nep171Resolver::nft_resolve_transfer`] call in promise chain during [`Nep171::nft_transfer_call`]. pub const GAS_FOR_RESOLVE_TRANSFER: Gas = Gas(5_000_000_000_000); @@ -67,75 +69,23 @@ pub const INSUFFICIENT_GAS_MESSAGE: &str = "More gas is required"; /// NFT token IDs. pub type TokenId = String; -/// NEP-171 standard events. -#[event( - crate = "crate", - macros = "crate", - serde = "serde", - standard = "nep171", - version = "1.2.0" -)] -#[derive(Debug, Clone)] -pub enum Nep171Event { - /// Emitted when a token is newly minted. - NftMint(Vec), - /// Emitted when a token is transferred between two parties. - NftTransfer(Vec), - /// Emitted when a token is burned. - NftBurn(Vec), - /// Emitted when the metadata associated with an NFT contract is updated. - NftMetadataUpdate(Vec), - /// Emitted when the metadata associated with an NFT contract is updated. - ContractMetadataUpdate(Vec), -} - #[derive(BorshSerialize, BorshStorageKey)] enum StorageKey<'a> { TokenOwner(&'a str), } -/// Potential errors encountered when performing a burn operation. -#[derive(Error, Clone, Debug)] -pub enum Nep171BurnError { - /// The token could not be burned because it does not exist. - #[error(transparent)] - TokenDoesNotExist(#[from] error::TokenDoesNotExistError), - /// The token could not be burned because it is not owned by the expected owner. - #[error(transparent)] - TokenNotOwnedByExpectedOwner(#[from] error::TokenNotOwnedByExpectedOwnerError), -} - -/// Potential errors encountered when attempting to mint a new token. -#[derive(Error, Clone, Debug)] -pub enum Nep171MintError { - /// The token could not be minted because a token with the same ID already exists. - #[error(transparent)] - TokenAlreadyExists(#[from] error::TokenAlreadyExistsError), -} - -/// Potential errors encountered when performing a token transfer. -#[derive(Error, Clone, Debug)] -pub enum Nep171TransferError { - /// The token could not be transferred because it does not exist. - #[error(transparent)] - TokenDoesNotExist(#[from] error::TokenDoesNotExistError), - /// The token could not be transferred because the sender is not allowed to perform transfers of this token on behalf of its current owner. See: NEP-178. - /// - /// NOTE: If you only implement NEP-171, approval IDs will _not work_, and this error will always be returned whenever the sender is not the current owner. - #[error(transparent)] - SenderNotApproved(#[from] error::SenderNotApprovedError), - /// The token could not be transferred because the token is being sent to the account that currently owns it. Reflexive transfers are not allowed. - #[error(transparent)] - TokenReceiverIsCurrentOwner(#[from] error::TokenReceiverIsCurrentOwnerError), - /// The token could not be transferred because it is no longer owned by the expected owner. - #[error(transparent)] - TokenNotOwnedByExpectedOwner(#[from] error::TokenNotOwnedByExpectedOwnerError), -} - /// Internal (storage location) methods for implementors of [`Nep171Controller`]. pub trait Nep171ControllerInternal { - /// Various lifecycle hooks for NEP-171 tokens. - type Hook: Nep171Hook + /// Hook for mint operations. + type MintHook: for<'a> Hook> + where + Self: Sized; + /// Hook for transfer operations. + type TransferHook: for<'a> Hook> + where + Self: Sized; + /// Hook for burn operations. + type BurnHook: for<'a> Hook> where Self: Sized; @@ -162,8 +112,16 @@ pub trait Nep171ControllerInternal { /// Non-public controller interface for NEP-171 implementations. pub trait Nep171Controller { - /// Various lifecycle hooks for NEP-171 tokens. - type Hook: Nep171Hook + /// Hook for mint operations. + type MintHook: for<'a> Hook> + where + Self: Sized; + /// Hook for transfer operations. + type TransferHook: for<'a> Hook> + where + Self: Sized; + /// Hook for burn operations. + type BurnHook: for<'a> Hook> where Self: Sized; @@ -180,12 +138,13 @@ pub trait Nep171Controller { /// Transfer a token from `sender_id` to `receiver_id`, as for an external /// call to `nft_transfer`. Checks that the transfer is valid using /// [`CheckExternalTransfer::check_external_transfer`] before performing - /// the transfer. Runs relevant hooks. + /// the transfer. Emits events and runs relevant hooks. fn external_transfer(&mut self, transfer: &Nep171Transfer) -> Result<(), Nep171TransferError> where Self: Sized; /// Performs a token transfer without running [`CheckExternalTransfer::check_external_transfer`]. + /// Does not emit events or run hooks. /// /// # Warning /// @@ -196,41 +155,22 @@ pub trait Nep171Controller { /// - Transferring a token "from" an account that does not own it. /// - Creating token IDs that did not previously exist. /// - Transferring a token to the account that already owns it. - fn transfer_unchecked( - &mut self, - token_ids: &[TokenId], - current_owner_id: AccountId, - sender_id: AccountId, - receiver_id: AccountId, - memo: Option, - ); - - /// Mints a new token `token_id` to `owner_id`. Runs relevant hooks. - fn mint( - &mut self, - token_ids: &[TokenId], - new_owner_id: &AccountId, - memo: Option, - ) -> Result<(), Nep171MintError>; + fn transfer_unchecked(&mut self, token_ids: &[TokenId], receiver_id: &AccountId); + + /// Mints a new token `token_id` to `owner_id`. Emits events and runs + /// relevant hooks. + fn mint(&mut self, action: &Nep171Mint<'_>) -> Result<(), Nep171MintError>; /// Mints a new token `token_id` to `owner_id` without checking if the - /// token already exists. Does not run hooks. - fn mint_unchecked( - &mut self, - token_ids: &[TokenId], - new_owner_id: &AccountId, - memo: Option, - ); - - /// Burns tokens `token_ids` owned by `current_owner_id`. Runs relevant hooks. - fn burn( - &mut self, - token_ids: &[TokenId], - current_owner_id: &AccountId, - memo: Option, - ) -> Result<(), Nep171BurnError>; - - /// Burns tokens `token_ids` without checking the owners. Does not run hooks. + /// token already exists. Does not emit events or run hooks. + fn mint_unchecked(&mut self, token_ids: &[TokenId], owner_id: &AccountId); + + /// Burns tokens `token_ids` owned by `current_owner_id`. Emits events and + /// runs relevant hooks. + fn burn(&mut self, action: &Nep171Burn<'_>) -> Result<(), Nep171BurnError>; + + /// Burns tokens `token_ids` without checking the owners. Does not emit + /// events or run hooks. fn burn_unchecked(&mut self, token_ids: &[TokenId]) -> bool; /// Returns the owner of a token, if it exists. @@ -240,28 +180,9 @@ pub trait Nep171Controller { fn load_token(&self, token_id: &TokenId) -> Option; } -/// Transfer metadata generic over both types of transfer (`nft_transfer` and -/// `nft_transfer_call`). -#[derive(Serialize, BorshSerialize, PartialEq, Eq, Clone, Debug, Hash)] -pub struct Nep171Transfer<'a> { - /// Why is this sender allowed to perform this transfer? - pub authorization: Nep171TransferAuthorization, - /// Sending account ID. - pub sender_id: &'a AccountId, - /// Receiving account ID. - pub receiver_id: &'a AccountId, - /// Token ID. - pub token_id: &'a TokenId, - /// Optional memo string. - pub memo: Option<&'a str>, - /// Message passed to contract located at `receiver_id` in the case of `nft_transfer_call`. - pub msg: Option<&'a str>, - /// `true` if the transfer is a revert for a `nft_transfer_call`. - pub revert: bool, -} - /// Authorization for a transfer. #[derive(Serialize, BorshSerialize, PartialEq, Eq, Clone, Debug, Hash)] +#[serde(crate = "near_sdk::serde")] pub enum Nep171TransferAuthorization { /// The sender is the owner of the token. Owner, @@ -288,16 +209,17 @@ impl CheckExternalTransfer for DefaultCheckExternalTrans contract: &T, transfer: &Nep171Transfer, ) -> Result { - let owner_id = contract.token_owner(transfer.token_id).ok_or_else(|| { - error::TokenDoesNotExistError { - token_id: transfer.token_id.clone(), - } - })?; + let owner_id = + contract + .token_owner(transfer.token_id) + .ok_or_else(|| TokenDoesNotExistError { + token_id: transfer.token_id.clone(), + })?; match transfer.authorization { Nep171TransferAuthorization::Owner => { if transfer.sender_id != &owner_id { - return Err(error::TokenNotOwnedByExpectedOwnerError { + return Err(TokenNotOwnedByExpectedOwnerError { expected_owner_id: transfer.sender_id.clone(), owner_id, token_id: transfer.token_id.clone(), @@ -306,7 +228,7 @@ impl CheckExternalTransfer for DefaultCheckExternalTrans } } Nep171TransferAuthorization::ApprovalId(approval_id) => { - return Err(error::SenderNotApprovedError { + return Err(SenderNotApprovedError { owner_id, sender_id: transfer.sender_id.clone(), token_id: transfer.token_id.clone(), @@ -317,7 +239,7 @@ impl CheckExternalTransfer for DefaultCheckExternalTrans } if transfer.receiver_id == &owner_id { - return Err(error::TokenReceiverIsCurrentOwnerError { + return Err(TokenReceiverIsCurrentOwnerError { owner_id, token_id: transfer.token_id.clone(), } @@ -328,238 +250,32 @@ impl CheckExternalTransfer for DefaultCheckExternalTrans } } -/// Contracts may implement this trait to inject code into NEP-171 functions. -/// -/// `T` is an optional value for passing state between different lifecycle -/// hooks. This may be useful for charging callers for storage usage, for -/// example. -pub trait Nep171Hook { - /// State value passed from `before_nft_transfer` to `after_nft_transfer`. - type NftTransferState; - /// State value passed from `before_mint` to `after_mint`. - type MintState; - /// State value passed from `before_burn` to `after_burn`. - type BurnState; - - /// Executed before a token transfer is conducted. - /// - /// May return an optional state value which will be passed along to the - /// following `after_nft_transfer`. - /// - /// MUST NOT PANIC if the transfer is a revert. - fn before_nft_transfer(contract: &C, transfer: &Nep171Transfer) -> Self::NftTransferState; - - /// Executed after a token transfer is conducted. - /// - /// Receives the state value returned by `before_nft_transfer`. - /// - /// MUST NOT PANIC if the transfer is a revert. - fn after_nft_transfer( - contract: &mut C, - transfer: &Nep171Transfer, - state: Self::NftTransferState, - ); - - /// Executed before a token is minted. - fn before_mint(contract: &C, token_ids: &[TokenId], owner_id: &AccountId) -> Self::MintState; - /// Executed after a token is minted. - fn after_mint( - contract: &mut C, - token_ids: &[TokenId], - owner_id: &AccountId, - state: Self::MintState, - ); - - /// Executed before a token is burned. - fn before_burn(contract: &C, token_ids: &[TokenId], owner_id: &AccountId) -> Self::BurnState; - /// Executed after a token is burned. - fn after_burn( - contract: &mut C, - token_ids: &[TokenId], - owner_id: &AccountId, - state: Self::BurnState, - ); -} - -impl Nep171Hook for () { - type NftTransferState = (); - type MintState = (); - type BurnState = (); - - fn before_nft_transfer(_contract: &C, _transfer: &Nep171Transfer) {} - - fn after_nft_transfer(_contract: &mut C, _transfer: &Nep171Transfer, _state: ()) {} - - fn before_mint( - _contract: &C, - _token_ids: &[TokenId], - _owner_id: &AccountId, - ) -> Self::MintState { - } - - fn after_mint( - _contract: &mut C, - _token_ids: &[TokenId], - _owner_id: &AccountId, - _state: Self::MintState, - ) { - } - - fn before_burn( - _contract: &C, - _token_ids: &[TokenId], - _owner_id: &AccountId, - ) -> Self::BurnState { - } - - fn after_burn( - _contract: &mut C, - _token_ids: &[TokenId], - _owner_id: &AccountId, - _state: Self::BurnState, - ) { - } -} - -impl Nep171Hook for (Handl0, Handl1) -where - Handl0: Nep171Hook, - Handl1: Nep171Hook, -{ - type MintState = (Handl0::MintState, Handl1::MintState); - type NftTransferState = (Handl0::NftTransferState, Handl1::NftTransferState); - type BurnState = (Handl0::BurnState, Handl1::BurnState); - - fn before_mint( - contract: &Cont, - token_ids: &[TokenId], - owner_id: &AccountId, - ) -> Self::MintState { - ( - Handl0::before_mint(contract, token_ids, owner_id), - Handl1::before_mint(contract, token_ids, owner_id), - ) - } - - fn after_mint( - contract: &mut Cont, - token_ids: &[TokenId], - owner_id: &AccountId, - state: Self::MintState, - ) { - Handl0::after_mint(contract, token_ids, owner_id, state.0); - Handl1::after_mint(contract, token_ids, owner_id, state.1); - } - - fn before_nft_transfer( - contract: &Cont, - transfer: &Nep171Transfer, - ) -> (Handl0::NftTransferState, Handl1::NftTransferState) { - ( - Handl0::before_nft_transfer(contract, transfer), - Handl1::before_nft_transfer(contract, transfer), - ) - } - - fn after_nft_transfer( - contract: &mut Cont, - transfer: &Nep171Transfer, - state: (Handl0::NftTransferState, Handl1::NftTransferState), - ) { - Handl0::after_nft_transfer(contract, transfer, state.0); - Handl1::after_nft_transfer(contract, transfer, state.1); - } - - fn before_burn( - contract: &Cont, - token_ids: &[TokenId], - owner_id: &AccountId, - ) -> Self::BurnState { - ( - Handl0::before_burn(contract, token_ids, owner_id), - Handl1::before_burn(contract, token_ids, owner_id), - ) - } - - fn after_burn( - contract: &mut Cont, - token_ids: &[TokenId], - owner_id: &AccountId, - state: Self::BurnState, - ) { - Handl0::after_burn(contract, token_ids, owner_id, state.0); - Handl1::after_burn(contract, token_ids, owner_id, state.1); - } -} - -/// Alternative to [`Nep171Hook`] for implementing NEP-171 hooks. Implementing -/// the full [`Nep171Hook`] trait is sometimes inconvenient, so this trait -/// provides a simpler interface. There is a blanket implementation of -/// [`Nep171Hook`] for all types that implement this trait. -pub trait SimpleNep171Hook { - /// Executed before a token is minted. - fn before_mint(&self, _token_ids: &[TokenId], _owner_id: &AccountId) {} - /// Executed after a token is minted. - fn after_mint(&mut self, _token_ids: &[TokenId], _owner_id: &AccountId) {} - /// Executed before a token transfer is conducted. - fn before_nft_transfer(&self, _transfer: &Nep171Transfer) {} - /// Executed after a token transfer is conducted. - fn after_nft_transfer(&mut self, _transfer: &Nep171Transfer) {} - /// Executed before a token is burned. - fn before_burn(&self, _token_ids: &[TokenId], _owner_id: &AccountId) {} - /// Executed after a token is burned. - fn after_burn(&mut self, _token_ids: &[TokenId], _owner_id: &AccountId) {} -} - -impl Nep171Hook for T { - type MintState = (); - type NftTransferState = (); - type BurnState = (); - - fn before_mint(contract: &Self, token_ids: &[TokenId], owner_id: &AccountId) { - SimpleNep171Hook::before_mint(contract, token_ids, owner_id); - } - - fn after_mint(contract: &mut Self, token_ids: &[TokenId], owner_id: &AccountId, _: ()) { - SimpleNep171Hook::after_burn(contract, token_ids, owner_id); - } - - fn before_nft_transfer(contract: &T, transfer: &Nep171Transfer) { - SimpleNep171Hook::before_nft_transfer(contract, transfer); - } - - fn after_nft_transfer(contract: &mut T, transfer: &Nep171Transfer, _: ()) { - SimpleNep171Hook::after_nft_transfer(contract, transfer); - } - - fn before_burn(contract: &T, token_ids: &[TokenId], owner_id: &AccountId) { - SimpleNep171Hook::before_burn(contract, token_ids, owner_id); - } - - fn after_burn(contract: &mut T, token_ids: &[TokenId], owner_id: &AccountId, _: ()) { - SimpleNep171Hook::after_burn(contract, token_ids, owner_id); - } -} - impl Nep171Controller for T { - type Hook = ::Hook; + type MintHook = ::MintHook; + type TransferHook = ::TransferHook; + type BurnHook = ::BurnHook; + type CheckExternalTransfer = ::CheckExternalTransfer; type LoadTokenMetadata = ::LoadTokenMetadata; fn external_transfer(&mut self, transfer: &Nep171Transfer) -> Result<(), Nep171TransferError> { match Self::CheckExternalTransfer::check_external_transfer(self, transfer) { Ok(current_owner_id) => { - let state = ::Hook::before_nft_transfer(self, transfer); - - self.transfer_unchecked( - &[transfer.token_id.to_string()], - current_owner_id, - transfer.sender_id.clone(), - transfer.receiver_id.clone(), - transfer.memo.map(ToString::to_string), - ); - - ::Hook::after_nft_transfer(self, transfer, state); + Self::TransferHook::hook(self, transfer, |contract| { + contract.transfer_unchecked( + std::array::from_ref(transfer.token_id), + transfer.receiver_id, + ); + + Nep171Event::NftTransfer(vec![NftTransferLog { + authorized_id: None, + old_owner_id: current_owner_id, + new_owner_id: transfer.receiver_id.clone(), + token_ids: vec![transfer.token_id.clone()], + memo: transfer.memo.map(ToString::to_string), + }]) + .emit(); + }); Ok(()) } @@ -567,122 +283,85 @@ impl Nep171Controller for T { } } - fn transfer_unchecked( - &mut self, - token_ids: &[TokenId], - current_owner_id: AccountId, - _sender_id: AccountId, - receiver_id: AccountId, - memo: Option, - ) { - if !token_ids.is_empty() { - Nep171Event::NftTransfer(vec![event::NftTransferLog { - authorized_id: None, - old_owner_id: current_owner_id, - new_owner_id: receiver_id.clone(), - token_ids: token_ids.iter().map(ToString::to_string).collect(), - memo, - }]) - .emit(); - } - + fn transfer_unchecked(&mut self, token_ids: &[TokenId], receiver_id: &AccountId) { for token_id in token_ids { let mut slot = Self::slot_token_owner(token_id); - slot.write(&receiver_id); + slot.write(receiver_id); } } - fn mint_unchecked( - &mut self, - token_ids: &[TokenId], - new_owner_id: &AccountId, - memo: Option, - ) { - if token_ids.is_empty() { - return; - } - - Nep171Event::NftMint(vec![event::NftMintLog { - token_ids: token_ids.iter().map(ToString::to_string).collect(), - owner_id: new_owner_id.clone(), - memo, - }]) - .emit(); - + fn mint_unchecked(&mut self, token_ids: &[TokenId], owner_id: &AccountId) { token_ids.iter().for_each(|token_id| { let mut slot = Self::slot_token_owner(token_id); - slot.write(new_owner_id); + slot.write(owner_id); }); } - fn mint( - &mut self, - token_ids: &[TokenId], - new_owner_id: &AccountId, - memo: Option, - ) -> Result<(), Nep171MintError> { - for token_id in token_ids { + fn mint(&mut self, action: &Nep171Mint<'_>) -> Result<(), Nep171MintError> { + if action.token_ids.is_empty() { + return Ok(()); + } + + for token_id in action.token_ids { let slot = Self::slot_token_owner(token_id); if slot.exists() { - return Err(error::TokenAlreadyExistsError { + return Err(TokenAlreadyExistsError { token_id: token_id.to_string(), } .into()); } } - let state = Self::Hook::before_mint(self, token_ids, new_owner_id); + Self::MintHook::hook(self, action, |contract| { + contract.mint_unchecked(action.token_ids, action.receiver_id); - self.mint_unchecked(token_ids, new_owner_id, memo); - - Self::Hook::after_mint(self, token_ids, new_owner_id, state); + Nep171Event::NftMint(vec![NftMintLog { + token_ids: action.token_ids.iter().map(ToString::to_string).collect(), + owner_id: action.receiver_id.clone(), + memo: action.memo.map(ToString::to_string), + }]) + .emit(); - Ok(()) + Ok(()) + }) } - fn burn( - &mut self, - token_ids: &[TokenId], - current_owner_id: &AccountId, - memo: Option, - ) -> Result<(), Nep171BurnError> { - if token_ids.is_empty() { + fn burn(&mut self, action: &Nep171Burn<'_>) -> Result<(), Nep171BurnError> { + if action.token_ids.is_empty() { return Ok(()); } - for token_id in token_ids { + for token_id in action.token_ids { if let Some(actual_owner_id) = self.token_owner(token_id) { - if &actual_owner_id != current_owner_id { - return Err(error::TokenNotOwnedByExpectedOwnerError { - expected_owner_id: current_owner_id.clone(), + if &actual_owner_id != action.owner_id { + return Err(TokenNotOwnedByExpectedOwnerError { + expected_owner_id: action.owner_id.clone(), owner_id: actual_owner_id, token_id: (*token_id).clone(), } .into()); } } else { - return Err(error::TokenDoesNotExistError { + return Err(TokenDoesNotExistError { token_id: (*token_id).clone(), } .into()); } } - let state = Self::Hook::before_burn(self, token_ids, current_owner_id); - - self.burn_unchecked(token_ids); + Self::BurnHook::hook(self, action, |contract| { + contract.burn_unchecked(action.token_ids); - Self::Hook::after_burn(self, token_ids, current_owner_id, state); - - Nep171Event::NftBurn(vec![event::NftBurnLog { - token_ids: token_ids.iter().map(ToString::to_string).collect(), - owner_id: current_owner_id.clone(), - authorized_id: None, - memo, - }]) - .emit(); + Nep171Event::NftBurn(vec![NftBurnLog { + token_ids: action.token_ids.iter().map(ToString::to_string).collect(), + owner_id: action.owner_id.clone(), + authorized_id: None, + memo: action.memo.map(ToString::to_string), + }]) + .emit(); - Ok(()) + Ok(()) + }) } fn burn_unchecked(&mut self, token_ids: &[TokenId]) -> bool { @@ -712,6 +391,7 @@ impl Nep171Controller for T { /// Token information structure. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(crate = "near_sdk::serde")] pub struct Token { /// Token ID. pub token_id: TokenId, diff --git a/src/standard/nep177.rs b/src/standard/nep177.rs index e508d1c..c549a37 100644 --- a/src/standard/nep177.rs +++ b/src/standard/nep177.rs @@ -16,10 +16,10 @@ use crate::{ slot::Slot, standard::{ nep171::{ - self, - error::TokenDoesNotExistError, - event::{NftContractMetadataUpdateLog, NftMetadataUpdateLog}, - *, + action::{Nep171Burn, Nep171Mint}, + error::{Nep171BurnError, Nep171MintError, TokenDoesNotExistError}, + event::{Nep171Event, NftContractMetadataUpdateLog, NftMetadataUpdateLog}, + LoadTokenMetadata, Nep171Controller, TokenId, }, nep297::Event, }, @@ -263,10 +263,11 @@ pub trait Nep177Controller { fn burn_with_metadata( &mut self, token_id: TokenId, - current_owner_id: &AccountId, + owner_id: &AccountId, ) -> Result<(), Nep171BurnError>; - /// Sets the metadata for a token ID without checking whether the token exists, etc. and emits an [`Nep171Event::NftMetadataUpdate`] event. + /// Sets the metadata for a token ID without checking whether the token + /// exists, etc. and emits an [`Nep171Event::NftMetadataUpdate`] event. fn set_token_metadata_unchecked(&mut self, token_id: TokenId, metadata: Option); /// Sets the metadata for a token ID and emits an [`Nep171Event::NftMetadataUpdate`] event. @@ -321,7 +322,12 @@ impl Nep177Controller for T { metadata: TokenMetadata, ) -> Result<(), Nep171MintError> { let token_ids = [token_id]; - self.mint(&token_ids, &owner_id, None)?; + let action = Nep171Mint { + token_ids: &token_ids, + receiver_id: &owner_id, + memo: None, + }; + self.mint(&action)?; let [token_id] = token_ids; self.set_token_metadata_unchecked(token_id, Some(metadata)); Ok(()) @@ -330,10 +336,15 @@ impl Nep177Controller for T { fn burn_with_metadata( &mut self, token_id: TokenId, - current_owner_id: &AccountId, + owner_id: &AccountId, ) -> Result<(), Nep171BurnError> { let token_ids = [token_id]; - self.burn(&token_ids, current_owner_id, None)?; + let action = Nep171Burn { + token_ids: &token_ids, + owner_id, + memo: None, + }; + self.burn(&action)?; let [token_id] = token_ids; self.set_token_metadata_unchecked(token_id, None); Ok(()) @@ -341,7 +352,7 @@ impl Nep177Controller for T { fn set_token_metadata_unchecked(&mut self, token_id: TokenId, metadata: Option) { ::slot_token_metadata(&token_id).set(metadata.as_ref()); - nep171::Nep171Event::NftMetadataUpdate(vec![NftMetadataUpdateLog { + Nep171Event::NftMetadataUpdate(vec![NftMetadataUpdateLog { token_ids: vec![token_id], memo: None, }]) diff --git a/src/standard/nep178.rs b/src/standard/nep178.rs deleted file mode 100644 index b64162e..0000000 --- a/src/standard/nep178.rs +++ /dev/null @@ -1,675 +0,0 @@ -//! NEP-178 non-fungible token approval management implementation. -//! -//! Reference: -use std::{collections::HashMap, error::Error}; - -use near_sdk::{ - borsh::{self, BorshDeserialize, BorshSerialize}, - store::UnorderedMap, - AccountId, BorshStorageKey, -}; -use thiserror::Error; - -use crate::{slot::Slot, standard::nep171::*, DefaultStorageKey}; - -pub use ext::*; - -/// Type for approval IDs. -pub type ApprovalId = u32; -/// Maximum number of approvals per token. -pub const MAX_APPROVALS: ApprovalId = 32; - -/// Non-fungible token metadata. -#[derive(BorshSerialize, BorshDeserialize, Debug)] -pub struct TokenApprovals { - /// The next approval ID to use. Only incremented. - pub next_approval_id: ApprovalId, - - /// The list of approved accounts. - pub accounts: UnorderedMap, -} - -impl LoadTokenMetadata for TokenApprovals { - fn load( - contract: &C, - token_id: &TokenId, - metadata: &mut std::collections::HashMap, - ) -> Result<(), Box> { - metadata.insert( - "approved_account_ids".to_string(), - near_sdk::serde_json::to_value(contract.get_approvals_for(token_id))?, - ); - Ok(()) - } -} - -impl Nep171Hook for TokenApprovals { - type MintState = (); - type NftTransferState = (); - type BurnState = (); - - fn before_mint(_contract: &C, _token_ids: &[TokenId], _owner_id: &AccountId) {} - - fn after_mint(_contract: &mut C, _token_ids: &[TokenId], _owner_id: &AccountId, _: ()) {} - - fn before_nft_transfer(_contract: &C, _transfer: &Nep171Transfer) {} - - fn after_nft_transfer(contract: &mut C, transfer: &Nep171Transfer, _: ()) { - contract.revoke_all_unchecked(transfer.token_id); - } - - fn before_burn(_contract: &C, _token_ids: &[TokenId], _owner_id: &AccountId) {} - - fn after_burn(contract: &mut C, token_ids: &[TokenId], _owner_id: &AccountId, _: ()) { - for token_id in token_ids { - contract.revoke_all_unchecked(token_id); - } - } -} - -impl CheckExternalTransfer for TokenApprovals { - fn check_external_transfer( - contract: &C, - transfer: &Nep171Transfer, - ) -> Result { - let normal_check = - DefaultCheckExternalTransfer::check_external_transfer(contract, transfer); - - match (&transfer.authorization, normal_check) { - (_, r @ Ok(_)) => r, - ( - Nep171TransferAuthorization::ApprovalId(approval_id), - Err(Nep171TransferError::SenderNotApproved(s)), - ) => { - let saved_approval = - contract.get_approval_id_for(transfer.token_id, transfer.sender_id); - - if saved_approval == Some(*approval_id) { - Ok(s.owner_id) - } else { - Err(s.into()) - } - } - (_, e @ Err(_)) => e, - } - } -} - -#[derive(BorshSerialize, BorshStorageKey)] -enum StorageKey<'a> { - TokenApprovals(&'a TokenId), - TokenApprovalsUnorderedMap(&'a TokenId), -} - -/// Internal functions for [`Nep178Controller`]. -pub trait Nep178ControllerInternal { - /// Lifecycle hooks for NEP-178. - type Hook: Nep178Hook - where - Self: Sized; - - /// Storage root. - fn root() -> Slot<()> { - Slot::root(DefaultStorageKey::Nep178) - } - - /// Storage slot for token approvals. - fn slot_token_approvals(token_id: &TokenId) -> Slot { - Self::root().field(StorageKey::TokenApprovals(token_id)) - } - - /// Storage slot for token approvals `UnorderedMap`. - fn slot_token_approvals_unordered_map( - token_id: &TokenId, - ) -> Slot> { - Self::root().field(StorageKey::TokenApprovalsUnorderedMap(token_id)) - } -} - -/// Errors that can occur when managing non-fungible token approvals. -#[derive(Error, Debug)] -pub enum Nep178ApproveError { - /// The account is not authorized to approve the token. - #[error("Account `{account_id}` cannot create approvals for token `{token_id}`.")] - Unauthorized { - /// The token ID. - token_id: TokenId, - /// The unauthorized account ID. - account_id: AccountId, - }, - /// The account is already approved for the token. - #[error("Account {account_id} is already approved for token {token_id}.")] - AccountAlreadyApproved { - /// The token ID. - token_id: TokenId, - /// The account ID that has already been approved. - account_id: AccountId, - }, - /// The token has too many approvals. - #[error( - "Too many approvals for token {token_id}, maximum is {}.", - MAX_APPROVALS - )] - TooManyApprovals { - /// The token ID. - token_id: TokenId, - }, -} - -/// Errors that can occur when revoking non-fungible token approvals. -#[derive(Error, Debug)] -pub enum Nep178RevokeError { - /// The account is not authorized to revoke approvals for the token. - #[error("Account `{account_id}` is cannot revoke approvals for token `{token_id}`.")] - Unauthorized { - /// The token ID. - token_id: TokenId, - /// The unauthorized account ID. - account_id: AccountId, - }, - /// The account is not approved for the token. - #[error("Account {account_id} is not approved for token {token_id}")] - AccountNotApproved { - /// The token ID. - token_id: TokenId, - /// The account ID that is not approved. - account_id: AccountId, - }, -} - -/// Errors that can occur when revoking all approvals for a non-fungible token. -#[derive(Error, Debug)] -pub enum Nep178RevokeAllError { - /// The account is not authorized to revoke approvals for the token. - #[error("Account `{account_id}` is cannot revoke approvals for token `{token_id}`.")] - Unauthorized { - /// The token ID. - token_id: TokenId, - /// The unauthorized account ID. - account_id: AccountId, - }, -} - -/// Functions for managing non-fungible tokens with attached metadata, NEP-178. -pub trait Nep178Controller { - /// Lifecycle hooks for NEP-178. - type Hook: Nep178Hook - where - Self: Sized; - - /// Approve a token for transfer by a delegated account. - fn approve( - &mut self, - token_id: &TokenId, - current_owner_id: &AccountId, - account_id: &AccountId, - ) -> Result; - - /// Approve a token without checking if the account is already approved or - /// if it exceeds the maximum number of approvals. - fn approve_unchecked(&mut self, token_id: &TokenId, account_id: &AccountId) -> ApprovalId; - - /// Revoke approval for an account to transfer token. - fn revoke( - &mut self, - token_id: &TokenId, - current_owner_id: &AccountId, - account_id: &AccountId, - ) -> Result<(), Nep178RevokeError>; - - /// Revoke approval for an account to transfer token without checking if - /// the account is approved. - fn revoke_unchecked(&mut self, token_id: &TokenId, account_id: &AccountId); - - /// Revoke all approvals for a token. - fn revoke_all( - &mut self, - token_id: &TokenId, - current_owner_id: &AccountId, - ) -> Result<(), Nep178RevokeAllError>; - - /// Revoke all approvals for a token without checking current owner. - fn revoke_all_unchecked(&mut self, token_id: &TokenId); - - /// Get the approval ID for an account, if it is approved for a token. - fn get_approval_id_for(&self, token_id: &TokenId, account_id: &AccountId) - -> Option; - - /// Get the approvals for a token. - fn get_approvals_for(&self, token_id: &TokenId) -> HashMap; -} - -impl Nep178Controller for T { - type Hook = ::Hook; - - fn approve_unchecked(&mut self, token_id: &TokenId, account_id: &AccountId) -> ApprovalId { - let mut slot = Self::slot_token_approvals(token_id); - let mut approvals = slot.read().unwrap_or_else(|| TokenApprovals { - next_approval_id: 0, - accounts: UnorderedMap::new(Self::slot_token_approvals_unordered_map(token_id)), - }); - let approval_id = approvals.next_approval_id; - approvals.accounts.insert(account_id.clone(), approval_id); - approvals.next_approval_id += 1; // overflow unrealistic - slot.write(&approvals); - - approval_id - } - - fn approve( - &mut self, - token_id: &TokenId, - current_owner_id: &AccountId, - account_id: &AccountId, - ) -> Result { - // owner check - if self.token_owner(token_id).as_ref() != Some(current_owner_id) { - return Err(Nep178ApproveError::Unauthorized { - token_id: token_id.clone(), - account_id: account_id.clone(), - }); - } - - let mut slot = Self::slot_token_approvals(token_id); - let mut approvals = slot.read().unwrap_or_else(|| TokenApprovals { - next_approval_id: 0, - accounts: UnorderedMap::new(Self::slot_token_approvals_unordered_map(token_id)), - }); - - if approvals.accounts.len() >= MAX_APPROVALS { - return Err(Nep178ApproveError::TooManyApprovals { - token_id: token_id.clone(), - }); - } - - let approval_id = approvals.next_approval_id; - if approvals.accounts.contains_key(account_id) { - return Err(Nep178ApproveError::AccountAlreadyApproved { - token_id: token_id.clone(), - account_id: account_id.clone(), - }); - } - - let hook_state = Self::Hook::before_nft_approve(self, token_id, account_id); - - approvals.accounts.insert(account_id.clone(), approval_id); - approvals.next_approval_id += 1; // overflow unrealistic - slot.write(&approvals); - - Self::Hook::after_nft_approve(self, token_id, account_id, &approval_id, hook_state); - - Ok(approval_id) - } - - fn revoke_unchecked(&mut self, token_id: &TokenId, account_id: &AccountId) { - let mut slot = Self::slot_token_approvals(token_id); - let mut approvals = match slot.read() { - Some(approvals) => approvals, - None => return, - }; - - let old = approvals.accounts.remove(account_id); - - if old.is_some() { - slot.write(&approvals); - } - } - - fn revoke( - &mut self, - token_id: &TokenId, - current_owner_id: &AccountId, - account_id: &AccountId, - ) -> Result<(), Nep178RevokeError> { - // owner check - if self.token_owner(token_id).as_ref() != Some(current_owner_id) { - return Err(Nep178RevokeError::Unauthorized { - token_id: token_id.clone(), - account_id: account_id.clone(), - }); - } - - let mut slot = Self::slot_token_approvals(token_id); - let mut approvals = slot - .read() - .ok_or_else(|| Nep178RevokeError::AccountNotApproved { - token_id: token_id.clone(), - account_id: account_id.clone(), - })?; - - if !approvals.accounts.contains_key(account_id) { - return Err(Nep178RevokeError::AccountNotApproved { - token_id: token_id.clone(), - account_id: account_id.clone(), - }); - } - - let hook_state = Self::Hook::before_nft_revoke(self, token_id, account_id); - - approvals.accounts.remove(account_id); - slot.write(&approvals); - - Self::Hook::after_nft_revoke(self, token_id, account_id, hook_state); - - Ok(()) - } - - fn revoke_all( - &mut self, - token_id: &TokenId, - current_owner_id: &AccountId, - ) -> Result<(), Nep178RevokeAllError> { - // owner check - if self.token_owner(token_id).as_ref() != Some(current_owner_id) { - return Err(Nep178RevokeAllError::Unauthorized { - token_id: token_id.clone(), - account_id: current_owner_id.clone(), - }); - } - - let hook_state = Self::Hook::before_nft_revoke_all(self, token_id); - - self.revoke_all_unchecked(token_id); - - Self::Hook::after_nft_revoke_all(self, token_id, hook_state); - - Ok(()) - } - - fn revoke_all_unchecked(&mut self, token_id: &TokenId) { - let mut slot = Self::slot_token_approvals(token_id); - let mut approvals = match slot.read() { - Some(approvals) => approvals, - None => return, - }; - - if !approvals.accounts.is_empty() { - approvals.accounts.clear(); - slot.write(&approvals); - } - } - - fn get_approval_id_for( - &self, - token_id: &TokenId, - account_id: &AccountId, - ) -> Option { - let slot = Self::slot_token_approvals(token_id); - let approvals = slot.read()?; - - approvals.accounts.get(account_id).copied() - } - - fn get_approvals_for(&self, token_id: &TokenId) -> HashMap { - let slot = Self::slot_token_approvals(token_id); - let approvals = match slot.read() { - Some(approvals) => approvals, - None => return HashMap::default(), - }; - - approvals - .accounts - .into_iter() - .map(|(k, v)| (k.clone(), *v)) - .collect() - } -} - -/// Hooks for NEP-178. -pub trait Nep178Hook { - /// State passed from [`Nep178Hook::before_nft_approve`] to - /// [`Nep178Hook::after_nft_approve`]. - type NftApproveState; - - /// State passed from [`Nep178Hook::before_nft_revoke`] to - /// [`Nep178Hook::after_nft_revoke`]. - type NftRevokeState; - - /// State passed from [`Nep178Hook::before_nft_revoke_all`] to - /// [`Nep178Hook::after_nft_revoke_all`]. - type NftRevokeAllState; - - /// Called before a token is approved for transfer. - fn before_nft_approve( - contract: &C, - token_id: &TokenId, - account_id: &AccountId, - ) -> Self::NftApproveState; - - /// Called after a token is approved for transfer. - fn after_nft_approve( - contract: &mut C, - token_id: &TokenId, - account_id: &AccountId, - approval_id: &ApprovalId, - state: Self::NftApproveState, - ); - - /// Called before a token approval is revoked. - fn before_nft_revoke( - contract: &C, - token_id: &TokenId, - account_id: &AccountId, - ) -> Self::NftRevokeState; - - /// Called after a token approval is revoked. - fn after_nft_revoke( - contract: &mut C, - token_id: &TokenId, - account_id: &AccountId, - state: Self::NftRevokeState, - ); - - /// Called before all approvals for a token are revoked. - fn before_nft_revoke_all(contract: &C, token_id: &TokenId) -> Self::NftRevokeAllState; - - /// Called after all approvals for a token are revoked. - fn after_nft_revoke_all(contract: &mut C, token_id: &TokenId, state: Self::NftRevokeAllState); -} - -impl Nep178Hook for () { - type NftApproveState = (); - type NftRevokeState = (); - type NftRevokeAllState = (); - - fn before_nft_approve(_contract: &C, _token_id: &TokenId, _account_id: &AccountId) {} - - fn after_nft_approve( - _contract: &mut C, - _token_id: &TokenId, - _account_id: &AccountId, - _approval_id: &ApprovalId, - _: (), - ) { - } - - fn before_nft_revoke(_contract: &C, _token_id: &TokenId, _account_id: &AccountId) {} - - fn after_nft_revoke(_contract: &mut C, _token_id: &TokenId, _account_id: &AccountId, _: ()) {} - - fn before_nft_revoke_all(_contract: &C, _token_id: &TokenId) {} - - fn after_nft_revoke_all(_contract: &mut C, _token_id: &TokenId, _: ()) {} -} - -impl Nep178Hook for (T, U) -where - T: Nep178Hook, - U: Nep178Hook, -{ - type NftApproveState = (T::NftApproveState, U::NftApproveState); - type NftRevokeState = (T::NftRevokeState, U::NftRevokeState); - type NftRevokeAllState = (T::NftRevokeAllState, U::NftRevokeAllState); - - fn before_nft_approve( - contract: &C, - token_id: &TokenId, - account_id: &AccountId, - ) -> Self::NftApproveState { - ( - T::before_nft_approve(contract, token_id, account_id), - U::before_nft_approve(contract, token_id, account_id), - ) - } - - fn after_nft_approve( - contract: &mut C, - token_id: &TokenId, - account_id: &AccountId, - approval_id: &ApprovalId, - (t_state, u_state): Self::NftApproveState, - ) { - T::after_nft_approve(contract, token_id, account_id, approval_id, t_state); - U::after_nft_approve(contract, token_id, account_id, approval_id, u_state); - } - - fn before_nft_revoke( - contract: &C, - token_id: &TokenId, - account_id: &AccountId, - ) -> Self::NftRevokeState { - ( - T::before_nft_revoke(contract, token_id, account_id), - U::before_nft_revoke(contract, token_id, account_id), - ) - } - - fn after_nft_revoke( - contract: &mut C, - token_id: &TokenId, - account_id: &AccountId, - (t_state, u_state): Self::NftRevokeState, - ) { - T::after_nft_revoke(contract, token_id, account_id, t_state); - U::after_nft_revoke(contract, token_id, account_id, u_state); - } - - fn before_nft_revoke_all(contract: &C, token_id: &TokenId) -> Self::NftRevokeAllState { - ( - T::before_nft_revoke_all(contract, token_id), - U::before_nft_revoke_all(contract, token_id), - ) - } - - fn after_nft_revoke_all( - contract: &mut C, - token_id: &TokenId, - (t_state, u_state): Self::NftRevokeAllState, - ) { - T::after_nft_revoke_all(contract, token_id, t_state); - U::after_nft_revoke_all(contract, token_id, u_state); - } -} - -/// Alternative to [`Nep178Hook`] that is simpler to implement. There is a -/// blanket implementation of [`Nep178Hook`] for all types that implement -/// [`SimpleNep178Hook`]. -pub trait SimpleNep178Hook { - /// Called before a token is approved for transfer. - fn before_nft_approve(&self, _token_id: &TokenId, _account_id: &AccountId) {} - /// Called after a token is approved for transfer. - fn after_nft_approve( - &mut self, - _token_id: &TokenId, - _account_id: &AccountId, - _approval_id: &ApprovalId, - ) { - } - - /// Called before a token approval is revoked. - fn before_nft_revoke(&self, _token_id: &TokenId, _account_id: &AccountId) {} - /// Called after a token approval is revoked. - fn after_nft_revoke(&mut self, _token_id: &TokenId, _account_id: &AccountId) {} - - /// Called before all approvals for a token are revoked. - fn before_nft_revoke_all(&self, _token_id: &TokenId) {} - /// Called after all approvals for a token are revoked. - fn after_nft_revoke_all(&mut self, _token_id: &TokenId) {} -} - -impl Nep178Hook for T { - type NftApproveState = (); - - type NftRevokeState = (); - - type NftRevokeAllState = (); - - fn before_nft_approve(contract: &Self, token_id: &TokenId, account_id: &AccountId) { - ::before_nft_approve(contract, token_id, account_id); - } - - fn after_nft_approve( - contract: &mut Self, - token_id: &TokenId, - account_id: &AccountId, - approval_id: &ApprovalId, - _: (), - ) { - ::after_nft_approve(contract, token_id, account_id, approval_id); - } - - fn before_nft_revoke(contract: &Self, token_id: &TokenId, account_id: &AccountId) { - ::before_nft_revoke(contract, token_id, account_id); - } - - fn after_nft_revoke(contract: &mut Self, token_id: &TokenId, account_id: &AccountId, _: ()) { - ::after_nft_revoke(contract, token_id, account_id); - } - - fn before_nft_revoke_all(contract: &Self, token_id: &TokenId) { - ::before_nft_revoke_all(contract, token_id); - } - - fn after_nft_revoke_all(contract: &mut Self, token_id: &TokenId, _: ()) { - ::after_nft_revoke_all(contract, token_id); - } -} - -// separate module with re-export because ext_contract doesn't play well with #![warn(missing_docs)] -mod ext { - #![allow(missing_docs)] - - use near_sdk::PromiseOrValue; - - use super::*; - - /// NEP-178 external interface. - /// - /// See for more details. - #[near_sdk::ext_contract(ext_nep178)] - pub trait Nep178 { - fn nft_approve( - &mut self, - token_id: TokenId, - account_id: AccountId, - msg: Option, - ) -> PromiseOrValue<()>; - - fn nft_revoke(&mut self, token_id: TokenId, account_id: AccountId); - - fn nft_revoke_all(&mut self, token_id: TokenId); - - fn nft_is_approved( - &self, - token_id: TokenId, - approved_account_id: AccountId, - approval_id: Option, - ) -> bool; - } - - /// NEP-178 receiver interface. - /// - /// Respond to notification that contract has been granted approval for a token. - /// - /// See for more details. - #[near_sdk::ext_contract(ext_nep178_receiver)] - pub trait Nep178Receiver { - fn nft_on_approve( - &mut self, - token_id: TokenId, - owner_id: AccountId, - approval_id: ApprovalId, - msg: String, - ); - } -} diff --git a/src/standard/nep178/action.rs b/src/standard/nep178/action.rs new file mode 100644 index 0000000..2de869c --- /dev/null +++ b/src/standard/nep178/action.rs @@ -0,0 +1,47 @@ +//! NEP-178 actions. +//! +//! Used when calling various functions on [`Nep178Controller`]. Also used when +//! implementing [`Hook`]s for the NEP-178 component. + +use super::*; +use near_sdk::{ + borsh::{self, BorshSerialize}, + serde::Serialize, +}; + +/// NEP-178 approve action. +#[derive(Clone, Debug, Serialize, BorshSerialize, PartialEq, Eq)] +#[serde(crate = "near_sdk::serde")] +pub struct Nep178Approve<'a> { + /// Token ID that the target account is being approved for. + pub token_id: &'a TokenId, + /// Account ID of the current owner of the token. + pub current_owner_id: &'a AccountId, + /// Account ID of the target account. This account will be able to + /// transfer the token. + pub account_id: &'a AccountId, +} + +/// NEP-178 revoke action. +#[derive(Clone, Debug, Serialize, BorshSerialize, PartialEq, Eq)] +#[serde(crate = "near_sdk::serde")] +pub struct Nep178Revoke<'a> { + /// Token ID that the target account will no longer be able to transfer + /// (approval revoked). + pub token_id: &'a TokenId, + /// Account ID of the current owner of the token. + pub current_owner_id: &'a AccountId, + /// Account ID of the target account. This account will no longer be able + /// to transfer the token. + pub account_id: &'a AccountId, +} + +/// NEP-178 revoke all action. +#[derive(Clone, Debug, Serialize, BorshSerialize, PartialEq, Eq)] +#[serde(crate = "near_sdk::serde")] +pub struct Nep178RevokeAll<'a> { + /// Token ID that all approvals will be revoked from. + pub token_id: &'a TokenId, + /// Account ID of the current owner of the token. + pub current_owner_id: &'a AccountId, +} diff --git a/src/standard/nep178/error.rs b/src/standard/nep178/error.rs new file mode 100644 index 0000000..3031e73 --- /dev/null +++ b/src/standard/nep178/error.rs @@ -0,0 +1,79 @@ +//! NEP-178 errors. + +use super::{TokenId, MAX_APPROVALS}; +use near_sdk::AccountId; +use thiserror::Error; + +/// Occurs when an account is not authorized to manage approvals for a token. +#[derive(Error, Debug)] +#[error("Account `{account_id}` is not authorized to manage approvals for token `{token_id}`.")] +pub struct UnauthorizedError { + /// The token ID. + pub token_id: TokenId, + /// The unauthorized account ID. + pub account_id: AccountId, +} + +/// The account is already approved for the token. +#[derive(Error, Debug)] +#[error("Account {account_id} is already approved for token {token_id}.")] +pub struct AccountAlreadyApprovedError { + /// The token ID. + pub token_id: TokenId, + /// The account ID that has already been approved. + pub account_id: AccountId, +} + +/// The token has too many approvals. +#[derive(Error, Debug)] +#[error( + "Too many approvals for token {token_id}, maximum is {}.", + MAX_APPROVALS +)] +pub struct TooManyApprovalsError { + /// The token ID. + pub token_id: TokenId, +} + +/// Errors that can occur when managing non-fungible token approvals. +#[derive(Error, Debug)] +pub enum Nep178ApproveError { + /// The account is not authorized to create approvals for the token. + #[error(transparent)] + Unauthorized(#[from] UnauthorizedError), + /// The account is already approved for the token. + #[error(transparent)] + AccountAlreadyApproved(#[from] AccountAlreadyApprovedError), + /// The token has too many approvals. + #[error(transparent)] + TooManyApprovals(#[from] TooManyApprovalsError), +} + +/// The account is not approved for the token. +#[derive(Error, Debug)] +#[error("Account {account_id} is not approved for token {token_id}")] +pub struct AccountNotApprovedError { + /// The token ID. + pub token_id: TokenId, + /// The account ID that is not approved. + pub account_id: AccountId, +} + +/// Errors that can occur when revoking non-fungible token approvals. +#[derive(Error, Debug)] +pub enum Nep178RevokeError { + /// The account is not authorized to revoke approvals for the token. + #[error(transparent)] + Unauthorized(#[from] UnauthorizedError), + /// The account is not approved for the token. + #[error(transparent)] + AccountNotApproved(#[from] AccountNotApprovedError), +} + +/// Errors that can occur when revoking all approvals for a non-fungible token. +#[derive(Error, Debug)] +pub enum Nep178RevokeAllError { + /// The account is not authorized to revoke approvals for the token. + #[error(transparent)] + Unauthorized(#[from] UnauthorizedError), +} diff --git a/src/standard/nep178/ext.rs b/src/standard/nep178/ext.rs new file mode 100644 index 0000000..3cb89f4 --- /dev/null +++ b/src/standard/nep178/ext.rs @@ -0,0 +1,45 @@ +#![allow(missing_docs)] + +use near_sdk::PromiseOrValue; + +use super::*; + +/// NEP-178 external interface. +/// +/// See for more details. +#[near_sdk::ext_contract(ext_nep178)] +pub trait Nep178 { + fn nft_approve( + &mut self, + token_id: TokenId, + account_id: AccountId, + msg: Option, + ) -> PromiseOrValue<()>; + + fn nft_revoke(&mut self, token_id: TokenId, account_id: AccountId); + + fn nft_revoke_all(&mut self, token_id: TokenId); + + fn nft_is_approved( + &self, + token_id: TokenId, + approved_account_id: AccountId, + approval_id: Option, + ) -> bool; +} + +/// NEP-178 receiver interface. +/// +/// Respond to notification that contract has been granted approval for a token. +/// +/// See for more details. +#[near_sdk::ext_contract(ext_nep178_receiver)] +pub trait Nep178Receiver { + fn nft_on_approve( + &mut self, + token_id: TokenId, + owner_id: AccountId, + approval_id: ApprovalId, + msg: String, + ); +} diff --git a/src/standard/nep178/mod.rs b/src/standard/nep178/mod.rs new file mode 100644 index 0000000..2c0a490 --- /dev/null +++ b/src/standard/nep178/mod.rs @@ -0,0 +1,356 @@ +//! NEP-178 non-fungible token approval management implementation. +//! +//! Reference: +use std::{collections::HashMap, error::Error}; + +use near_sdk::{ + borsh::{self, BorshDeserialize, BorshSerialize}, + store::UnorderedMap, + AccountId, BorshStorageKey, +}; + +use crate::{ + hook::Hook, + slot::Slot, + standard::nep171::{ + action::{Nep171Burn, Nep171Mint, Nep171Transfer}, + error::Nep171TransferError, + CheckExternalTransfer, DefaultCheckExternalTransfer, LoadTokenMetadata, Nep171Controller, + Nep171TransferAuthorization, TokenId, + }, + DefaultStorageKey, +}; + +pub mod action; +use action::*; +pub mod error; +use error::*; +// separate module with re-export because ext_contract doesn't play well with #![warn(missing_docs)] +mod ext; +pub use ext::*; + +/// Type for approval IDs. +pub type ApprovalId = u32; +/// Maximum number of approvals per token. +pub const MAX_APPROVALS: ApprovalId = 32; + +/// NFT token approvals. Hooks are implemented on this struct. +#[derive(BorshSerialize, BorshDeserialize, Debug)] +pub struct TokenApprovals { + /// The next approval ID to use. Only incremented. + pub next_approval_id: ApprovalId, + + /// The list of approved accounts. + pub accounts: UnorderedMap, +} + +impl LoadTokenMetadata for TokenApprovals { + fn load( + contract: &C, + token_id: &TokenId, + metadata: &mut std::collections::HashMap, + ) -> Result<(), Box> { + metadata.insert( + "approved_account_ids".to_string(), + near_sdk::serde_json::to_value(contract.get_approvals_for(token_id))?, + ); + Ok(()) + } +} + +impl Hook> for TokenApprovals {} + +impl Hook> for TokenApprovals { + fn hook(contract: &mut C, args: &Nep171Transfer<'_>, f: impl FnOnce(&mut C) -> R) -> R { + let r = f(contract); + contract.revoke_all_unchecked(args.token_id); + r + } +} + +impl Hook> for TokenApprovals { + fn hook(contract: &mut C, args: &Nep171Burn<'_>, f: impl FnOnce(&mut C) -> R) -> R { + let r = f(contract); + for token_id in args.token_ids { + contract.revoke_all_unchecked(token_id); + } + r + } +} + +impl CheckExternalTransfer for TokenApprovals { + fn check_external_transfer( + contract: &C, + transfer: &Nep171Transfer, + ) -> Result { + let normal_check = + DefaultCheckExternalTransfer::check_external_transfer(contract, transfer); + + match (&transfer.authorization, normal_check) { + (_, r @ Ok(_)) => r, + ( + Nep171TransferAuthorization::ApprovalId(approval_id), + Err(Nep171TransferError::SenderNotApproved(s)), + ) => { + let saved_approval = + contract.get_approval_id_for(transfer.token_id, transfer.sender_id); + + if saved_approval == Some(*approval_id) { + Ok(s.owner_id) + } else { + Err(s.into()) + } + } + (_, e @ Err(_)) => e, + } + } +} + +#[derive(BorshSerialize, BorshStorageKey)] +enum StorageKey<'a> { + TokenApprovals(&'a TokenId), + TokenApprovalsUnorderedMap(&'a TokenId), +} + +/// Internal functions for [`Nep178Controller`]. +pub trait Nep178ControllerInternal { + /// Hook for approve operations. + type ApproveHook: for<'a> Hook> + where + Self: Sized; + /// Hook for revoke operations. + type RevokeHook: for<'a> Hook> + where + Self: Sized; + /// Hook for revoke all operations. + type RevokeAllHook: for<'a> Hook> + where + Self: Sized; + + /// Storage root. + fn root() -> Slot<()> { + Slot::root(DefaultStorageKey::Nep178) + } + + /// Storage slot for token approvals. + fn slot_token_approvals(token_id: &TokenId) -> Slot { + Self::root().field(StorageKey::TokenApprovals(token_id)) + } + + /// Storage slot for token approvals `UnorderedMap`. + fn slot_token_approvals_unordered_map( + token_id: &TokenId, + ) -> Slot> { + Self::root().field(StorageKey::TokenApprovalsUnorderedMap(token_id)) + } +} + +/// Functions for managing token approvals, NEP-178. +pub trait Nep178Controller { + /// Hook for approve operations. + type ApproveHook: for<'a> Hook> + where + Self: Sized; + /// Hook for revoke operations. + type RevokeHook: for<'a> Hook> + where + Self: Sized; + /// Hook for revoke all operations. + type RevokeAllHook: for<'a> Hook> + where + Self: Sized; + + /// Approve a token for transfer by a delegated account. + fn approve(&mut self, action: &Nep178Approve<'_>) -> Result; + + /// Approve a token without checking if the account is already approved or + /// if it exceeds the maximum number of approvals. + fn approve_unchecked(&mut self, token_id: &TokenId, account_id: &AccountId) -> ApprovalId; + + /// Revoke approval for an account to transfer token. + fn revoke(&mut self, action: &Nep178Revoke<'_>) -> Result<(), Nep178RevokeError>; + + /// Revoke approval for an account to transfer token without checking if + /// the account is approved. + fn revoke_unchecked(&mut self, token_id: &TokenId, account_id: &AccountId); + + /// Revoke all approvals for a token. + fn revoke_all(&mut self, action: &Nep178RevokeAll<'_>) -> Result<(), Nep178RevokeAllError>; + + /// Revoke all approvals for a token without checking current owner. + fn revoke_all_unchecked(&mut self, token_id: &TokenId); + + /// Get the approval ID for an account, if it is approved for a token. + fn get_approval_id_for(&self, token_id: &TokenId, account_id: &AccountId) + -> Option; + + /// Get the approvals for a token. + fn get_approvals_for(&self, token_id: &TokenId) -> HashMap; +} + +impl Nep178Controller for T { + type ApproveHook = T::ApproveHook; + type RevokeHook = T::RevokeHook; + type RevokeAllHook = T::RevokeAllHook; + + fn approve_unchecked(&mut self, token_id: &TokenId, account_id: &AccountId) -> ApprovalId { + let mut slot = Self::slot_token_approvals(token_id); + let mut approvals = slot.read().unwrap_or_else(|| TokenApprovals { + next_approval_id: 0, + accounts: UnorderedMap::new(Self::slot_token_approvals_unordered_map(token_id)), + }); + let approval_id = approvals.next_approval_id; + approvals.accounts.insert(account_id.clone(), approval_id); + approvals.next_approval_id += 1; // overflow unrealistic + slot.write(&approvals); + + approval_id + } + + fn approve(&mut self, action: &Nep178Approve<'_>) -> Result { + // owner check + if self.token_owner(action.token_id).as_ref() != Some(action.current_owner_id) { + return Err(UnauthorizedError { + token_id: action.token_id.clone(), + account_id: action.account_id.clone(), + } + .into()); + } + + let mut slot = Self::slot_token_approvals(action.token_id); + let mut approvals = slot.read().unwrap_or_else(|| TokenApprovals { + next_approval_id: 0, + accounts: UnorderedMap::new(Self::slot_token_approvals_unordered_map(action.token_id)), + }); + + if approvals.accounts.len() >= MAX_APPROVALS { + return Err(TooManyApprovalsError { + token_id: action.token_id.clone(), + } + .into()); + } + + let approval_id = approvals.next_approval_id; + if approvals.accounts.contains_key(action.account_id) { + return Err(AccountAlreadyApprovedError { + token_id: action.token_id.clone(), + account_id: action.account_id.clone(), + } + .into()); + } + + Self::ApproveHook::hook(self, action, |_| { + approvals + .accounts + .insert(action.account_id.clone(), approval_id); + approvals.accounts.flush(); + approvals.next_approval_id += 1; // overflow unrealistic + slot.write(&approvals); + + Ok(approval_id) + }) + } + + fn revoke_unchecked(&mut self, token_id: &TokenId, account_id: &AccountId) { + let mut slot = Self::slot_token_approvals(token_id); + let mut approvals = match slot.read() { + Some(approvals) => approvals, + None => return, + }; + + let old = approvals.accounts.remove(account_id); + + if old.is_some() { + slot.write(&approvals); + } + } + + fn revoke(&mut self, action: &Nep178Revoke<'_>) -> Result<(), Nep178RevokeError> { + // owner check + if self.token_owner(action.token_id).as_ref() != Some(action.current_owner_id) { + return Err(UnauthorizedError { + token_id: action.token_id.clone(), + account_id: action.account_id.clone(), + } + .into()); + } + + let mut slot = Self::slot_token_approvals(action.token_id); + let mut approvals = slot.read().ok_or_else(|| AccountNotApprovedError { + token_id: action.token_id.clone(), + account_id: action.account_id.clone(), + })?; + + if !approvals.accounts.contains_key(action.account_id) { + return Err(AccountNotApprovedError { + token_id: action.token_id.clone(), + account_id: action.account_id.clone(), + } + .into()); + } + + Self::RevokeHook::hook(self, action, |_| { + approvals.accounts.remove(action.account_id); + approvals.accounts.flush(); + slot.write(&approvals); + + Ok(()) + }) + } + + fn revoke_all(&mut self, action: &Nep178RevokeAll<'_>) -> Result<(), Nep178RevokeAllError> { + // owner check + if self.token_owner(action.token_id).as_ref() != Some(action.current_owner_id) { + return Err(UnauthorizedError { + token_id: action.token_id.clone(), + account_id: action.current_owner_id.clone(), + } + .into()); + } + + Self::RevokeAllHook::hook(self, action, |contract| { + contract.revoke_all_unchecked(action.token_id); + + Ok(()) + }) + } + + fn revoke_all_unchecked(&mut self, token_id: &TokenId) { + let mut slot = Self::slot_token_approvals(token_id); + let mut approvals = match slot.read() { + Some(approvals) => approvals, + None => return, + }; + + if !approvals.accounts.is_empty() { + approvals.accounts.clear(); + approvals.accounts.flush(); + slot.write(&approvals); + } + } + + fn get_approval_id_for( + &self, + token_id: &TokenId, + account_id: &AccountId, + ) -> Option { + let slot = Self::slot_token_approvals(token_id); + let approvals = slot.read()?; + + approvals.accounts.get(account_id).copied() + } + + fn get_approvals_for(&self, token_id: &TokenId) -> HashMap { + let slot = Self::slot_token_approvals(token_id); + let approvals = match slot.read() { + Some(approvals) => approvals, + None => return HashMap::default(), + }; + + approvals + .accounts + .into_iter() + .map(|(k, v)| (k.clone(), *v)) + .collect() + } +} diff --git a/src/standard/nep181.rs b/src/standard/nep181.rs index ff1fe50..9d6bbef 100644 --- a/src/standard/nep181.rs +++ b/src/standard/nep181.rs @@ -10,45 +10,51 @@ use near_sdk::{ AccountId, BorshStorageKey, }; -use crate::{slot::Slot, standard::nep171::*, DefaultStorageKey}; +use crate::{hook::Hook, slot::Slot, standard::nep171::*, DefaultStorageKey}; pub use ext::*; /// Extension hook for [`Nep171Controller`]. pub struct TokenEnumeration; -impl Nep171Hook for TokenEnumeration { - type MintState = (); - type NftTransferState = (); - type BurnState = (); - - fn before_mint(_contract: &C, _token_ids: &[TokenId], _owner_id: &AccountId) {} - - fn after_mint(contract: &mut C, token_ids: &[TokenId], owner_id: &AccountId, _: ()) { - contract.add_tokens_to_enumeration(token_ids, owner_id); +impl Hook> for TokenEnumeration { + fn hook(contract: &mut C, args: &action::Nep171Mint<'_>, f: impl FnOnce(&mut C) -> R) -> R { + let r = f(contract); + contract.add_tokens_to_enumeration(args.token_ids, args.receiver_id); + r } +} - fn before_nft_transfer(_contract: &C, _transfer: &Nep171Transfer) {} - - fn after_nft_transfer(contract: &mut C, transfer: &Nep171Transfer, _: ()) { - let owner_id = match transfer.authorization { - Nep171TransferAuthorization::Owner => Cow::Borrowed(transfer.sender_id), - Nep171TransferAuthorization::ApprovalId(_) => Cow::Owned(contract.token_owner(transfer.token_id).unwrap_or_else(|| { - env::panic_str(&format!("Inconsistent state: Enumeration reconciliation should only run after a token has been transferred, but token {} does not exist.", transfer.token_id)) +impl Hook> + for TokenEnumeration +{ + fn hook( + contract: &mut C, + args: &action::Nep171Transfer<'_>, + f: impl FnOnce(&mut C) -> R, + ) -> R { + let r = f(contract); + let owner_id = match args.authorization { + Nep171TransferAuthorization::Owner => Cow::Borrowed(args.sender_id), + Nep171TransferAuthorization::ApprovalId(_) => Cow::Owned(contract.token_owner(args.token_id).unwrap_or_else(|| { + env::panic_str(&format!("Inconsistent state: Enumeration reconciliation should only run after a token has been transferred, but token {} does not exist.", args.token_id)) })), }; contract.transfer_token_enumeration( - std::array::from_ref(transfer.token_id), + std::array::from_ref(args.token_id), owner_id.as_ref(), - transfer.receiver_id, + args.receiver_id, ); + r } +} - fn before_burn(_contract: &C, _token_ids: &[TokenId], _owner_id: &AccountId) {} - - fn after_burn(contract: &mut C, token_ids: &[TokenId], owner_id: &AccountId, _: ()) { - contract.remove_tokens_from_enumeration(token_ids, owner_id); +impl Hook> for TokenEnumeration { + fn hook(contract: &mut C, args: &action::Nep171Burn<'_>, f: impl FnOnce(&mut C) -> R) -> R { + let r = f(contract); + contract.remove_tokens_from_enumeration(args.token_ids, args.owner_id); + r } } diff --git a/src/standard/nep297.rs b/src/standard/nep297.rs index 589fe50..572aa4c 100644 --- a/src/standard/nep297.rs +++ b/src/standard/nep297.rs @@ -1,6 +1,6 @@ //! Helpers for `#[derive(near_sdk_contract_tools::Nep297)]` -use near_sdk::serde::Serialize; +use near_sdk::{serde::Serialize, serde_json}; /// Emit events according to the [NEP-297 event standard](https://nomicon.io/Standards/EventsFormat). /// @@ -41,7 +41,17 @@ where fn to_event_string(&self) -> String { format!( "EVENT_JSON:{}", - serde_json::to_string(&self.to_event_log()).unwrap_or_else(|_| near_sdk::env::abort()), + serde_json::to_string(&self.to_event_log()).unwrap_or_else(|e| { + #[cfg(not(target_arch = "wasm32"))] + { + panic!("Failed to serialize event: {e}") + } + + #[cfg(target_arch = "wasm32")] + { + near_sdk::env::panic_str(&format!("Failed to serialize event: {e}")) + } + }), ) } @@ -62,6 +72,7 @@ pub trait ToEventLog { /// NEP-297 Event Log Data /// #[derive(Serialize, Clone, Debug)] +#[serde(crate = "near_sdk::serde")] pub struct EventLog { /// Name of the event standard, e.g. "nep171" pub standard: &'static str, diff --git a/src/utils.rs b/src/utils.rs index 614194f..2091389 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -85,7 +85,6 @@ pub fn assert_nonzero_deposit() { "Attached deposit must be greater than zero" ); } - #[cfg(test)] mod tests { use super::prefix_key; diff --git a/tests/macros/event.rs b/tests/macros/event.rs index f2ff513..b408ab7 100644 --- a/tests/macros/event.rs +++ b/tests/macros/event.rs @@ -3,16 +3,18 @@ use near_sdk_contract_tools::standard::nep297::{Event, ToEventLog}; use crate::macros::event::test_events::Nep171NftMintData; mod test_events { + use near_sdk::serde::Serialize; use near_sdk_contract_tools::Nep297; - use serde::Serialize; #[derive(Serialize)] + #[serde(crate = "near_sdk::serde")] pub struct Nep171NftMintData { pub owner_id: String, pub token_ids: Vec, } #[derive(Nep297, Serialize)] + #[serde(crate = "near_sdk::serde")] // Required fields #[nep297(standard = "nep171", version = "1.0.0")] // Optional. Default event name is the untransformed variant name, e.g. NftMint, AnotherEvent, CustomEvent @@ -20,14 +22,17 @@ mod test_events { pub struct NftMint(pub Vec); // Name will be "nft_mint" because rename = snake_case #[derive(Nep297, Serialize)] + #[serde(crate = "near_sdk::serde")] #[nep297(standard = "nep171", version = "1.0.0", name = "sneaky_event")] pub struct AnotherEvent; // Name will be "sneaky_event" #[derive(Nep297, Serialize)] + #[serde(crate = "near_sdk::serde")] #[nep297(standard = "nep171", version = "1.0.0", rename = "SHOUTY-KEBAB-CASE")] pub struct CustomEvent; // Name will be "CUSTOM-EVENT" #[derive(Nep297, Serialize)] + #[serde(crate = "near_sdk::serde")] #[nep297(standard = "enum-event", version = "1.0.0")] #[allow(clippy::enum_variant_names)] pub enum EnumEvent { @@ -45,6 +50,7 @@ mod test_events { } #[derive(Nep297, Serialize)] + #[serde(crate = "near_sdk::serde")] #[nep297(standard = "enum-event", version = "1.0.0", rename_all = "snake_case")] #[allow(clippy::enum_variant_names)] pub enum EnumEventRenameAll { diff --git a/tests/macros/mod.rs b/tests/macros/mod.rs index 5efe265..48c9303 100644 --- a/tests/macros/mod.rs +++ b/tests/macros/mod.rs @@ -4,8 +4,8 @@ use near_sdk::{ test_utils::VMContextBuilder, testing_env, AccountId, BorshStorageKey, }; -use near_sdk_contract_tools::escrow::Escrow; use near_sdk_contract_tools::{ + escrow::Escrow, migrate::{MigrateExternal, MigrateHook}, owner::Owner, pause::Pause, @@ -22,11 +22,11 @@ mod pause; mod standard; mod my_event { - use near_sdk::AccountId; + use near_sdk::{serde::Serialize, AccountId}; use near_sdk_contract_tools::Nep297; - use serde::Serialize; #[derive(Serialize, Nep297)] + #[serde(crate = "near_sdk::serde")] #[nep297(standard = "x-myevent", version = "1.0.0", rename = "snake_case")] pub struct ValueChanged { pub from: u32, @@ -34,6 +34,7 @@ mod my_event { } #[derive(Serialize, Nep297)] + #[serde(crate = "near_sdk::serde")] #[nep297(standard = "x-myevent", version = "1.0.0", rename = "snake_case")] pub struct PermissionGranted { pub to: AccountId, @@ -392,39 +393,54 @@ mod pausable_fungible_token { borsh::{self, BorshDeserialize, BorshSerialize}, env, near_bindgen, test_utils::VMContextBuilder, - testing_env, AccountId, + testing_env, AccountId, ONE_NEAR, }; use near_sdk_contract_tools::{ - pause::Pause, - standard::nep141::{Nep141, Nep141Controller, Nep141Hook, Nep141Transfer}, - FungibleToken, Pause, + ft::*, + hook::Hook, + pause::{hooks::PausableHook, Pause}, + Pause, }; #[derive(FungibleToken, Pause, BorshDeserialize, BorshSerialize)] - #[fungible_token(name = "Pausable Fungible Token", symbol = "PFT", decimals = 18)] + #[fungible_token(all_hooks = "PausableHook", transfer_hook = "TransferHook")] #[near_bindgen] struct Contract { pub storage_usage: u64, } - #[derive(Default)] - struct HookState { - pub storage_usage_start: u64, - } - - impl Nep141Hook for Contract { - fn before_transfer(&mut self, _transfer: &Nep141Transfer) -> HookState { - Contract::require_unpaused(); - HookState { - storage_usage_start: env::storage_usage(), - } + #[near_bindgen] + impl Contract { + #[init] + pub fn new() -> Self { + let mut contract = Self { storage_usage: 0 }; + + contract.set_metadata(&FungibleTokenMetadata::new( + "Pausable Fungible Token".into(), + "PFT".into(), + 18, + )); + + contract } + } - fn after_transfer(&mut self, _transfer: &Nep141Transfer, state: HookState) { - let storage_delta = env::storage_usage() - state.storage_usage_start; - println!("Storage delta: {storage_delta}",); - - self.storage_usage = storage_delta; + #[derive(Default)] + struct TransferHook; + + impl Hook> for TransferHook { + fn hook( + contract: &mut Contract, + _args: &Nep141Transfer, + f: impl FnOnce(&mut Contract) -> R, + ) -> R { + let state = env::storage_usage(); + let r = f(contract); + let storage_delta = env::storage_usage() - state; + println!("Storage delta: {storage_delta}"); + + contract.storage_usage = storage_delta; + r } } @@ -433,15 +449,27 @@ mod pausable_fungible_token { let alice: AccountId = "alice".parse().unwrap(); let bob: AccountId = "bob_account".parse().unwrap(); - let mut c = Contract { storage_usage: 0 }; + let mut c = Contract::new(); + + let context = VMContextBuilder::new() + .attached_deposit(ONE_NEAR / 100) + .predecessor_account_id(alice.clone()) + .build(); + testing_env!(context); + c.storage_deposit(None, None); + let context = VMContextBuilder::new() + .attached_deposit(ONE_NEAR / 100) + .predecessor_account_id(bob.clone()) + .build(); + testing_env!(context); + c.storage_deposit(None, None); - c.deposit_unchecked(&alice, 100); + c.deposit_unchecked(&alice, 100).unwrap(); let context = VMContextBuilder::new() .attached_deposit(1) .predecessor_account_id(alice.clone()) .build(); - testing_env!(context); c.ft_transfer(bob.clone(), 50.into(), None); @@ -455,9 +483,9 @@ mod pausable_fungible_token { let alice: AccountId = "alice".parse().unwrap(); let bob: AccountId = "bob_account".parse().unwrap(); - let mut c = Contract { storage_usage: 0 }; + let mut c = Contract::new(); - c.deposit_unchecked(&alice, 100); + c.deposit_unchecked(&alice, 100).unwrap(); let context = VMContextBuilder::new() .attached_deposit(1) diff --git a/tests/macros/standard/fungible_token.rs b/tests/macros/standard/fungible_token.rs index aab81b6..411d650 100644 --- a/tests/macros/standard/fungible_token.rs +++ b/tests/macros/standard/fungible_token.rs @@ -1,71 +1,94 @@ -use near_sdk::{ - json_types::Base64VecU8, near_bindgen, test_utils::VMContextBuilder, testing_env, AccountId, -}; -use near_sdk_contract_tools::{ - standard::nep141::{Nep141, Nep141Controller}, - FungibleToken, -}; +use near_sdk::{json_types::Base64VecU8, near_bindgen}; +use near_sdk_contract_tools::ft::*; #[derive(FungibleToken)] -#[fungible_token( - name = "My Fungible Token", - symbol = "MYFT", - decimals = 18, - icon = "https://example.com/icon.png", - reference = "https://example.com/metadata.json", - reference_hash = "YXNkZg==", - no_hooks -)] #[near_bindgen] struct MyFungibleTokenContract {} -#[test] -fn fungible_token_transfer() { - let mut ft = MyFungibleTokenContract {}; +#[near_bindgen] +impl MyFungibleTokenContract { + #[init] + pub fn new() -> Self { + let mut contract = Self {}; - let alice: AccountId = "alice".parse().unwrap(); - let bob: AccountId = "bob".parse().unwrap(); + contract.set_metadata( + &FungibleTokenMetadata::new("My Fungible Token".into(), "MYFT".into(), 24) + .icon("https://example.com/icon.png".into()) + .reference("https://example.com/metadata.json".into()) + .reference_hash(Base64VecU8::from([97, 115, 100, 102].to_vec())), + ); - assert_eq!(ft.ft_balance_of(alice.clone()).0, 0); - assert_eq!(ft.ft_balance_of(bob.clone()).0, 0); - assert_eq!(ft.ft_total_supply().0, 0); + contract + } +} - ft.deposit_unchecked(&alice, 100); - ft.deposit_unchecked(&bob, 20); +#[cfg(test)] +mod tests { + use super::*; + use near_sdk::{test_utils::VMContextBuilder, testing_env, AccountId, ONE_NEAR}; - assert_eq!(ft.ft_balance_of(alice.clone()).0, 100); - assert_eq!(ft.ft_balance_of(bob.clone()).0, 20); - assert_eq!(ft.ft_total_supply().0, 120); + #[test] + fn fungible_token_transfer() { + let mut ft = MyFungibleTokenContract::new(); - let context = VMContextBuilder::new() - .predecessor_account_id(alice.clone()) - .attached_deposit(1) - .build(); + let alice: AccountId = "alice".parse().unwrap(); + let bob: AccountId = "bob".parse().unwrap(); - testing_env!(context); + let context = VMContextBuilder::new() + .predecessor_account_id(alice.clone()) + .attached_deposit(ONE_NEAR / 100) + .build(); + testing_env!(context); + ft.storage_deposit(None, None); - ft.ft_transfer(bob.clone(), 50.into(), None); + let context = VMContextBuilder::new() + .predecessor_account_id(bob.clone()) + .attached_deposit(ONE_NEAR / 100) + .build(); + testing_env!(context); + ft.storage_deposit(None, None); - assert_eq!(ft.ft_balance_of(alice.clone()).0, 50); - assert_eq!(ft.ft_balance_of(bob.clone()).0, 70); - assert_eq!(ft.ft_total_supply().0, 120); -} + assert_eq!(ft.ft_balance_of(alice.clone()).0, 0); + assert_eq!(ft.ft_balance_of(bob.clone()).0, 0); + assert_eq!(ft.ft_total_supply().0, 0); + + ft.deposit_unchecked(&alice, 100).unwrap(); + ft.deposit_unchecked(&bob, 20).unwrap(); + + assert_eq!(ft.ft_balance_of(alice.clone()).0, 100); + assert_eq!(ft.ft_balance_of(bob.clone()).0, 20); + assert_eq!(ft.ft_total_supply().0, 120); + + let context = VMContextBuilder::new() + .predecessor_account_id(alice.clone()) + .attached_deposit(1) + .build(); + + testing_env!(context); + + ft.ft_transfer(bob.clone(), 50.into(), None); + + assert_eq!(ft.ft_balance_of(alice.clone()).0, 50); + assert_eq!(ft.ft_balance_of(bob.clone()).0, 70); + assert_eq!(ft.ft_total_supply().0, 120); + } + + #[test] + fn metadata() { + let ft = MyFungibleTokenContract::new(); + let meta = ft.ft_metadata(); -#[test] -fn metadata() { - let ft = MyFungibleTokenContract {}; - let meta = ft.ft_metadata(); - - assert_eq!(meta.decimals, 18); - assert_eq!(meta.name, "My Fungible Token"); - assert_eq!(meta.symbol, "MYFT"); - assert_eq!(meta.icon, Some("https://example.com/icon.png".into())); - assert_eq!( - meta.reference, - Some("https://example.com/metadata.json".into()) - ); - assert_eq!( - meta.reference_hash, - Some(Base64VecU8::from([97, 115, 100, 102].to_vec())) - ); + assert_eq!(meta.decimals, 24); + assert_eq!(meta.name, "My Fungible Token"); + assert_eq!(meta.symbol, "MYFT"); + assert_eq!(meta.icon, Some("https://example.com/icon.png".into())); + assert_eq!( + meta.reference, + Some("https://example.com/metadata.json".into()) + ); + assert_eq!( + meta.reference_hash, + Some(Base64VecU8::from([97, 115, 100, 102].to_vec())) + ); + } } diff --git a/tests/macros/standard/mod.rs b/tests/macros/standard/mod.rs index 6685e67..2bde6cf 100644 --- a/tests/macros/standard/mod.rs +++ b/tests/macros/standard/mod.rs @@ -1,4 +1,5 @@ pub mod fungible_token; pub mod nep141; +pub mod nep145; pub mod nep148; pub mod nep171; diff --git a/tests/macros/standard/nep141.rs b/tests/macros/standard/nep141.rs index 9c8ccd8..ca5cd53 100644 --- a/tests/macros/standard/nep141.rs +++ b/tests/macros/standard/nep141.rs @@ -7,36 +7,35 @@ use near_sdk::{ test_utils::VMContextBuilder, testing_env, AccountId, PromiseOrValue, }; -use near_sdk_contract_tools::{standard::nep141::*, Nep141}; +use near_sdk_contract_tools::{hook::Hook, standard::nep141::*, Nep141}; #[derive(Nep141, BorshDeserialize, BorshSerialize)] +#[nep141(transfer_hook = "TransferHook")] #[near_bindgen] struct FungibleToken { - pub transfers: Vector, + pub transfers: Vector>, pub hooks: Vector, } #[derive(Default)] -struct HookState { - pub storage_usage_start: u64, -} - -impl Nep141Hook for FungibleToken { - fn before_transfer(&mut self, transfer: &Nep141Transfer) -> HookState { - self.transfers.push(transfer); - self.hooks.push(&"before_transfer".to_string()); - - HookState { - storage_usage_start: env::storage_usage(), - } - } - - fn after_transfer(&mut self, _transfer: &Nep141Transfer, state: HookState) { - self.hooks.push(&"after_transfer".to_string()); - println!( - "Storage delta: {}", - env::storage_usage() - state.storage_usage_start - ); +struct TransferHook; + +impl Hook> for TransferHook { + fn hook( + contract: &mut FungibleToken, + args: &Nep141Transfer, + f: impl FnOnce(&mut FungibleToken) -> R, + ) -> R { + let storage_usage_start = env::storage_usage(); + contract.hooks.push(&"before_transfer".to_string()); + let r = f(contract); + contract.hooks.push(&"after_transfer".to_string()); + contract + .transfers + .push(&BorshSerialize::try_to_vec(&args).unwrap()); + let storage_usage_end = env::storage_usage(); + println!("Storage delta: {}", storage_usage_end - storage_usage_start); + r } } @@ -64,8 +63,6 @@ impl near_sdk_contract_tools::standard::nep141::Nep141Receiver for FungibleToken } } -// TODO: transfer_call testing (not possible without workspaces-rs or something -// like that, and workspaces-rs doesn't work on macOS) #[test] fn nep141_transfer() { let mut ft = FungibleToken { @@ -80,8 +77,8 @@ fn nep141_transfer() { assert_eq!(ft.ft_balance_of(bob.clone()).0, 0); assert_eq!(ft.ft_total_supply().0, 0); - ft.deposit_unchecked(&alice, 100); - ft.deposit_unchecked(&bob, 20); + ft.deposit_unchecked(&alice, 100).unwrap(); + ft.deposit_unchecked(&bob, 20).unwrap(); assert_eq!(ft.transfers.pop(), None); assert_eq!(ft.ft_balance_of(alice.clone()).0, 100); @@ -99,13 +96,18 @@ fn nep141_transfer() { assert_eq!( ft.transfers.pop(), - Some(Nep141Transfer { - sender_id: alice.clone(), - receiver_id: bob.clone(), - amount: 50, - memo: None, - msg: None, - }) + Some( + Nep141Transfer { + sender_id: &alice, + receiver_id: &bob, + amount: 50, + memo: None, + msg: None, + revert: false, + } + .try_to_vec() + .unwrap() + ) ); let expected_hook_execution_order = vec!["before_transfer", "after_transfer"]; diff --git a/tests/macros/standard/nep145.rs b/tests/macros/standard/nep145.rs new file mode 100644 index 0000000..f1a1aa5 --- /dev/null +++ b/tests/macros/standard/nep145.rs @@ -0,0 +1,118 @@ +use near_sdk::{ + borsh::{self, BorshDeserialize, BorshSerialize}, + env, + json_types::U128, + log, near_bindgen, + store::LookupMap, + AccountId, PanicOnDefault, +}; +use near_sdk_contract_tools::{hook::Hook, standard::nep145::*, Nep145}; + +#[derive(BorshSerialize, BorshDeserialize, PanicOnDefault, Nep145)] +#[nep145(force_unregister_hook = "ForceUnregisterHook")] +#[near_bindgen] +pub struct Contract { + pub storage: LookupMap>, +} + +pub struct ForceUnregisterHook; + +impl Hook> for ForceUnregisterHook { + fn hook( + contract: &mut Contract, + _args: &Nep145ForceUnregister<'_>, + f: impl FnOnce(&mut Contract) -> R, + ) -> R { + log!("Before force unregister"); + let r = f(contract); + log!("After force unregister"); + r + } +} + +#[near_bindgen] +impl Contract { + #[init] + pub fn new() -> Self { + let mut contract = Self { + storage: LookupMap::new(b"s"), + }; + + Nep145Controller::set_storage_balance_bounds( + &mut contract, + &StorageBalanceBounds { + min: U128(0), + max: None, + }, + ); + + contract + } + + pub fn use_storage(&mut self, num: u64) { + let storage_usage_start = env::storage_usage(); + + let predecessor = env::predecessor_account_id(); + + self.storage.insert(predecessor.clone(), (0..num).collect()); + + self.storage.flush(); + + let storage_usage = env::storage_usage() - storage_usage_start; + let storage_fee = env::storage_byte_cost() * storage_usage as u128; + + Nep145Controller::lock_storage(self, &predecessor, storage_fee.into()) + .unwrap_or_else(|e| env::panic_str(&format!("Storage lock error: {}", e))); + } +} + +#[cfg(test)] +mod tests { + use near_sdk::{test_utils::VMContextBuilder, testing_env, ONE_NEAR}; + + use super::*; + + fn alice() -> AccountId { + "alice.near".parse().unwrap() + } + + #[test] + fn storage_sanity_check() { + let byte_cost = env::storage_byte_cost(); + + let mut contract = Contract::new(); + + testing_env!(VMContextBuilder::new() + .predecessor_account_id(alice()) + .attached_deposit(ONE_NEAR) + .build()); + + Nep145::storage_deposit(&mut contract, None, None); + + assert_eq!( + Nep145::storage_balance_of(&contract, alice()), + Some(StorageBalance { + total: U128(ONE_NEAR), + available: U128(ONE_NEAR), + }), + ); + + testing_env!(VMContextBuilder::new() + .predecessor_account_id(alice()) + .build()); + + contract.use_storage(1000); + + let first = Nep145::storage_balance_of(&contract, alice()).unwrap(); + + assert_eq!(first.total.0, ONE_NEAR); + assert!(ONE_NEAR - (first.available.0 + 8 * 1000 * byte_cost) < 100 * byte_cost); // about 100 bytes for storing keys, etc. + + contract.use_storage(2000); + + let second = Nep145::storage_balance_of(&contract, alice()).unwrap(); + + assert_eq!(second.total.0, ONE_NEAR); + assert_eq!(second.available.0, first.available.0 - 8 * 1000 * byte_cost); + } +} diff --git a/tests/macros/standard/nep148.rs b/tests/macros/standard/nep148.rs index f5b79c0..b1adc1b 100644 --- a/tests/macros/standard/nep148.rs +++ b/tests/macros/standard/nep148.rs @@ -1,21 +1,30 @@ use near_sdk::{json_types::Base64VecU8, near_bindgen}; -use near_sdk_contract_tools::Nep148; +use near_sdk_contract_tools::{standard::nep148::*, Nep148}; #[derive(Nep148)] -#[nep148( - name = "Test Fungible Token", - symbol = "TFT", - decimals = 18, - icon = "https://example.com/icon.png", - reference = "https://example.com/metadata.json", - reference_hash = "YXNkZg==" -)] #[near_bindgen] struct DerivesFTMetadata {} +#[near_bindgen] +impl DerivesFTMetadata { + #[init] + pub fn new() -> Self { + let mut contract = Self {}; + + contract.set_metadata( + &FungibleTokenMetadata::new("Test Fungible Token".into(), "TFT".into(), 18) + .icon("https://example.com/icon.png".into()) + .reference("https://example.com/metadata.json".into()) + .reference_hash(Base64VecU8::from([97, 115, 100, 102].to_vec())), + ); + + contract + } +} + #[test] fn test() { - let ft = DerivesFTMetadata {}; + let ft = DerivesFTMetadata::new(); let meta = ft.ft_metadata(); println!("{:?}", &meta); assert_eq!(meta.decimals, 18); diff --git a/tests/macros/standard/nep171/hooks.rs b/tests/macros/standard/nep171/hooks.rs index 83215b3..777a769 100644 --- a/tests/macros/standard/nep171/hooks.rs +++ b/tests/macros/standard/nep171/hooks.rs @@ -2,25 +2,29 @@ use near_sdk::{ borsh::{self, BorshDeserialize, BorshSerialize}, log, near_bindgen, PanicOnDefault, }; -use near_sdk_contract_tools::nft::*; +use near_sdk_contract_tools::{hook::Hook, nft::*}; #[derive(BorshSerialize, BorshDeserialize, PanicOnDefault, Nep171)] +#[nep171(transfer_hook = "Self")] #[near_bindgen] pub struct Contract { transfer_count: u32, } -impl SimpleNep171Hook for Contract { - fn before_nft_transfer(&self, transfer: &Nep171Transfer) { +impl Hook> for Contract { + fn hook( + contract: &mut Contract, + args: &Nep171Transfer<'_>, + f: impl FnOnce(&mut Contract) -> R, + ) -> R { log!( "{:?} is transferring {} to {}", - transfer.sender_id, - transfer.token_id, - transfer.receiver_id, + args.sender_id, + args.token_id, + args.receiver_id, ); - } - - fn after_nft_transfer(&mut self, _transfer: &Nep171Transfer) { - self.transfer_count += 1; + let r = f(contract); + contract.transfer_count += 1; + r } } diff --git a/tests/macros/standard/nep171/manual_integration.rs b/tests/macros/standard/nep171/manual_integration.rs index 817ce96..2bb3033 100644 --- a/tests/macros/standard/nep171/manual_integration.rs +++ b/tests/macros/standard/nep171/manual_integration.rs @@ -3,6 +3,7 @@ use near_sdk::{ env, near_bindgen, PanicOnDefault, }; use near_sdk_contract_tools::{ + hook::Hook, owner::Owner, pause::Pause, standard::{ @@ -17,19 +18,25 @@ use near_sdk_contract_tools::{ BorshSerialize, BorshDeserialize, PanicOnDefault, Nep171, Nep177, Nep178, Nep181, Pause, Owner, )] #[nep171( - extension_hooks = "(nep178::TokenApprovals, nep181::TokenEnumeration)", + all_hooks = "(nep178::TokenApprovals, nep181::TokenEnumeration)", + transfer_hook = "Self", check_external_transfer = "nep178::TokenApprovals", token_data = "(nep177::TokenMetadata, nep178::TokenApprovals)" )] -#[nep178(no_hooks)] +#[nep178()] #[near_bindgen] pub struct Contract { next_token_id: u32, } -impl SimpleNep171Hook for Contract { - fn before_nft_transfer(&self, _transfer: &Nep171Transfer) { - Self::require_unpaused(); +impl Hook> for Contract { + fn hook( + contract: &mut Contract, + _args: &action::Nep171Transfer<'_>, + f: impl FnOnce(&mut Contract) -> R, + ) -> R { + Contract::require_unpaused(); + f(contract) } } diff --git a/tests/macros/standard/nep171/mod.rs b/tests/macros/standard/nep171/mod.rs index 1da09a4..2b6399a 100644 --- a/tests/macros/standard/nep171/mod.rs +++ b/tests/macros/standard/nep171/mod.rs @@ -2,15 +2,9 @@ use near_sdk::{ borsh::{self, BorshDeserialize, BorshSerialize}, - env, near_bindgen, store, AccountId, -}; -use near_sdk_contract_tools::{ - standard::{ - nep171::*, - nep177::{Nep177Controller, TokenMetadata}, - }, - Nep171, NonFungibleToken, + env, near_bindgen, store, AccountId, ONE_NEAR, }; +use near_sdk_contract_tools::{hook::Hook, nft::*}; mod hooks; mod manual_integration; @@ -33,7 +27,6 @@ impl From for TokenRecord { } #[derive(NonFungibleToken, BorshDeserialize, BorshSerialize)] -#[non_fungible_token(no_core_hooks, no_approval_hooks)] #[near_bindgen] struct NonFungibleTokenNoHooks { pub before_nft_transfer_balance_record: store::Vector>, @@ -48,71 +41,42 @@ fn t() { }; let token_id = "token1".to_string(); + let alice: AccountId = "alice".parse().unwrap(); - n.mint_with_metadata( - token_id.clone(), - "alice".parse().unwrap(), - TokenMetadata { - title: Some("Title".to_string()), - description: None, - media: None, - media_hash: None, - copies: None, - issued_at: None, - expires_at: None, - starts_at: None, - updated_at: None, - extra: None, - reference: None, - reference_hash: None, - }, - ) - .unwrap(); + Nep145Controller::deposit_to_storage_account(&mut n, &alice, ONE_NEAR.into()).unwrap(); + + n.mint_with_metadata(token_id.clone(), alice, TokenMetadata::new().title("Title")) + .unwrap(); let nft_tok = n.nft_token(token_id); dbg!(nft_tok); } #[derive(Nep171, BorshDeserialize, BorshSerialize)] +#[nep171(transfer_hook = "Self")] #[near_bindgen] struct NonFungibleToken { pub before_nft_transfer_balance_record: store::Vector>, pub after_nft_transfer_balance_record: store::Vector>, } -impl Nep171Hook for NonFungibleToken { - type NftTransferState = Option; - - fn before_nft_transfer(contract: &Self, transfer: &Nep171Transfer) -> Option { - let token = Nep171::nft_token(contract, transfer.token_id.clone()); - token.map(Into::into) - } - - fn after_nft_transfer( - contract: &mut Self, - transfer: &Nep171Transfer, - before_nft_transfer: Option, - ) { - let token = Nep171::nft_token(contract, transfer.token_id.clone()); +impl Hook> for NonFungibleToken { + fn hook( + contract: &mut NonFungibleToken, + args: &Nep171Transfer<'_>, + f: impl FnOnce(&mut NonFungibleToken) -> R, + ) -> R { + let before_nft_transfer = contract.nft_token(args.token_id.clone()).map(Into::into); contract .before_nft_transfer_balance_record .push(before_nft_transfer); + let r = f(contract); + let after_nft_transfer = contract.nft_token(args.token_id.clone()).map(Into::into); contract .after_nft_transfer_balance_record - .push(token.map(Into::into)); + .push(after_nft_transfer); + r } - - type MintState = (); - - fn before_mint(_contract: &Self, _token_ids: &[TokenId], _owner_id: &AccountId) {} - - fn after_mint(_contract: &mut Self, _token_ids: &[TokenId], _owner_id: &AccountId, _: ()) {} - - type BurnState = (); - - fn before_burn(_contract: &Self, _token_ids: &[TokenId], _owner_id: &AccountId) {} - - fn after_burn(_contract: &mut Self, _token_ids: &[TokenId], _owner_id: &AccountId, _: ()) {} } #[near_bindgen] @@ -125,8 +89,13 @@ impl NonFungibleToken { } } - pub fn mint(&mut self, token_id: TokenId, owner_id: AccountId) { - Nep171Controller::mint(self, &[token_id], &owner_id, None).unwrap_or_else(|e| { + pub fn mint(&mut self, token_id: TokenId, receiver_id: AccountId) { + let action = Nep171Mint { + token_ids: &[token_id], + receiver_id: &receiver_id, + memo: None, + }; + Nep171Controller::mint(self, &action).unwrap_or_else(|e| { env::panic_str(&format!("Mint failed: {e:?}")); }); } @@ -137,9 +106,15 @@ mod tests { test_utils::{get_logs, VMContextBuilder}, testing_env, AccountId, }; - use near_sdk_contract_tools::standard::{nep171::Nep171, nep297::Event}; + use near_sdk_contract_tools::standard::{ + nep171::{ + event::{Nep171Event, NftTransferLog}, + Nep171, + }, + nep297::Event, + }; - use super::NonFungibleToken; + use super::*; #[test] fn hook_execution_success() { @@ -170,7 +145,7 @@ mod tests { assert_eq!( contract.before_nft_transfer_balance_record.get(0), - Some(&Some(super::TokenRecord { + Some(&Some(TokenRecord { owner_id: account_alice.clone(), token_id: token_id.to_string(), })), @@ -178,7 +153,7 @@ mod tests { ); assert_eq!( contract.after_nft_transfer_balance_record.get(0), - Some(&Some(super::TokenRecord { + Some(&Some(TokenRecord { owner_id: account_bob.clone(), token_id: token_id.to_string(), })), @@ -187,16 +162,14 @@ mod tests { assert_eq!( get_logs(), - vec![ - super::Nep171Event::NftTransfer(vec![super::event::NftTransferLog { - memo: None, - authorized_id: None, - old_owner_id: account_alice.clone(), - new_owner_id: account_bob.clone(), - token_ids: vec![token_id.to_string()] - }]) - .to_event_string() - ] + vec![Nep171Event::NftTransfer(vec![NftTransferLog { + memo: None, + authorized_id: None, + old_owner_id: account_alice.clone(), + new_owner_id: account_bob.clone(), + token_ids: vec![token_id.to_string()] + }]) + .to_event_string()] ); } } diff --git a/tests/macros/standard/nep171/no_hooks.rs b/tests/macros/standard/nep171/no_hooks.rs index de6e490..30b793f 100644 --- a/tests/macros/standard/nep171/no_hooks.rs +++ b/tests/macros/standard/nep171/no_hooks.rs @@ -5,7 +5,6 @@ use near_sdk::{ use near_sdk_contract_tools::nft::*; #[derive(BorshSerialize, BorshDeserialize, PanicOnDefault, Nep171)] -#[nep171(no_hooks)] #[near_bindgen] pub struct Contract { pub next_token_id: u32, @@ -16,13 +15,18 @@ impl Contract { pub fn mint(&mut self) -> TokenId { let token_id = format!("token_{}", self.next_token_id); self.next_token_id += 1; - Nep171Controller::mint( - self, - &[token_id.clone()], - &env::predecessor_account_id(), - None, - ) - .unwrap_or_else(|e| env::panic_str(&format!("Minting failed: {e}"))); + + let token_ids = [token_id]; + let action = Nep171Mint { + token_ids: &token_ids, + receiver_id: &env::predecessor_account_id(), + memo: None, + }; + Nep171Controller::mint(self, &action) + .unwrap_or_else(|e| env::panic_str(&format!("Minting failed: {e}"))); + + let [token_id] = token_ids; + token_id } } diff --git a/tests/macros/standard/nep171/non_fungible_token.rs b/tests/macros/standard/nep171/non_fungible_token.rs index 72fcb5e..496ad6a 100644 --- a/tests/macros/standard/nep171/non_fungible_token.rs +++ b/tests/macros/standard/nep171/non_fungible_token.rs @@ -2,21 +2,20 @@ use near_sdk::{ borsh::{self, BorshDeserialize, BorshSerialize}, env, near_bindgen, PanicOnDefault, }; -use near_sdk_contract_tools::{nft::*, owner::Owner, pause::Pause, Owner, Pause}; +use near_sdk_contract_tools::{ + nft::*, + owner::Owner, + pause::{hooks::PausableHook, Pause}, + Owner, Pause, +}; #[derive(BorshSerialize, BorshDeserialize, PanicOnDefault, NonFungibleToken, Pause, Owner)] -#[non_fungible_token(no_approval_hooks)] +#[non_fungible_token(transfer_hook = "PausableHook")] #[near_bindgen] pub struct Contract { next_token_id: u32, } -impl SimpleNep171Hook for Contract { - fn before_nft_transfer(&self, _transfer: &Nep171Transfer) { - Self::require_unpaused(); - } -} - #[near_bindgen] impl Contract { #[init] diff --git a/workspaces-tests-utils/Cargo.toml b/workspaces-tests-utils/Cargo.toml index 2304f9f..ebf642b 100644 --- a/workspaces-tests-utils/Cargo.toml +++ b/workspaces-tests-utils/Cargo.toml @@ -6,4 +6,5 @@ publish = false [target.'cfg(not(windows))'.dependencies] near-sdk.workspace = true -workspaces = "0.7" +near-workspaces = "0.8" +pretty_assertions.workspace = true diff --git a/workspaces-tests-utils/src/lib.rs b/workspaces-tests-utils/src/lib.rs index ef49adf..e38b072 100644 --- a/workspaces-tests-utils/src/lib.rs +++ b/workspaces-tests-utils/src/lib.rs @@ -1,8 +1,9 @@ #![allow(missing_docs)] #![cfg(not(windows))] -use near_sdk::{serde::de::DeserializeOwned, serde_json::json}; -use workspaces::{result::ExecutionFinalResult, Account, Contract}; +use near_sdk::{json_types::U128, serde::de::DeserializeOwned, serde_json::json}; +use near_workspaces::{result::ExecutionFinalResult, Account, AccountId, Contract}; +use pretty_assertions::assert_eq; pub async fn nft_token(contract: &Contract, token_id: &str) -> Option { contract @@ -14,6 +15,17 @@ pub async fn nft_token(contract: &Contract, token_id: &str) .unwrap() } +pub async fn ft_balance_of(contract: &Contract, account: &AccountId) -> u128 { + contract + .view("ft_balance_of") + .args_json(json!({ "account_id": account })) + .await + .unwrap() + .json::() + .map(u128::from) + .unwrap() +} + pub struct Setup { pub contract: Contract, pub accounts: Vec, @@ -21,7 +33,7 @@ pub struct Setup { /// Setup for individual tests pub async fn setup(wasm: &[u8], num_accounts: usize) -> Setup { - let worker = workspaces::sandbox().await.unwrap(); + let worker = near_workspaces::sandbox().await.unwrap(); // Initialize contract let contract = worker.dev_deploy(wasm).await.unwrap(); diff --git a/workspaces-tests/Cargo.toml b/workspaces-tests/Cargo.toml index 216b1c5..3e8151a 100644 --- a/workspaces-tests/Cargo.toml +++ b/workspaces-tests/Cargo.toml @@ -1,6 +1,6 @@ [package] autobins = false -edition = "2021" +edition.workspace = true name = "workspaces-tests" publish = false version = "0.1.0" @@ -14,6 +14,9 @@ name = "counter_multisig" [[bin]] name = "cross_target" +[[bin]] +name = "escrow" + [[bin]] name = "fungible_token" @@ -56,20 +59,18 @@ name = "upgrade_old_multisig" [[bin]] name = "upgrade_old_raw" -[[bin]] -name = "escrow" - [dependencies] near-sdk.workspace = true near-sdk-contract-tools = { path = "../", features = ["unstable"] } -strum = "0.24.1" -strum_macros = "0.24.3" +strum.workspace = true +strum_macros.workspace = true thiserror.workspace = true [dev-dependencies] -near-crypto = "0.15.0" -tokio = "1.21.1" +near-crypto.workspace = true +tokio.workspace = true +pretty_assertions.workspace = true [target.'cfg(not(windows))'.dev-dependencies] -workspaces = "0.7" +near-workspaces.workspace = true workspaces-tests-utils = { path = "../workspaces-tests-utils" } diff --git a/workspaces-tests/Makefile.toml b/workspaces-tests/Makefile.toml index 98919a8..0691b41 100644 --- a/workspaces-tests/Makefile.toml +++ b/workspaces-tests/Makefile.toml @@ -15,6 +15,22 @@ set -e if [[ -n $1 ]]; then cargo test --package workspaces-tests --test "$1" "$2" -- --nocapture else - cargo test + cargo test --package workspaces-tests -- --nocapture +fi +""" + +[tasks.nextest] +clear = true +dependencies = ["build"] +script = """ +#!/usr/bin/env bash +set -e + +if [[ -n $2 ]]; then + cargo nextest run --package workspaces-tests --test "$1" -E "test($2)" +elif [[ -n $1 ]]; then + cargo nextest run --package workspaces-tests --test "$1" +else + cargo nextest run --package workspaces-tests fi """ diff --git a/workspaces-tests/src/bin/fungible_token.rs b/workspaces-tests/src/bin/fungible_token.rs index e7f655d..3b7c478 100644 --- a/workspaces-tests/src/bin/fungible_token.rs +++ b/workspaces-tests/src/bin/fungible_token.rs @@ -6,24 +6,58 @@ pub fn main() {} use near_sdk::{ borsh::{self, BorshDeserialize, BorshSerialize}, env, - json_types::U128, - near_bindgen, PanicOnDefault, + json_types::{Base64VecU8, U128}, + near_bindgen, + store::Vector, + PanicOnDefault, }; -use near_sdk_contract_tools::{standard::nep141::*, FungibleToken}; +use near_sdk_contract_tools::ft::*; #[derive(PanicOnDefault, BorshSerialize, BorshDeserialize, FungibleToken)] -#[fungible_token(name = "My Fungible Token", symbol = "MYFT", decimals = 18, no_hooks)] #[near_bindgen] -pub struct Contract {} +pub struct Contract { + blobs: Vector>, +} #[near_bindgen] impl Contract { #[init] pub fn new() -> Self { - Self {} + let mut contract = Self { + blobs: Vector::new(b"b"), + }; + + contract.set_metadata(&FungibleTokenMetadata::new( + "My Fungible Token".into(), + "MYFT".into(), + 24, + )); + + contract } pub fn mint(&mut self, amount: U128) { - self.deposit_unchecked(&env::predecessor_account_id(), amount.into()); + Nep141Controller::mint( + self, + &Nep141Mint { + amount: amount.into(), + receiver_id: &env::predecessor_account_id(), + memo: None, + }, + ) + .unwrap(); + } + + pub fn use_storage(&mut self, blob: Base64VecU8) { + let storage_start = env::storage_usage(); + let blob = blob.into(); + self.blobs.push(blob); + self.blobs.flush(); + let storage_end = env::storage_usage(); + self.lock_storage( + &env::predecessor_account_id(), + ((storage_end - storage_start) as u128 * env::storage_byte_cost()).into(), + ) + .unwrap_or_else(|e| env::panic_str(&format!("Storage lock error: {}", e))); } } diff --git a/workspaces-tests/src/bin/non_fungible_token_full.rs b/workspaces-tests/src/bin/non_fungible_token_full.rs index fcf9806..08eea0e 100644 --- a/workspaces-tests/src/bin/non_fungible_token_full.rs +++ b/workspaces-tests/src/bin/non_fungible_token_full.rs @@ -5,52 +5,63 @@ pub fn main() {} use near_sdk::{ borsh::{self, BorshDeserialize, BorshSerialize}, - env, log, near_bindgen, AccountId, PanicOnDefault, + env, log, near_bindgen, PanicOnDefault, }; -use near_sdk_contract_tools::nft::*; +use near_sdk_contract_tools::{hook::Hook, nft::*}; #[derive(PanicOnDefault, BorshSerialize, BorshDeserialize, NonFungibleToken)] #[near_bindgen] pub struct Contract {} -impl SimpleNep178Hook for Contract { - fn before_nft_approve(&self, token_id: &TokenId, _account_id: &AccountId) { - log!("before_nft_approve({})", token_id); - } - - fn after_nft_approve( - &mut self, - token_id: &TokenId, - _account_id: &AccountId, - _approval_id: &ApprovalId, - ) { - log!("after_nft_approve({})", token_id); - } - - fn before_nft_revoke(&self, token_id: &TokenId, _account_id: &AccountId) { - log!("before_nft_revoke({})", token_id); - } - - fn after_nft_revoke(&mut self, token_id: &TokenId, _account_id: &AccountId) { - log!("after_nft_revoke({})", token_id); - } - - fn before_nft_revoke_all(&self, token_id: &TokenId) { - log!("before_nft_revoke_all({})", token_id); +impl Hook> for Contract { + fn hook( + contract: &mut Contract, + args: &Nep178Approve<'_>, + f: impl FnOnce(&mut Contract) -> R, + ) -> R { + log!("before_nft_approve({})", args.token_id); + let r = f(contract); + log!("after_nft_approve({})", args.token_id); + r } +} - fn after_nft_revoke_all(&mut self, token_id: &TokenId) { - log!("after_nft_revoke_all({})", token_id); +impl Hook> for Contract { + fn hook( + contract: &mut Contract, + args: &Nep178Revoke<'_>, + f: impl FnOnce(&mut Contract) -> R, + ) -> R { + log!("before_nft_revoke({})", args.token_id); + let r = f(contract); + log!("after_nft_revoke({})", args.token_id); + r } } -impl SimpleNep171Hook for Contract { - fn before_nft_transfer(&self, transfer: &Nep171Transfer) { - log!("before_nft_transfer({})", transfer.token_id); +impl Hook> for Contract { + fn hook( + contract: &mut Contract, + args: &Nep178RevokeAll<'_>, + f: impl FnOnce(&mut Contract) -> R, + ) -> R { + log!("before_nft_revoke_all({})", args.token_id); + let r = f(contract); + log!("after_nft_revoke_all({})", args.token_id); + r } +} - fn after_nft_transfer(&mut self, transfer: &Nep171Transfer) { - log!("after_nft_transfer({})", transfer.token_id); +impl Hook> for Contract { + fn hook( + contract: &mut Contract, + args: &Nep171Transfer<'_>, + f: impl FnOnce(&mut Contract) -> R, + ) -> R { + log!("before_nft_transfer({})", args.token_id); + let r = f(contract); + log!("after_nft_transfer({})", args.token_id); + r } } diff --git a/workspaces-tests/src/bin/non_fungible_token_nep171.rs b/workspaces-tests/src/bin/non_fungible_token_nep171.rs index 0aee4cb..0687818 100644 --- a/workspaces-tests/src/bin/non_fungible_token_nep171.rs +++ b/workspaces-tests/src/bin/non_fungible_token_nep171.rs @@ -7,19 +7,23 @@ use near_sdk::{ borsh::{self, BorshDeserialize, BorshSerialize}, env, log, near_bindgen, PanicOnDefault, }; -use near_sdk_contract_tools::{standard::nep171::*, Nep171}; +use near_sdk_contract_tools::{hook::Hook, standard::nep171::*, Nep171}; #[derive(PanicOnDefault, BorshSerialize, BorshDeserialize, Nep171)] +#[nep171(transfer_hook = "Self")] #[near_bindgen] pub struct Contract {} -impl SimpleNep171Hook for Contract { - fn before_nft_transfer(&self, transfer: &Nep171Transfer) { - log!("before_nft_transfer({})", transfer.token_id); - } - - fn after_nft_transfer(&mut self, transfer: &Nep171Transfer) { - log!("after_nft_transfer({})", transfer.token_id); +impl Hook> for Contract { + fn hook( + contract: &mut Contract, + args: &action::Nep171Transfer<'_>, + f: impl FnOnce(&mut Contract) -> R, + ) -> R { + log!("before_nft_transfer({})", args.token_id); + let r = f(contract); + log!("after_nft_transfer({})", args.token_id); + r } } @@ -31,7 +35,12 @@ impl Contract { } pub fn mint(&mut self, token_ids: Vec) { - Nep171Controller::mint(self, &token_ids, &env::predecessor_account_id(), None) + let action = action::Nep171Mint { + token_ids: &token_ids, + receiver_id: &env::predecessor_account_id(), + memo: None, + }; + Nep171Controller::mint(self, &action) .unwrap_or_else(|e| env::panic_str(&format!("Failed to mint: {:#?}", e))); } } diff --git a/workspaces-tests/tests/counter_multisig.rs b/workspaces-tests/tests/counter_multisig.rs index f82d9c9..640da22 100644 --- a/workspaces-tests/tests/counter_multisig.rs +++ b/workspaces-tests/tests/counter_multisig.rs @@ -1,7 +1,8 @@ #![cfg(not(windows))] use near_sdk::serde_json::json; -use workspaces::{Account, Contract}; +use near_workspaces::{Account, Contract}; +use pretty_assertions::assert_eq; const WASM: &[u8] = include_bytes!("../../target/wasm32-unknown-unknown/release/counter_multisig.wasm"); @@ -13,7 +14,7 @@ struct Setup { /// Setup for individual tests async fn setup(num_accounts: usize) -> Setup { - let worker = workspaces::sandbox().await.unwrap(); + let worker = near_workspaces::sandbox().await.unwrap(); // Initialize contract let contract = worker.dev_deploy(WASM).await.unwrap(); diff --git a/workspaces-tests/tests/escrow.rs b/workspaces-tests/tests/escrow.rs index db216b9..b0308fe 100644 --- a/workspaces-tests/tests/escrow.rs +++ b/workspaces-tests/tests/escrow.rs @@ -4,8 +4,9 @@ use near_sdk::{ serde::{Deserialize, Serialize}, serde_json::{self, json}, }; +use near_workspaces::{Account, AccountId, Contract}; +use pretty_assertions::assert_eq; use tokio::join; -use workspaces::{Account, AccountId, Contract}; const WASM: &[u8] = include_bytes!("../../target/wasm32-unknown-unknown/release/escrow.wasm"); @@ -36,7 +37,7 @@ struct Setup { /// Setup for individual tests async fn setup(num_accounts: usize, wasm: &[u8]) -> Setup { - let worker = workspaces::sandbox().await.unwrap(); + let worker = near_workspaces::sandbox().await.unwrap(); // Initialize user accounts let mut accounts = vec![]; diff --git a/workspaces-tests/tests/fungible_token.rs b/workspaces-tests/tests/fungible_token.rs index 0be29f0..1006787 100644 --- a/workspaces-tests/tests/fungible_token.rs +++ b/workspaces-tests/tests/fungible_token.rs @@ -1,30 +1,30 @@ #![cfg(not(windows))] -use near_sdk::{json_types::U128, serde_json::json}; -use workspaces::{Account, AccountId, Contract}; +use near_sdk::{ + json_types::{Base64VecU8, U128}, + serde_json::json, + ONE_NEAR, +}; +use near_sdk_contract_tools::{ + nft::StorageBalance, standard::nep145::error::InsufficientBalanceError, +}; +use near_workspaces::{network::Sandbox, operations::Function, Account, Contract, Worker}; +use pretty_assertions::assert_eq; +use tokio::task::JoinSet; +use workspaces_tests_utils::{expect_execution_error, ft_balance_of}; const WASM: &[u8] = include_bytes!("../../target/wasm32-unknown-unknown/release/fungible_token.wasm"); -async fn balance(contract: &Contract, account: &AccountId) -> u128 { - contract - .view("ft_balance_of") - .args_json(json!({ "account_id": account })) - .await - .unwrap() - .json::() - .map(u128::from) - .unwrap() -} - struct Setup { pub contract: Contract, pub accounts: Vec, + pub worker: Worker, } /// Setup for individual tests async fn setup(num_accounts: usize) -> Setup { - let worker = workspaces::sandbox().await.unwrap(); + let worker = near_workspaces::sandbox().await.unwrap(); // Initialize contract let contract = worker.dev_deploy(WASM).await.unwrap(); @@ -36,51 +36,70 @@ async fn setup(num_accounts: usize) -> Setup { accounts.push(worker.dev_create_account().await.unwrap()); } - Setup { contract, accounts } + Setup { + contract, + accounts, + worker, + } } -async fn setup_balances(num_accounts: usize, balance: impl Fn(usize) -> U128) -> Setup { - let s = setup(num_accounts).await; - - for (i, account) in s.accounts.iter().enumerate() { - account - .call(s.contract.id(), "mint") - .args_json(json!({ "amount": balance(i) })) - .transact() - .await - .unwrap() - .unwrap(); +async fn setup_balances(num_accounts: usize, amount: impl Fn(usize) -> U128) -> Setup { + let setup = setup(num_accounts).await; + + let mut transaction_set = JoinSet::new(); + + for (i, account) in setup.accounts.iter().enumerate() { + let transaction = account + .batch(setup.contract.id()) + .call( + Function::new("storage_deposit") + .args_json(json!({})) + .deposit(ONE_NEAR / 100), + ) + .call(Function::new("mint").args_json(json!({ "amount": amount(i) }))) + .transact(); + transaction_set.spawn(async move { + transaction.await.unwrap().unwrap(); + }); } - s + while transaction_set.join_next().await.is_some() {} + + setup } #[tokio::test] async fn start_empty() { - let Setup { contract, accounts } = setup(3).await; + let Setup { + contract, accounts, .. + } = setup(3).await; // All accounts must start with 0 balance for account in accounts.iter() { - assert_eq!(balance(&contract, account.id()).await, 0); + assert_eq!(ft_balance_of(&contract, account.id()).await, 0); } } #[tokio::test] async fn mint() { - let Setup { contract, accounts } = setup_balances(3, |i| 10u128.pow(3 - i as u32).into()).await; + let Setup { + contract, accounts, .. + } = setup_balances(3, |i| 10u128.pow(3 - i as u32).into()).await; let alice = &accounts[0]; let bob = &accounts[1]; let charlie = &accounts[2]; // Verify issued balances - assert_eq!(balance(&contract, alice.id()).await, 1000); - assert_eq!(balance(&contract, bob.id()).await, 100); - assert_eq!(balance(&contract, charlie.id()).await, 10); + assert_eq!(ft_balance_of(&contract, alice.id()).await, 1000); + assert_eq!(ft_balance_of(&contract, bob.id()).await, 100); + assert_eq!(ft_balance_of(&contract, charlie.id()).await, 10); } #[tokio::test] async fn transfer_normal() { - let Setup { contract, accounts } = setup_balances(3, |i| 10u128.pow(3 - i as u32).into()).await; + let Setup { + contract, accounts, .. + } = setup_balances(3, |i| 10u128.pow(3 - i as u32).into()).await; let alice = &accounts[0]; let bob = &accounts[1]; let charlie = &accounts[2]; @@ -96,14 +115,16 @@ async fn transfer_normal() { .await .unwrap() .unwrap(); - assert_eq!(balance(&contract, alice.id()).await, 990); - assert_eq!(balance(&contract, bob.id()).await, 110); - assert_eq!(balance(&contract, charlie.id()).await, 10); + assert_eq!(ft_balance_of(&contract, alice.id()).await, 990); + assert_eq!(ft_balance_of(&contract, bob.id()).await, 110); + assert_eq!(ft_balance_of(&contract, charlie.id()).await, 10); } #[tokio::test] async fn transfer_zero() { - let Setup { contract, accounts } = setup_balances(3, |i| 10u128.pow(3 - i as u32).into()).await; + let Setup { + contract, accounts, .. + } = setup_balances(3, |i| 10u128.pow(3 - i as u32).into()).await; let alice = &accounts[0]; let bob = &accounts[1]; let charlie = &accounts[2]; @@ -119,15 +140,17 @@ async fn transfer_zero() { .await .unwrap() .unwrap(); - assert_eq!(balance(&contract, alice.id()).await, 1000); - assert_eq!(balance(&contract, bob.id()).await, 100); - assert_eq!(balance(&contract, charlie.id()).await, 10); + assert_eq!(ft_balance_of(&contract, alice.id()).await, 1000); + assert_eq!(ft_balance_of(&contract, bob.id()).await, 100); + assert_eq!(ft_balance_of(&contract, charlie.id()).await, 10); } #[tokio::test] #[should_panic(expected = "invalid digit found in string")] async fn transfer_negative() { - let Setup { contract, accounts } = setup_balances(3, |i| 10u128.pow(3 - i as u32).into()).await; + let Setup { + contract, accounts, .. + } = setup_balances(3, |i| 10u128.pow(3 - i as u32).into()).await; let alice = &accounts[0]; let bob = &accounts[1]; @@ -147,7 +170,9 @@ async fn transfer_negative() { #[tokio::test] #[should_panic(expected = "Requires attached deposit of exactly 1 yoctoNEAR")] async fn transfer_no_deposit() { - let Setup { contract, accounts } = setup_balances(3, |i| 10u128.pow(3 - i as u32).into()).await; + let Setup { + contract, accounts, .. + } = setup_balances(3, |i| 10u128.pow(3 - i as u32).into()).await; let alice = &accounts[0]; let bob = &accounts[1]; @@ -162,3 +187,112 @@ async fn transfer_no_deposit() { .unwrap() .unwrap(); } + +#[tokio::test] +#[should_panic(expected = "Balance of the sender is insufficient")] +async fn transfer_more_than_balance() { + let Setup { + contract, accounts, .. + } = setup_balances(3, |i| 10u128.pow(3 - i as u32).into()).await; + let alice = &accounts[0]; + let bob = &accounts[1]; + + alice + .call(contract.id(), "ft_transfer") + .args_json(json!({ + "receiver_id": bob.id(), + "amount": "1000000", + })) + .deposit(1) + .transact() + .await + .unwrap() + .unwrap(); +} + +#[tokio::test] +#[should_panic(expected = "TotalSupplyOverflowError")] +async fn transfer_overflow_u128() { + let Setup { + contract, accounts, .. + } = setup_balances(2, |_| (u128::MAX / 2).into()).await; + let alice = &accounts[0]; + + alice + .call(contract.id(), "mint") + .args_json(json!({ + "amount": "2", + })) + .transact() + .await + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn transfer_fail_not_registered() { + let Setup { + contract, + accounts, + worker, + } = setup_balances(2, |i| 10u128.pow(3 - i as u32).into()).await; + let alice = &accounts[0]; + let charlie = worker.dev_create_account().await.unwrap(); + + let result = alice + .call(contract.id(), "ft_transfer") + .deposit(1) + .args_json(json!({ + "receiver_id": charlie.id(), + "amount": "10", + })) + .transact() + .await + .unwrap(); + + expect_execution_error( + &result, + format!( + "Smart contract panicked: Account {} is not registered", + charlie.id(), + ), + ); +} + +#[tokio::test] +async fn fail_run_out_of_space() { + let Setup { + contract, accounts, .. + } = setup_balances(2, |i| 10u128.pow(3 - i as u32).into()).await; + let alice = &accounts[0]; + + let balance = contract + .view("storage_balance_of") + .args_json(json!({ "account_id": alice.id() })) + .await + .unwrap() + .json::>() + .unwrap() + .unwrap(); + + let result = alice + .call(contract.id(), "use_storage") + .args_json(json!({ + "blob": Base64VecU8::from(vec![1u8; 10000]), + })) + .transact() + .await + .unwrap(); + + expect_execution_error( + &result, + format!( + "Smart contract panicked: Storage lock error: {}", + InsufficientBalanceError { + account_id: alice.id().parse().unwrap(), + available: balance.available, + attempted_to_lock: 100490000000000000000000u128.into() + } + ), + ); +} diff --git a/workspaces-tests/tests/native_multisig.rs b/workspaces-tests/tests/native_multisig.rs index 9034b3a..46b3003 100644 --- a/workspaces-tests/tests/native_multisig.rs +++ b/workspaces-tests/tests/native_multisig.rs @@ -5,13 +5,14 @@ use std::{future::IntoFuture, time::Duration}; use near_crypto::{KeyType, SecretKey}; use near_sdk::{serde_json::json, Gas, ONE_NEAR}; use near_sdk_contract_tools::approval::native_transaction_action::PromiseAction; -use tokio::{join, task::JoinSet, time::sleep}; -use workspaces::{ +use near_workspaces::{ result::{ExecutionResult, Value}, sandbox, types::{AccessKeyPermission, Finality}, - Account, Contract, DevNetwork, Worker, + Account, AccountDetailsPatch, Contract, DevNetwork, Worker, }; +use pretty_assertions::assert_eq; +use tokio::{join, time::sleep}; const WASM: &[u8] = include_bytes!("../../target/wasm32-unknown-unknown/release/native_multisig.wasm"); @@ -106,23 +107,12 @@ async fn stake() { const MINIMUM_STAKE: u128 = 800_000_000_000_000_000_000_000_000; - let mut set = JoinSet::new(); - - // Fund contract account to meet minumum stake requirement. - // At the time of writing, it looks like workspaces network configuration is not yet released: - // https://github.com/near/workspaces-rs/commit/8df17e5d6ebbfe9ced52beb133f3e5b07a86dffb - // Otherwise we could just decrease the staking requirement. - for _ in 0..10 { - let w = worker.clone(); - let contract_id = contract.id().clone(); - set.spawn(async move { - let a = w.dev_create_account().await.unwrap(); - a.delete_account(&contract_id).await.unwrap().unwrap(); - }); - } - - // Await all - while set.join_next().await.is_some() {} + worker + .patch(contract.id()) + .account(AccountDetailsPatch::default().balance(MINIMUM_STAKE * 4)) + .transact() + .await + .unwrap(); let alice = &accounts[0]; let bob = &accounts[1]; @@ -136,7 +126,7 @@ async fn stake() { "Account should start with no staked tokens" ); - let stake_amount = MINIMUM_STAKE; + let stake_amount = MINIMUM_STAKE * 2; let request_id = alice .call(contract.id(), "request") @@ -235,7 +225,7 @@ async fn create_account_transfer_deploy_contract_function_call() { let bob = &accounts[1]; let new_account_id_str = format!("new.{}", contract.id()); - let new_account_id: workspaces::AccountId = new_account_id_str.parse().unwrap(); + let new_account_id: near_workspaces::AccountId = new_account_id_str.parse().unwrap(); // Account does not exist yet assert!(worker.view_account(&new_account_id).await.is_err()); diff --git a/workspaces-tests/tests/non_fungible_token.rs b/workspaces-tests/tests/non_fungible_token.rs index 84f2c29..952326e 100644 --- a/workspaces-tests/tests/non_fungible_token.rs +++ b/workspaces-tests/tests/non_fungible_token.rs @@ -2,15 +2,22 @@ use std::collections::HashMap; -use near_sdk::{json_types::U128, serde_json::json}; +use near_sdk::{json_types::U128, serde_json::json, ONE_NEAR}; use near_sdk_contract_tools::standard::{ - nep171::{self, event::NftTransferLog, Nep171Event, Token}, + nep171::{ + self, + event::{Nep171Event, NftTransferLog}, + Token, + }, nep177::{self, TokenMetadata}, - nep178, + nep178::error::{ + AccountAlreadyApprovedError, Nep178ApproveError, TooManyApprovalsError, UnauthorizedError, + }, nep297::Event, }; +use near_workspaces::{operations::Function, types::Gas}; +use pretty_assertions::assert_eq; use tokio::task::JoinSet; -use workspaces::operations::Function; use workspaces_tests_utils::{expect_execution_error, nft_token, setup, Setup}; const WASM_171_ONLY: &[u8] = @@ -22,6 +29,8 @@ const WASM_FULL: &[u8] = const RECEIVER_WASM: &[u8] = include_bytes!("../../target/wasm32-unknown-unknown/release/non_fungible_token_receiver.wasm"); +const THIRTY_TERAGAS: Gas = Gas::from_gas(30_000_000_000_000); + fn token_meta(id: String) -> near_sdk::serde_json::Value { near_sdk::serde_json::to_value(TokenMetadata { title: Some(id), @@ -44,13 +53,23 @@ async fn setup_balances( wasm: &[u8], num_accounts: usize, token_ids: impl Fn(usize) -> Vec, + storage_deposit: bool, ) -> Setup { let s = setup(wasm, num_accounts).await; for (i, account) in s.accounts.iter().enumerate() { - account - .call(s.contract.id(), "mint") - .args_json(json!({ "token_ids": token_ids(i) })) + let batch = if storage_deposit { + account.batch(s.contract.id()).call( + Function::new("storage_deposit") + .args_json(json!({})) + .deposit(ONE_NEAR / 100), + ) + } else { + account.batch(s.contract.id()) + }; + + batch + .call(Function::new("mint").args_json(json!({ "token_ids": token_ids(i) }))) .transact() .await .unwrap() @@ -63,7 +82,7 @@ async fn setup_balances( #[tokio::test] async fn create_and_mint() { let Setup { contract, accounts } = - setup_balances(WASM_171_ONLY, 3, |i| vec![format!("token_{i}")]).await; + setup_balances(WASM_171_ONLY, 3, |i| vec![format!("token_{i}")], false).await; let alice = &accounts[0]; let bob = &accounts[1]; let charlie = &accounts[2]; @@ -106,7 +125,7 @@ async fn create_and_mint() { #[tokio::test] async fn create_and_mint_with_metadata_and_enumeration() { let Setup { contract, accounts } = - setup_balances(WASM_FULL, 3, |i| vec![format!("token_{i}")]).await; + setup_balances(WASM_FULL, 3, |i| vec![format!("token_{i}")], true).await; let alice = &accounts[0]; let bob = &accounts[1]; let charlie = &accounts[2]; @@ -293,7 +312,7 @@ async fn create_and_mint_with_metadata_and_enumeration() { #[tokio::test] async fn transfer_success() { let Setup { contract, accounts } = - setup_balances(WASM_171_ONLY, 3, |i| vec![format!("token_{i}")]).await; + setup_balances(WASM_171_ONLY, 3, |i| vec![format!("token_{i}")], false).await; let alice = &accounts[0]; let bob = &accounts[1]; let charlie = &accounts[2]; @@ -361,18 +380,18 @@ async fn transfer_success() { #[tokio::test] #[should_panic = "Smart contract panicked: Requires attached deposit of exactly 1 yoctoNEAR"] async fn transfer_fail_no_deposit_full() { - transfer_fail_no_deposit(WASM_FULL).await; + transfer_fail_no_deposit(WASM_FULL, true).await; } #[tokio::test] #[should_panic = "Smart contract panicked: Requires attached deposit of exactly 1 yoctoNEAR"] async fn transfer_fail_no_deposit_171() { - transfer_fail_no_deposit(WASM_171_ONLY).await; + transfer_fail_no_deposit(WASM_171_ONLY, false).await; } -async fn transfer_fail_no_deposit(wasm: &[u8]) { +async fn transfer_fail_no_deposit(wasm: &[u8], storage_deposit: bool) { let Setup { contract, accounts } = - setup_balances(wasm, 2, |i| vec![format!("token_{i}")]).await; + setup_balances(wasm, 2, |i| vec![format!("token_{i}")], storage_deposit).await; let alice = &accounts[0]; let bob = &accounts[1]; @@ -391,18 +410,18 @@ async fn transfer_fail_no_deposit(wasm: &[u8]) { #[tokio::test] #[should_panic = "Smart contract panicked: Token `token_5` does not exist"] async fn transfer_fail_token_dne_full() { - transfer_fail_token_dne(WASM_FULL).await; + transfer_fail_token_dne(WASM_FULL, true).await; } #[tokio::test] #[should_panic = "Smart contract panicked: Token `token_5` does not exist"] async fn transfer_fail_token_dne_171() { - transfer_fail_token_dne(WASM_171_ONLY).await; + transfer_fail_token_dne(WASM_171_ONLY, false).await; } -async fn transfer_fail_token_dne(wasm: &[u8]) { +async fn transfer_fail_token_dne(wasm: &[u8], storage_deposit: bool) { let Setup { contract, accounts } = - setup_balances(wasm, 2, |i| vec![format!("token_{i}")]).await; + setup_balances(wasm, 2, |i| vec![format!("token_{i}")], storage_deposit).await; let alice = &accounts[0]; let bob = &accounts[1]; @@ -421,17 +440,17 @@ async fn transfer_fail_token_dne(wasm: &[u8]) { #[tokio::test] async fn transfer_fail_not_owner_full() { - transfer_fail_not_owner(WASM_FULL).await; + transfer_fail_not_owner(WASM_FULL, true).await; } #[tokio::test] async fn transfer_fail_not_owner_171() { - transfer_fail_not_owner(WASM_171_ONLY).await; + transfer_fail_not_owner(WASM_171_ONLY, false).await; } -async fn transfer_fail_not_owner(wasm: &[u8]) { +async fn transfer_fail_not_owner(wasm: &[u8], storage_deposit: bool) { let Setup { contract, accounts } = - setup_balances(wasm, 3, |i| vec![format!("token_{i}")]).await; + setup_balances(wasm, 3, |i| vec![format!("token_{i}")], storage_deposit).await; let alice = &accounts[0]; let bob = &accounts[1]; let charlie = &accounts[2]; @@ -459,17 +478,17 @@ async fn transfer_fail_not_owner(wasm: &[u8]) { #[tokio::test] async fn transfer_fail_reflexive_transfer_full() { - transfer_fail_reflexive_transfer(WASM_FULL).await; + transfer_fail_reflexive_transfer(WASM_FULL, true).await; } #[tokio::test] async fn transfer_fail_reflexive_transfer_171() { - transfer_fail_reflexive_transfer(WASM_171_ONLY).await; + transfer_fail_reflexive_transfer(WASM_171_ONLY, false).await; } -async fn transfer_fail_reflexive_transfer(wasm: &[u8]) { +async fn transfer_fail_reflexive_transfer(wasm: &[u8], storage_deposit: bool) { let Setup { contract, accounts } = - setup_balances(wasm, 2, |i| vec![format!("token_{i}")]).await; + setup_balances(wasm, 2, |i| vec![format!("token_{i}")], storage_deposit).await; let alice = &accounts[0]; let result = alice @@ -489,7 +508,7 @@ async fn transfer_fail_reflexive_transfer(wasm: &[u8]) { #[tokio::test] async fn transfer_call_success() { let Setup { contract, accounts } = - setup_balances(WASM_171_ONLY, 2, |i| vec![format!("token_{i}")]).await; + setup_balances(WASM_171_ONLY, 2, |i| vec![format!("token_{i}")], false).await; let alice = &accounts[0]; let bob = &accounts[1]; @@ -508,7 +527,7 @@ async fn transfer_call_success() { "receiver_id": bob.id(), "msg": "", })) - .gas(30_000_000_000_000) + .gas(THIRTY_TERAGAS) .deposit(1) .transact() .await @@ -548,7 +567,7 @@ async fn transfer_call_success() { #[tokio::test] async fn transfer_call_return_success() { let Setup { contract, accounts } = - setup_balances(WASM_171_ONLY, 2, |i| vec![format!("token_{i}")]).await; + setup_balances(WASM_171_ONLY, 2, |i| vec![format!("token_{i}")], false).await; let alice = &accounts[0]; let bob = &accounts[1]; @@ -567,7 +586,7 @@ async fn transfer_call_return_success() { "receiver_id": bob.id(), "msg": "return", })) - .gas(30_000_000_000_000) + .gas(THIRTY_TERAGAS) .deposit(1) .transact() .await @@ -617,7 +636,7 @@ async fn transfer_call_return_success() { #[tokio::test] async fn transfer_call_receiver_panic() { let Setup { contract, accounts } = - setup_balances(WASM_171_ONLY, 2, |i| vec![format!("token_{i}")]).await; + setup_balances(WASM_171_ONLY, 2, |i| vec![format!("token_{i}")], false).await; let alice = &accounts[0]; let bob = &accounts[1]; @@ -636,7 +655,7 @@ async fn transfer_call_receiver_panic() { "receiver_id": bob.id(), "msg": "panic", })) - .gas(30_000_000_000_000) + .gas(THIRTY_TERAGAS) .deposit(1) .transact() .await @@ -686,7 +705,7 @@ async fn transfer_call_receiver_panic() { #[tokio::test] async fn transfer_call_receiver_send_return() { let Setup { contract, accounts } = - setup_balances(WASM_171_ONLY, 3, |i| vec![format!("token_{i}")]).await; + setup_balances(WASM_171_ONLY, 3, |i| vec![format!("token_{i}")], false).await; let alice = &accounts[0]; let bob = &accounts[1]; let charlie = &accounts[2]; @@ -706,7 +725,7 @@ async fn transfer_call_receiver_send_return() { "receiver_id": bob.id(), "msg": format!("transfer:{}", charlie.id()), })) - .gas(300_000_000_000_000) // xtra gas + .gas(THIRTY_TERAGAS.saturating_mul(10)) // xtra gas .deposit(1) .transact() .await @@ -760,7 +779,7 @@ async fn transfer_call_receiver_send_return() { #[tokio::test] async fn transfer_approval_success() { let Setup { contract, accounts } = - setup_balances(WASM_FULL, 3, |i| vec![format!("token_{i}")]).await; + setup_balances(WASM_FULL, 3, |i| vec![format!("token_{i}")], true).await; let alice = &accounts[0]; let bob = &accounts[1]; let charlie = &accounts[2]; @@ -838,7 +857,7 @@ async fn transfer_approval_success() { #[tokio::test] async fn transfer_approval_unapproved_fail() { let Setup { contract, accounts } = - setup_balances(WASM_FULL, 4, |i| vec![format!("token_{i}")]).await; + setup_balances(WASM_FULL, 4, |i| vec![format!("token_{i}")], true).await; let alice = &accounts[0]; let bob = &accounts[1]; let charlie = &accounts[2]; @@ -898,7 +917,7 @@ async fn transfer_approval_unapproved_fail() { #[should_panic = "Attached deposit must be greater than zero"] async fn transfer_approval_no_deposit_fail() { let Setup { contract, accounts } = - setup_balances(WASM_FULL, 2, |i| vec![format!("token_{i}")]).await; + setup_balances(WASM_FULL, 2, |i| vec![format!("token_{i}")], true).await; let alice = &accounts[0]; let bob = &accounts[1]; @@ -917,7 +936,7 @@ async fn transfer_approval_no_deposit_fail() { #[tokio::test] async fn transfer_approval_double_approval_fail() { let Setup { contract, accounts } = - setup_balances(WASM_FULL, 2, |i| vec![format!("token_{i}")]).await; + setup_balances(WASM_FULL, 2, |i| vec![format!("token_{i}")], true).await; let alice = &accounts[0]; let bob = &accounts[1]; @@ -946,10 +965,10 @@ async fn transfer_approval_double_approval_fail() { let expected_error = format!( "Smart contract panicked: {}", - nep178::Nep178ApproveError::AccountAlreadyApproved { + Nep178ApproveError::AccountAlreadyApproved(AccountAlreadyApprovedError { account_id: bob.id().parse().unwrap(), token_id: "token_0".to_string(), - }, + }), ); expect_execution_error(&result, expected_error); @@ -958,7 +977,7 @@ async fn transfer_approval_double_approval_fail() { #[tokio::test] async fn transfer_approval_unauthorized_approval_fail() { let Setup { contract, accounts } = - setup_balances(WASM_FULL, 2, |i| vec![format!("token_{i}")]).await; + setup_balances(WASM_FULL, 2, |i| vec![format!("token_{i}")], true).await; let _alice = &accounts[0]; let bob = &accounts[1]; @@ -975,10 +994,10 @@ async fn transfer_approval_unauthorized_approval_fail() { let expected_error = format!( "Smart contract panicked: {}", - nep178::Nep178ApproveError::Unauthorized { + Nep178ApproveError::Unauthorized(UnauthorizedError { account_id: bob.id().parse().unwrap(), token_id: "token_0".to_string(), - }, + }), ); expect_execution_error(&result, expected_error); @@ -987,7 +1006,7 @@ async fn transfer_approval_unauthorized_approval_fail() { #[tokio::test] async fn transfer_approval_too_many_approvals_fail() { let Setup { contract, accounts } = - setup_balances(WASM_FULL, 2, |i| vec![format!("token_{i}")]).await; + setup_balances(WASM_FULL, 2, |i| vec![format!("token_{i}")], true).await; let alice = &accounts[0]; let bob = &accounts[1]; @@ -1026,9 +1045,9 @@ async fn transfer_approval_too_many_approvals_fail() { let expected_error = format!( "Smart contract panicked: {}", - nep178::Nep178ApproveError::TooManyApprovals { + Nep178ApproveError::TooManyApprovals(TooManyApprovalsError { token_id: "token_0".to_string(), - }, + }), ); expect_execution_error(&result, expected_error); @@ -1037,7 +1056,7 @@ async fn transfer_approval_too_many_approvals_fail() { #[tokio::test] async fn transfer_approval_approved_but_wrong_approval_id_fail() { let Setup { contract, accounts } = - setup_balances(WASM_FULL, 3, |i| vec![format!("token_{i}")]).await; + setup_balances(WASM_FULL, 3, |i| vec![format!("token_{i}")], true).await; let alice = &accounts[0]; let bob = &accounts[1]; let charlie = &accounts[2]; @@ -1068,13 +1087,35 @@ async fn transfer_approval_approved_but_wrong_approval_id_fail() { let expected_error = format!( "Smart contract panicked: {}", - nep171::Nep171TransferError::SenderNotApproved(nep171::error::SenderNotApprovedError { - sender_id: bob.id().parse().unwrap(), - owner_id: alice.id().parse().unwrap(), - token_id: "token_0".to_string(), - approval_id: 1, - }), + nep171::error::Nep171TransferError::SenderNotApproved( + nep171::error::SenderNotApprovedError { + sender_id: bob.id().parse().unwrap(), + owner_id: alice.id().parse().unwrap(), + token_id: "token_0".to_string(), + approval_id: 1, + } + ), ); expect_execution_error(&result, expected_error); } + +#[tokio::test] +#[should_panic = "Account this_account_is_not_registered.near is not registered"] +async fn transfer_fail_not_registered_nep145() { + let Setup { contract, accounts } = + setup_balances(WASM_FULL, 1, |i| vec![format!("token_{i}")], true).await; + let alice = &accounts[0]; + + alice + .call(contract.id(), "nft_transfer") + .args_json(json!({ + "token_id": "token_0", + "receiver_id": "this_account_is_not_registered.near", + })) + .deposit(1) + .transact() + .await + .unwrap() + .unwrap(); +} diff --git a/workspaces-tests/tests/rbac.rs b/workspaces-tests/tests/rbac.rs index d6d9484..4a5ffdb 100644 --- a/workspaces-tests/tests/rbac.rs +++ b/workspaces-tests/tests/rbac.rs @@ -6,8 +6,9 @@ use near_sdk::{ serde::Deserialize, serde_json::{self, json}, }; +use near_workspaces::{Account, AccountId, Contract}; +use pretty_assertions::assert_eq; use tokio::join; -use workspaces::{Account, AccountId, Contract}; const WASM: &[u8] = include_bytes!("../../target/wasm32-unknown-unknown/release/rbac.wasm"); @@ -27,7 +28,7 @@ struct Setup { /// Setup for individual tests async fn setup(num_accounts: usize, wasm: &[u8]) -> Setup { - let worker = workspaces::sandbox().await.unwrap(); + let worker = near_workspaces::sandbox().await.unwrap(); // Initialize user accounts let mut accounts = vec![]; diff --git a/workspaces-tests/tests/simple_multisig.rs b/workspaces-tests/tests/simple_multisig.rs index 16e1e18..cf75814 100644 --- a/workspaces-tests/tests/simple_multisig.rs +++ b/workspaces-tests/tests/simple_multisig.rs @@ -1,7 +1,8 @@ #![cfg(not(windows))] use near_sdk::serde_json::json; -use workspaces::{Account, Contract}; +use near_workspaces::{Account, Contract}; +use pretty_assertions::assert_eq; const WASM: &[u8] = include_bytes!("../../target/wasm32-unknown-unknown/release/simple_multisig.wasm"); @@ -13,7 +14,7 @@ struct Setup { /// Setup for individual tests async fn setup(num_accounts: usize) -> Setup { - let worker = workspaces::sandbox().await.unwrap(); + let worker = near_workspaces::sandbox().await.unwrap(); // Initialize contract let contract = worker.dev_deploy(WASM).await.unwrap(); diff --git a/workspaces-tests/tests/storage_fee.rs b/workspaces-tests/tests/storage_fee.rs index ebd2dd3..fe230e4 100644 --- a/workspaces-tests/tests/storage_fee.rs +++ b/workspaces-tests/tests/storage_fee.rs @@ -1,8 +1,7 @@ #![cfg(not(windows))] use near_sdk::{json_types::U128, serde_json::json, ONE_NEAR}; - -use workspaces::{sandbox, Account, Contract, DevNetwork, Worker}; +use near_workspaces::{sandbox, Account, Contract, DevNetwork, Worker}; const WASM: &[u8] = include_bytes!("../../target/wasm32-unknown-unknown/release/storage_fee.wasm"); @@ -73,7 +72,8 @@ async fn storage_fee() { // How much was actually charged to the account? // Note that there will be *some* overhead, e.g. collection indexing - let net_fee = balance_before - balance_after - (r.total_gas_burnt as u128 * gas_price); + let net_fee = + balance_before - balance_after - (r.total_gas_burnt.as_gas() as u128 * gas_price); assert!(net_fee >= minimum_storage_fee); assert!(net_fee - minimum_storage_fee < byte_cost * 100); // Sanity/validity check / allow up to 100 bytes worth of additional storage to be charged diff --git a/workspaces-tests/tests/upgrade.rs b/workspaces-tests/tests/upgrade.rs index 3726eb1..6392b3e 100644 --- a/workspaces-tests/tests/upgrade.rs +++ b/workspaces-tests/tests/upgrade.rs @@ -4,7 +4,8 @@ use near_sdk::{ borsh::{self, BorshSerialize}, serde::Serialize, }; -use workspaces::{Account, Contract}; +use near_workspaces::{Account, Contract}; +use pretty_assertions::assert_eq; const WASM_BORSH: &[u8] = include_bytes!("../../target/wasm32-unknown-unknown/release/upgrade_old_borsh.wasm"); @@ -42,7 +43,7 @@ struct Setup { /// Setup for individual tests async fn setup(num_accounts: usize, wasm: &[u8]) -> Setup { - let worker = workspaces::sandbox().await.unwrap(); + let worker = near_workspaces::sandbox().await.unwrap(); // Initialize user accounts let mut accounts = vec![]; diff --git a/workspaces-tests/tests/upgrade_multisig.rs b/workspaces-tests/tests/upgrade_multisig.rs index 5dbc8c2..715d8c4 100644 --- a/workspaces-tests/tests/upgrade_multisig.rs +++ b/workspaces-tests/tests/upgrade_multisig.rs @@ -1,7 +1,8 @@ #![cfg(not(windows))] use near_sdk::{json_types::Base64VecU8, serde_json::json}; -use workspaces::{Account, Contract}; +use near_workspaces::{Account, Contract}; +use pretty_assertions::assert_eq; const WASM: &[u8] = include_bytes!("../../target/wasm32-unknown-unknown/release/upgrade_old_multisig.wasm"); @@ -16,7 +17,7 @@ struct Setup { /// Setup for individual tests async fn setup(num_accounts: usize, wasm: &[u8]) -> Setup { - let worker = workspaces::testnet().await.unwrap(); + let worker = near_workspaces::sandbox().await.unwrap(); // Initialize user accounts let mut accounts = vec![];