diff --git a/dapps/azle/dfx.json b/dapps/azle/dfx.json index c3ae8ca9..19bb7929 100644 --- a/dapps/azle/dfx.json +++ b/dapps/azle/dfx.json @@ -7,6 +7,14 @@ "build": "npx azle basic_dao", "wasm": ".azle/basic_dao/basic_dao.wasm", "gzip": false + }, + "dip721_nft": { + "type": "custom", + "main": "dip721-nft/index.ts", + "candid": "dip721-nft/index.did", + "build": "npx azle dip721_nft", + "wasm": ".azle/dip721_nft/dip721_nft.wasm", + "gzip": false } } } diff --git a/dapps/azle/dip721-nft/index.did b/dapps/azle/dip721-nft/index.did new file mode 100644 index 00000000..eb7cd0bb --- /dev/null +++ b/dapps/azle/dip721-nft/index.did @@ -0,0 +1,24 @@ +service: (record {logo:record {data:text; logo_type:text}; name:text; custodians:opt vec principal; symbol:text}) -> { + approveDip721: (principal, nat64) -> (variant {Ok:nat; Err:variant {ZeroAddress; InvalidTokenId; Unauthorized; Other}}); + balanceOfDip721: (principal) -> (nat64) query; + burnDip721: (nat64) -> (variant {Ok:nat; Err:variant {ZeroAddress; InvalidTokenId; Unauthorized; Other}}); + getMetadataDip721: (nat64) -> (variant {Ok:vec record {data:vec nat8; key_val_data:vec record {text; variant {Nat64Content:nat64; Nat32Content:nat32; Nat8Content:nat8; NatContent:nat; Nat16Content:nat16; BlobContent:vec nat8; TextContent:text}}; purpose:variant {Preview; Rendered}}; Err:variant {ZeroAddress; InvalidTokenId; Unauthorized; Other}}) query; + getMetadataForUserDip721: (principal) -> (vec record {token_id:nat64; metadata_desc:vec record {data:vec nat8; key_val_data:vec record {text; variant {Nat64Content:nat64; Nat32Content:nat32; Nat8Content:nat8; NatContent:nat; Nat16Content:nat16; BlobContent:vec nat8; TextContent:text}}; purpose:variant {Preview; Rendered}}}) query; + isApprovedForAllDip721: (principal) -> (bool) query; + is_custodian: (principal) -> (bool) query; + mintDip721: (principal, vec record {data:vec nat8; key_val_data:vec record {text; variant {Nat64Content:nat64; Nat32Content:nat32; Nat8Content:nat8; NatContent:nat; Nat16Content:nat16; BlobContent:vec nat8; TextContent:text}}; purpose:variant {Preview; Rendered}}) -> (variant {Ok:record {id:nat; token_id:nat64}; Err:variant {Unauthorized}}); + nameDip721: () -> (text) query; + ownerOfDip721: (nat64) -> (variant {Ok:principal; Err:variant {ZeroAddress; InvalidTokenId; Unauthorized; Other}}) query; + safeTransferFromDip721: (principal, principal, nat64) -> (variant {Ok:nat; Err:variant {ZeroAddress; InvalidTokenId; Unauthorized; Other}}); + safeTransferFromNotifyDip721: (principal, principal, nat64, vec nat8) -> (variant {Ok:nat; Err:variant {ZeroAddress; InvalidTokenId; Unauthorized; Other}}); + setApprovalForAllDip721: (principal, bool) -> (variant {Ok:nat; Err:variant {ZeroAddress; InvalidTokenId; Unauthorized; Other}}); + set_custodian: (principal, bool) -> (variant {Ok; Err:variant {ZeroAddress; InvalidTokenId; Unauthorized; Other}}); + set_logo: (record {data:text; logo_type:text}) -> (variant {Ok; Err:variant {ZeroAddress; InvalidTokenId; Unauthorized; Other}}); + set_name: (text) -> (variant {Ok; Err:variant {ZeroAddress; InvalidTokenId; Unauthorized; Other}}); + set_symbol: (text) -> (variant {Ok; Err:variant {ZeroAddress; InvalidTokenId; Unauthorized; Other}}); + supportedInterfacesDip721: () -> (vec variant {Burn; Mint; Approval; TransactionHistory; TransferNotification}) query; + symbolDip721: () -> (text) query; + totalSupplyDip721: () -> (nat64) query; + transferFromDip721: (principal, principal, nat64) -> (variant {Ok:nat; Err:variant {ZeroAddress; InvalidTokenId; Unauthorized; Other}}); + transferFromNotifyDip721: (principal, principal, nat64, vec nat8) -> (variant {Ok:nat; Err:variant {ZeroAddress; InvalidTokenId; Unauthorized; Other}}); +} diff --git a/dapps/azle/dip721-nft/index.ts b/dapps/azle/dip721-nft/index.ts new file mode 100644 index 00000000..d0dc89fa --- /dev/null +++ b/dapps/azle/dip721-nft/index.ts @@ -0,0 +1,373 @@ +import { + blob, + bool, + Canister, + Err, + ic, + init, + nat, + nat64, + None, + Null, + Ok, + Principal, + query, + Result, + Some, + text, + update, + Vec, +} from "azle"; +import { + ConstrainedError, + Error, + ExtendedMetadataResult, + InitArgs, + InterfaceId, + LogoResult, + MetadataDesc, + MintResult, + Nft, + State, + TransferSubscriber, +} from "./types"; + +const MGMT: Principal = Principal.fromUint8Array(new Uint8Array([])); + +let state: State = { + nfts: [], + custodians: new Set(), + operators: new Map(), + logo: { + logo_type: "", + data: "", + }, + name: "", + symbol: "", + txid: 0n, + nextTxid() { + return ++this.txid; + }, +}; + +function transferFrom( + from: Principal, + to: Principal, + tokenId: nat64 +): Result { + const nft = state.nfts[Number(tokenId)]; + + if (nft === undefined) { + return Err({ InvalidTokenId: null }); + } + + const caller = ic.caller().toText(); + const callerIsOwner = nft.owner.toText() === caller; + const operators = state.operators.get(from.toText()); + const callerIsOperator = + operators === undefined ? false : operators.has(caller); + const callerIsCustodian = state.custodians.has(caller); + if (!callerIsOwner && !callerIsOperator && !callerIsCustodian) { + return Err({ Unauthorized: null }); + } else if (nft.owner.toText() !== from.toText()) { + return Err({ Other: null }); + } else { + nft.approved = None; + nft.owner = to; + return Ok(state.nextTxid()); + } +} + +function transferFromNotify( + from: Principal, + to: Principal, + tokenId: nat64, + data: blob +): Result { + const result = transferFrom(from, to, tokenId); + + if (result.Err !== undefined) { + return Err(result.Err); + } + + ic.notify(TransferSubscriber(to).onDIP721Received, { + args: [ic.caller(), from, tokenId, data], + }); + + return Ok(result.Ok); +} + +export default Canister({ + init: init([InitArgs], (args) => { + ic.stableGrow(4096); + + const custodians = + args.custodians.Some !== undefined + ? new Set(args.custodians.Some.map((principal) => principal.toText())) + : new Set([ic.caller().toText()]); + + state.custodians = custodians; + state.name = args.name; + state.symbol = args.symbol; + state.logo = args.logo; + }), + // -------------- + // base interface + // -------------- + balanceOfDip721: query([Principal], nat64, (user) => { + const count = state.nfts.filter((nft) => { + nft.owner.toText() === user.toText(); + }).length; + + return BigInt(count); + }), + ownerOfDip721: query([nat64], Result(Principal, Error), (tokenId) => { + const nft = state.nfts[Number(tokenId)]; + + if (nft === undefined) { + return Err({ InvalidTokenId: null }); + } + + return Ok(nft.owner); + }), + transferFromDip721: update( + [Principal, Principal, nat64], + Result(nat, Error), + transferFrom + ), + safeTransferFromDip721: update( + [Principal, Principal, nat64], + Result(nat, Error), + (from, to, tokenId) => { + if (to.toText() === MGMT.toText()) { + return Err({ ZeroAddress: null }); + } else { + return transferFrom(from, to, tokenId); + } + } + ), + supportedInterfacesDip721: query([], Vec(InterfaceId), () => { + return [{ TransferNotification: null }, { Burn: null }, { Mint: null }]; + }), + nameDip721: query([], text, () => { + return state.name; + }), + symbolDip721: query([], text, () => { + return state.symbol; + }), + totalSupplyDip721: query([], nat64, () => { + return BigInt(state.nfts.length); + }), + getMetadataDip721: query([nat64], Result(MetadataDesc, Error), (tokenId) => { + const nft = state.nfts[Number(tokenId)]; + + if (nft === undefined) { + return Err({ InvalidTokenId: null }); + } + + return Ok(nft.metadata); + }), + getMetadataForUserDip721: query( + [Principal], + Vec(ExtendedMetadataResult), + (user) => { + return state.nfts + .filter((n) => n.owner.toText() === user.toText()) + .map((n) => ({ + metadata_desc: n.metadata, + token_id: n.id, + })); + } + ), + // ---------------------- + // notification interface + // ---------------------- + transferFromNotifyDip721: update( + [Principal, Principal, nat64, blob], + Result(nat, Error), + transferFromNotify + ), + safeTransferFromNotifyDip721: update( + [Principal, Principal, nat64, blob], + Result(nat, Error), + (from, to, tokenId, data) => { + if (to.toText() === MGMT.toText()) { + return Err({ ZeroAddress: null }); + } else { + return transferFromNotify(from, to, tokenId, data); + } + } + ), + // ------------------ + // approval interface + // ------------------ + approveDip721: update( + [Principal, nat64], + Result(nat, Error), + (user, tokenId) => { + const caller = ic.caller().toText(); + const nft = state.nfts[Number(tokenId)]; + + if (nft === undefined) { + return Err({ InvalidTokenId: null }); + } + + const callerIsOwner = nft.owner.toText() === caller; + const callerIsApproved = nft.approved.Some?.toText() === caller; + const operators = state.operators.get(user.toText()); + const callerIsOperator = + operators === undefined ? false : operators.has(caller); + const callerIsCustodian = state.custodians.has(caller); + if ( + !callerIsOwner && + !callerIsApproved && + !callerIsOperator && + !callerIsCustodian + ) { + return Err({ Unauthorized: null }); + } else { + nft.approved = Some(user); + return Ok(state.nextTxid()); + } + } + ), + setApprovalForAllDip721: update( + [Principal, bool], + Result(nat, Error), + (op, isApproved) => { + const caller = ic.caller().toText(); + const operator = op.toText(); + + if (operator === caller) { + let operators = + state.operators.get(caller) ?? + (() => { + const newOperatorsList = new Set([]); + state.operators.set(caller, newOperatorsList); + return newOperatorsList; + })(); + if (operator === MGMT.toText()) { + if (!isApproved) { + operators.clear(); + } else { + // cannot enable everyone as an operator + } + } else { + if (isApproved) { + operators.add(operator); + } else { + operators.delete(operator); + } + } + } + + return Ok(state.nextTxid()); + } + ), + // Not exported as canister method because of incorrect interface. + // See https://github.com/Psychedelic/DIP721/issues/5 + // getApprovedDip721: query([nat64], Result(Principal, Error), (tokenId) => { + // const nft = state.nfts[Number(tokenId)]; + + // if (nft === undefined) { + // return Err({ InvalidTokenId: null }); + // } + + // return nft.approved.Some === undefined + // ? Ok(ic.caller()) + // : Ok(nft.approved.Some); + // }), + isApprovedForAllDip721: query([Principal], bool, (operator) => { + const operators = state.operators.get(ic.caller().toText()); + const callerIsOperator = + operators === undefined ? false : operators.has(operator.toText()); + return callerIsOperator; + }), + // -------------- + // mint interface + // -------------- + mintDip721: update( + [Principal, MetadataDesc], + Result(MintResult, ConstrainedError), + (to, metadata) => { + if (!state.custodians.has(ic.caller().toText())) { + return Err({ Unauthorized: null }); + } + + const newId = BigInt(state.nfts.length); + const nft: Nft = { + owner: to, + approved: None, + id: newId, + metadata, + content: new Uint8Array([]), + }; + + state.nfts.push(nft); + + return Ok({ + id: state.nextTxid(), + token_id: newId, + }); + } + ), + // -------------- + // burn interface + // -------------- + burnDip721: update([nat64], Result(nat, Error), (tokenId) => { + const nft = state.nfts[Number(tokenId)]; + if (nft === undefined) { + return Err({ InvalidTokenId: null }); + } + + if (nft.owner.toText() != ic.caller().toText()) { + return Err({ Unauthorized: null }); + } else { + nft.owner = MGMT; + return Ok(state.nextTxid()); + } + }), + set_name: update([text], Result(Null, Error), (name) => { + if (state.custodians.has(ic.caller().toText())) { + state.name = name; + return Ok(null); + } else { + return Err({ Unauthorized: null }); + } + }), + set_symbol: update([text], Result(Null, Error), (sym) => { + if (state.custodians.has(ic.caller().toText())) { + state.symbol = sym; + return Ok(null); + } else { + return Err({ Unauthorized: null }); + } + }), + set_logo: update([LogoResult], Result(Null, Error), (logo) => { + if (state.custodians.has(ic.caller().toText())) { + state.logo = logo; + return Ok(null); + } else { + return Err({ Unauthorized: null }); + } + }), + set_custodian: update( + [Principal, bool], + Result(Null, Error), + (user, custodian) => { + if (state.custodians.has(ic.caller().toText())) { + if (custodian) { + state.custodians.add(user.toText()); + } else { + state.custodians.delete(user.toText()); + } + return Ok(null); + } else { + return Err({ Unauthorized: null }); + } + } + ), + is_custodian: query([Principal], bool, (principal) => { + return state.custodians.has(principal.toText()); + }), +}); diff --git a/dapps/azle/dip721-nft/types.ts b/dapps/azle/dip721-nft/types.ts new file mode 100644 index 00000000..b5f53704 --- /dev/null +++ b/dapps/azle/dip721-nft/types.ts @@ -0,0 +1,113 @@ +import { + blob, + Canister, + nat, + nat16, + nat32, + nat64, + nat8, + Null, + Opt, + Principal, + Record, + text, + Tuple, + update, + Variant, + Vec, + Void, +} from "azle"; + +export const ConstrainedError = Variant({ + Unauthorized: Null, +}); + +export const InterfaceId = Variant({ + Approval: Null, + TransactionHistory: Null, + Mint: Null, + Burn: Null, + TransferNotification: Null, +}); + +export const MintResult = Record({ + token_id: nat64, + id: nat, +}); + +export const MetadataPurpose = Variant({ + Preview: Null, + Rendered: Null, +}); + +export const MetadataVal = Variant({ + TextContent: text, + BlobContent: blob, + NatContent: nat, + Nat8Content: nat8, + Nat16Content: nat16, + Nat32Content: nat32, + Nat64Content: nat64, +}); + +export const Error = Variant({ + Unauthorized: Null, + InvalidTokenId: Null, + ZeroAddress: Null, + Other: Null, +}); +export type Error = typeof Error; + +export const MetadataKeyVal = Tuple(text, MetadataVal); + +export const MetadataPart = Record({ + purpose: MetadataPurpose, + key_val_data: Vec(MetadataKeyVal), + data: blob, +}); + +export const MetadataDesc = Vec(MetadataPart); + +export const ExtendedMetadataResult = Record({ + metadata_desc: MetadataDesc, + token_id: nat64, +}); + +export const Nft = Record({ + owner: Principal, + approved: Opt(Principal), + id: nat64, + metadata: MetadataDesc, + content: blob, +}); +export type Nft = typeof Nft; + +type PrincipalString = string; + +export type State = { + nfts: Vec; + custodians: Set; + operators: Map>; // owner to operators + logo: LogoResult; + name: string; + symbol: string; + txid: nat; + nextTxid: () => nat; +}; + +export const LogoResult = Record({ + logo_type: text, + data: text, +}); +export type LogoResult = typeof LogoResult; + +export const InitArgs = Record({ + custodians: Opt(Vec(Principal)), + logo: LogoResult, + name: text, + symbol: text, +}); + +export const TransferSubscriber = Canister({ + onDIP721Received: update([Principal, Principal, nat64, blob], Void), +}); diff --git a/dapps/nft.sh b/dapps/nft.sh index b1602b01..e751ba8e 100644 --- a/dapps/nft.sh +++ b/dapps/nft.sh @@ -7,6 +7,7 @@ identity bob; let motoko = wasm_profiling("motoko/.dfx/local/canisters/dip721_nft/dip721_nft.wasm", mo_config); let rust = wasm_profiling("rust/.dfx/local/canisters/dip721_nft/dip721_nft.wasm", rs_config); +let azle = wasm_profiling("azle/.dfx/local/canisters/dip721_nft/dip721_nft.wasm", ts_config); let file = "README.md"; output(file, "\n## DIP721 NFT\n\n| |binary_size|init|mint_token|transfer_token|upgrade|\n|--|--:|--:|--:|--:|--:|\n"); @@ -59,3 +60,4 @@ function perf(wasm, title) { perf(motoko, "Motoko"); perf(rust, "Rust"); +perf(azle, "Azle");