From 4a4ff653bba2857cca131b6fc91562db74d0a23a Mon Sep 17 00:00:00 2001 From: William G Hatch Date: Fri, 10 Feb 2023 22:01:56 +0000 Subject: [PATCH] add arc-72 example --- examples/arc-72/index.mjs | 165 +++++++++++++++++++++++++++++ examples/arc-72/index.rsh | 214 ++++++++++++++++++++++++++++++++++++++ examples/arc-72/index.txt | 8 ++ 3 files changed, 387 insertions(+) create mode 100644 examples/arc-72/index.mjs create mode 100644 examples/arc-72/index.rsh create mode 100644 examples/arc-72/index.txt diff --git a/examples/arc-72/index.mjs b/examples/arc-72/index.mjs new file mode 100644 index 000000000..f533382dd --- /dev/null +++ b/examples/arc-72/index.mjs @@ -0,0 +1,165 @@ +import { loadStdlib } from '@reach-sh/stdlib'; +import * as backend from './build/index.main.mjs'; +const stdlib = loadStdlib(process.env); +const assert = stdlib.assert; + +const assertFail = async (promise, errStr) => { + try { + await promise; + } catch (e) { + if (errStr) { + if (`${e}`.includes(errStr)) { + return; + } + throw `Expected exception including message: "${errStr}", but got: ${e}`; + } else { + return; + } + } + throw `Expected exception but did not catch one: ${errStr}`; +}; +const assertEq = (a, b, context = 'assertEq') => { + if (a === b) return; + try { + const res1BN = bigNumberify(a); + const res2BN = bigNumberify(b); + if (res1BN.eq(res2BN)) return; + } catch {} + try { + const stripNulls = (s) => s.replace(/\0*$/g, ""); + if (stripNulls(`${a}`) === stripNulls(`${b}`)) return; + } catch {} + try { + if (JSON.stringify(a) === JSON.stringify(b)) return; + } catch {} + try { + if (parseInt(a) == parseInt(b)) return; + } catch {} + assert(false, `${context}: ${a} == ${b}`); +}; + +const initBal = stdlib.parseCurrency(100); +const accs = await stdlib.newTestAccounts(4, initBal); +accs.forEach(acc => acc.setGasLimit(5000000)); +const [acc0, acc1, acc2, acc3] = accs; +const [addr0, addr1, addr2, addr3] = accs.map(a => a.getAddress()); + +const zeroAddress = "0x" + "0".repeat(40); + +const ctc0 = acc0.contract(backend); +const params = { + zeroAddress, + // This is the length of an ipfs base32 cid v1 + metadataUriBase: "ipfs://bafy0000000000000000000000000000000000000000000000000000000/", +}; +console.log("About to launch NFT contract."); +await stdlib.withDisconnect(() => ctc0.participants.Deployer({ + params, + ready: () => {stdlib.disconnect()} +})); +const ctcinfo = await ctc0.getInfo(); +const ctc = acc => acc.contract(backend, ctcinfo); +const [ctc1, ctc2, ctc3] = [acc1, acc2, acc3].map(a => ctc(a)); + +const assertEvent = async (event, ...expectedArgs) => { + const e = await ctc0.events[event].next(); + const actualArgs = e.what; + expectedArgs.forEach((expectedArg, i) => + assertEq(actualArgs[i], expectedArg, `${event} field ${i}`) + ); +}; + +const assertView = async (view, args, expectedRet) => { + const ret = await ctc0.unsafeViews[view](...args); + assertEq( + ret, + expectedRet, + `view ${view}: expected ${expectedRet}, got ${ret}` + ); +}; + +//////////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// +// Test happy/sad paths + +console.log("About to start testing."); +// Interface ID that must be false +await assertView("supportsInterface", ["0xffffffff"], false); +// supportsInterface (ARC-73) interface ID +await assertView("supportsInterface", ["0x4e22a3ba"], true); +// ARC-72 core id +await assertView("supportsInterface", ["0x15974096"], true); +// ARC-72 metadata id +await assertView("supportsInterface", ["0x9112544c"], true); +// ARC-72 transfer management id +await assertView("supportsInterface", ["0x924d64fb"], true); +// Some non-existent interface ID +await assertView("supportsInterface", ["0x12345678"], false); + +await assertView("ownerOf", [1], zeroAddress); +await assertFail(ctc1.a.mintTo(addr2), "must be admin"); +await assertFail(ctc2.a.transferFrom(addr2, addr3, 1), "nft must exist"); + +console.log("About to mint first NFT."); +await ctc0.a.mintTo(addr2); +await assertEvent("Transfer", zeroAddress, addr2, 1); +await assertView("ownerOf", [1], addr2) +await assertView("tokenURI", [1], params.metadataUriBase + "0001"); +await assertFail(ctc0.a.transferFrom(addr2, addr3, 1), "must be nft owner or approved operator"); +await assertFail(ctc0.a.transferFrom(addr0, addr3, 1), "owner specified in API must be correct"); +await ctc2.a.transferFrom(addr2, addr3, 1); +await assertEvent("Transfer", addr2, addr3, 1); +await assertView("ownerOf", [1], addr3) +await assertFail(ctc0.a.transferFrom(addr3, addr1, 1), "must be nft owner or approved operator"); +await assertFail(ctc0.a.transferFrom(addr0, addr1, 1), "owner specified in API must be correct"); +await ctc3.a.transferFrom(addr3, addr2, 1); +await assertEvent("Transfer", addr3, addr2, 1); +await assertView("ownerOf", [1], addr2) + +console.log("About to test transfer management."); +await ctc0.a.mintTo(addr2); +await assertEvent("Transfer", zeroAddress, addr2, 2); +await assertView("ownerOf", [2], addr2) +await assertView("tokenURI", [2], params.metadataUriBase + "0002"); +await assertView("getApproved", [2], zeroAddress); +await assertView("isApprovedForAll", [addr3, addr1], false); +await ctc3.a.setApprovalForAll(addr1, true); +await assertEvent("ApprovalForAll", addr3, addr1, true); +await assertView("isApprovedForAll", [addr3, addr1], true); +await assertFail(ctc0.a.transferFrom(addr2, addr3, 2), "must be nft owner or approved operator"); +await ctc2.a.approve(addr0, 2); +await assertView("getApproved", [2], addr0); +await ctc0.a.transferFrom(addr2, addr3, 2); +await assertEvent("Transfer", addr2, addr3, 2); +await assertView("getApproved", [2], zeroAddress); +// addr1 is approved for all for addr3 +await ctc1.a.transferFrom(addr3, addr1, 2); +await assertEvent("Transfer", addr3, addr1, 2); +await assertView("isApprovedForAll", [addr3, addr1], true); +await ctc1.a.transferFrom(addr1, addr3, 2); +await assertEvent("Transfer", addr1, addr3, 2); +await ctc3.a.setApprovalForAll(addr1, false); +await assertEvent("ApprovalForAll", addr3, addr1, false); +await assertView("isApprovedForAll", [addr3, addr1], false); +// addr1 is no longer approved +await assertFail(ctc1.a.transferFrom(addr3, addr1, 2)); + +await assertView("totalSupply", [], 2); +await assertFail(ctc1.a.burn(2), "must be nft owner or approved operator"); +await ctc3.a.burn(2); +await assertEvent("Transfer", addr3, zeroAddress, 2); +await assertView("totalSupply", [], 1); + +console.log("About to test admin update."); +await assertFail(ctc1.a.updateAdmin(addr2), "must be admin"); +await ctc0.a.updateAdmin(addr2); +await assertFail(ctc0.a.updateAdmin(addr3), "must be admin"); +await assertFail(ctc0.a.mintTo(addr3), "must be admin"); +await ctc2.a.mintTo(addr3) +await assertEvent("Transfer", zeroAddress, addr3, 3); +await assertView("ownerOf", [3], addr3) +await assertView("totalSupply", [], 2); + + +console.log("Done testing NFT contract."); + diff --git a/examples/arc-72/index.rsh b/examples/arc-72/index.rsh new file mode 100644 index 000000000..30ac253b4 --- /dev/null +++ b/examples/arc-72/index.rsh @@ -0,0 +1,214 @@ +'reach 0.1'; +'use strict'; + +// baseUriLength is the length of ipfs:/// +const baseUriLength = 67; +const metadataUriType = Bytes(256); +const NftId = UInt256; +// This NFT is limitted to 10^3 tokens for the sake of the `tokenUri` method to construct a URI. But this limit could be raised by extending the URI construction. +const maxNftId = 9999; + +export const main = Reach.App(() => { + setOptions({ + connectors: [ALGO], + }); + const D = Participant('Deployer', { + ready: Fun([], Null), + params: Object({ + zeroAddress: Address, + metadataUriBase: Bytes(baseUriLength), + }), + }); + const A = API({ + transferFrom: Fun([Address, Address, NftId], Null), + approve: Fun([Address, NftId], Null), + setApprovalForAll: Fun([Address, Bool], Null), + updateAdmin: Fun([Address], Null), + mintTo: Fun([Address], NftId), + burn: Fun([NftId], Null), + }); + const V = View({ + ownerOf: Fun([NftId], Address), + tokenURI: Fun([NftId], metadataUriType), + supportsInterface: Fun([Bytes(4)], Bool), + getApproved: Fun([NftId], Address), + isApprovedForAll: Fun([Address, Address], Bool), + totalSupply: Fun([], UInt256), + currentAdmin: Fun([], Address), + + }); + const E = Events({ + Transfer: [Address, Address, NftId], + Approval: [Address, Address, NftId], + ApprovalForAll: [Address, Address, Bool], + }); + + init(); + D.only(() => { + const params = declassify(interact.params); + }); + D.publish(params); + + // nftData is [ownerAddress, approvedAddress] + const nftData = new Map(NftId, Tuple(Address, Address)); + const getNft = (nftId, mustExist) => { + if (mustExist) { + check(isSome(nftData[nftId]), "nft must exist"); + } + return fromSome(nftData[nftId], [params.zeroAddress, params.zeroAddress]); + } + const getNftOwner = (nftId, mustExist) => getNft(nftId, mustExist)[0]; + const getNftApproved = (nftId, mustExist) => getNft(nftId, mustExist)[1]; + + const operatorData = new Map(Tuple(Address, Address), Bool); + const getApprovalForAll = (owner, operator) => { + return fromSome(operatorData[[owner, operator]], false); + } + + const canTransfer = (nftId, addr) => { + const [owner, controller] = getNft(nftId, true); + return addr == owner || addr == controller || getApprovalForAll(owner, addr); + } + + D.interact.ready(); + + const vars = parallelReduce({ + adminAddress: D, + nMinted: UInt256(0), + totalSupply: UInt256(0), + }) + .define(() => { + + // helpers + const noPayment = () => [0]; + const tokenUri = (nftId) => { + check(isSome(nftData[nftId]), "nft must exist"); + const idShort = UInt(nftId, true); + const digitArr = array(Bytes(1), + ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",]); + const digit0 = digitArr[idShort % 10]; + const digit1 = digitArr[(idShort / 10) % 10]; + const digit2 = digitArr[(idShort / 100) % 10]; + const digit3 = digitArr[(idShort / 1000) % 10]; + const digits1 = Bytes.concat(digit1, digit0); + const digits2 = Bytes.concat(digit2, digits1); + const digits3 = Bytes.concat(digit3, digits2); + const uri = Bytes.concat(params.metadataUriBase, digits3); + return metadataUriType.pad(uri); + } + const ifaceSelectors = [ + // ARC-73 (supportsInterface) + Bytes.fromHex("0x4e22a3ba"), + // ARC-72 Core + Bytes.fromHex("0x15974096"), + // ARC-72 Metadata extension + Bytes.fromHex("0x9112544c"), + // ARC-72 Transfer Management extension + Bytes.fromHex("0x924d64fb"), + ]; + const supportsInterface = (ifaceSelector) => { + return ifaceSelectors.includes(ifaceSelector); + } + + //views + V.ownerOf.set(nftId => getNftOwner(nftId, false)); + V.tokenURI.set(tokenUri); + V.supportsInterface.set(supportsInterface); + V.getApproved.set(nftId => getNftApproved(nftId, false)); + V.isApprovedForAll.set(getApprovalForAll); + V.totalSupply.set(() => vars.totalSupply); + V.currentAdmin.set(() => vars.adminAddress); + }) + .invariant(balance() === 0) + .while(true) + .api_(A.updateAdmin, (newAdmin) => { + check(vars.adminAddress == this, "must be admin") + return [ + noPayment(), + (k) => { + k(null); + return {...vars, adminAddress: newAdmin}; + }, + ]; + }) + .api_(A.mintTo, (firstOwner) => { + check(vars.adminAddress == this, "must be admin"); + check(vars.nMinted <= UInt256(maxNftId), "already minted max NFT") + const nftId = vars.nMinted + UInt256(1); + const newTotalSupply = vars.totalSupply + UInt256(1); + return [ + noPayment(), + (k) => { + nftData[nftId] = [firstOwner, params.zeroAddress]; + E.Transfer(params.zeroAddress, firstOwner, nftId); + k(nftId); + return { + ...vars, + nMinted: nftId, + totalSupply: newTotalSupply, + }; + }, + ]; + }) + .api_(A.approve, (controller, nftId) => { + const owner = getNftOwner(nftId, true); + check(this == owner, "must be nft owner"); + return [ + noPayment(), + (k) => { + nftData[nftId] = [owner, controller]; + E.Approval(owner, controller, nftId); + k(null); + return vars; + }, + ]; + }) + .api_(A.setApprovalForAll, (operator, tOrF) => { + return [ + noPayment(), + (k) => { + if (tOrF) { + operatorData[[this, operator]] = true; + } else { + delete operatorData[[this, operator]]; + } + E.ApprovalForAll(this, operator, tOrF); + k(null); + return vars; + }, + ]; + }) + .api_(A.transferFrom, (oldOwner, newOwner, nftId) => { + const oldOwnerReal = getNftOwner(nftId, true); + check(oldOwnerReal == oldOwner, "owner specified in API must be correct"); + check(canTransfer(nftId, this), "must be nft owner or approved operator"); + return [ + noPayment(), + (k) => { + nftData[nftId] = [newOwner, params.zeroAddress]; + E.Transfer(oldOwner, newOwner, nftId); + k(null); + return vars; + }, + ]; + }) + .api_(A.burn, (nftId) => { + const owner = getNftOwner(nftId, true); + check(canTransfer(nftId, this), "must be nft owner or approved operator"); + return [ + noPayment(), + (k) => { + delete nftData[nftId]; + E.Transfer(owner, params.zeroAddress, nftId); + k(null); + return { + ...vars, + totalSupply: vars.totalSupply - UInt256(1), + }; + }, + ]; + }) + ; + commit(); + exit(); +}); diff --git a/examples/arc-72/index.txt b/examples/arc-72/index.txt new file mode 100644 index 000000000..3b4a14ade --- /dev/null +++ b/examples/arc-72/index.txt @@ -0,0 +1,8 @@ +Compiling `main`... +Verifying knowledge assertions +Verifying for generic connector + Verifying when ALL participants are honest + Verifying when NO participants are honest +Checked 230 theorems; No failures! +WARNING: Compiler instructed to emit for Algorand, but the conservative analysis found these potential problems: + * This program uses 'ALGOExitMode: DeleteAndCloseOutAll_SoundASAs_UnsoundElse' (the default) _and_ creates Map entries and Reach cannot guarantee that these closed at application exit, but we are generating a close out anyways. If those resources are not freed, then this close out will fail and the final transaction will always be rejected.