├── .gitignore ├── LICENSE ├── README.md ├── motoko ├── demo.sh ├── dfx.json ├── src │ ├── main.mo │ ├── sale.mo │ └── types.mo ├── start.sh └── test │ ├── dfx.json │ ├── test.mo │ └── test.sh └── spec.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Various IDEs and Editors 2 | .vscode/ 3 | .idea/ 4 | **/*~ 5 | 6 | # Mac OSX temporary files 7 | .DS_Store 8 | **/.DS_Store 9 | 10 | # dfx temporary files 11 | .dfx/ 12 | 13 | # frontend code 14 | node_modules/ 15 | dist/ 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2021 Rocklabs 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Non-fungible Token Standard for the IC 2 | 3 | This is an NFT standard implementation for the DFINITY Internet Computer, the interfaces mainly follow the ERC721 standard, and we also added support for transaction history storage and query, make NFTs(including their metadata) traceable and verifiable. 4 | 5 | Read the [specification file](./spec.md) for details. 6 | 7 | ## Development 8 | 9 | You need the latest DFINITY Canister SDK to be able to build and deploy a token canister: 10 | 11 | ``` 12 | sh -ci "$(curl -fsSL https://sdk.dfinity.org/install.sh)" 13 | ``` 14 | 15 | Navigate to a the sub directory and start a local development network: 16 | 17 | ``` 18 | cd motoko 19 | dfx start --background 20 | ``` 21 | 22 | Create canister: 23 | 24 | ``` 25 | dfx canister create --all 26 | ``` 27 | 28 | Install code for the NFT canister: 29 | 30 | ``` 31 | dfx build 32 | 33 | dfx canister install nft --argument="(\"\", \"\", \"\", )" 34 | e.g.: 35 | dfx canister install token --argument="(\"Test NFT\", \"TEST\", \"Test NFT collection\", principal \"4qehi-lqyo6-afz4c-hwqwo-lubfi-4evgk-5vrn5-rldx2-lheha-xs7a4-gae\")" 36 | ``` 37 | 38 | ## Contributing 39 | 40 | Contributions are welcome, open an issue or make a PR. 41 | -------------------------------------------------------------------------------- /motoko/demo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # set -e 4 | 5 | # clear 6 | dfx stop 7 | rm -rf .dfx 8 | 9 | ALICE_HOME=$(mktemp -d -t alice-temp) 10 | BOB_HOME=$(mktemp -d -t bob-temp) 11 | DAN_HOME=$(mktemp -d -t dan-temp) 12 | FEE_HOME=$(mktemp -d -t fee-temp) 13 | HOME=$ALICE_HOME 14 | 15 | ALICE_PUBLIC_KEY="principal \"$( \ 16 | HOME=$ALICE_HOME dfx identity get-principal 17 | )\"" 18 | BOB_PUBLIC_KEY="principal \"$( \ 19 | HOME=$BOB_HOME dfx identity get-principal 20 | )\"" 21 | DAN_PUBLIC_KEY="principal \"$( \ 22 | HOME=$DAN_HOME dfx identity get-principal 23 | )\"" 24 | FEE_PUBLIC_KEY="principal \"$( \ 25 | HOME=$FEE_HOME dfx identity get-principal 26 | )\"" 27 | 28 | echo Alice id = $ALICE_PUBLIC_KEY 29 | echo Bob id = $BOB_PUBLIC_KEY 30 | echo Dan id = $DAN_PUBLIC_KEY 31 | echo Fee id = $FEE_PUBLIC_KEY 32 | 33 | dfx start --background 34 | dfx canister --no-wallet create --all 35 | dfx build 36 | 37 | TOKENID=$(dfx canister --no-wallet id nft) 38 | TOKENID="principal \"$TOKENID\"" 39 | 40 | echo NFT id : $TOKENID 41 | 42 | echo 43 | echo == Install NFT canisters 44 | echo 45 | 46 | HOME=$ALICE_HOME 47 | eval dfx canister --no-wallet install nft --argument="'(\"Test logo\", \"Test NFT1\", \"NFT1\", \"This is a NFT demo test!\", principal \"$(dfx identity get-principal)\")'" 48 | 49 | echo == Get NFT metadata: name, desciption, total supply, owner 50 | dfx canister --no-wallet call nft getMetadata 51 | 52 | echo == Mint 3 NFT to Alice, 3 NFT to Bob 53 | eval dfx canister --no-wallet call nft mint "'($ALICE_PUBLIC_KEY, opt record { filetype = \"jpg\"; location = variant {IPFS = \"hash0\"}; attributes = vec {record {key = \"url\"; value = \"a.link/0\"}}})'" 54 | eval dfx canister --no-wallet call nft mint "'($ALICE_PUBLIC_KEY, opt record { filetype = \"jpg\"; location = variant {IPFS = \"hash1\"}; attributes = vec {record {key = \"url\"; value = \"a.link/1\"}}})'" 55 | eval dfx canister --no-wallet call nft mint "'($ALICE_PUBLIC_KEY, opt record { filetype = \"jpg\"; location = variant {IPFS = \"hash2\"}; attributes = vec {record {key = \"url\"; value = \"a.link/2\"}}})'" 56 | eval dfx canister --no-wallet call nft mint "'($BOB_PUBLIC_KEY, opt record { filetype = \"jpg\"; location = variant {IPFS = \"hash3\"}; attributes = vec {record {key = \"url\"; value = \"a.link/3\"}}})'" 57 | eval dfx canister --no-wallet call nft mint "'($BOB_PUBLIC_KEY, opt record { filetype = \"jpg\"; location = variant {IPFS = \"has4\"}; attributes = vec {record {key = \"url\"; value = \"a.link/4\"}}})'" 58 | eval dfx canister --no-wallet call nft mint "'($BOB_PUBLIC_KEY, opt record { filetype = \"jpg\"; location = variant {IPFS = \"has5\"}; attributes = vec {record {key = \"url\"; value = \"a.link/5\"}}})'" 59 | echo 60 | 61 | echo == Get all tokens info 62 | dfx canister --no-wallet call nft getAllTokens 63 | 64 | echo == Get 0~5 transactions 65 | dfx canister --no-wallet call nft getTransactions "(0,6)" 66 | 67 | echo == Get totalSupply 6 68 | dfx canister --no-wallet call nft totalSupply 69 | 70 | echo == Get balance 3, 3, 0 71 | eval dfx canister --no-wallet call nft balanceOf "'($ALICE_PUBLIC_KEY)'" 72 | eval dfx canister --no-wallet call nft balanceOf "'($BOB_PUBLIC_KEY)'" 73 | eval dfx canister --no-wallet call nft balanceOf "'($DAN_PUBLIC_KEY)'" 74 | 75 | echo == setTokenMetadata: Bob change the Token 3 filetype to png 76 | eval HOME=$BOB_HOME dfx canister --no-wallet call nft setTokenMetadata "'(3, record { filetype = \"png\"; location = variant {IPFS = \"hash0\"}; attributes = vec {record {key = \"url\"; value = \"a.link/0\"}}})'" 77 | 78 | echo Get Token 3 info to check the filetype 79 | dfx canister --no-wallet call nft getTokenInfo 3 80 | 81 | echo == Get Alice Bob Dan UserInfo 82 | eval dfx canister --no-wallet call nft getUserInfo "'($ALICE_PUBLIC_KEY)'" 83 | eval dfx canister --no-wallet call nft getUserInfo "'($BOB_PUBLIC_KEY)'" 84 | eval dfx canister --no-wallet call nft getUserInfo "'($DAN_PUBLIC_KEY)'" 85 | 86 | echo == Alice transfer token 0 to Dan 87 | eval HOME=$ALICE_HOME dfx canister --no-wallet call nft transferFrom "'($ALICE_PUBLIC_KEY, $DAN_PUBLIC_KEY, 0)'" 88 | echo == Get Alice user info and Dan user info 89 | eval dfx canister --no-wallet call nft getUserInfo "'($ALICE_PUBLIC_KEY)'" 90 | eval dfx canister --no-wallet call nft getUserInfo "'($DAN_PUBLIC_KEY)'" 91 | 92 | echo == Bob approve Alice token 3 93 | eval HOME=$BOB_HOME dfx canister --no-wallet call nft approve "'(3, $ALICE_PUBLIC_KEY)'" 94 | echo == get token 3 info 95 | dfx canister --no-wallet call nft getTokenInfo 3 96 | echo == Alice transfer Bob token 3 to Alice 97 | eval HOME=$BOB_HOME dfx canister --no-wallet call nft transferFrom "'($BOB_PUBLIC_KEY, $ALICE_PUBLIC_KEY, 3)'" 98 | 99 | echo == Get Alice and Bob UserInfo: Alice has 1,2,3 token , Bob has 4,5 token , Dan has 0 token 100 | eval dfx canister --no-wallet call nft getUserInfo "'($ALICE_PUBLIC_KEY)'" 101 | eval dfx canister --no-wallet call nft getUserInfo "'($BOB_PUBLIC_KEY)'" 102 | eval dfx canister --no-wallet call nft getUserInfo "'($DAN_PUBLIC_KEY)'" 103 | 104 | 105 | echo == Bob set Fee approval For All 106 | eval HOME=$BOB_HOME dfx canister --no-wallet call nft setApprovalForAll "'($FEE_PUBLIC_KEY, true)'" 107 | echo == Fee transfer token 4 from Bob 108 | eval HOME=$FEE_HOME dfx canister --no-wallet call nft transferFrom "'($BOB_PUBLIC_KEY, $FEE_PUBLIC_KEY, 4)'" 109 | 110 | echo == get isApprovedForAll true false false 111 | eval dfx canister --no-wallet call nft isApprovedForAll "'($BOB_PUBLIC_KEY, $FEE_PUBLIC_KEY)'" 112 | eval dfx canister --no-wallet call nft isApprovedForAll "'($BOB_PUBLIC_KEY, $ALICE_PUBLIC_KEY)'" 113 | eval dfx canister --no-wallet call nft isApprovedForAll "'($ALICE_PUBLIC_KEY, $DAN_PUBLIC_KEY)'" 114 | 115 | echo == Bob set self approved For All, False 116 | eval HOME=$BOB_HOME dfx canister --no-wallet call nft setApprovalForAll "'($BOB_PUBLIC_KEY, true)'" 117 | echo == get bob approved bob false 118 | eval dfx canister --no-wallet call nft isApprovedForAll "'($BOB_PUBLIC_KEY, $BOB_PUBLIC_KEY)'" 119 | 120 | echo == Get Alice Bob Dan Fee UserInfo: Alice has token 1,2,3, Bob has token 5, Dan has token 0, Fee has token 4 121 | eval dfx canister --no-wallet call nft getUserInfo "'($ALICE_PUBLIC_KEY)'" 122 | eval dfx canister --no-wallet call nft getUserInfo "'($BOB_PUBLIC_KEY)'" 123 | eval dfx canister --no-wallet call nft getUserInfo "'($DAN_PUBLIC_KEY)'" 124 | eval dfx canister --no-wallet call nft getUserInfo "'($FEE_PUBLIC_KEY)'" 125 | 126 | echo == get balance 3,1,1,1 127 | eval dfx canister --no-wallet call nft balanceOf "'($ALICE_PUBLIC_KEY)'" 128 | eval dfx canister --no-wallet call nft balanceOf "'($BOB_PUBLIC_KEY)'" 129 | eval dfx canister --no-wallet call nft balanceOf "'($DAN_PUBLIC_KEY)'" 130 | eval dfx canister --no-wallet call nft balanceOf "'($FEE_PUBLIC_KEY)'" 131 | 132 | 133 | echo == Alice transfer nft 3 to canister 134 | eval HOME=$ALICE_HOME dfx canister --no-wallet call nft transferFrom "'($ALICE_PUBLIC_KEY, $TOKENID, 3)'" 135 | echo == owner of token3 136 | dfx canister --no-wallet call nft ownerOf 3 137 | 138 | echo == Alice burn token 2 139 | eval HOME=$ALICE_HOME dfx canister --no-wallet call nft burn 2 140 | 141 | echo == Get allTokens 142 | dfx canister --no-wallet call nft getAllTokens 143 | 144 | echo == Get Alice Bob Dan Fee UserInfo : Alice has token 1, Bob has token 5, Dan has token 0, Fee has token 4 145 | eval dfx canister --no-wallet call nft getUserInfo "'($ALICE_PUBLIC_KEY)'" 146 | eval dfx canister --no-wallet call nft getUserInfo "'($BOB_PUBLIC_KEY)'" 147 | eval dfx canister --no-wallet call nft getUserInfo "'($DAN_PUBLIC_KEY)'" 148 | eval dfx canister --no-wallet call nft getUserInfo "'($FEE_PUBLIC_KEY)'" 149 | 150 | echo == get token owner 151 | dfx canister --no-wallet call nft ownerOf 0 152 | dfx canister --no-wallet call nft ownerOf 1 153 | dfx canister --no-wallet call nft ownerOf 2 154 | dfx canister --no-wallet call nft ownerOf 3 155 | dfx canister --no-wallet call nft ownerOf 4 156 | dfx canister --no-wallet call nft ownerOf 5 157 | 158 | echo == get balance 1,1,1,1 159 | eval dfx canister --no-wallet call nft balanceOf "'($ALICE_PUBLIC_KEY)'" 160 | eval dfx canister --no-wallet call nft balanceOf "'($BOB_PUBLIC_KEY)'" 161 | eval dfx canister --no-wallet call nft balanceOf "'($DAN_PUBLIC_KEY)'" 162 | eval dfx canister --no-wallet call nft balanceOf "'($FEE_PUBLIC_KEY)'" 163 | 164 | echo == get tx size 165 | dfx canister --no-wallet call nft historySize 166 | 167 | echo == get some transactions 168 | dfx canister --no-wallet call nft getTransactions "(2,7)" 169 | 170 | echo == get operation 171 | dfx canister --no-wallet call nft getTransaction 3 172 | 173 | echo == get transactions amount of a user 174 | eval dfx canister --no-wallet call nft getUserTransactionAmount "'($ALICE_PUBLIC_KEY)'" 175 | 176 | echo == get some operations of a user 177 | eval dfx canister --no-wallet call nft getUserTransactions "'($ALICE_PUBLIC_KEY, 2, 6)'" 178 | 179 | echo == demo finished!!! 180 | dfx stop 181 | -------------------------------------------------------------------------------- /motoko/dfx.json: -------------------------------------------------------------------------------- 1 | { 2 | "canisters": { 3 | "nft": { 4 | "main": "src/main.mo", 5 | "type": "motoko" 6 | }, 7 | "sale": { 8 | "main": "src/sale.mo", 9 | "type": "motoko" 10 | } 11 | }, 12 | "defaults": { 13 | "build": { 14 | "packtool": "" 15 | } 16 | }, 17 | "networks": { 18 | "local": { 19 | "bind": "127.0.0.1:8000", 20 | "type": "ephemeral" 21 | } 22 | }, 23 | "version": 1 24 | } 25 | -------------------------------------------------------------------------------- /motoko/src/main.mo: -------------------------------------------------------------------------------- 1 | /** 2 | * Module : main.mo 3 | * Copyright : 2022 Rocklabs Team 4 | * License : Apache 2.0 with LLVM Exception 5 | * Maintainer : Rocklabs Team 6 | * Stability : Experimental 7 | */ 8 | 9 | import Array "mo:base/Array"; 10 | import Buffer "mo:base/Buffer"; 11 | import Cycles "mo:base/ExperimentalCycles"; 12 | import Error "mo:base/Error"; 13 | import Hash "mo:base/Hash"; 14 | import HashMap "mo:base/HashMap"; 15 | import Int "mo:base/Int"; 16 | import Iter "mo:base/Iter"; 17 | import Nat "mo:base/Nat"; 18 | import Prelude "mo:base/Prelude"; 19 | import Principal "mo:base/Principal"; 20 | import Result "mo:base/Result"; 21 | import Text "mo:base/Text"; 22 | import Time "mo:base/Time"; 23 | import TrieSet "mo:base/TrieSet"; 24 | 25 | import Types "./types"; 26 | 27 | shared(msg) actor class NFToken( 28 | _logo: Text, 29 | _name: Text, 30 | _symbol: Text, 31 | _desc: Text, 32 | _owner: Principal 33 | ) = this { 34 | 35 | type Metadata = Types.Metadata; 36 | type Location = Types.Location; 37 | type Attribute = Types.Attribute; 38 | type TokenMetadata = Types.TokenMetadata; 39 | type Record = Types.Record; 40 | type TxRecord = Types.TxRecord; 41 | type Operation = Types.Operation; 42 | type TokenInfo = Types.TokenInfo; 43 | type TokenInfoExt = Types.TokenInfoExt; 44 | type UserInfo = Types.UserInfo; 45 | type UserInfoExt = Types.UserInfoExt; 46 | 47 | public type Errors = { 48 | #Unauthorized; 49 | #TokenNotExist; 50 | #InvalidOperator; 51 | }; 52 | // to be compatible with Rust canister 53 | // in Rust, Result is `Ok` and `Err` 54 | public type TxReceipt = { 55 | #Ok: Nat; 56 | #Err: Errors; 57 | }; 58 | public type MintResult = { 59 | #Ok: (Nat, Nat); 60 | #Err: Errors; 61 | }; 62 | 63 | public type Result = {#ok : Ok; #err : Err}; 64 | 65 | private stable var logo_ : Text = _logo; // base64 encoded image 66 | private stable var name_ : Text = _name; 67 | private stable var symbol_ : Text = _symbol; 68 | private stable var desc_ : Text = _desc; 69 | private stable var owner_: Principal = _owner; 70 | private stable var totalSupply_: Nat = 0; 71 | private stable var blackhole: Principal = Principal.fromText("aaaaa-aa"); 72 | 73 | private stable var tokensEntries : [(Nat, TokenInfo)] = []; 74 | private stable var usersEntries : [(Principal, UserInfo)] = []; 75 | private var tokens = HashMap.HashMap(1, Nat.equal, Hash.hash); 76 | private var users = HashMap.HashMap(1, Principal.equal, Principal.hash); 77 | private stable var txs: [TxRecord] = []; 78 | private stable var txIndex: Nat = 0; 79 | 80 | private func addTxRecord( 81 | caller: Principal, op: Operation, tokenIndex: ?Nat, 82 | from: Record, to: Record, timestamp: Time.Time 83 | ): Nat { 84 | let record: TxRecord = { 85 | caller = caller; 86 | op = op; 87 | index = txIndex; 88 | tokenIndex = tokenIndex; 89 | from = from; 90 | to = to; 91 | timestamp = timestamp; 92 | }; 93 | txs := Array.append(txs, [record]); 94 | txIndex += 1; 95 | return txIndex - 1; 96 | }; 97 | 98 | private func _unwrap(x : ?T) : T = 99 | switch x { 100 | case null { Prelude.unreachable() }; 101 | case (?x_) { x_ }; 102 | }; 103 | 104 | private func _exists(tokenId: Nat) : Bool { 105 | switch (tokens.get(tokenId)) { 106 | case (?info) { return true; }; 107 | case _ { return false; }; 108 | } 109 | }; 110 | 111 | private func _ownerOf(tokenId: Nat) : ?Principal { 112 | switch (tokens.get(tokenId)) { 113 | case (?info) { return ?info.owner; }; 114 | case (_) { return null; }; 115 | } 116 | }; 117 | 118 | private func _isOwner(who: Principal, tokenId: Nat) : Bool { 119 | switch (tokens.get(tokenId)) { 120 | case (?info) { return info.owner == who; }; 121 | case _ { return false; }; 122 | }; 123 | }; 124 | 125 | private func _isApproved(who: Principal, tokenId: Nat) : Bool { 126 | switch (tokens.get(tokenId)) { 127 | case (?info) { return info.operator == ?who; }; 128 | case _ { return false; }; 129 | } 130 | }; 131 | 132 | private func _balanceOf(who: Principal) : Nat { 133 | switch (users.get(who)) { 134 | case (?user) { return TrieSet.size(user.tokens); }; 135 | case (_) { return 0; }; 136 | } 137 | }; 138 | 139 | private func _newUser() : UserInfo { 140 | { 141 | var operators = TrieSet.empty(); 142 | var allowedBy = TrieSet.empty(); 143 | var allowedTokens = TrieSet.empty(); 144 | var tokens = TrieSet.empty(); 145 | } 146 | }; 147 | 148 | private func _tokenInfotoExt(info: TokenInfo) : TokenInfoExt { 149 | return { 150 | index = info.index; 151 | owner = info.owner; 152 | metadata = info.metadata; 153 | timestamp = info.timestamp; 154 | operator = info.operator; 155 | }; 156 | }; 157 | 158 | private func _userInfotoExt(info: UserInfo) : UserInfoExt { 159 | return { 160 | operators = TrieSet.toArray(info.operators); 161 | allowedBy = TrieSet.toArray(info.allowedBy); 162 | allowedTokens = TrieSet.toArray(info.allowedTokens); 163 | tokens = TrieSet.toArray(info.tokens); 164 | }; 165 | }; 166 | 167 | private func _isApprovedOrOwner(spender: Principal, tokenId: Nat) : Bool { 168 | switch (_ownerOf(tokenId)) { 169 | case (?owner) { 170 | return spender == owner or _isApproved(spender, tokenId) or _isApprovedForAll(owner, spender); 171 | }; 172 | case _ { 173 | return false; 174 | }; 175 | }; 176 | }; 177 | 178 | private func _getApproved(tokenId: Nat) : ?Principal { 179 | switch (tokens.get(tokenId)) { 180 | case (?info) { 181 | return info.operator; 182 | }; 183 | case (_) { 184 | return null; 185 | }; 186 | } 187 | }; 188 | 189 | private func _isApprovedForAll(owner: Principal, operator: Principal) : Bool { 190 | switch (users.get(owner)) { 191 | case (?user) { 192 | return TrieSet.mem(user.operators, operator, Principal.hash(operator), Principal.equal); 193 | }; 194 | case _ { return false; }; 195 | }; 196 | }; 197 | 198 | private func _addTokenTo(to: Principal, tokenId: Nat) { 199 | switch(users.get(to)) { 200 | case (?user) { 201 | user.tokens := TrieSet.put(user.tokens, tokenId, Hash.hash(tokenId), Nat.equal); 202 | users.put(to, user); 203 | }; 204 | case _ { 205 | let user = _newUser(); 206 | user.tokens := TrieSet.put(user.tokens, tokenId, Hash.hash(tokenId), Nat.equal); 207 | users.put(to, user); 208 | }; 209 | } 210 | }; 211 | 212 | private func _removeTokenFrom(owner: Principal, tokenId: Nat) { 213 | assert(_exists(tokenId) and _isOwner(owner, tokenId)); 214 | switch(users.get(owner)) { 215 | case (?user) { 216 | user.tokens := TrieSet.delete(user.tokens, tokenId, Hash.hash(tokenId), Nat.equal); 217 | users.put(owner, user); 218 | }; 219 | case _ { 220 | assert(false); 221 | }; 222 | } 223 | }; 224 | 225 | private func _clearApproval(owner: Principal, tokenId: Nat) { 226 | assert(_exists(tokenId) and _isOwner(owner, tokenId)); 227 | switch (tokens.get(tokenId)) { 228 | case (?info) { 229 | if (info.operator != null) { 230 | let op = _unwrap(info.operator); 231 | let opInfo = _unwrap(users.get(op)); 232 | opInfo.allowedTokens := TrieSet.delete(opInfo.allowedTokens, tokenId, Hash.hash(tokenId), Nat.equal); 233 | users.put(op, opInfo); 234 | info.operator := null; 235 | tokens.put(tokenId, info); 236 | } 237 | }; 238 | case _ { 239 | assert(false); 240 | }; 241 | } 242 | }; 243 | 244 | private func _transfer(to: Principal, tokenId: Nat) { 245 | assert(_exists(tokenId)); 246 | switch(tokens.get(tokenId)) { 247 | case (?info) { 248 | _removeTokenFrom(info.owner, tokenId); 249 | _addTokenTo(to, tokenId); 250 | info.owner := to; 251 | tokens.put(tokenId, info); 252 | }; 253 | case (_) { 254 | assert(false); 255 | }; 256 | }; 257 | }; 258 | 259 | private func _burn(owner: Principal, tokenId: Nat) { 260 | _clearApproval(owner, tokenId); 261 | _transfer(blackhole, tokenId); 262 | }; 263 | 264 | public shared(msg) func setOwner(new: Principal): async Principal { 265 | assert(msg.caller == owner_); 266 | owner_ := new; 267 | new 268 | }; 269 | 270 | // public update calls 271 | public shared(msg) func mint(to: Principal, metadata: ?TokenMetadata): async MintResult { 272 | if(msg.caller != owner_) { 273 | return #Err(#Unauthorized); 274 | }; 275 | let token: TokenInfo = { 276 | index = totalSupply_; 277 | var owner = to; 278 | var metadata = metadata; 279 | var operator = null; 280 | timestamp = Time.now(); 281 | }; 282 | tokens.put(totalSupply_, token); 283 | _addTokenTo(to, totalSupply_); 284 | totalSupply_ += 1; 285 | let txid = addTxRecord(msg.caller, #mint(metadata), ?token.index, #user(blackhole), #user(to), Time.now()); 286 | return #Ok((token.index, txid)); 287 | }; 288 | 289 | public shared(msg) func batchMint(to: Principal, arr: [?TokenMetadata]): async MintResult { 290 | if(msg.caller != owner_) { 291 | return #Err(#Unauthorized); 292 | }; 293 | let startIndex = totalSupply_; 294 | for(metadata in Iter.fromArray(arr)) { 295 | let token: TokenInfo = { 296 | index = totalSupply_; 297 | var owner = to; 298 | var metadata = metadata; 299 | var operator = null; 300 | timestamp = Time.now(); 301 | }; 302 | tokens.put(totalSupply_, token); 303 | _addTokenTo(to, totalSupply_); 304 | totalSupply_ += 1; 305 | ignore addTxRecord(msg.caller, #mint(metadata), ?token.index, #user(blackhole), #user(to), Time.now()); 306 | }; 307 | return #Ok((startIndex, txs.size() - arr.size())); 308 | }; 309 | 310 | public shared(msg) func burn(tokenId: Nat): async TxReceipt { 311 | if(_exists(tokenId) == false) { 312 | return #Err(#TokenNotExist) 313 | }; 314 | if(_isOwner(msg.caller, tokenId) == false) { 315 | return #Err(#Unauthorized); 316 | }; 317 | _burn(msg.caller, tokenId); //not delete tokenId from tokens temporarily. (consider storage limited, it should be delete.) 318 | let txid = addTxRecord(msg.caller, #burn, ?tokenId, #user(msg.caller), #user(blackhole), Time.now()); 319 | return #Ok(txid); 320 | }; 321 | 322 | public shared(msg) func setTokenMetadata(tokenId: Nat, new_metadata: TokenMetadata) : async TxReceipt { 323 | // only canister owner can set 324 | if(msg.caller != owner_) { 325 | return #Err(#Unauthorized); 326 | }; 327 | if(_exists(tokenId) == false) { 328 | return #Err(#TokenNotExist) 329 | }; 330 | let token = _unwrap(tokens.get(tokenId)); 331 | let old_metadate = token.metadata; 332 | token.metadata := ?new_metadata; 333 | tokens.put(tokenId, token); 334 | let txid = addTxRecord(msg.caller, #setMetadata, ?token.index, #metadata(old_metadate), #metadata(?new_metadata), Time.now()); 335 | return #Ok(txid); 336 | }; 337 | 338 | public shared(msg) func approve(tokenId: Nat, operator: Principal) : async TxReceipt { 339 | var owner: Principal = switch (_ownerOf(tokenId)) { 340 | case (?own) { 341 | own; 342 | }; 343 | case (_) { 344 | return #Err(#TokenNotExist) 345 | } 346 | }; 347 | if(Principal.equal(msg.caller, owner) == false) 348 | if(_isApprovedForAll(owner, msg.caller) == false) 349 | return #Err(#Unauthorized); 350 | if(owner == operator) { 351 | return #Err(#InvalidOperator); 352 | }; 353 | switch (tokens.get(tokenId)) { 354 | case (?info) { 355 | info.operator := ?operator; 356 | tokens.put(tokenId, info); 357 | }; 358 | case _ { 359 | return #Err(#TokenNotExist); 360 | }; 361 | }; 362 | switch (users.get(operator)) { 363 | case (?user) { 364 | user.allowedTokens := TrieSet.put(user.allowedTokens, tokenId, Hash.hash(tokenId), Nat.equal); 365 | users.put(operator, user); 366 | }; 367 | case _ { 368 | let user = _newUser(); 369 | user.allowedTokens := TrieSet.put(user.allowedTokens, tokenId, Hash.hash(tokenId), Nat.equal); 370 | users.put(operator, user); 371 | }; 372 | }; 373 | let txid = addTxRecord(msg.caller, #approve, ?tokenId, #user(msg.caller), #user(operator), Time.now()); 374 | return #Ok(txid); 375 | }; 376 | 377 | public shared(msg) func setApprovalForAll(operator: Principal, value: Bool): async TxReceipt { 378 | if(msg.caller == operator) { 379 | return #Err(#Unauthorized); 380 | }; 381 | var txid = 0; 382 | if value { 383 | let caller = switch (users.get(msg.caller)) { 384 | case (?user) { user }; 385 | case _ { _newUser() }; 386 | }; 387 | caller.operators := TrieSet.put(caller.operators, operator, Principal.hash(operator), Principal.equal); 388 | users.put(msg.caller, caller); 389 | let user = switch (users.get(operator)) { 390 | case (?user) { user }; 391 | case _ { _newUser() }; 392 | }; 393 | user.allowedBy := TrieSet.put(user.allowedBy, msg.caller, Principal.hash(msg.caller), Principal.equal); 394 | users.put(operator, user); 395 | txid := addTxRecord(msg.caller, #approveAll, null, #user(msg.caller), #user(operator), Time.now()); 396 | } else { 397 | switch (users.get(msg.caller)) { 398 | case (?user) { 399 | user.operators := TrieSet.delete(user.operators, operator, Principal.hash(operator), Principal.equal); 400 | users.put(msg.caller, user); 401 | }; 402 | case _ { }; 403 | }; 404 | switch (users.get(operator)) { 405 | case (?user) { 406 | user.allowedBy := TrieSet.delete(user.allowedBy, msg.caller, Principal.hash(msg.caller), Principal.equal); 407 | users.put(operator, user); 408 | }; 409 | case _ { }; 410 | }; 411 | txid := addTxRecord(msg.caller, #revokeAll, null, #user(msg.caller), #user(operator), Time.now()); 412 | }; 413 | return #Ok(txid); 414 | }; 415 | 416 | public shared(msg) func transfer(to: Principal, tokenId: Nat): async TxReceipt { 417 | var owner: Principal = switch (_ownerOf(tokenId)) { 418 | case (?own) { 419 | own; 420 | }; 421 | case (_) { 422 | return #Err(#TokenNotExist) 423 | } 424 | }; 425 | if (owner != msg.caller) { 426 | return #Err(#Unauthorized); 427 | }; 428 | _clearApproval(msg.caller, tokenId); 429 | _transfer(to, tokenId); 430 | let txid = addTxRecord(msg.caller, #transfer, ?tokenId, #user(msg.caller), #user(to), Time.now()); 431 | return #Ok(txid); 432 | }; 433 | 434 | public shared(msg) func transferFrom(from: Principal, to: Principal, tokenId: Nat): async TxReceipt { 435 | if(_exists(tokenId) == false) { 436 | return #Err(#TokenNotExist) 437 | }; 438 | if(_isApprovedOrOwner(msg.caller, tokenId) == false) { 439 | return #Err(#Unauthorized); 440 | }; 441 | _clearApproval(from, tokenId); 442 | _transfer(to, tokenId); 443 | let txid = addTxRecord(msg.caller, #transferFrom, ?tokenId, #user(from), #user(to), Time.now()); 444 | return #Ok(txid); 445 | }; 446 | 447 | public shared(msg) func batchTransferFrom(from: Principal, to: Principal, tokenIds: [Nat]): async TxReceipt { 448 | var num: Nat = 0; 449 | label l for(tokenId in Iter.fromArray(tokenIds)) { 450 | if(_exists(tokenId) == false) { 451 | continue l; 452 | }; 453 | if(_isApprovedOrOwner(msg.caller, tokenId) == false) { 454 | continue l; 455 | }; 456 | _clearApproval(from, tokenId); 457 | _transfer(to, tokenId); 458 | num += 1; 459 | ignore addTxRecord(msg.caller, #transferFrom, ?tokenId, #user(from), #user(to), Time.now()); 460 | }; 461 | return #Ok(txs.size() - num); 462 | }; 463 | 464 | // public query function 465 | public query func logo(): async Text { 466 | return logo_; 467 | }; 468 | 469 | public query func name(): async Text { 470 | return name_; 471 | }; 472 | 473 | public query func symbol(): async Text { 474 | return symbol_; 475 | }; 476 | 477 | public query func desc(): async Text { 478 | return desc_; 479 | }; 480 | 481 | public query func balanceOf(who: Principal): async Nat { 482 | return _balanceOf(who); 483 | }; 484 | 485 | public query func totalSupply(): async Nat { 486 | return totalSupply_; 487 | }; 488 | 489 | // get metadata about this NFT collection 490 | public query func getMetadata(): async Metadata { 491 | { 492 | logo = logo_; 493 | name = name_; 494 | symbol = symbol_; 495 | desc = desc_; 496 | totalSupply = totalSupply_; 497 | owner = owner_; 498 | cycles = Cycles.balance(); 499 | } 500 | }; 501 | 502 | public query func isApprovedForAll(owner: Principal, operator: Principal) : async Bool { 503 | return _isApprovedForAll(owner, operator); 504 | }; 505 | 506 | public query func getOperator(tokenId: Nat) : async Result { 507 | switch (_exists(tokenId)) { 508 | case true { 509 | switch (_getApproved(tokenId)) { 510 | case (?who) { 511 | return #ok(who); 512 | }; 513 | case (_) { 514 | return #ok(Principal.fromText("aaaaa-aa")); 515 | }; 516 | } 517 | }; 518 | case (_) { 519 | return #err(#TokenNotExist); 520 | }; 521 | } 522 | }; 523 | 524 | public query func getUserInfo(who: Principal) : async Result { 525 | switch (users.get(who)) { 526 | case (?user) { 527 | return #ok(_userInfotoExt(user)); 528 | }; 529 | case _ { 530 | return #err(#Unauthorized); 531 | }; 532 | }; 533 | }; 534 | 535 | public query func getUserTokens(owner: Principal) : async [TokenInfoExt] { 536 | let tokenIds = switch (users.get(owner)) { 537 | case (?user) { 538 | TrieSet.toArray(user.tokens) 539 | }; 540 | case _ { 541 | [] 542 | }; 543 | }; 544 | let ret = Buffer.Buffer(tokenIds.size()); 545 | 546 | for(id in Iter.fromArray(tokenIds)) { 547 | ret.add(_tokenInfotoExt(_unwrap(tokens.get(id)))); 548 | }; 549 | return ret.toArray(); 550 | }; 551 | 552 | public query func ownerOf(tokenId: Nat): async Result { 553 | switch (_ownerOf(tokenId)) { 554 | case (?owner) { 555 | return #ok(owner); 556 | }; 557 | case _ { 558 | return #err(#TokenNotExist); 559 | }; 560 | } 561 | }; 562 | 563 | public query func getTokenInfo(tokenId: Nat) : async Result { 564 | switch(tokens.get(tokenId)){ 565 | case(?tokeninfo) { 566 | return #ok(_tokenInfotoExt(tokeninfo)); 567 | }; 568 | case(_) { 569 | return #err(#TokenNotExist); 570 | }; 571 | }; 572 | }; 573 | 574 | // Optional 575 | public query func getAllTokens() : async [TokenInfoExt] { 576 | Iter.toArray(Iter.map(tokens.entries(), func (i: (Nat, TokenInfo)): TokenInfoExt {_tokenInfotoExt(i.1)})) 577 | }; 578 | 579 | // transaction history related 580 | public query func historySize(): async Nat { 581 | return txs.size(); 582 | }; 583 | 584 | public query func getTransaction(index: Nat): async TxRecord { 585 | return txs[index]; 586 | }; 587 | 588 | public query func getTransactions(start: Nat, limit: Nat): async [TxRecord] { 589 | let res = Buffer.Buffer(limit); 590 | var i = start; 591 | while (i < start + limit and i < txs.size()) { 592 | res.add(txs[i]); 593 | i += 1; 594 | }; 595 | return res.toArray(); 596 | }; 597 | 598 | public query func getUserTransactionAmount(user: Principal): async Nat { 599 | var res: Nat = 0; 600 | for (i in txs.vals()) { 601 | if (i.caller == user or i.from == #user(user) or i.to == #user(user)) { 602 | res += 1; 603 | }; 604 | }; 605 | return res; 606 | }; 607 | 608 | public query func getUserTransactions(user: Principal, start: Nat, limit: Nat): async [TxRecord] { 609 | let res = Buffer.Buffer(limit); 610 | var idx = 0; 611 | label l for (i in txs.vals()) { 612 | if (i.caller == user or i.from == #user(user) or i.to == #user(user)) { 613 | if(idx < start) { 614 | idx += 1; 615 | continue l; 616 | }; 617 | if(idx >= start + limit) { 618 | break l; 619 | }; 620 | res.add(i); 621 | idx += 1; 622 | }; 623 | }; 624 | return res.toArray(); 625 | }; 626 | 627 | // upgrade functions 628 | system func preupgrade() { 629 | usersEntries := Iter.toArray(users.entries()); 630 | tokensEntries := Iter.toArray(tokens.entries()); 631 | }; 632 | 633 | system func postupgrade() { 634 | type TokenInfo = Types.TokenInfo; 635 | type UserInfo = Types.UserInfo; 636 | 637 | users := HashMap.fromIter(usersEntries.vals(), 1, Principal.equal, Principal.hash); 638 | tokens := HashMap.fromIter(tokensEntries.vals(), 1, Nat.equal, Hash.hash); 639 | usersEntries := []; 640 | tokensEntries := []; 641 | }; 642 | }; 643 | 644 | -------------------------------------------------------------------------------- /motoko/src/sale.mo: -------------------------------------------------------------------------------- 1 | /** 2 | * Module : main.mo 3 | * Copyright : 2022 Rocklabs Team 4 | * License : Apache 2.0 with LLVM Exception 5 | * Maintainer : Rocklabs Team 6 | * Stability : Experimental 7 | */ 8 | 9 | import HashMap "mo:base/HashMap"; 10 | import Cycles "mo:base/ExperimentalCycles"; 11 | import Principal "mo:base/Principal"; 12 | import Error "mo:base/Error"; 13 | import Nat "mo:base/Nat"; 14 | import Int "mo:base/Int"; 15 | import Hash "mo:base/Hash"; 16 | import Text "mo:base/Text"; 17 | import Time "mo:base/Time"; 18 | import Iter "mo:base/Iter"; 19 | import TrieSet "mo:base/TrieSet"; 20 | import Array "mo:base/Array"; 21 | import Result "mo:base/Result"; 22 | import Prelude "mo:base/Prelude"; 23 | import Buffer "mo:base/Buffer"; 24 | import Types "./types"; 25 | 26 | shared(msg) actor class NFTSale( 27 | _logo: Text, 28 | _name: Text, 29 | _symbol: Text, 30 | _desc: Text, 31 | _owner: Principal, 32 | _startTime: Int, 33 | _endTime: Int, 34 | _minPerUser: Nat, 35 | _maxPerUser: Nat, 36 | _amount: Nat, 37 | _devFee: Nat, // /1e6 38 | _devAddr: Principal, 39 | _price: Nat, 40 | _paymentToken: Principal, 41 | _whitelist: ?Principal 42 | ) = this { 43 | 44 | type Metadata = Types.Metadata; 45 | type Location = Types.Location; 46 | type Attribute = Types.Attribute; 47 | type TokenMetadata = Types.TokenMetadata; 48 | type Record = Types.Record; 49 | type TxRecord = Types.TxRecord; 50 | type Operation = Types.Operation; 51 | type TokenInfo = Types.TokenInfo; 52 | type TokenInfoExt = Types.TokenInfoExt; 53 | type UserInfo = Types.UserInfo; 54 | type UserInfoExt = Types.UserInfoExt; 55 | 56 | public type Errors = { 57 | #Unauthorized; 58 | #TokenNotExist; 59 | #InvalidOperator; 60 | }; 61 | // to be compatible with Rust canister 62 | // in Rust, Result is `Ok` and `Err` 63 | public type TxReceipt = { 64 | #Ok: Nat; 65 | #Err: Errors; 66 | }; 67 | public type MintResult = { 68 | #Ok: (Nat, Nat); 69 | #Err: Errors; 70 | }; 71 | 72 | public type SaleInfo = { 73 | startTime: Int; 74 | endTime: Int; 75 | minPerUser: Nat; 76 | maxPerUser: Nat; 77 | amount: Nat; 78 | var amountLeft: Nat; 79 | var fundRaised: Nat; 80 | devFee: Nat; // /1e6 81 | devAddr: Principal; 82 | price: Nat; 83 | paymentToken: Principal; 84 | whitelist: ?Principal; 85 | var fundClaimed: Bool; 86 | var feeClaimed: Bool; 87 | }; 88 | 89 | public type SaleInfoExt = { 90 | startTime: Int; 91 | endTime: Int; 92 | minPerUser: Nat; 93 | maxPerUser: Nat; 94 | amount: Nat; 95 | amountLeft: Nat; 96 | fundRaised: Nat; 97 | devFee: Nat; // /1e6 98 | devAddr: Principal; 99 | price: Nat; 100 | paymentToken: Principal; 101 | whitelist: ?Principal; 102 | fundClaimed: Bool; 103 | feeClaimed: Bool; 104 | }; 105 | 106 | // DIP20 token actor 107 | type DIP20Errors = { 108 | #InsufficientBalance; 109 | #InsufficientAllowance; 110 | #LedgerTrap; 111 | #AmountTooSmall; 112 | #BlockUsed; 113 | #ErrorOperationStyle; 114 | #ErrorTo; 115 | #Other; 116 | }; 117 | type DIP20Metadata = { 118 | logo : Text; 119 | name : Text; 120 | symbol : Text; 121 | decimals : Nat8; 122 | totalSupply : Nat; 123 | owner : Principal; 124 | fee : Nat; 125 | }; 126 | public type TxReceiptToken = { 127 | #Ok: Nat; 128 | #Err: DIP20Errors; 129 | }; 130 | type TokenActor = actor { 131 | allowance: shared (owner: Principal, spender: Principal) -> async Nat; 132 | approve: shared (spender: Principal, value: Nat) -> async TxReceiptToken; 133 | balanceOf: (owner: Principal) -> async Nat; 134 | decimals: () -> async Nat8; 135 | name: () -> async Text; 136 | symbol: () -> async Text; 137 | getMetadata: () -> async DIP20Metadata; 138 | totalSupply: () -> async Nat; 139 | transfer: shared (to: Principal, value: Nat) -> async TxReceiptToken; 140 | transferFrom: shared (from: Principal, to: Principal, value: Nat) -> async TxReceiptToken; 141 | }; 142 | 143 | public type WhitelistActor = actor { 144 | check: shared(user: Principal) -> async Bool; 145 | }; 146 | 147 | private stable var saleInfo: ?SaleInfo = ?{ 148 | startTime = _startTime; 149 | endTime = _endTime; 150 | minPerUser = _minPerUser; 151 | maxPerUser = _maxPerUser; 152 | amount = _amount; 153 | var amountLeft = _amount; 154 | var fundRaised = 0; 155 | devFee = _devFee; 156 | devAddr = _devAddr; 157 | price = _price; 158 | paymentToken = _paymentToken; 159 | whitelist = _whitelist; 160 | var fundClaimed = false; 161 | var feeClaimed = false; 162 | }; 163 | 164 | private stable var logo_ : Text = _logo; // base64 encoded image 165 | private stable var name_ : Text = _name; 166 | private stable var symbol_ : Text = _symbol; 167 | private stable var desc_ : Text = _desc; 168 | private stable var owner_: Principal = _owner; 169 | private stable var totalSupply_: Nat = 0; 170 | private stable var blackhole: Principal = Principal.fromText("aaaaa-aa"); 171 | 172 | private stable var tokensEntries : [(Nat, TokenInfo)] = []; 173 | private stable var usersEntries : [(Principal, UserInfo)] = []; 174 | private var tokens = HashMap.HashMap(1, Nat.equal, Hash.hash); 175 | private var users = HashMap.HashMap(1, Principal.equal, Principal.hash); 176 | private stable var txs: [TxRecord] = []; 177 | private stable var txIndex: Nat = 0; 178 | 179 | private func addTxRecord( 180 | caller: Principal, op: Operation, tokenIndex: ?Nat, 181 | from: Record, to: Record, timestamp: Time.Time 182 | ): Nat { 183 | let record: TxRecord = { 184 | caller = caller; 185 | op = op; 186 | index = txIndex; 187 | tokenIndex = tokenIndex; 188 | from = from; 189 | to = to; 190 | timestamp = timestamp; 191 | }; 192 | txs := Array.append(txs, [record]); 193 | txIndex += 1; 194 | return txIndex - 1; 195 | }; 196 | 197 | private func _unwrap(x : ?T) : T = 198 | switch x { 199 | case null { Prelude.unreachable() }; 200 | case (?x_) { x_ }; 201 | }; 202 | 203 | private func _exists(tokenId: Nat) : Bool { 204 | switch (tokens.get(tokenId)) { 205 | case (?info) { return true; }; 206 | case _ { return false; }; 207 | } 208 | }; 209 | 210 | private func _ownerOf(tokenId: Nat) : ?Principal { 211 | switch (tokens.get(tokenId)) { 212 | case (?info) { return ?info.owner; }; 213 | case (_) { return null; }; 214 | } 215 | }; 216 | 217 | private func _isOwner(who: Principal, tokenId: Nat) : Bool { 218 | switch (tokens.get(tokenId)) { 219 | case (?info) { return info.owner == who; }; 220 | case _ { return false; }; 221 | }; 222 | }; 223 | 224 | private func _isApproved(who: Principal, tokenId: Nat) : Bool { 225 | switch (tokens.get(tokenId)) { 226 | case (?info) { return info.operator == ?who; }; 227 | case _ { return false; }; 228 | } 229 | }; 230 | 231 | private func _balanceOf(who: Principal) : Nat { 232 | switch (users.get(who)) { 233 | case (?user) { return TrieSet.size(user.tokens); }; 234 | case (_) { return 0; }; 235 | } 236 | }; 237 | 238 | private func _newUser() : UserInfo { 239 | { 240 | var operators = TrieSet.empty(); 241 | var allowedBy = TrieSet.empty(); 242 | var allowedTokens = TrieSet.empty(); 243 | var tokens = TrieSet.empty(); 244 | } 245 | }; 246 | 247 | private func _tokenInfotoExt(info: TokenInfo) : TokenInfoExt { 248 | return { 249 | index = info.index; 250 | owner = info.owner; 251 | metadata = info.metadata; 252 | timestamp = info.timestamp; 253 | operator = info.operator; 254 | }; 255 | }; 256 | 257 | private func _userInfotoExt(info: UserInfo) : UserInfoExt { 258 | return { 259 | operators = TrieSet.toArray(info.operators); 260 | allowedBy = TrieSet.toArray(info.allowedBy); 261 | allowedTokens = TrieSet.toArray(info.allowedTokens); 262 | tokens = TrieSet.toArray(info.tokens); 263 | }; 264 | }; 265 | 266 | private func _isApprovedOrOwner(spender: Principal, tokenId: Nat) : Bool { 267 | switch (_ownerOf(tokenId)) { 268 | case (?owner) { 269 | return spender == owner or _isApproved(spender, tokenId) or _isApprovedForAll(owner, spender); 270 | }; 271 | case _ { 272 | return false; 273 | }; 274 | }; 275 | }; 276 | 277 | private func _getApproved(tokenId: Nat) : ?Principal { 278 | switch (tokens.get(tokenId)) { 279 | case (?info) { 280 | return info.operator; 281 | }; 282 | case (_) { 283 | return null; 284 | }; 285 | } 286 | }; 287 | 288 | private func _isApprovedForAll(owner: Principal, operator: Principal) : Bool { 289 | switch (users.get(owner)) { 290 | case (?user) { 291 | return TrieSet.mem(user.operators, operator, Principal.hash(operator), Principal.equal); 292 | }; 293 | case _ { return false; }; 294 | }; 295 | }; 296 | 297 | private func _addTokenTo(to: Principal, tokenId: Nat) { 298 | switch(users.get(to)) { 299 | case (?user) { 300 | user.tokens := TrieSet.put(user.tokens, tokenId, Hash.hash(tokenId), Nat.equal); 301 | users.put(to, user); 302 | }; 303 | case _ { 304 | let user = _newUser(); 305 | user.tokens := TrieSet.put(user.tokens, tokenId, Hash.hash(tokenId), Nat.equal); 306 | users.put(to, user); 307 | }; 308 | } 309 | }; 310 | 311 | private func _removeTokenFrom(owner: Principal, tokenId: Nat) { 312 | assert(_exists(tokenId) and _isOwner(owner, tokenId)); 313 | switch(users.get(owner)) { 314 | case (?user) { 315 | user.tokens := TrieSet.delete(user.tokens, tokenId, Hash.hash(tokenId), Nat.equal); 316 | users.put(owner, user); 317 | }; 318 | case _ { 319 | assert(false); 320 | }; 321 | } 322 | }; 323 | 324 | private func _clearApproval(owner: Principal, tokenId: Nat) { 325 | assert(_exists(tokenId) and _isOwner(owner, tokenId)); 326 | switch (tokens.get(tokenId)) { 327 | case (?info) { 328 | if (info.operator != null) { 329 | let op = _unwrap(info.operator); 330 | let opInfo = _unwrap(users.get(op)); 331 | opInfo.allowedTokens := TrieSet.delete(opInfo.allowedTokens, tokenId, Hash.hash(tokenId), Nat.equal); 332 | users.put(op, opInfo); 333 | info.operator := null; 334 | tokens.put(tokenId, info); 335 | } 336 | }; 337 | case _ { 338 | assert(false); 339 | }; 340 | } 341 | }; 342 | 343 | private func _transfer(to: Principal, tokenId: Nat) { 344 | assert(_exists(tokenId)); 345 | switch(tokens.get(tokenId)) { 346 | case (?info) { 347 | _removeTokenFrom(info.owner, tokenId); 348 | _addTokenTo(to, tokenId); 349 | info.owner := to; 350 | tokens.put(tokenId, info); 351 | }; 352 | case (_) { 353 | assert(false); 354 | }; 355 | }; 356 | }; 357 | 358 | private func _burn(owner: Principal, tokenId: Nat) { 359 | _clearApproval(owner, tokenId); 360 | _transfer(blackhole, tokenId); 361 | }; 362 | 363 | private func _batchMint(to: Principal, amount: Nat): async Bool { 364 | var startIndex = totalSupply_; 365 | var endIndex = startIndex + amount; 366 | while(startIndex < endIndex) { 367 | let token: TokenInfo = { 368 | index = totalSupply_; 369 | var owner = to; 370 | var metadata = null; 371 | var operator = null; 372 | timestamp = Time.now(); 373 | }; 374 | tokens.put(totalSupply_, token); 375 | _addTokenTo(to, totalSupply_); 376 | totalSupply_ += 1; 377 | startIndex += 1; 378 | ignore addTxRecord(msg.caller, #mint(null), ?token.index, #user(blackhole), #user(to), Time.now()); 379 | }; 380 | return true; 381 | }; 382 | 383 | public shared(msg) func setSaleInfo(info: ?SaleInfoExt): async ?SaleInfoExt { 384 | assert(msg.caller == owner_); 385 | switch(info) { 386 | case(?i) { 387 | saleInfo := ?{ 388 | startTime = i.startTime; 389 | endTime = i.endTime; 390 | minPerUser = i.minPerUser; 391 | maxPerUser = i.maxPerUser; 392 | amount = i.amount; 393 | var amountLeft = i.amountLeft; 394 | var fundRaised = i.fundRaised; 395 | devFee = i.devFee; 396 | devAddr = i.devAddr; 397 | price = i.price; 398 | paymentToken = i.paymentToken; 399 | whitelist = i.whitelist; 400 | var fundClaimed = false; 401 | var feeClaimed = false; 402 | }; 403 | return info; 404 | }; 405 | case(_) { 406 | saleInfo := null; 407 | return null; 408 | }; 409 | }; 410 | }; 411 | 412 | public query func getSaleInfo(): async ?SaleInfoExt { 413 | switch(saleInfo) { 414 | case(?i) { 415 | ?{ 416 | startTime = i.startTime; 417 | endTime = i.endTime; 418 | minPerUser = i.minPerUser; 419 | maxPerUser = i.maxPerUser; 420 | amount = i.amount; 421 | amountLeft = i.amountLeft; 422 | fundRaised = i.fundRaised; 423 | devFee = i.devFee; 424 | devAddr = i.devAddr; 425 | price = i.price; 426 | paymentToken = i.paymentToken; 427 | whitelist = i.whitelist; 428 | fundClaimed = i.fundClaimed; 429 | feeClaimed = i.feeClaimed; 430 | } 431 | }; 432 | case(_) { 433 | null 434 | }; 435 | } 436 | }; 437 | 438 | public shared(msg) func buy(amount: Nat): async Result.Result { 439 | let info = switch(saleInfo) { 440 | case(?i) { i }; 441 | case(_) { return #err("not in sale"); }; 442 | }; 443 | if(Time.now() < info.startTime or Time.now() > info.endTime) return #err("sale not started or already ended"); 444 | let userBalance = _balanceOf(msg.caller); 445 | if(amount < info.minPerUser or userBalance + amount > info.maxPerUser) return #err("amount error"); 446 | if(amount > info.amountLeft) return #err("not enough tokens left for sale"); 447 | switch(info.whitelist){ 448 | case(?whitelist){ 449 | let whitelistActor: WhitelistActor = actor(Principal.toText(whitelist)); 450 | switch(await whitelistActor.check(msg.caller)){ 451 | case(false) { 452 | return #err("you are not in the whitelist"); 453 | }; 454 | case(true) { }; 455 | }; 456 | }; 457 | case(_) {}; 458 | }; 459 | let tokenActor: TokenActor = actor(Principal.toText(info.paymentToken)); 460 | switch(await tokenActor.transferFrom(msg.caller, Principal.fromActor(this), amount * info.price)) { 461 | case(#Ok(id)) { 462 | ignore _batchMint(msg.caller, amount); 463 | info.amountLeft -= amount; 464 | info.fundRaised += amount * info.price; 465 | saleInfo := ?info; 466 | return #ok(amount); 467 | }; 468 | case(#Err(e)) { 469 | return #err("payment failed"); 470 | }; 471 | }; 472 | }; 473 | 474 | public shared(msg) func setOwner(new: Principal): async Principal { 475 | assert(msg.caller == owner_); 476 | owner_ := new; 477 | new 478 | }; 479 | 480 | public shared(msg) func claimFunds(): async Result.Result<(Bool, Bool), Text> { 481 | let info = switch(saleInfo) { 482 | case(?i) { i }; 483 | case(_) { return #err("no sale"); }; 484 | }; 485 | assert(msg.caller == owner_ or msg.caller == info.devAddr); 486 | 487 | let fee = info.fundRaised * info.devFee / 1_000_000; 488 | 489 | let tokenActor: TokenActor = actor(Principal.toText(info.paymentToken)); 490 | let metadata = await tokenActor.getMetadata(); 491 | if(not info.fundClaimed) { 492 | info.fundClaimed := true; 493 | saleInfo := ?info; 494 | switch(await tokenActor.transfer(owner_, info.fundRaised - fee - metadata.fee)) { 495 | case(#Ok(id)) {}; 496 | case(#Err(e)) { 497 | info.fundClaimed := false; 498 | saleInfo := ?info; 499 | }; 500 | }; 501 | }; 502 | if(not info.feeClaimed) { 503 | info.feeClaimed := true; 504 | saleInfo := ?info; 505 | switch(await tokenActor.transfer(info.devAddr, fee - metadata.fee)) { 506 | case(#Ok(id)) {}; 507 | case(#Err(e)) { 508 | info.feeClaimed := false; 509 | saleInfo := ?info; 510 | }; 511 | }; 512 | }; 513 | #ok((info.fundClaimed, info.feeClaimed)) 514 | }; 515 | 516 | // public update calls 517 | public shared(msg) func mint(to: Principal, metadata: ?TokenMetadata): async MintResult { 518 | if(msg.caller != owner_) { 519 | return #Err(#Unauthorized); 520 | }; 521 | let token: TokenInfo = { 522 | index = totalSupply_; 523 | var owner = to; 524 | var metadata = metadata; 525 | var operator = null; 526 | timestamp = Time.now(); 527 | }; 528 | tokens.put(totalSupply_, token); 529 | _addTokenTo(to, totalSupply_); 530 | totalSupply_ += 1; 531 | let txid = addTxRecord(msg.caller, #mint(metadata), ?token.index, #user(blackhole), #user(to), Time.now()); 532 | return #Ok((token.index, txid)); 533 | }; 534 | 535 | public shared(msg) func batchMint(to: Principal, arr: [?TokenMetadata]): async MintResult { 536 | if(msg.caller != owner_) { 537 | return #Err(#Unauthorized); 538 | }; 539 | let startIndex = totalSupply_; 540 | for(metadata in Iter.fromArray(arr)) { 541 | let token: TokenInfo = { 542 | index = totalSupply_; 543 | var owner = to; 544 | var metadata = metadata; 545 | var operator = null; 546 | timestamp = Time.now(); 547 | }; 548 | tokens.put(totalSupply_, token); 549 | _addTokenTo(to, totalSupply_); 550 | totalSupply_ += 1; 551 | ignore addTxRecord(msg.caller, #mint(metadata), ?token.index, #user(blackhole), #user(to), Time.now()); 552 | }; 553 | return #Ok((startIndex, txs.size() - arr.size())); 554 | }; 555 | 556 | public shared(msg) func burn(tokenId: Nat): async TxReceipt { 557 | if(_exists(tokenId) == false) { 558 | return #Err(#TokenNotExist) 559 | }; 560 | if(_isOwner(msg.caller, tokenId) == false) { 561 | return #Err(#Unauthorized); 562 | }; 563 | _burn(msg.caller, tokenId); //not delete tokenId from tokens temporarily. (consider storage limited, it should be delete.) 564 | let txid = addTxRecord(msg.caller, #burn, ?tokenId, #user(msg.caller), #user(blackhole), Time.now()); 565 | return #Ok(txid); 566 | }; 567 | 568 | public shared(msg) func setTokenMetadata(tokenId: Nat, new_metadata: TokenMetadata) : async TxReceipt { 569 | // only canister owner can set 570 | if(msg.caller != owner_) { 571 | return #Err(#Unauthorized); 572 | }; 573 | if(_exists(tokenId) == false) { 574 | return #Err(#TokenNotExist) 575 | }; 576 | let token = _unwrap(tokens.get(tokenId)); 577 | let old_metadate = token.metadata; 578 | token.metadata := ?new_metadata; 579 | tokens.put(tokenId, token); 580 | let txid = addTxRecord(msg.caller, #setMetadata, ?token.index, #metadata(old_metadate), #metadata(?new_metadata), Time.now()); 581 | return #Ok(txid); 582 | }; 583 | 584 | public shared(msg) func batchSetTokenMetadata(arr: [(Nat, TokenMetadata)]) : async TxReceipt { 585 | // only canister owner can set 586 | if(msg.caller != owner_) { 587 | return #Err(#Unauthorized); 588 | }; 589 | var txid = 0; 590 | for((tokenId, metadata) in Iter.fromArray(arr)) { 591 | if(_exists(tokenId) == false) { 592 | return #Err(#TokenNotExist) 593 | }; 594 | let token = _unwrap(tokens.get(tokenId)); 595 | let old_metadate = token.metadata; 596 | token.metadata := ?metadata; 597 | tokens.put(tokenId, token); 598 | txid := addTxRecord(msg.caller, #setMetadata, ?token.index, #metadata(old_metadate), #metadata(?metadata), Time.now()); 599 | }; 600 | return #Ok(txid); 601 | }; 602 | 603 | public shared(msg) func approve(tokenId: Nat, operator: Principal) : async TxReceipt { 604 | var owner: Principal = switch (_ownerOf(tokenId)) { 605 | case (?own) { 606 | own; 607 | }; 608 | case (_) { 609 | return #Err(#TokenNotExist) 610 | } 611 | }; 612 | if(Principal.equal(msg.caller, owner) == false) 613 | if(_isApprovedForAll(owner, msg.caller) == false) 614 | return #Err(#Unauthorized); 615 | if(owner == operator) { 616 | return #Err(#InvalidOperator); 617 | }; 618 | switch (tokens.get(tokenId)) { 619 | case (?info) { 620 | info.operator := ?operator; 621 | tokens.put(tokenId, info); 622 | }; 623 | case _ { 624 | return #Err(#TokenNotExist); 625 | }; 626 | }; 627 | switch (users.get(operator)) { 628 | case (?user) { 629 | user.allowedTokens := TrieSet.put(user.allowedTokens, tokenId, Hash.hash(tokenId), Nat.equal); 630 | users.put(operator, user); 631 | }; 632 | case _ { 633 | let user = _newUser(); 634 | user.allowedTokens := TrieSet.put(user.allowedTokens, tokenId, Hash.hash(tokenId), Nat.equal); 635 | users.put(operator, user); 636 | }; 637 | }; 638 | let txid = addTxRecord(msg.caller, #approve, ?tokenId, #user(msg.caller), #user(operator), Time.now()); 639 | return #Ok(txid); 640 | }; 641 | 642 | public shared(msg) func setApprovalForAll(operator: Principal, value: Bool): async TxReceipt { 643 | if(msg.caller == operator) { 644 | return #Err(#Unauthorized); 645 | }; 646 | var txid = 0; 647 | if value { 648 | let caller = switch (users.get(msg.caller)) { 649 | case (?user) { user }; 650 | case _ { _newUser() }; 651 | }; 652 | caller.operators := TrieSet.put(caller.operators, operator, Principal.hash(operator), Principal.equal); 653 | users.put(msg.caller, caller); 654 | let user = switch (users.get(operator)) { 655 | case (?user) { user }; 656 | case _ { _newUser() }; 657 | }; 658 | user.allowedBy := TrieSet.put(user.allowedBy, msg.caller, Principal.hash(msg.caller), Principal.equal); 659 | users.put(operator, user); 660 | txid := addTxRecord(msg.caller, #approveAll, null, #user(msg.caller), #user(operator), Time.now()); 661 | } else { 662 | switch (users.get(msg.caller)) { 663 | case (?user) { 664 | user.operators := TrieSet.delete(user.operators, operator, Principal.hash(operator), Principal.equal); 665 | users.put(msg.caller, user); 666 | }; 667 | case _ { }; 668 | }; 669 | switch (users.get(operator)) { 670 | case (?user) { 671 | user.allowedBy := TrieSet.delete(user.allowedBy, msg.caller, Principal.hash(msg.caller), Principal.equal); 672 | users.put(operator, user); 673 | }; 674 | case _ { }; 675 | }; 676 | txid := addTxRecord(msg.caller, #revokeAll, null, #user(msg.caller), #user(operator), Time.now()); 677 | }; 678 | return #Ok(txid); 679 | }; 680 | 681 | public shared(msg) func transfer(to: Principal, tokenId: Nat): async TxReceipt { 682 | var owner: Principal = switch (_ownerOf(tokenId)) { 683 | case (?own) { 684 | own; 685 | }; 686 | case (_) { 687 | return #Err(#TokenNotExist) 688 | } 689 | }; 690 | if (owner != msg.caller) { 691 | return #Err(#Unauthorized); 692 | }; 693 | _clearApproval(msg.caller, tokenId); 694 | _transfer(to, tokenId); 695 | let txid = addTxRecord(msg.caller, #transfer, ?tokenId, #user(msg.caller), #user(to), Time.now()); 696 | return #Ok(txid); 697 | }; 698 | 699 | public shared(msg) func transferFrom(from: Principal, to: Principal, tokenId: Nat): async TxReceipt { 700 | if(_exists(tokenId) == false) { 701 | return #Err(#TokenNotExist) 702 | }; 703 | if(_isApprovedOrOwner(msg.caller, tokenId) == false) { 704 | return #Err(#Unauthorized); 705 | }; 706 | _clearApproval(from, tokenId); 707 | _transfer(to, tokenId); 708 | let txid = addTxRecord(msg.caller, #transferFrom, ?tokenId, #user(from), #user(to), Time.now()); 709 | return #Ok(txid); 710 | }; 711 | 712 | public shared(msg) func batchTransferFrom(from: Principal, to: Principal, tokenIds: [Nat]): async TxReceipt { 713 | var num: Nat = 0; 714 | label l for(tokenId in Iter.fromArray(tokenIds)) { 715 | if(_exists(tokenId) == false) { 716 | continue l; 717 | }; 718 | if(_isApprovedOrOwner(msg.caller, tokenId) == false) { 719 | continue l; 720 | }; 721 | _clearApproval(from, tokenId); 722 | _transfer(to, tokenId); 723 | num += 1; 724 | ignore addTxRecord(msg.caller, #transferFrom, ?tokenId, #user(from), #user(to), Time.now()); 725 | }; 726 | return #Ok(txs.size() - num); 727 | }; 728 | 729 | // public query function 730 | public query func logo(): async Text { 731 | return logo_; 732 | }; 733 | 734 | public query func name(): async Text { 735 | return name_; 736 | }; 737 | 738 | public query func symbol(): async Text { 739 | return symbol_; 740 | }; 741 | 742 | public query func desc(): async Text { 743 | return desc_; 744 | }; 745 | 746 | public query func balanceOf(who: Principal): async Nat { 747 | return _balanceOf(who); 748 | }; 749 | 750 | public query func totalSupply(): async Nat { 751 | return totalSupply_; 752 | }; 753 | 754 | // get metadata about this NFT collection 755 | public query func getMetadata(): async Metadata { 756 | { 757 | logo = logo_; 758 | name = name_; 759 | symbol = symbol_; 760 | desc = desc_; 761 | totalSupply = totalSupply_; 762 | owner = owner_; 763 | cycles = Cycles.balance(); 764 | } 765 | }; 766 | 767 | public query func isApprovedForAll(owner: Principal, operator: Principal) : async Bool { 768 | return _isApprovedForAll(owner, operator); 769 | }; 770 | 771 | public query func getOperator(tokenId: Nat) : async Principal { 772 | switch (_exists(tokenId)) { 773 | case true { 774 | switch (_getApproved(tokenId)) { 775 | case (?who) { 776 | return who; 777 | }; 778 | case (_) { 779 | return Principal.fromText("aaaaa-aa"); 780 | }; 781 | } 782 | }; 783 | case (_) { 784 | throw Error.reject("token not exist") 785 | }; 786 | } 787 | }; 788 | 789 | public query func getUserInfo(who: Principal) : async UserInfoExt { 790 | switch (users.get(who)) { 791 | case (?user) { 792 | return _userInfotoExt(user) 793 | }; 794 | case _ { 795 | throw Error.reject("unauthorized"); 796 | }; 797 | }; 798 | }; 799 | 800 | public query func getUserTokens(owner: Principal) : async [TokenInfoExt] { 801 | let tokenIds = switch (users.get(owner)) { 802 | case (?user) { 803 | TrieSet.toArray(user.tokens) 804 | }; 805 | case _ { 806 | [] 807 | }; 808 | }; 809 | let ret = Buffer.Buffer(tokenIds.size()); 810 | for(id in Iter.fromArray(tokenIds)) { 811 | ret.add(_tokenInfotoExt(_unwrap(tokens.get(id)))); 812 | }; 813 | return ret.toArray(); 814 | }; 815 | 816 | public query func ownerOf(tokenId: Nat): async Principal { 817 | switch (_ownerOf(tokenId)) { 818 | case (?owner) { 819 | return owner; 820 | }; 821 | case _ { 822 | throw Error.reject("token not exist") 823 | }; 824 | } 825 | }; 826 | 827 | public query func getTokenInfo(tokenId: Nat) : async TokenInfoExt { 828 | switch(tokens.get(tokenId)){ 829 | case(?tokeninfo) { 830 | return _tokenInfotoExt(tokeninfo); 831 | }; 832 | case(_) { 833 | throw Error.reject("token not exist"); 834 | }; 835 | }; 836 | }; 837 | 838 | // Optional 839 | public query func getAllTokens() : async [TokenInfoExt] { 840 | Iter.toArray(Iter.map(tokens.entries(), func (i: (Nat, TokenInfo)): TokenInfoExt {_tokenInfotoExt(i.1)})) 841 | }; 842 | 843 | // transaction history related 844 | public query func historySize(): async Nat { 845 | return txs.size(); 846 | }; 847 | 848 | public query func getTransaction(index: Nat): async TxRecord { 849 | return txs[index]; 850 | }; 851 | 852 | public query func getTransactions(start: Nat, limit: Nat): async [TxRecord] { 853 | let res = Buffer.Buffer(limit); 854 | var i = start; 855 | while (i < start + limit and i < txs.size()) { 856 | res.add(txs[i]); 857 | i += 1; 858 | }; 859 | return res.toArray(); 860 | }; 861 | 862 | public query func getUserTransactionAmount(user: Principal): async Nat { 863 | var res: Nat = 0; 864 | for (i in txs.vals()) { 865 | if (i.caller == user or i.from == #user(user) or i.to == #user(user)) { 866 | res += 1; 867 | }; 868 | }; 869 | return res; 870 | }; 871 | 872 | public query func getUserTransactions(user: Principal, start: Nat, limit: Nat): async [TxRecord] { 873 | let res = Buffer.Buffer(limit); 874 | var idx = 0; 875 | label l for (i in txs.vals()) { 876 | if (i.caller == user or i.from == #user(user) or i.to == #user(user)) { 877 | if(idx < start) { 878 | idx += 1; 879 | continue l; 880 | }; 881 | if(idx >= start + limit) { 882 | break l; 883 | }; 884 | res.add(i); 885 | idx += 1; 886 | }; 887 | }; 888 | return res.toArray(); 889 | }; 890 | 891 | // upgrade functions 892 | system func preupgrade() { 893 | usersEntries := Iter.toArray(users.entries()); 894 | tokensEntries := Iter.toArray(tokens.entries()); 895 | }; 896 | 897 | system func postupgrade() { 898 | type TokenInfo = Types.TokenInfo; 899 | type UserInfo = Types.UserInfo; 900 | 901 | users := HashMap.fromIter(usersEntries.vals(), 1, Principal.equal, Principal.hash); 902 | tokens := HashMap.fromIter(tokensEntries.vals(), 1, Nat.equal, Hash.hash); 903 | usersEntries := []; 904 | tokensEntries := []; 905 | }; 906 | }; 907 | 908 | -------------------------------------------------------------------------------- /motoko/src/types.mo: -------------------------------------------------------------------------------- 1 | /** 2 | * Module : types.mo 3 | * Copyright : 2021 Rocklabs Team 4 | * License : Apache 2.0 with LLVM Exception 5 | * Maintainer : Rocklabs Team 6 | * Stability : Experimental 7 | */ 8 | 9 | import Time "mo:base/Time"; 10 | import TrieSet "mo:base/TrieSet"; 11 | 12 | module { 13 | public type Metadata = { 14 | logo: Text; 15 | name: Text; 16 | symbol: Text; 17 | desc: Text; 18 | totalSupply: Nat; 19 | owner: Principal; 20 | cycles: Nat; 21 | }; 22 | 23 | public type Location = { 24 | #InCanister: Blob; // NFT encoded data 25 | #AssetCanister: (Principal, Blob); // asset canister id, storage key 26 | #IPFS: Text; // IPFS content hash 27 | #Web: Text; // URL pointing to the file 28 | }; 29 | public type Attribute = { 30 | key: Text; 31 | value: Text; 32 | }; 33 | public type TokenMetadata = { 34 | filetype: Text; // jpg, png, mp4, etc. 35 | location: Location; 36 | attributes: [Attribute]; 37 | }; 38 | 39 | public type TokenInfo = { 40 | index: Nat; 41 | var owner: Principal; 42 | var metadata: ?TokenMetadata; 43 | var operator: ?Principal; 44 | timestamp: Time.Time; 45 | }; 46 | 47 | public type TokenInfoExt = { 48 | index: Nat; 49 | owner: Principal; 50 | metadata: ?TokenMetadata; 51 | operator: ?Principal; 52 | timestamp: Time.Time; 53 | }; 54 | 55 | public type UserInfo = { 56 | var operators: TrieSet.Set; // principals allowed to operate on the user's behalf 57 | var allowedBy: TrieSet.Set; // principals approved user to operate their's tokens 58 | var allowedTokens: TrieSet.Set; // tokens the user can operate 59 | var tokens: TrieSet.Set; // user's tokens 60 | }; 61 | 62 | public type UserInfoExt = { 63 | operators: [Principal]; 64 | allowedBy: [Principal]; 65 | allowedTokens: [Nat]; 66 | tokens: [Nat]; 67 | }; 68 | /// Update call operations 69 | public type Operation = { 70 | #mint: ?TokenMetadata; 71 | #burn; 72 | #transfer; 73 | #transferFrom; 74 | #approve; 75 | #approveAll; 76 | #revokeAll; // revoke approvals 77 | #setMetadata; 78 | }; 79 | /// Update call operation record fields 80 | public type Record = { 81 | #user: Principal; 82 | #metadata: ?TokenMetadata; // op == #setMetadata 83 | }; 84 | public type TxRecord = { 85 | caller: Principal; 86 | op: Operation; 87 | index: Nat; 88 | tokenIndex: ?Nat; 89 | from: Record; 90 | to: Record; 91 | timestamp: Time.Time; 92 | }; 93 | }; 94 | -------------------------------------------------------------------------------- /motoko/start.sh: -------------------------------------------------------------------------------- 1 | dfx stop 2 | rm -rf .dfx 3 | 4 | ALICE_HOME=$(mktemp -d -t alice-temp) 5 | 6 | ALICE_PUBLIC_KEY="principal \"$( \ 7 | HOME=$ALICE_HOME dfx identity get-principal 8 | )\"" 9 | 10 | dfx start --background 11 | dfx canister --no-wallet create token_ERC721 12 | dfx build 13 | dfx canister --no-wallet install token_ERC721 --argument="(\"Test NFT 1\", \"NFT1\",$ALICE_PUBLIC_KEY,\"First NFT on this market\",false,false)" -m=reinstall -------------------------------------------------------------------------------- /motoko/test/dfx.json: -------------------------------------------------------------------------------- 1 | { 2 | "canisters": { 3 | "token_ERC721": { 4 | "main": "../src/main.mo", 5 | "type": "motoko" 6 | }, 7 | "testflow": { 8 | "main": "./test.mo", 9 | "type": "motoko" 10 | } 11 | }, 12 | "defaults": { 13 | "build": { 14 | "packtool": "" 15 | } 16 | }, 17 | "networks": { 18 | "local": { 19 | "bind": "127.0.0.1:8000", 20 | "type": "ephemeral" 21 | } 22 | }, 23 | "version": 1 24 | } -------------------------------------------------------------------------------- /motoko/test/test.mo: -------------------------------------------------------------------------------- 1 | import Debug "mo:base/Debug"; 2 | import Int "mo:base/Int"; 3 | import Iter "mo:base/Iter"; 4 | import Nat "mo:base/Nat"; 5 | import Prelude "mo:base/Prelude"; 6 | import Principal "mo:base/Principal"; 7 | import Result "mo:base/Result"; 8 | import Text "mo:base/Text"; 9 | import Time "mo:base/Time"; 10 | 11 | import Types "../src//types"; 12 | 13 | actor class Testflow(token_ERC721_id : Principal, alice: Principal, bob: Principal) = this { 14 | 15 | type Metadata = Types.Metadata; 16 | type Location = Types.Location; 17 | type Attribute = Types.Attribute; 18 | type TokenMetadata = Types.TokenMetadata; 19 | type Record = Types.Record; 20 | type TxRecord = Types.TxRecord; 21 | type Operation = Types.Operation; 22 | type TokenInfo = Types.TokenInfo; 23 | type TokenInfoExt = Types.TokenInfoExt; 24 | type UserInfo = Types.UserInfo; 25 | type UserInfoExt = Types.UserInfoExt; 26 | 27 | public type Error = { 28 | #Unauthorized; 29 | #TokenNotExist; 30 | #InvalidOperator; 31 | }; 32 | public type TxReceipt = Result.Result; 33 | public type MintResult = Result.Result<(Nat, Nat), Error>; // token index, txid 34 | public type Result = {#ok : Ok; #err : Err}; 35 | 36 | public type NftActor = actor { 37 | mint: shared (to: Principal, metadata: TokenMetadata) -> async MintResult; 38 | burn: shared (tokenId: Nat) -> async MintResult; 39 | setTokenMetadata: shared (tokenId: Nat, new_metadata: TokenMetadata) -> async TxReceipt; 40 | approve: shared (tokenId: Nat, operator: Principal) -> async TxReceipt; 41 | setApprovalForAll: shared (operator: Principal, value: Bool) -> async TxReceipt; 42 | transfer: shared (to: Principal, tokenId: Nat) -> async TxReceipt; 43 | transferFrom: shared (from: Principal, to: Principal, tokenId: Nat) -> async TxReceipt; 44 | 45 | // query functions 46 | logo: query () -> async Text; 47 | name: query () -> async Text; 48 | symbol: query () -> async Text; 49 | desc: query () -> async Text; 50 | 51 | balanceOf: query (who: Principal) -> async Nat; 52 | totalSupply: query () -> async Nat; 53 | getMetadata: query () -> async Metadata; 54 | isApprovedForAll: query (owner: Principal, operator: Principal) -> async Bool; 55 | getOperator: query (tokenId: Nat) -> async Result; 56 | getUserInfo: query (who: Principal) -> async Result; 57 | getUserTokens:query (owner: Principal) -> async [TokenInfoExt]; 58 | ownerOf: query (tokenId: Nat) -> async Result; 59 | getTokenInfo: query (tokenId: Nat) -> async Result; 60 | getAllTokens: query () -> async [TokenInfoExt]; 61 | getTransaction: query (index: Nat) -> async TxRecord; 62 | getTransactions: query (start: Nat, limit: Nat) -> async [TxRecord]; 63 | getUserTransactionAmount: query (user: Principal) -> async Nat; 64 | getUserTransactions: query (user: Principal, start: Nat, limit: Nat) -> async [TxRecord]; 65 | historySize: query () -> async Nat; 66 | }; 67 | 68 | 69 | let nftCanister : NftActor = actor(Principal.toText(token_ERC721_id)); 70 | let user_alice: Principal = alice; 71 | let user_bob: Principal = bob; 72 | let blackhole: Principal = Principal.fromText("aaaaa-aa"); 73 | // test result status 74 | private var result : Bool = false; 75 | private var total_count: Nat = 0; 76 | private var pass_count : Nat = 0; 77 | private var fail_count : Nat = 0; 78 | private var skip_count : Nat = 0; 79 | 80 | 81 | func log_info (message: Text) { 82 | Debug.print(message); 83 | }; 84 | 85 | public func testMint(to: Principal, metadata: TokenMetadata): async Bool { 86 | switch(await nftCanister.mint(to, metadata)){ 87 | case(#ok(tokenId, txid)){ 88 | log_info("[ok]: mint token successed?"); 89 | log_info("token index: " # Nat.toText(tokenId) # " txid: " # Nat.toText(txid)); 90 | return true; 91 | }; 92 | case(#err(#Unauthorized)){ 93 | log_info("[error]: " # "unauthotized"); 94 | return false; 95 | }; 96 | case(#err(#TokenNotExist)){ 97 | log_info("[error]: " # "token not exist"); 98 | return false; 99 | }; 100 | case(#err(#InvalidOperator)){ 101 | log_info("[error]: " # "invalid operator"); 102 | return false; 103 | }; 104 | }; 105 | }; 106 | 107 | public func testBurn(tokenId: Nat): async Bool { 108 | switch(await nftCanister.burn(tokenId)){ 109 | case(#ok(tokenId, txid)){ 110 | log_info("[ok]: burn token successed?"); 111 | log_info("token index: " # Nat.toText(tokenId) # " txid: " # Nat.toText(txid)); 112 | return true; 113 | }; 114 | case(#err(#Unauthorized)){ 115 | log_info("[error]: " # "unauthotized"); 116 | return false; 117 | }; 118 | case(#err(#TokenNotExist)){ 119 | log_info("[error]: " # "token not exist"); 120 | return false; 121 | }; 122 | case(#err(#InvalidOperator)){ 123 | log_info("[error]: " # "invalid operator"); 124 | return false; 125 | }; 126 | }; 127 | }; 128 | 129 | public func testSetTokenMetadata(tokenId: Nat, new_metadata: TokenMetadata): async Bool { 130 | switch(await nftCanister.setTokenMetadata(tokenId, new_metadata)){ 131 | case(#ok(txid)){ 132 | log_info("[ok]: setTokenMetadata successed?"); 133 | log_info("txid: " # Nat.toText(txid)); 134 | return true; 135 | }; 136 | case(#err(#Unauthorized)){ 137 | log_info("[error]: " # "unauthotized"); 138 | return false; 139 | }; 140 | case(#err(#TokenNotExist)){ 141 | log_info("[error]: " # "token not exist"); 142 | return false; 143 | }; 144 | case(#err(#InvalidOperator)){ 145 | log_info("[error]: " # "invalid operator"); 146 | return false; 147 | }; 148 | }; 149 | }; 150 | 151 | public func testApprove(tokenId: Nat, operator: Principal): async Bool { 152 | switch(await nftCanister.approve(tokenId, operator)){ 153 | case(#ok(txid)){ 154 | log_info("[ok]: approve successed?"); 155 | log_info("txid: " # Nat.toText(txid)); 156 | return true; 157 | }; 158 | case(#err(#Unauthorized)){ 159 | log_info("[error]: " # "unauthotized"); 160 | return false; 161 | }; 162 | case(#err(#TokenNotExist)){ 163 | log_info("[error]: " # "token not exist"); 164 | return false; 165 | }; 166 | case(#err(#InvalidOperator)){ 167 | log_info("[error]: " # "invalid operator"); 168 | return false; 169 | }; 170 | }; 171 | }; 172 | 173 | public func testSetApprovalForAll(operator: Principal, value: Bool): async Bool { 174 | switch(await nftCanister.setApprovalForAll(operator, value)){ 175 | case(#ok(txid)){ 176 | log_info("[ok]: setApprovalForAll successed?"); 177 | log_info("txid: " # Nat.toText(txid)); 178 | return true; 179 | }; 180 | case(#err(#Unauthorized)){ 181 | log_info("[error]: " # "unauthotized"); 182 | return false; 183 | }; 184 | case(#err(#TokenNotExist)){ 185 | log_info("[error]: " # "token not exist"); 186 | return false; 187 | }; 188 | case(#err(#InvalidOperator)){ 189 | log_info("[error]: " # "invalid operator"); 190 | return false; 191 | }; 192 | }; 193 | }; 194 | 195 | public func testTransfer(to: Principal, tokenId: Nat): async Bool { 196 | switch(await nftCanister.transfer(to, tokenId)){ 197 | case(#ok(txid)){ 198 | log_info("[ok]: transfer successed?"); 199 | log_info("txid: " # Nat.toText(txid)); 200 | return true; 201 | }; 202 | case(#err(#Unauthorized)){ 203 | log_info("[error]: " # "unauthotized"); 204 | return false; 205 | }; 206 | case(#err(#TokenNotExist)){ 207 | log_info("[error]: " # "token not exist"); 208 | return false; 209 | }; 210 | case(#err(#InvalidOperator)){ 211 | log_info("[error]: " # "invalid operator"); 212 | return false; 213 | }; 214 | }; 215 | }; 216 | 217 | public func testTransferFrom(from: Principal, to: Principal, tokenId: Nat): async Bool { 218 | switch(await nftCanister.transferFrom(from, to, tokenId)){ 219 | case(#ok(txid)){ 220 | log_info("[ok]: transferFrom successed?"); 221 | log_info("txid: " # Nat.toText(txid)); 222 | return true; 223 | }; 224 | case(#err(#Unauthorized)){ 225 | log_info("[error]: " # "unauthotized"); 226 | return false; 227 | }; 228 | case(#err(#TokenNotExist)){ 229 | log_info("[error]: " # "token not exist"); 230 | return false; 231 | }; 232 | case(#err(#InvalidOperator)){ 233 | log_info("[error]: " # "invalid operator"); 234 | return false; 235 | }; 236 | }; 237 | }; 238 | 239 | public func logMetadataInfos() : async Metadata { 240 | let metadata: Metadata = await nftCanister.getMetadata(); 241 | log_info("MetaData Info: "); 242 | log_info("logo: "# metadata.logo); 243 | log_info("name: "# metadata.name); 244 | log_info("desc: "# metadata.desc); 245 | log_info("totalSupply: "# Nat.toText(metadata.totalSupply)); 246 | log_info("owner: "# Principal.toText(metadata.owner)); 247 | metadata 248 | }; 249 | 250 | public func logTokenInfos(tokenId:Nat) : async Bool{ 251 | let tokenInfo: TokenInfoExt = switch(await nftCanister.getTokenInfo(tokenId)) { 252 | case (#ok(tokenInfo)) { 253 | tokenInfo; 254 | }; 255 | case (#err(code)) { 256 | Prelude.unreachable(); 257 | }; 258 | }; 259 | log_info("\n"); 260 | log_info("Token " # Nat.toText(tokenId) # " Info:"); 261 | log_info("* index: "# Nat.toText(tokenInfo.index)); 262 | log_info("* token owner:" # Principal.toText(tokenInfo.owner)); 263 | switch(tokenInfo.operator){ 264 | case (?p) log_info("* operator: " # Principal.toText(p)); 265 | case null log_info("* no operator"); 266 | }; 267 | log_info("* Timestamp: " # Int.toText(tokenInfo.timestamp)); 268 | log_info("* TokenMetadata:"); 269 | switch(tokenInfo.metadata) { 270 | case (null) log_info("* no metadata"); 271 | case (?metadata) { 272 | log_info(" filetype: " # metadata.filetype); 273 | switch(metadata.location){ 274 | case (#InCanister(b)) log_info(" location: " # "blob" # ""); 275 | case (#AssetCanister((p,b))) log_info(" location: " # "Principal: "# Principal.toText(p) # " blob:" # ""); 276 | case (#IPFS(t)) log_info(" location: " # t); 277 | case (#Web(t)) log_info(" location: " # t); 278 | }; 279 | log_info(" attributes: {key: " # metadata.attributes[0].key # " , value: " # metadata.attributes[0].value # "}"); 280 | }; 281 | }; 282 | 283 | true 284 | }; 285 | 286 | public func logUserInfos(who: Principal): async Bool { 287 | let userInfo: UserInfoExt = switch(await nftCanister.getUserInfo(who)) { 288 | case (#ok(userInfo)) { 289 | userInfo; 290 | }; 291 | case (#err(code)) { 292 | Prelude.unreachable(); 293 | }; 294 | }; 295 | //Todo 296 | log_info("User Info:" # Principal.toText(who)); 297 | var operators: Text = ""; 298 | Iter.iterate(Iter.fromArray(userInfo.operators), func(p, _index) { 299 | operators := operators # Principal.toText(p) # ","; 300 | }); 301 | var allowedBy: Text = ""; 302 | Iter.iterate(Iter.fromArray(userInfo.allowedBy), func(p, _index) { 303 | allowedBy := allowedBy # Principal.toText(p) # ","; 304 | }); 305 | var allowedTokens: Text = ""; 306 | Iter.iterate(Iter.fromArray(userInfo.allowedTokens), func(n, _index) { 307 | allowedTokens := allowedTokens # Nat.toText(n) # ","; 308 | }); 309 | var tokens: Text = ""; 310 | Iter.iterate(Iter.fromArray(userInfo.tokens), func(n, _index) { 311 | tokens := tokens # Nat.toText(n) # ","; 312 | }); 313 | log_info(" operators: " # operators ); 314 | log_info(" allowedBy: " # allowedBy); 315 | log_info(" allowedTokens: "# allowedTokens); 316 | log_info(" tokens: "# tokens); 317 | true 318 | }; 319 | 320 | /** 321 | *** @brief: init info test. 322 | **/ 323 | public func testCase1(): async Text { 324 | 325 | log_info("- - - - - - - - - - - - - - - - - - "); 326 | log_info("a): get Metadata "); 327 | let init_metadata = await logMetadataInfos(); // log_info metadata info 328 | if ( 329 | init_metadata.logo == "Test logo" and 330 | init_metadata.name == "Test NFT1" and 331 | init_metadata.symbol == "NFT1" and 332 | init_metadata.desc == "This is a NFT demo test!" and 333 | init_metadata.totalSupply == 9 and 334 | init_metadata.owner == user_alice 335 | ) { 336 | pass_count += 1; 337 | } else { 338 | log_info("! ! ! test fail"); 339 | log_info("ref metadata:"); 340 | log_info(" logo: Test logo"); 341 | log_info(" name: Test NFT1"); 342 | log_info(" name: NFT1"); 343 | log_info(" desc: This is a NFT demo test!"); 344 | log_info(" totalSupply: 9"); 345 | log_info(" owner: "# Principal.toText(user_alice)); 346 | fail_count += 1; 347 | }; 348 | total_count += 1; 349 | 350 | log_info("\n"); 351 | log_info("- - - - - - - - - - - - - - - - - - "); 352 | log_info("b): Get Token Infos"); 353 | var token_id = 0; 354 | let tokens_num = await nftCanister.totalSupply(); 355 | while(token_id < tokens_num) { 356 | ignore await logTokenInfos(token_id); 357 | token_id += 1; 358 | }; 359 | 360 | log_info("\n"); 361 | log_info("- - - - - - - - - - - - - - - - - - "); 362 | log_info("c): Get User Infos"); 363 | log_info("* Alice"); 364 | ignore await logUserInfos(user_alice); 365 | log_info("\n"); 366 | log_info("* Bob"); 367 | ignore await logUserInfos(user_bob); 368 | log_info("\n"); 369 | log_info("* This canister"); 370 | ignore await logUserInfos(Principal.fromActor(this)); 371 | 372 | log_info("- - - - - - - - - - - - - - - - - - "); 373 | log_info("\n"); 374 | "Case1: test finished! " 375 | }; 376 | 377 | /** 378 | *** @brief: setMetadata, approve, transferFrom, setApproveForAll 379 | **/ 380 | public func testCase2(): async Text { 381 | // test parameters: to, metadata 382 | let canister = Principal.fromActor(this); 383 | 384 | /** 385 | filetype: change to png 386 | location: change to #Web 387 | attributes: change key and value. 388 | **/ 389 | let new_location = #Web("Web url"); 390 | let new_meta: TokenMetadata = { 391 | filetype = "png"; 392 | location = new_location; 393 | attributes = [{key = "new_test_key"; value = "new_test_value"}]; 394 | }; 395 | 396 | // tests 397 | log_info("\n"); 398 | log_info("- - - - - - - - - - - - - - - - - - "); 399 | log_info("a): Mint"); 400 | log_info("Only owner can mint, this canister try mint. error occur?"); 401 | result := await testMint(canister, new_meta); 402 | if (result) { 403 | log_info("! ! ! test fail"); 404 | fail_count += 1; 405 | } 406 | else { 407 | pass_count += 1; 408 | }; 409 | total_count += 1; 410 | 411 | log_info("\n"); 412 | log_info("- - - - - - - - - - - - - - - - - - "); 413 | log_info("b): Set New Token Metadata"); 414 | log_info("\n"); 415 | log_info("Only owner can set token metadata, this canister try to set. error occur?"); 416 | result := await testSetTokenMetadata(0, new_meta); 417 | if (result) { 418 | log_info("! ! ! test fail"); 419 | fail_count += 1; 420 | } 421 | else { 422 | pass_count += 1; 423 | }; 424 | total_count += 1; 425 | 426 | log_info("\n"); 427 | log_info("- - - - - - - - - - - - - - - - - - "); 428 | log_info("c): Test approve"); 429 | log_info("There is no token 10, now this canister try to approve it. error occur?"); 430 | result := await testApprove(10, user_alice); 431 | if (result) { 432 | log_info("! ! ! test fail"); 433 | fail_count += 1; 434 | } 435 | else { 436 | pass_count += 1; 437 | }; 438 | total_count += 1; 439 | 440 | log_info("\n"); 441 | log_info("This canister has token 6,7,8, now it try to approve token 0 (not belong to itself). error occur?"); 442 | result := await testApprove(0, user_alice); 443 | if (result) { 444 | log_info("! ! ! test fail"); 445 | fail_count += 1; 446 | } 447 | else { 448 | pass_count += 1; 449 | }; 450 | total_count += 1; 451 | 452 | log_info("\n"); 453 | log_info("This canister has token 6,7,8, now it try to approve token 6 to itself. error occur?"); 454 | result := await testApprove(6, canister); 455 | if (result) { 456 | log_info("! ! ! test fail"); 457 | fail_count += 1; 458 | } 459 | else { 460 | pass_count += 1; 461 | }; 462 | total_count += 1; 463 | 464 | 465 | log_info("\n"); 466 | log_info("This canister has token 6,7,8, now it try to approve token 6 to Alice. successed?"); 467 | result := await testApprove(6, user_alice); 468 | if (result) {pass_count += 1;} 469 | else { 470 | log_info("! ! ! test fail"); 471 | fail_count += 1; 472 | }; 473 | total_count += 1; 474 | 475 | log_info("\n"); 476 | log_info("This canister trys to approve token 3 (belongs to Bob, but he has approved this canister for all!) to Alice. successed?"); 477 | result := await testApprove(3, user_alice); 478 | if (result) {pass_count += 1;} 479 | else { 480 | log_info("! ! ! test fail"); 481 | fail_count += 1; 482 | }; 483 | total_count += 1; 484 | 485 | log_info("\n "); 486 | log_info("Get User Infos"); 487 | log_info("* Alice Infos:"); 488 | ignore await logUserInfos(user_alice); 489 | log_info("\n"); 490 | log_info("* Bob Infos:"); 491 | ignore await logUserInfos(user_bob); 492 | log_info("\n"); 493 | log_info("* This canister Infos:"); 494 | ignore await logUserInfos(canister); 495 | 496 | 497 | log_info("\n"); 498 | log_info("- - - - - - - - - - - - - - - - - - "); 499 | log_info("d): Test setApproveForAll"); 500 | 501 | log_info("\n"); 502 | log_info("This canister has token 6,7,8, now it try to approve all token for itself. error occur?"); 503 | result := await testSetApprovalForAll(canister, true); 504 | if (result) { 505 | log_info("! ! ! test fail"); 506 | fail_count += 1; 507 | } 508 | else { 509 | pass_count += 1; 510 | }; 511 | total_count += 1; 512 | 513 | log_info("\n"); 514 | log_info("This canister has token 6,7,8, now it try to approve all token for alice. successed?"); 515 | result := await testSetApprovalForAll(user_alice, true); 516 | if (result) {pass_count += 1;} 517 | else { 518 | log_info("! ! ! test fail"); 519 | fail_count += 1; 520 | }; 521 | total_count += 1; 522 | 523 | log_info("\n"); 524 | log_info("This canister has token 6,7,8, now it try to approve all token for bob. successed?"); 525 | result := await testSetApprovalForAll(user_bob, true); 526 | if (result) {pass_count += 1;} 527 | else { 528 | log_info("! ! ! test fail"); 529 | fail_count += 1; 530 | }; 531 | total_count += 1; 532 | 533 | log_info("\n "); 534 | log_info("Get User Infos"); 535 | log_info("* Alice Infos:"); 536 | ignore await logUserInfos(user_alice); 537 | log_info("\n"); 538 | log_info("* Bob Infos:"); 539 | ignore await logUserInfos(user_bob); 540 | log_info("\n"); 541 | log_info("* This canister Infos:"); 542 | ignore await logUserInfos(canister); 543 | 544 | log_info("\n"); 545 | log_info("This canister has token 6,7,8, now it try to cancel bob approvol for all token. successed?"); 546 | result := await testSetApprovalForAll(user_bob, false); 547 | if (result) {pass_count += 1;} 548 | else { 549 | log_info("! ! ! test fail"); 550 | fail_count += 1; 551 | }; 552 | total_count += 1; 553 | 554 | log_info("\n "); 555 | log_info("Get User Infos"); 556 | log_info("* Alice Infos:"); 557 | ignore await logUserInfos(user_alice); 558 | log_info("* Bob Infos:"); 559 | ignore await logUserInfos(user_bob); 560 | log_info("* This canister Infos:"); 561 | ignore await logUserInfos(canister); 562 | 563 | 564 | log_info("\n"); 565 | log_info("- - - - - - - - - - - - - - - - - - "); 566 | log_info("e): Test transfer"); 567 | 568 | log_info("\n"); 569 | log_info("There is no token 10, now this canister trys to transfer it. error occur?"); 570 | result := await testTransfer(canister, 10); 571 | if (result) { 572 | log_info("! ! ! test fail"); 573 | fail_count += 1; 574 | } 575 | else { 576 | pass_count += 1; 577 | }; 578 | total_count += 1; 579 | 580 | log_info("\n"); 581 | log_info("This canister has token 6,7,8, allowed token 0, 3, 4, 5. Now it try to transfer token 3. error occur?"); 582 | result := await testTransfer(canister, 1); 583 | if (result) { 584 | log_info("! ! ! test fail"); 585 | fail_count += 1; 586 | } 587 | else { 588 | pass_count += 1; 589 | }; 590 | total_count += 1; 591 | 592 | log_info("\n"); 593 | log_info("This canister has token 6,7,8, allowed token 0, 3, 4, 5. Now it try to transfer token 6 to alice from itself. successed?"); 594 | result := await testTransfer(user_alice, 6); 595 | if (result) {pass_count += 1;} 596 | else { 597 | log_info("! ! ! test fail"); 598 | fail_count += 1; 599 | }; 600 | total_count += 1; 601 | 602 | log_info("\n "); 603 | log_info("Get User Infos"); 604 | log_info("* Alice Infos:"); 605 | ignore await logUserInfos(user_alice); 606 | log_info("\n "); 607 | log_info("* Bob Infos:"); 608 | ignore await logUserInfos(user_bob); 609 | log_info("\n "); 610 | log_info("* This canister Infos:"); 611 | ignore await logUserInfos(canister); 612 | 613 | 614 | log_info("\n "); 615 | log_info("- - - - - - - - - - - - - - - - - - "); 616 | log_info("f): Test transferFrom"); 617 | 618 | log_info("\n"); 619 | log_info("There is no token 10, now this canister trys to transfer it. error occur?"); 620 | result := await testTransferFrom(user_bob, canister, 10); 621 | if (result) { 622 | log_info("! ! ! test fail"); 623 | fail_count += 1; 624 | } 625 | else { 626 | pass_count += 1; 627 | }; 628 | total_count += 1; 629 | 630 | log_info("\n"); 631 | log_info("This canister has token 7,8, allowed token 0, 3, 4, 5. Now it try to transfer token 1. error occur?"); 632 | result := await testTransferFrom(user_alice, canister, 1); 633 | if (result) { 634 | log_info("! ! ! test fail"); 635 | fail_count += 1; 636 | } 637 | else { 638 | pass_count += 1; 639 | }; 640 | total_count += 1; 641 | 642 | log_info("\n"); 643 | log_info("This canister has token 7,8, allowed token 0, 3, 4, 5. Now it try to transfer token 7 to alice from itself. successed?"); 644 | result := await testTransferFrom(canister, user_alice, 7); 645 | if (result) {pass_count += 1;} 646 | else { 647 | log_info("! ! ! test fail"); 648 | fail_count += 1; 649 | }; 650 | total_count += 1; 651 | 652 | log_info("\n"); 653 | log_info("This canister has token 8, allowed token 0, 3, 4, 5. Now it try to transfer token 3 to itself from bob. successed?"); 654 | result := await testTransferFrom(user_bob, canister, 3); 655 | if (result) {pass_count += 1;} 656 | else { 657 | log_info("! ! ! test fail"); 658 | fail_count += 1; 659 | }; 660 | total_count += 1; 661 | 662 | log_info("\n "); 663 | log_info("Get User Infos"); 664 | log_info("* Alice Infos:"); 665 | ignore await logUserInfos(user_alice); 666 | log_info("* Bob Infos:"); 667 | log_info("\n "); 668 | ignore await logUserInfos(user_bob); 669 | log_info("\n "); 670 | log_info("* This canister Infos:"); 671 | ignore await logUserInfos(canister); 672 | 673 | 674 | log_info("\n "); 675 | log_info("- - - - - - - - - - - - - - - - - - "); 676 | log_info("g): Test burn"); 677 | 678 | log_info("\n"); 679 | log_info("There is no token 10, now this canister trys to burn it. error occur?"); 680 | result := await testBurn(10); 681 | if (result) { 682 | log_info("! ! ! test fail"); 683 | fail_count += 1; 684 | } 685 | else { 686 | pass_count += 1; 687 | }; 688 | total_count += 1; 689 | 690 | log_info("\n"); 691 | log_info("This canister has token 3, 8, allowed token 0, 4, 5. now it try to burn 4. error occur?"); 692 | result := await testBurn(4); 693 | if (result) { 694 | log_info("! ! ! test fail"); 695 | fail_count += 1; 696 | } 697 | else { 698 | pass_count += 1; 699 | }; 700 | total_count += 1; 701 | 702 | log_info("\n"); 703 | log_info("This canister has token 3,8, now it try to burn token 8. successed?"); 704 | result := await testBurn(8); 705 | if (result) {pass_count += 1;} 706 | else { 707 | log_info("! ! ! test fail"); 708 | fail_count += 1; 709 | }; 710 | total_count += 1; 711 | 712 | log_info("\n "); 713 | log_info("Get User Infos"); 714 | log_info("* Alice Infos:"); 715 | ignore await logUserInfos(user_alice); 716 | log_info("* Bob Infos:"); 717 | ignore await logUserInfos(user_bob); 718 | log_info("* This canister Infos:"); 719 | ignore await logUserInfos(canister); 720 | 721 | "Case2: test finished! " 722 | }; 723 | 724 | /** 725 | *** @brief: get history tx receipt 726 | **/ 727 | public func testCase3(): async Text { 728 | 729 | "Case3: todo " 730 | }; 731 | 732 | /** 733 | *** @brief: test query funs 734 | **/ 735 | public func testCase4(): async Text { 736 | 737 | "Case4: todo " 738 | 739 | }; 740 | 741 | /** 742 | *** @brief: testfolw: test all cases. 743 | **/ 744 | public func testflow(): async () { 745 | 746 | log_info("****** Testing beginning! ******"); 747 | log_info("===================================="); 748 | log_info("Currently, Alice has token 0, 1, 2; Bob has token 3, 4, 5; This canister has token 6, 7, 8"); 749 | 750 | log_info("%%%%% Case1: Testing init Metadata %%%%%"); 751 | log_info(await testCase1()); 752 | 753 | log_info("\n"); 754 | log_info("***********************************"); 755 | log_info("%%%%% Case2: Testing setMetadata, approve, transferFrom, setApproveForAll %%%%%"); 756 | log_info(await testCase2()); 757 | 758 | log_info("\n"); 759 | log_info("***********************************"); 760 | log_info("%%%%% Case3: Testing history tx receipt! %%%%%"); 761 | log_info(await testCase3()); 762 | 763 | log_info("\n"); 764 | log_info("***********************************"); 765 | log_info("%%%%% Case4: Testing query functions! %%%%%"); 766 | log_info(await testCase4()); 767 | 768 | log_info("===================================="); 769 | log_info("****** Testing end! ******"); 770 | log_info("****** Test results showed below! ******"); 771 | log_info("Total: " # Nat.toText(total_count) # " Pass: " # Nat.toText(pass_count) # " Fail: " # Nat.toText(fail_count) # " Skip: " # Nat.toText(skip_count)); 772 | 773 | }; 774 | 775 | } -------------------------------------------------------------------------------- /motoko/test/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | # clear 6 | dfx stop 7 | rm -rf .dfx 8 | 9 | ALICE_HOME=$(mktemp -d -t alice-temp) 10 | BOB_HOME=$(mktemp -d -t bob-temp) 11 | HOME=$ALICE_HOME 12 | 13 | ALICE_PUBLIC_KEY="principal \"$( \ 14 | HOME=$ALICE_HOME dfx identity get-principal 15 | )\"" 16 | BOB_PUBLIC_KEY="principal \"$( \ 17 | HOME=$BOB_HOME dfx identity get-principal 18 | )\"" 19 | 20 | echo Alice id = $ALICE_PUBLIC_KEY 21 | echo Bob id = $BOB_PUBLIC_KEY 22 | 23 | dfx start --background 24 | dfx canister --no-wallet create --all 25 | dfx build 26 | 27 | HOME=$ALICE_HOME 28 | eval dfx canister --no-wallet install token_ERC721 --argument="'(\"Test logo\", \"Test NFT1\", \"NFT1\", \"This is a NFT demo test!\", principal \"$(dfx identity get-principal)\")'" 29 | eval dfx canister --no-wallet install testflow --argument="'(principal \"$(dfx canister id token_ERC721)\", $ALICE_PUBLIC_KEY, $BOB_PUBLIC_KEY)'" 30 | 31 | TEST_ID=$(dfx canister id testflow) 32 | TEST_ID="principal \"$TEST_ID\"" 33 | echo testflow principal: $TEST_ID 34 | 35 | 36 | echo == Mint 3 NFT to Alice, 3 NFT to Bob, 3 NFT to testflow canister 37 | eval dfx canister --no-wallet call token_ERC721 mint "'($ALICE_PUBLIC_KEY, record { filetype = \"jpg\"; location = variant {IPFS = \"hash0\"}; attributes = vec {record {key = \"url\"; value = \"a.link/0\"}}})'" 38 | eval dfx canister --no-wallet call token_ERC721 mint "'($ALICE_PUBLIC_KEY, record { filetype = \"jpg\"; location = variant {IPFS = \"hash1\"}; attributes = vec {record {key = \"url\"; value = \"a.link/1\"}}})'" 39 | eval dfx canister --no-wallet call token_ERC721 mint "'($ALICE_PUBLIC_KEY, record { filetype = \"jpg\"; location = variant {IPFS = \"hash2\"}; attributes = vec {record {key = \"url\"; value = \"a.link/2\"}}})'" 40 | eval dfx canister --no-wallet call token_ERC721 mint "'($BOB_PUBLIC_KEY, record { filetype = \"jpg\"; location = variant {IPFS = \"hash3\"}; attributes = vec {record {key = \"url\"; value = \"a.link/3\"}}})'" 41 | eval dfx canister --no-wallet call token_ERC721 mint "'($BOB_PUBLIC_KEY, record { filetype = \"jpg\"; location = variant {IPFS = \"has4\"}; attributes = vec {record {key = \"url\"; value = \"a.link/4\"}}})'" 42 | eval dfx canister --no-wallet call token_ERC721 mint "'($BOB_PUBLIC_KEY, record { filetype = \"jpg\"; location = variant {IPFS = \"has5\"}; attributes = vec {record {key = \"url\"; value = \"a.link/5\"}}})'" 43 | eval dfx canister --no-wallet call token_ERC721 mint "'($TEST_ID, record { filetype = \"jpg\"; location = variant {IPFS = \"hash6\"}; attributes = vec {record {key = \"url\"; value = \"a.link/6\"}}})'" 44 | eval dfx canister --no-wallet call token_ERC721 mint "'($TEST_ID, record { filetype = \"jpg\"; location = variant {IPFS = \"has7\"}; attributes = vec {record {key = \"url\"; value = \"a.link/7\"}}})'" 45 | eval dfx canister --no-wallet call token_ERC721 mint "'($TEST_ID, record { filetype = \"jpg\"; location = variant {IPFS = \"has8\"}; attributes = vec {record {key = \"url\"; value = \"a.link/8\"}}})'" 46 | 47 | echo == Alice approve testflow token 0 48 | eval HOME=$ALICE_HOME dfx canister --no-wallet call token_ERC721 approve "'(0, $TEST_ID)'" 49 | 50 | echo == Bob approve testflow all token 51 | eval HOME=$BOB_HOME dfx canister --no-wallet call token_ERC721 setApprovalForAll "'($TEST_ID, true)'" 52 | 53 | echo Testing begin !!! 54 | dfx canister call testflow testflow 55 | 56 | dfx stop -------------------------------------------------------------------------------- /spec.md: -------------------------------------------------------------------------------- 1 | # NFT Standard Spec 2 | 3 | A non-fungible token standard for the DFINITY Internet Computer. 4 | 5 | 6 | ## Abstract 7 | 8 | NFTs are basic building blocks for the web3 economy, such as gaming, social network, digital art, etc. To better help the web3 economy grow on the IC ecosystem, we propose a standard token interface for non-fungible tokens, the standard provides basic functionality to transfer tokens, allow tokens to be approved so they can be operated by a third party, it also support history transaction storage and query, provide full traceability and verifiability for NFTs including their metadata. 9 | 10 | ## Specification 11 | 12 | ### 1. Data structures 13 | 14 | 1. Metadata: information about this NFT collection 15 | 16 | ``` 17 | public type Metadata = { 18 | logo: Text; 19 | name: Text; 20 | symbol: Text; 21 | desc: Text; 22 | totalSupply: Nat; 23 | owner: Principal; 24 | }; 25 | ``` 26 | 27 | 2. TokenMetadata: metadata of a single token 28 | 29 | ``` 30 | public type Location = { 31 | #InCanister: Blob; // NFT encoded data 32 | #AssetCanister: (Principal, Blob); // asset storage canister id, storage key 33 | #IPFS: Text; // IPFS content hash 34 | #Web: Text; // URL pointing to the file 35 | }; 36 | public type Attribute = { 37 | key: Text; 38 | value: Text; 39 | }; 40 | public type TokenMetadata = { 41 | filetype: Text; // jpg, png, mp4, etc. 42 | location: Location; 43 | attributes: [Attribute]; 44 | }; 45 | ``` 46 | 47 | 3. TokenInfo: information of a single token 48 | 49 | ``` 50 | public type TokenInfo = { 51 | index: Nat; 52 | var owner: Principal; 53 | var metadata: ?TokenMetadata; 54 | var operator: ?Principal; 55 | timestamp: Time.Time; 56 | }; 57 | ``` 58 | 59 | 4. UserInfo: user information 60 | 61 | ``` 62 | public type UserInfo = { 63 | var operators: TrieSet.Set; // principals allowed to operate on the user's behalf 64 | var allowedBy: TrieSet.Set; // principals approved user to operate their's tokens 65 | var allowedTokens: TrieSet.Set; // tokens the user can operate 66 | var tokens: TrieSet.Set; // user's tokens 67 | }; 68 | ``` 69 | 70 | 5. TxRecord: transaction record 71 | 72 | ``` 73 | /// Update call operations 74 | public type Operation = { 75 | #mint: ?TokenMetadata; 76 | #burn; 77 | #transfer; 78 | #approve; 79 | #approveAll; 80 | #revokeAll; // revoke approvals 81 | #setMetadata; 82 | }; 83 | /// Update call operation record fields 84 | public type Record = { 85 | #user: Principal; 86 | #metadata: ?TokenMetadata; // op == #setMetadata 87 | }; 88 | public type TxRecord = { 89 | caller: Principal; 90 | op: Operation; 91 | index: Nat; 92 | tokenIndex: ?Nat; 93 | from: Record; 94 | to: Record; 95 | timestamp: Time.Time; 96 | }; 97 | ``` 98 | 99 | 6. TxReceipt & MintResult 100 | 101 | ``` 102 | public type Error = { 103 | #Unauthorized; 104 | #TokenNotExist; 105 | #InvalidSpender; 106 | }; 107 | public type TxReceipt = Result.Result; // txid 108 | public type MintResult = Result.Result<(Nat, Nat), Error>; // token index, txid 109 | ``` 110 | 111 | ### 2. Basic interfaces 112 | 113 | #### Update calls 114 | 115 | ##### mint 116 | 117 | Mint a new token with metadata `metadata` to user `to`, returns a `TxReceipt`. Note that `metadata` can be `null` when mint, and can be set later with `setTokenMetadata` interface. 118 | 119 | ``` 120 | public shared(msg) func mint(to: Principal, metadata: ?TokenMetadata): async MintResult 121 | ``` 122 | 123 | ##### setTokenMetadata 124 | 125 | Set metadata for the existing token `tokenId`. 126 | 127 | ``` 128 | public shared(msg) func setTokenMetadata(tokenId: Nat, metadata: TokenMetadata): async TxReceipt 129 | ``` 130 | 131 | ##### approve 132 | 133 | Set operator for the token `tokenId` to `operator`, each token can only have 1 operator. 134 | 135 | ``` 136 | public shared(msg) func approve(tokenId: Nat, operator: Principal): async TxReceipt 137 | ``` 138 | 139 | ##### setApprovalForAll 140 | 141 | Set approval for the operator `operator` to `value`, if `value == true`, this means allow `operator` to spend owner's tokens, if `value == false`, this means revoke the approval to `operator`. 142 | 143 | ``` 144 | public shared(msg) func setApprovalForAll(operator: Principal, value: Bool): async TxReceipt 145 | ``` 146 | 147 | ##### transferFrom 148 | 149 | Transfer the token `tokenId` from user `from` to user `to`. 150 | 151 | ``` 152 | public shared(msg) func transferFrom(from: Principal, to: Principal, tokenId: Nat): async TxReceipt 153 | ``` 154 | 155 | #### Query calls 156 | 157 | ##### logo 158 | 159 | Returns the logo of the token. 160 | 161 | ``` 162 | public query func logo() : async Text 163 | ``` 164 | 165 | ##### name 166 | 167 | Returns the name of the token. 168 | 169 | ``` 170 | public query func name() : async Text 171 | ``` 172 | 173 | ##### symbol 174 | 175 | Returns the symbol of the token. 176 | 177 | ``` 178 | public query func symbol() : async Text 179 | ``` 180 | 181 | ##### desc 182 | 183 | Returns the description of the token. 184 | 185 | ``` 186 | public query func desc() : async Text 187 | ``` 188 | 189 | ##### balanceOf 190 | 191 | Returns the number of tokens user `who` holds. 192 | 193 | ``` 194 | public query func balanceOf(who: Principal): async Nat 195 | ``` 196 | 197 | ##### totalSupply 198 | 199 | Returns the total supply the this NFT collection. 200 | 201 | ``` 202 | public query func totalSupply(): async Nat 203 | ``` 204 | 205 | ##### getMetadata 206 | 207 | Returns the metadata about this NFT collection. 208 | 209 | ``` 210 | public query func getMetadata(): async Metadata 211 | ``` 212 | 213 | ##### isApprovedForAll 214 | 215 | Check if `operator ` is allowed to operate `owner` 's tokens. 216 | 217 | ``` 218 | public query func isApprovedForAll(owner: Principal, operator: Principal): async Bool 219 | ``` 220 | 221 | ##### getOperator 222 | 223 | Get the operator of the token `tokenId`. 224 | 225 | ``` 226 | public query func getOperator(tokenId: Nat): async Principal 227 | ``` 228 | 229 | ##### getUserInfo 230 | 231 | Get user information. 232 | 233 | ``` 234 | public query func getUserInfo(who: Principal): async UserInfoExt 235 | ``` 236 | 237 | ##### getUserTokens 238 | 239 | Get the token information user `who` holds. 240 | 241 | ``` 242 | public query func getUserTokens(owner: Principal): async [TokenInfoExt] 243 | ``` 244 | 245 | ##### ownerOf 246 | 247 | Return owner of token `tokenId`. 248 | 249 | ``` 250 | public query func ownerOf(tokenId: Nat): async Principal 251 | ``` 252 | 253 | ##### getTokenInfo 254 | 255 | Get the token information of `tokenId`. 256 | 257 | ``` 258 | public query func getTokenInfo(tokenId: Nat): async TokenInfoExt 259 | ``` 260 | 261 | ##### historySize 262 | 263 | Returns transaction history size. 264 | 265 | ``` 266 | public query func historySize(): async Nat 267 | ``` 268 | 269 | ##### getTransaction 270 | 271 | Get the transaction record with index `index`. 272 | 273 | ``` 274 | public query func getTransaction(index: Nat): async TxRecord 275 | ``` 276 | 277 | ##### getTransactions 278 | 279 | Get transaction records in the range `[start, start + limit)`. 280 | 281 | ``` 282 | public query func getTransactions(start: Nat, limit: Nat): async [TxRecord] 283 | ``` 284 | 285 | ##### getUserTransactionAmount 286 | 287 | Get the transaction amount of user. 288 | 289 | ``` 290 | public query func getUserTransactionAmount(user: Principal): async Nat 291 | ``` 292 | 293 | ##### getUserTransactions 294 | 295 | Get the transactions records in the range `[start, start + limit)` related to user `user`. Note the range is not the global range of all transactions. 296 | 297 | ``` 298 | public query func getUserTransactions(user: Principal, start: Nat, limit: Nat): async [TxRecord] 299 | ``` 300 | 301 | ### 3. Optional interfaces 302 | 303 | #### Update calls 304 | 305 | ##### burn 306 | 307 | Burn the token `tokenId`, only the owner of the token can do this. 308 | 309 | ``` 310 | public func burn(tokenId: Nat): async TxReceipt 311 | ``` 312 | 313 | ##### batchMint 314 | 315 | Mint multiple NFTs in one update call, only the NFT issuer can do this. 316 | 317 | ``` 318 | public shared(msg) func batchMint(to: Principal, arr: [?TokenMetadata]): async MintResult 319 | ``` 320 | 321 | #### Query calls 322 | 323 | ##### getAllTokens 324 | 325 | Returns information of all tokens. 326 | 327 | ``` 328 | public query func getAllTokens() : async [TokenInfoExt] 329 | ``` 330 | 331 | --------------------------------------------------------------------------------