diff --git a/smart-contracts/assembly/contracts/NFT/NFT-internals.ts b/smart-contracts/assembly/contracts/NFT/NFT-internals.ts index 8d068df..e863a05 100644 --- a/smart-contracts/assembly/contracts/NFT/NFT-internals.ts +++ b/smart-contracts/assembly/contracts/NFT/NFT-internals.ts @@ -54,7 +54,7 @@ export function _constructor(name: string, symbol: string): void { * @param address - address to get the balance for * @returns the key of the balance in the storage for the given address */ -function balanceKey(address: string): StaticArray { +export function balanceKey(address: string): StaticArray { return BALANCE_KEY_PREFIX.concat(stringToBytes(address)); } @@ -62,7 +62,7 @@ function balanceKey(address: string): StaticArray { * @param tokenId - the tokenID of the owner * @returns the key of the owner in the storage for the given tokenId */ -function ownerKey(tokenId: u256): StaticArray { +export function ownerKey(tokenId: u256): StaticArray { return OWNER_KEY_PREFIX.concat(u256ToBytes(tokenId)); } @@ -227,8 +227,7 @@ export function _isAuthorized(operator: string, tokenId: u256): bool { * For example if you were to wrap this helper in a `transfer` function, * you should check that the caller is the owner of the token, and then call the _update function. */ - -export function _update(to: string, tokenId: u256, auth: string): void { +export function _update(to: string, tokenId: u256, auth: string): string { const from = _ownerOf(tokenId); assert(to != from, 'The from and to addresses are the same'); if (auth != '') { @@ -258,7 +257,10 @@ export function _update(to: string, tokenId: u256, auth: string): void { // burn the token Storage.del(ownerKey(tokenId)); } + + return from; } + /** * Transfers the ownership of an NFT from one address to another address. * @param from - The address of the current owner diff --git a/smart-contracts/assembly/contracts/NFT/NFTEnumerable-example.ts b/smart-contracts/assembly/contracts/NFT/NFTEnumerable-example.ts new file mode 100644 index 0000000..4e57664 --- /dev/null +++ b/smart-contracts/assembly/contracts/NFT/NFTEnumerable-example.ts @@ -0,0 +1,253 @@ +/** + * This is an example of an NFT contract that uses the `NFTEnumerable-internals` + * helper functions to implement enumeration functionality similar to the ERC-721 Enumerable extension. + * + * **Note:** We have diverged from the ERC-721 Enumerable standard in this implementation. + * On the Massa blockchain, indices are not necessary for token enumeration because we can directly access + * the storage and structure data in a way that allows developers to easily retrieve the needed values. + * + * Instead of maintaining explicit arrays or mappings of token IDs and owner tokens by index, + * we utilize key prefix querying to retrieve all token IDs and tokens owned by specific addresses. + * This approach leverages Massa's storage capabilities for efficient data access and simplifies the contract logic. + * + * **Benefits of This Approach:** + * - **Reduced Storage Costs:** Eliminates the need for additional storage structures to maintain indices. + * - **Simplified Logic:** Streamlines the process of token enumeration, making the contract easier to maintain. + * + * **Underlying Storage Structure:** + * + * We store important information in the following formats: + * + * - **Total Supply:** + * + * [TOTAL_SUPPLY_KEY] = totalSupply + * - `TOTAL_SUPPLY_KEY`: A constant key for the total supply of tokens. + * - `totalSupply`: A `u256` value representing the total number of tokens in existence. + * + * - **Owned Tokens:** + * + * [OWNED_TOKENS_KEY][owner][tokenId] = tokenId + * - `OWNED_TOKENS_KEY`: A constant prefix for all owned tokens. + * - `owner`: The owner's address. + * - `tokenId`: The token ID. + * - The value `tokenId` is stored to facilitate easy retrieval. + * + * **Retrieving Data Using Key Prefixes:** + * + * We utilize the `getKeys` function from the `massa-as-sdk`, which allows us to retrieve all keys that start with a + * specific prefix. This enables us to: + * - Retrieve all tokens owned by a specific address by querying keys with the prefix `[OWNED_TOKENS_KEY][owner]`. + * - Retrieve all existing token IDs by querying keys with the appropriate prefix if needed. + * + * **Key Points:** + * - The `getKeys` function from the `massa-as-sdk` allows us to filter storage keys by a given prefix, + * enabling efficient data retrieval. + * + * **This file does two things:** + * 1. It wraps the `NFTEnumerable-internals` functions, manages the deserialization/serialization of the arguments + * and return values, and exposes them to the outside world. + * 2. It implements some custom features that are not part of the ERC-721 standard, + * such as `mint`, `burn`, or ownership management. + * + * **Important:** The `NFTEnumerable-internals` functions are not supposed to be re-exported by this file. + */ + +import { + Args, + boolToByte, + stringToBytes, + u256ToBytes, +} from '@massalabs/as-types'; +import { + _approve, + _balanceOf, + _constructor, + _getApproved, + _isApprovedForAll, + _name, + _ownerOf, + _setApprovalForAll, + _symbol, + _update, + _transferFrom, + _totalSupply, +} from './NFTEnumerable-internals'; +import { setOwner, onlyOwner } from '../utils/ownership'; +import { Context, isDeployingContract } from '@massalabs/massa-as-sdk'; + +/** + * @param binaryArgs - serialized strings representing the name and the symbol of the NFT + * + * @remarks This is the constructor of the contract. It can only be called once, when the contract is being deployed. + * It expects two serialized arguments: the name and the symbol of the NFT. + * Once the constructor has handled the deserialization of the arguments, + * it calls the _constructor function from the NFT-enumerable-internals. + * + * Finally, it sets the owner of the contract to the caller of the constructor. + */ +export function constructor(binaryArgs: StaticArray): void { + assert(isDeployingContract()); + const args = new Args(binaryArgs); + const name = args.nextString().expect('name argument is missing or invalid'); + const symbol = args + .nextString() + .expect('symbol argument is missing or invalid'); + _constructor(name, symbol); + setOwner(new Args().add(Context.caller().toString()).serialize()); +} + +export function name(): string { + return _name(); +} + +export function symbol(): string { + return _symbol(); +} + +/** + * + * @param binaryArgs - serialized string representing the address whose balance we want to check + * @returns a serialized u256 representing the balance of the address + */ +export function balanceOf(binaryArgs: StaticArray): StaticArray { + const args = new Args(binaryArgs); + const address = args + .nextString() + .expect('address argument is missing or invalid'); + return u256ToBytes(_balanceOf(address)); +} + +/** + * + * @param binaryArgs - serialized u256 representing the tokenId whose owner we want to check + * @returns a serialized string representing the address of owner of the tokenId + */ +export function ownerOf(binaryArgs: StaticArray): StaticArray { + const args = new Args(binaryArgs); + const tokenId = args + .nextU256() + .expect('tokenId argument is missing or invalid'); + return stringToBytes(_ownerOf(tokenId)); +} + +/** + * + * @param binaryArgs - serialized u256 representing the tokenId whose approved address we want to check + * @returns a serialized string representing the address of the approved address of the tokenId + */ +export function getApproved(binaryArgs: StaticArray): StaticArray { + const args = new Args(binaryArgs); + const tokenId = args + .nextU256() + .expect('tokenId argument is missing or invalid'); + return stringToBytes(_getApproved(tokenId)); +} + +/** + * + * @param binaryArgs - serialized strings representing the address of an owner and an operator + * @returns a serialized u8 representing a boolean value indicating if + * the operator is approved for all the owner's tokens + */ +export function isApprovedForAll(binaryArgs: StaticArray): StaticArray { + const args = new Args(binaryArgs); + const owner = args + .nextString() + .expect('owner argument is missing or invalid'); + const operator = args + .nextString() + .expect('operator argument is missing or invalid'); + return boolToByte(_isApprovedForAll(owner, operator)); +} + +/** + * + * @param binaryArgs - serialized strings representing the address of the recipient and the tokenId to approve + * @remarks This function is only callable by the owner of the tokenId or an approved operator. + * Indeed, this will be checked by the _approve function of the NFT-internals. + * + */ +export function approve(binaryArgs: StaticArray): void { + const args = new Args(binaryArgs); + const to = args.nextString().expect('to argument is missing or invalid'); + const tokenId = args + .nextU256() + .expect('tokenId argument is missing or invalid'); + _approve(to, tokenId); +} + +/** + * + * @param binaryArgs - serialized arguments representing the address of the operator and a boolean value indicating + * if the operator should be approved for all the caller's tokens + * + */ +export function setApprovalForAll(binaryArgs: StaticArray): void { + const args = new Args(binaryArgs); + const to = args.nextString().expect('to argument is missing or invalid'); + const approved = args + .nextBool() + .expect('approved argument is missing or invalid'); + _setApprovalForAll(to, approved); +} + +/** + * + * @param binaryArgs - serialized arguments representing the address of the sender, + * the address of the recipient, and the tokenId to transfer. + * + * @remarks This function is only callable by the owner of the tokenId or an approved operator. + */ +export function transferFrom(binaryArgs: StaticArray): void { + const args = new Args(binaryArgs); + const from = args.nextString().expect('from argument is missing or invalid'); + const to = args.nextString().expect('to argument is missing or invalid'); + const tokenId = args + .nextU256() + .expect('tokenId argument is missing or invalid'); + _transferFrom(from, to, tokenId); +} + +/** + * + * @param binaryArgs - serialized arguments representing the address of the recipient and the tokenId to mint + * + * @remarks This function is only callable by the owner of the contract. + */ +export function mint(binaryArgs: StaticArray): void { + onlyOwner(); + const args = new Args(binaryArgs); + const to = args.nextString().expect('to argument is missing or invalid'); + const tokenId = args + .nextU256() + .expect('tokenId argument is missing or invalid'); + _update(to, tokenId, ''); +} + +/** + * + * @param binaryArgs - serialized u256 representing the tokenId to burn + * + * @remarks This function is not part of the ERC721 standard. + * It serves as an example of how to use the NFT-enumerable-internals functions to implement custom features. + */ +export function burn(binaryArgs: StaticArray): void { + const args = new Args(binaryArgs); + const tokenId = args + .nextU256() + .expect('tokenId argument is missing or invalid'); + _update('', tokenId, ''); +} + +/** + * Returns the total number of tokens. + * @returns a serialized u256 representing the total supply + */ +export function totalSupply(_: StaticArray): StaticArray { + return u256ToBytes(_totalSupply()); +} + +/** + * Expose the ownerAddress function to allow checking the owner of the contract. + */ +export { ownerAddress } from '../utils/ownership'; diff --git a/smart-contracts/assembly/contracts/NFT/NFTEnumerable-internals.ts b/smart-contracts/assembly/contracts/NFT/NFTEnumerable-internals.ts new file mode 100644 index 0000000..8af1de1 --- /dev/null +++ b/smart-contracts/assembly/contracts/NFT/NFTEnumerable-internals.ts @@ -0,0 +1,164 @@ +/** + * This file provides internal functions to support token enumeration functionality for the NFT contract on Massa. + * It utilizes key prefix querying to retrieve all token IDs and tokens owned by specific addresses + * in the datastore without maintaining explicit indices. + */ + +import { Context, Storage } from '@massalabs/massa-as-sdk'; +import { u256 } from 'as-bignum/assembly'; +import { bytesToU256, stringToBytes, u256ToBytes } from '@massalabs/as-types'; +import { + _isAuthorized, + _ownerOf, + ownerKey, + _update as _updateBase, + _constructor as _constructorBase, +} from './NFT-internals'; +export const TOTAL_SUPPLY_KEY: StaticArray = stringToBytes('totalSupply'); +export const OWNED_TOKENS_KEY: StaticArray = stringToBytes('ownedTokens'); + +/** + * Constructs a new NFT contract. + * @param binaryArgs - the binary arguments name and symbol + * + * @remarks This function shouldn't be directly exported by the implementation contract. + * It is meant to be called by the constructor of the implementation contract. + * Please check the NFTEnumerable-example.ts file for an example of how to use this function. + */ +export function _constructor(name: string, symbol: string): void { + _constructorBase(name, symbol); + Storage.set(TOTAL_SUPPLY_KEY, u256ToBytes(u256.Zero)); +} + +/* -------------------------------------------------------------------------- */ +/* TOTAL SUPPLY */ +/* -------------------------------------------------------------------------- */ + +/** + * Returns the total number of tokens in existence. + */ +export function _totalSupply(): u256 { + return bytesToU256(Storage.get(TOTAL_SUPPLY_KEY)); +} + +/** + * Increases the total supply by the given delta. + * @param delta - The amount to increase the total supply by. + * + * @throws Will throw an error if the addition of delta to currentSupply exceeds u256.Max. + */ +export function _increaseTotalSupply(delta: u256): void { + const currentSupply = _totalSupply(); + const maxAllowedDelta = u256.sub(u256.Max, currentSupply); + assert(u256.le(delta, maxAllowedDelta), 'Total supply overflow'); + const newSupply = u256.add(currentSupply, delta); + Storage.set(TOTAL_SUPPLY_KEY, u256ToBytes(newSupply)); +} + +/** + * Decreases the total supply by the given delta. + * @param delta - The amount to decrease the total supply by. + * + * @throws Will throw an error if `delta` exceeds the current total supply, causing an underflow. + */ +export function _decreaseTotalSupply(delta: u256): void { + const currentSupply = _totalSupply(); + assert(u256.le(delta, currentSupply), 'Total supply underflow'); + const newSupply = u256.sub(currentSupply, delta); + Storage.set(TOTAL_SUPPLY_KEY, u256ToBytes(newSupply)); +} + +/* -------------------------------------------------------------------------- */ +/* OWNED TOKENS */ +/* -------------------------------------------------------------------------- */ + +/** + * Returns the key prefix for the owned tokens of an owner. + * @param owner - The owner's address. + */ +export function _getOwnedTokensKeyPrefix(owner: string): StaticArray { + return OWNED_TOKENS_KEY.concat(stringToBytes(owner)); +} + +/** + * Adds a token to the owner's list of tokens. + * @param owner - The owner's address. + * @param tokenId - The token ID to add. + */ +function _addTokenToOwnerEnumeration(owner: string, tokenId: u256): void { + const key = _getOwnedTokensKeyPrefix(owner).concat(u256ToBytes(tokenId)); + Storage.set(key, u256ToBytes(tokenId)); +} + +/** + * Removes a token from the owner's list of tokens. + * @param owner - The owner's address. + * @param tokenId - The token ID to remove. + */ +function _removeTokenFromOwnerEnumeration(owner: string, tokenId: u256): void { + const key = _getOwnedTokensKeyPrefix(owner).concat(u256ToBytes(tokenId)); + Storage.del(key); +} + +/* -------------------------------------------------------------------------- */ +/* UPDATE */ +/* -------------------------------------------------------------------------- */ + +/** + * Updates the token ownership and enumerations. + * @param to - The address to transfer the token to. + * @param tokenId - The token ID. + * @param auth - The address authorized to perform the update. + */ +export function _update(to: string, tokenId: u256, auth: string): void { + const previousOwner = _updateBase(to, tokenId, auth); + + // Mint + if (previousOwner == '') { + _addTokenToOwnerEnumeration(to, tokenId); + _increaseTotalSupply(u256.One); + } else { + // Transfer + if (to != '' && to != previousOwner) { + _removeTokenFromOwnerEnumeration(previousOwner, tokenId); + _addTokenToOwnerEnumeration(to, tokenId); + } + // Burn + else if (to == '') { + _removeTokenFromOwnerEnumeration(previousOwner, tokenId); + _decreaseTotalSupply(u256.One); + } + } +} + +/* -------------------------------------------------------------------------- */ +/* EXPORT NECESSARY FUNCTIONS */ +/* -------------------------------------------------------------------------- */ + +/** + * Transfers a token from one address to another. + * @param from - The current owner's address. + * @param to - The new owner's address. + * @param tokenId - The token ID to transfer. + */ +export function _transferFrom(from: string, to: string, tokenId: u256): void { + assert( + _isAuthorized(Context.caller().toString(), tokenId), + 'Unauthorized caller', + ); + assert(from == _ownerOf(tokenId), 'Unauthorized from'); + assert(to != '', 'Unauthorized to'); + assert(Storage.has(ownerKey(tokenId)), 'Nonexistent token'); + _update(to, tokenId, from); +} + +export { + _approve, + _balanceOf, + _getApproved, + _isApprovedForAll, + _name, + _ownerOf, + _setApprovalForAll, + _symbol, +} from './NFT-internals'; diff --git a/smart-contracts/assembly/contracts/NFT/__tests__/NFTEnumerable-example.spec.ts b/smart-contracts/assembly/contracts/NFT/__tests__/NFTEnumerable-example.spec.ts new file mode 100644 index 0000000..866f72e --- /dev/null +++ b/smart-contracts/assembly/contracts/NFT/__tests__/NFTEnumerable-example.spec.ts @@ -0,0 +1,310 @@ +import { + changeCallStack, + resetStorage, + setDeployContext, +} from '@massalabs/massa-as-sdk'; + +import { + Args, + byteToBool, + bytesToString, + bytesToU256, +} from '@massalabs/as-types'; +import { u256 } from 'as-bignum/assembly'; +import { + approve, + balanceOf, + burn, + constructor, + getApproved, + isApprovedForAll, + mint, + name, + ownerAddress, + ownerOf, + setApprovalForAll, + symbol, + totalSupply, + transferFrom, +} from '../NFTEnumerable-example'; + +import { getOwnedTokens } from './helpers'; + +const NFTName = 'MASSA_NFT'; +const NFTSymbol = 'NFT'; +const contractOwner = 'A12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKq'; +const tokenAddress = 'AS12BqZEQ6sByhRLyEuf0YbQmcF2PsDdkNNG1akBJu9XcjZA1eT'; +const from = 'AU12CzoKEASaeBHnxGLnHDG2u73dLzWWfgvW6bc4L1UfMA5Uc5Fg7'; +const to = 'AU178qZCfaNXkz9tQiXJcVfAEnYGJ27UoNtFFJh3BiT8jTfY8P2D'; +const approved = 'AU1sF3HSa7fcBoE12bE1Eq2ohKqcRPBHuNRmdqAMfw8WEkHCU3aF'; +const zeroAddress = ''; +const tokenIds = [ + u256.One, + u256.fromU32(2), + u256.fromU32(3), + u256.fromU32(4), + u256.fromU32(5), +]; + +function switchUser(user: string): void { + changeCallStack(user + ' , ' + tokenAddress); +} + +beforeEach(() => { + resetStorage(); + switchUser(contractOwner); + setDeployContext(contractOwner); + constructor(new Args().add(NFTName).add(NFTSymbol).serialize()); +}); + +describe('NFT Enumerable Contract', () => { + describe('Initialization', () => { + test('should return correct name and symbol', () => { + expect(name()).toBe(NFTName); + expect(symbol()).toBe(NFTSymbol); + }); + test('should return correct contract owner', () => { + expect(bytesToString(ownerAddress([]))).toBe(contractOwner); + }); + }); + + describe('Minting', () => { + test('should mint a token to an address', () => { + mint(new Args().add(to).add(tokenIds[0]).serialize()); + expect( + bytesToString(ownerOf(new Args().add(tokenIds[0]).serialize())), + ).toBe(to); + expect( + bytesToU256(balanceOf(new Args().add(to).serialize())), + ).toStrictEqual(u256.One); + expect(bytesToU256(totalSupply([]))).toStrictEqual(u256.One); + }); + + test('should mint multiple tokens to different addresses', () => { + mint(new Args().add(to).add(tokenIds[0]).serialize()); + mint(new Args().add(from).add(tokenIds[1]).serialize()); + expect( + bytesToU256(balanceOf(new Args().add(to).serialize())), + ).toStrictEqual(u256.One); + expect( + bytesToU256(balanceOf(new Args().add(from).serialize())), + ).toStrictEqual(u256.One); + expect(bytesToU256(totalSupply([]))).toStrictEqual(u256.fromU32(2)); + }); + + test('should not mint to zero address', () => { + expect(() => { + mint(new Args().add(zeroAddress).add(tokenIds[0]).serialize()); + }).toThrow('Unauthorized to'); + }); + + test('should not mint an already existing tokenId', () => { + mint(new Args().add(to).add(tokenIds[0]).serialize()); + expect(() => { + mint(new Args().add(to).add(tokenIds[0]).serialize()); + }).toThrow('Token already minted'); + }); + + test('should not allow non-owner to mint tokens', () => { + switchUser(from); + expect(() => { + mint(new Args().add(to).add(tokenIds[0]).serialize()); + }).toThrow('Only owner can call this function'); + }); + }); + + describe('Approval', () => { + test('should approve a token for an address', () => { + mint(new Args().add(from).add(tokenIds[0]).serialize()); + switchUser(from); + approve(new Args().add(approved).add(tokenIds[0]).serialize()); + expect( + bytesToString(getApproved(new Args().add(tokenIds[0]).serialize())), + ).toBe(approved); + }); + + test('should set approval for all', () => { + mint(new Args().add(from).add(tokenIds[0]).serialize()); + switchUser(from); + setApprovalForAll(new Args().add(approved).add(true).serialize()); + expect( + byteToBool( + isApprovedForAll(new Args().add(from).add(approved).serialize()), + ), + ).toBe(true); + }); + + test('should revoke approval for all', () => { + mint(new Args().add(from).add(tokenIds[0]).serialize()); + switchUser(from); + setApprovalForAll(new Args().add(approved).add(true).serialize()); + setApprovalForAll(new Args().add(approved).add(false).serialize()); + expect( + byteToBool( + isApprovedForAll(new Args().add(from).add(approved).serialize()), + ), + ).toBe(false); + }); + + test('should not approve token not owned', () => { + mint(new Args().add(from).add(tokenIds[0]).serialize()); + switchUser(approved); + expect(() => { + approve(new Args().add(approved).add(tokenIds[0]).serialize()); + }).toThrow('Unauthorized caller'); + }); + }); + + describe('Transfers', () => { + test('should transfer token from owner', () => { + mint(new Args().add(from).add(tokenIds[0]).serialize()); + switchUser(from); + transferFrom(new Args().add(from).add(to).add(tokenIds[0]).serialize()); + expect( + bytesToString(ownerOf(new Args().add(tokenIds[0]).serialize())), + ).toBe(to); + expect( + bytesToU256(balanceOf(new Args().add(to).serialize())), + ).toStrictEqual(u256.One); + expect( + bytesToU256(balanceOf(new Args().add(from).serialize())), + ).toStrictEqual(u256.Zero); + }); + + test('should transfer approved token', () => { + mint(new Args().add(from).add(tokenIds[0]).serialize()); + switchUser(from); + approve(new Args().add(approved).add(tokenIds[0]).serialize()); + switchUser(approved); + transferFrom(new Args().add(from).add(to).add(tokenIds[0]).serialize()); + expect( + bytesToString(ownerOf(new Args().add(tokenIds[0]).serialize())), + ).toBe(to); + }); + + test('should transfer token using approval for all', () => { + mint(new Args().add(from).add(tokenIds[0]).serialize()); + switchUser(from); + setApprovalForAll(new Args().add(approved).add(true).serialize()); + switchUser(approved); + transferFrom(new Args().add(from).add(to).add(tokenIds[0]).serialize()); + expect( + bytesToString(ownerOf(new Args().add(tokenIds[0]).serialize())), + ).toBe(to); + }); + + test('should not transfer token without approval', () => { + mint(new Args().add(from).add(tokenIds[0]).serialize()); + switchUser(approved); + expect(() => { + transferFrom(new Args().add(from).add(to).add(tokenIds[0]).serialize()); + }).toThrow('Unauthorized caller'); + }); + }); + + describe('Burning', () => { + test('should burn a token', () => { + mint(new Args().add(from).add(tokenIds[0]).serialize()); + switchUser(from); + burn(new Args().add(tokenIds[0]).serialize()); + + expect(ownerOf(new Args().add(tokenIds[0]).serialize())).toStrictEqual( + [], + ); + + expect(bytesToU256(totalSupply([]))).toStrictEqual(u256.Zero); + }); + + test('should burn a token with approval', () => { + mint(new Args().add(from).add(tokenIds[0]).serialize()); + switchUser(from); + approve(new Args().add(approved).add(tokenIds[0]).serialize()); + switchUser(approved); + burn(new Args().add(tokenIds[0]).serialize()); + expect(ownerOf(new Args().add(tokenIds[0]).serialize())).toStrictEqual( + [], + ); + expect(bytesToU256(totalSupply([]))).toStrictEqual(u256.Zero); + }); + + test('should burn a token using approval for all', () => { + mint(new Args().add(from).add(tokenIds[0]).serialize()); + switchUser(from); + setApprovalForAll(new Args().add(approved).add(true).serialize()); + switchUser(approved); + burn(new Args().add(tokenIds[0]).serialize()); + expect(ownerOf(new Args().add(tokenIds[0]).serialize())).toStrictEqual( + [], + ); + expect(bytesToU256(totalSupply([]))).toStrictEqual(u256.Zero); + }); + + test('should not burn token without approval', () => { + mint(new Args().add(from).add(tokenIds[0]).serialize()); + switchUser(to); + expect(() => { + burn(new Args().add(tokenIds[0]).serialize()); + }).toThrow('Unauthorized caller'); + }); + }); + + describe('Enumeration', () => { + test('should return correct total supply', () => { + expect(bytesToU256(totalSupply([]))).toStrictEqual(u256.Zero); + mint(new Args().add(from).add(tokenIds[0]).serialize()); + expect(bytesToU256(totalSupply([]))).toStrictEqual(u256.One); + mint(new Args().add(to).add(tokenIds[1]).serialize()); + expect(bytesToU256(totalSupply([]))).toStrictEqual(u256.fromU32(2)); + }); + + test('should return correct tokens owned by an address', () => { + // Assuming we have an exported function to get owned tokens + mint(new Args().add(from).add(tokenIds[0]).serialize()); + mint(new Args().add(from).add(tokenIds[1]).serialize()); + mint(new Args().add(to).add(tokenIds[2]).serialize()); + + // Get tokens owned by 'from' address + const fromTokens = getOwnedTokens(from); + expect(fromTokens.length).toBe(2); + expect(fromTokens).toContainEqual(tokenIds[0]); + expect(fromTokens).toContainEqual(tokenIds[1]); + + // Get tokens owned by 'to' address + const toTokens = getOwnedTokens(to); + expect(toTokens.length).toBe(1); + expect(toTokens).toContainEqual(tokenIds[2]); + }); + + test('should update owned tokens after transfer', () => { + mint(new Args().add(from).add(tokenIds[0]).serialize()); + mint(new Args().add(from).add(tokenIds[1]).serialize()); + switchUser(from); + transferFrom(new Args().add(from).add(to).add(tokenIds[0]).serialize()); + + // Get tokens owned by 'from' address + const fromTokens = getOwnedTokens(from); + expect(fromTokens.length).toBe(1); + expect(fromTokens).toContainEqual(tokenIds[1]); + + // Get tokens owned by 'to' address + const toTokens = getOwnedTokens(to); + expect(toTokens.length).toBe(1); + expect(toTokens).toContainEqual(tokenIds[0]); + }); + + test('should update owned tokens after burn', () => { + mint(new Args().add(from).add(tokenIds[0]).serialize()); + mint(new Args().add(from).add(tokenIds[1]).serialize()); + switchUser(from); + burn(new Args().add(tokenIds[0]).serialize()); + + // Get tokens owned by 'from' address + const fromTokens = getOwnedTokens(from); + expect(fromTokens.length).toBe(1); + expect(fromTokens).toContainEqual(tokenIds[1]); + + // Total supply should be updated + expect(bytesToU256(totalSupply([]))).toStrictEqual(u256.One); + }); + }); +}); diff --git a/smart-contracts/assembly/contracts/NFT/__tests__/NFTEnumerable-internals.spec.ts b/smart-contracts/assembly/contracts/NFT/__tests__/NFTEnumerable-internals.spec.ts new file mode 100644 index 0000000..c89d903 --- /dev/null +++ b/smart-contracts/assembly/contracts/NFT/__tests__/NFTEnumerable-internals.spec.ts @@ -0,0 +1,316 @@ +import { resetStorage, setDeployContext } from '@massalabs/massa-as-sdk'; +import { u256 } from 'as-bignum/assembly'; +import { + _update, + _balanceOf, + _totalSupply, + _constructor, + _ownerOf, + _transferFrom, + _decreaseTotalSupply, + _increaseTotalSupply, +} from '../NFTEnumerable-internals'; +import { getOwnedTokens } from './helpers'; + +const caller = 'A12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKq'; +const owner1 = caller; +const owner2 = 'AU178qZCfaNXkz9tQiXJcVfAEnYGJ27UoNtFFJh3BiT8jTfY8P2D'; +const zeroAddress = ''; +const tokenIds = [ + u256.fromU32(1), + u256.fromU32(2), + u256.fromU32(3), + u256.fromU32(4), + u256.fromU32(5), +]; + +const NFTName = 'MASSA_NFT'; +const NFTSymbol = 'NFT'; + +function mint(to: string, tokenId: u256): void { + _update(to, tokenId, zeroAddress); +} + +function transfer(from: string, to: string, tokenId: u256): void { + _update(to, tokenId, from); +} + +function burn(owner: string, tokenId: u256): void { + _update(zeroAddress, tokenId, owner); +} + +describe('NFT Enumerable Internals', () => { + beforeEach(() => { + resetStorage(); + setDeployContext(caller); + _constructor(NFTName, NFTSymbol); + }); + + describe('Initialization', () => { + it('should have zero total supply initially', () => { + expect(_totalSupply()).toStrictEqual(u256.Zero); + }); + }); + + describe('Total Supply Management', () => { + it('should update total supply when token is minted', () => { + mint(owner1, tokenIds[0]); + expect(_totalSupply()).toStrictEqual(u256.One); + }); + + it('should update total supply when token is burned', () => { + mint(owner1, tokenIds[0]); + expect(_totalSupply()).toStrictEqual(u256.One); + burn(owner1, tokenIds[0]); + expect(_totalSupply()).toStrictEqual(u256.Zero); + }); + + it('should not allow total supply to exceed u256.Max', () => { + // Set total supply to u256.Max - 1 + const nearMaxSupply = u256.sub(u256.Max, u256.One); + _increaseTotalSupply(u256.sub(u256.Max, u256.One)); + expect(_totalSupply()).toStrictEqual(nearMaxSupply); + + // Mint one more token should succeed (totalSupply = u256.Max) + mint(owner1, tokenIds[0]); + expect(_totalSupply()).toStrictEqual(u256.Max); + + // Minting another token should fail due to overflow + expect(() => { + _increaseTotalSupply(u256.One); + }).toThrow('Total supply overflow'); // Ensure your contract throws this exact error + }); + + it('should not allow total supply to underflow', () => { + // Ensure total supply is zero + expect(_totalSupply()).toStrictEqual(u256.Zero); + + // Attempt to decrease supply by 1 should fail + expect(() => { + _decreaseTotalSupply(u256.One); + }).toThrow('Total supply underflow'); // Ensure your contract throws this exact error + + // Set total supply to 1 + _increaseTotalSupply(u256.One); + expect(_totalSupply()).toStrictEqual(u256.One); + + // Decrease supply by 1 should succeed + _decreaseTotalSupply(u256.One); + expect(_totalSupply()).toStrictEqual(u256.Zero); + + // Attempt to decrease supply by another 1 should fail + expect(() => { + _decreaseTotalSupply(u256.One); + }).toThrow('Total supply underflow'); // Ensure your contract throws this exact error + }); + }); + + describe('Owner Token Enumeration', () => { + it('should return correct balances and owned tokens after minting', () => { + mint(owner1, tokenIds[0]); + mint(owner1, tokenIds[1]); + mint(owner2, tokenIds[2]); + + expect(_balanceOf(owner1)).toStrictEqual(u256.fromU32(2)); + expect(_balanceOf(owner2)).toStrictEqual(u256.One); + + const owner1Tokens = getOwnedTokens(owner1); + expect(owner1Tokens.length).toBe(2); + expect(owner1Tokens).toContainEqual(tokenIds[0]); + expect(owner1Tokens).toContainEqual(tokenIds[1]); + + const owner2Tokens = getOwnedTokens(owner2); + expect(owner2Tokens.length).toBe(1); + expect(owner2Tokens).toContainEqual(tokenIds[2]); + }); + + it('should update balances and tokens after transfer', () => { + mint(owner1, tokenIds[0]); + mint(owner1, tokenIds[1]); + + transfer(owner1, owner2, tokenIds[0]); + + expect(_balanceOf(owner1)).toStrictEqual(u256.One); + expect(_balanceOf(owner2)).toStrictEqual(u256.One); + + // Verify ownership + expect(_ownerOf(tokenIds[0])).toStrictEqual(owner2); + expect(_ownerOf(tokenIds[1])).toStrictEqual(owner1); + + // Verify owned tokens + const owner1Tokens = getOwnedTokens(owner1); + expect(owner1Tokens.length).toBe(1); + expect(owner1Tokens).toContainEqual(tokenIds[1]); + + const owner2Tokens = getOwnedTokens(owner2); + expect(owner2Tokens.length).toBe(1); + expect(owner2Tokens).toContainEqual(tokenIds[0]); + }); + }); + + describe('Token Transfers and Ownership', () => { + it('should update ownership after transfer', () => { + mint(owner1, tokenIds[0]); + transfer(owner1, owner2, tokenIds[0]); + + expect(_ownerOf(tokenIds[0])).toStrictEqual(owner2); + expect(_balanceOf(owner1)).toStrictEqual(u256.Zero); + expect(_balanceOf(owner2)).toStrictEqual(u256.One); + + // Verify owned tokens + const owner1Tokens = getOwnedTokens(owner1); + expect(owner1Tokens.length).toBe(0); + + const owner2Tokens = getOwnedTokens(owner2); + expect(owner2Tokens.length).toBe(1); + expect(owner2Tokens).toContainEqual(tokenIds[0]); + }); + + it('should handle transfer to owner with balance', () => { + mint(owner1, tokenIds[0]); + mint(owner2, tokenIds[1]); + + transfer(owner1, owner2, tokenIds[0]); + + expect(_ownerOf(tokenIds[0])).toStrictEqual(owner2); + expect(_balanceOf(owner1)).toStrictEqual(u256.Zero); + expect(_balanceOf(owner2)).toStrictEqual(u256.fromU32(2)); + + // Verify owned tokens + const owner1Tokens = getOwnedTokens(owner1); + expect(owner1Tokens.length).toBe(0); + + const owner2Tokens = getOwnedTokens(owner2); + expect(owner2Tokens.length).toBe(2); + expect(owner2Tokens).toContainEqual(tokenIds[0]); + expect(owner2Tokens).toContainEqual(tokenIds[1]); + }); + }); + + describe('Token Burning and Ownership', () => { + it('should update balances and tokens after burning', () => { + mint(owner1, tokenIds[0]); + mint(owner1, tokenIds[1]); + + burn(owner1, tokenIds[0]); + + expect(_balanceOf(owner1)).toStrictEqual(u256.One); + expect(_totalSupply()).toStrictEqual(u256.One); + + // Verify that accessing the burned token's owner returns zero address + expect(_ownerOf(tokenIds[0])).toStrictEqual(zeroAddress); + + // Verify owned tokens + const owner1Tokens = getOwnedTokens(owner1); + expect(owner1Tokens.length).toBe(1); + expect(owner1Tokens).toContainEqual(tokenIds[1]); + }); + + it('should handle burning multiple tokens', () => { + mint(owner1, tokenIds[0]); + mint(owner1, tokenIds[1]); + + burn(owner1, tokenIds[0]); + burn(owner1, tokenIds[1]); + + expect(_balanceOf(owner1)).toStrictEqual(u256.Zero); + expect(_totalSupply()).toStrictEqual(u256.Zero); + + // Verify owned tokens + const owner1Tokens = getOwnedTokens(owner1); + expect(owner1Tokens.length).toBe(0); + }); + }); + + describe('Complex Token Interactions', () => { + it('should handle mints, transfers, and burns correctly', () => { + // Mint tokens to owner1 and owner2 + mint(owner1, tokenIds[0]); + mint(owner1, tokenIds[1]); + mint(owner2, tokenIds[2]); + + // Transfer tokenIds[1] from owner1 to owner2 + transfer(owner1, owner2, tokenIds[1]); + + // Burn tokenIds[0] owned by owner1 + burn(owner1, tokenIds[0]); + + // Verify total supply + expect(_totalSupply()).toStrictEqual(u256.fromU32(2)); + + // Verify balances + expect(_balanceOf(owner1)).toStrictEqual(u256.Zero); + expect(_balanceOf(owner2)).toStrictEqual(u256.fromU32(2)); + + // Verify ownership + expect(_ownerOf(tokenIds[1])).toStrictEqual(owner2); + expect(_ownerOf(tokenIds[2])).toStrictEqual(owner2); + + // Verify that accessing the burned token's owner returns zero address + expect(_ownerOf(tokenIds[0])).toStrictEqual(zeroAddress); + + // Verify owned tokens + const owner1Tokens = getOwnedTokens(owner1); + expect(owner1Tokens.length).toBe(0); + + const owner2Tokens = getOwnedTokens(owner2); + expect(owner2Tokens.length).toBe(2); + expect(owner2Tokens).toContainEqual(tokenIds[1]); + expect(owner2Tokens).toContainEqual(tokenIds[2]); + }); + }); + + describe('Error Handling and Boundary Conditions', () => { + it('should not mint existing token ID', () => { + mint(owner1, tokenIds[0]); + + expect(() => { + mint(owner1, tokenIds[0]); + }).toThrow('Token already minted'); + }); + + it('should not transfer nonexistent token', () => { + expect(() => { + transfer(owner1, owner2, tokenIds[0]); + }).toThrow('Nonexistent token'); + }); + + it('should not transfer to zero address', () => { + mint(owner1, tokenIds[0]); + + expect(() => { + _transferFrom(owner1, zeroAddress, tokenIds[0]); + }).toThrow('Unauthorized to'); + }); + + it('should not burn nonexistent token', () => { + expect(() => { + burn(owner1, tokenIds[0]); + }).toThrow('Nonexistent token'); + }); + + it('should have zero total supply after all tokens are burned', () => { + mint(owner1, tokenIds[0]); + burn(owner1, tokenIds[0]); + + expect(_totalSupply()).toStrictEqual(u256.Zero); + + // Check token ownership has been cleared + expect(_ownerOf(tokenIds[0])).toStrictEqual(zeroAddress); + + // Verify owned tokens + const owner1Tokens = getOwnedTokens(owner1); + expect(owner1Tokens.length).toBe(0); + }); + + it('should mint a token with id=u256.Max', () => { + mint(owner1, u256.Max); + expect(_totalSupply()).toStrictEqual(u256.One); + expect(_balanceOf(owner1)).toStrictEqual(u256.One); + expect(_ownerOf(u256.Max)).toStrictEqual(owner1); + const owner1Tokens = getOwnedTokens(owner1); + expect(owner1Tokens.length).toBe(1); + expect(owner1Tokens).toContainEqual(u256.Max); + }); + }); +}); diff --git a/smart-contracts/assembly/contracts/NFT/__tests__/helpers.ts b/smart-contracts/assembly/contracts/NFT/__tests__/helpers.ts new file mode 100644 index 0000000..e4a0312 --- /dev/null +++ b/smart-contracts/assembly/contracts/NFT/__tests__/helpers.ts @@ -0,0 +1,22 @@ +import { bytesToU256 } from '@massalabs/as-types'; +import { getKeys, Storage } from '@massalabs/massa-as-sdk'; +import { u256 } from 'as-bignum/assembly'; +import { _getOwnedTokensKeyPrefix } from '../NFTEnumerable-internals'; + +/** + * Returns the all the tokens owned by a specific address. + * + * @param owner - The address of the owner. + * + * @returns An array of u256 representing the tokens owned by the address. + * + */ +export function getOwnedTokens(owner: string): u256[] { + const tokens: u256[] = []; + const keys = getKeys(_getOwnedTokensKeyPrefix(owner)); + + for (let i = 0; i < keys.length; i++) { + tokens.push(bytesToU256(Storage.get(keys[i]))); + } + return tokens; +}