From f59653dde6a32dd14f9e4cfe2314c95ad5d677bd Mon Sep 17 00:00:00 2001 From: matijamarjanovic Date: Thu, 6 Mar 2025 16:37:31 +0100 Subject: [PATCH 01/17] initial commit - grc721 (remake) now looks like a combination of grc721 (old) and grc20 - currently grc721 is a copy of grc20 adapted for non fungible tokens - remake impl also contains a new functionality when compared to the old one - batch operations (transfer, transfer from, mint and burn) - tellers have been updated with these batch operations --- .../gno.land/p/demo/grc/grc721-remake/gno.mod | 1 + .../gno.land/p/demo/grc/grc721-remake/nft.gno | 424 ++++++++++++++++++ .../p/demo/grc/grc721-remake/tellers.gno | 186 ++++++++ .../p/demo/grc/grc721-remake/types.gno | 144 ++++++ 4 files changed, 755 insertions(+) create mode 100644 examples/gno.land/p/demo/grc/grc721-remake/gno.mod create mode 100644 examples/gno.land/p/demo/grc/grc721-remake/nft.gno create mode 100644 examples/gno.land/p/demo/grc/grc721-remake/tellers.gno create mode 100644 examples/gno.land/p/demo/grc/grc721-remake/types.gno diff --git a/examples/gno.land/p/demo/grc/grc721-remake/gno.mod b/examples/gno.land/p/demo/grc/grc721-remake/gno.mod new file mode 100644 index 00000000000..62085e67928 --- /dev/null +++ b/examples/gno.land/p/demo/grc/grc721-remake/gno.mod @@ -0,0 +1 @@ +module gno.land/p/demo/grc/grc721-remake diff --git a/examples/gno.land/p/demo/grc/grc721-remake/nft.gno b/examples/gno.land/p/demo/grc/grc721-remake/nft.gno new file mode 100644 index 00000000000..c4182654c1b --- /dev/null +++ b/examples/gno.land/p/demo/grc/grc721-remake/nft.gno @@ -0,0 +1,424 @@ +package grc721-remake + +import ( + "errors" + "std" + "strconv" + + "gno.land/p/demo/avl" +) + +// NewNFT creates a new NFT. +// It returns a pointer to the NFT and a pointer to the Ledger. +func NewNFT(name, symbol string) (*NFT, *PrivateLedger) { + if name == "" { + panic("name should not be empty") + } + if symbol == "" { + panic("symbol should not be empty") + } + + ledger := &PrivateLedger{} + nft := &NFT{ + name: name, + symbol: symbol, + ledger: ledger, + } + ledger.nft = nft + return nft, ledger +} + +// GetName returns the name of the NFT. +func (n NFT) GetName() string { return n.name } + +// GetSymbol returns the symbol of the NFT. +func (n NFT) GetSymbol() string { return n.symbol } + +// TokenCount returns the total number of tokens. +func (n NFT) TokenCount() uint64 { return uint64(n.ledger.owners.Size()) } + +// KnownAccounts returns the number of known accounts. +func (n NFT) KnownAccounts() int { return n.ledger.balances.Size() } + +// BalanceOf returns the balance of the specified address. +func (n NFT) BalanceOf(address std.Address) (uint64, error) { + return n.ledger.balanceOf(address) +} + +// OwnerOf returns the owner of the specified token ID. +func (n NFT) OwnerOf(tid TokenID) (std.Address, error) { + return n.ledger.ownerOf(tid) +} + +// TokenURI returns the URI of the specified token ID. +func (n NFT) TokenURI(tid TokenID) (string, error) { + return n.ledger.tokenURI(tid) +} + +// GetApproved returns the approved address for the specified token ID. +func (n NFT) GetApproved(tid TokenID) (std.Address, error) { + return n.ledger.getApproved(tid) +} + +// IsApprovedForAll returns whether the specified operator is approved for all tokens of the owner. +func (n NFT) IsApprovedForAll(owner, operator std.Address) bool { + return n.ledger.isApprovedForAll(owner, operator) +} + +// Getter returns an NFTGetter function that returns this NFT. +func (n *NFT) Getter() NFTGetter { + return func() *NFT { + return n + } +} + +// SetTokenURI sets the URI for a token +func (led *PrivateLedger) SetTokenURI(tid TokenID, tURI TokenURI) error { + if !led.exists(tid) { + return ErrInvalidTokenId + } + + owner, err := led.ownerOf(tid) + if err != nil { + return err + } + + caller := std.PreviousRealm().Address() + if caller != owner { + return ErrCallerIsNotOwner + } + + led.tokenURIs.Set(string(tid), string(tURI)) + return nil +} + +// Approve approves an address to transfer a specific token +func (led *PrivateLedger) Approve(to std.Address, tid TokenID) error { + if err := isValidAddress(to); err != nil { + return err + } + + owner, err := led.ownerOf(tid) + if err != nil { + return err + } + + if owner == to { + return ErrApprovalToCurrentOwner + } + + caller := std.PreviousRealm().Address() + if caller != owner && !led.isApprovedForAll(owner, caller) { + return ErrCallerIsNotOwnerOrApproved + } + + led.tokenApprovals.Set(string(tid), to.String()) + std.Emit( + ApprovalEvent, + "owner", string(owner), + "to", string(to), + "tokenId", string(tid), + ) + + return nil +} + +// SetApprovalForAll approves or removes an operator to transfer all tokens +func (led *PrivateLedger) SetApprovalForAll(operator std.Address, approved bool) error { + if err := isValidAddress(operator); err != nil { + return ErrInvalidAddress + } + + caller := std.PreviousRealm().Address() + if caller == operator { + return ErrApprovalToCurrentOwner + } + + key := caller.String() + ":" + operator.String() + led.operatorApprovals.Set(key, approved) + + std.Emit( + ApprovalForAllEvent, + "owner", string(caller), + "operator", string(operator), + "approved", strconv.FormatBool(approved), + ) + + return nil +} + +// TransferFrom transfers a token from one address to another +func (led *PrivateLedger) TransferFrom(from, to std.Address, tid TokenID) error { + if err := isValidAddress(from); err != nil { + return err + } + if err := isValidAddress(to); err != nil { + return err + } + + if from == to { + return ErrCannotTransferToSelf + } + + caller := std.PreviousRealm().Address() + if !led.isApprovedOrOwner(caller, tid) { + return ErrCallerIsNotOwnerOrApproved + } + + owner, err := led.ownerOf(tid) + if err != nil { + return err + } + if owner != from { + return ErrTransferFromIncorrectOwner + } + + return led.transferToken(from, to, tid) +} + +// Mint creates a new token +func (led *PrivateLedger) Mint(to std.Address, tid TokenID) error { + if err := isValidAddress(to); err != nil { + return err + } + + if led.exists(tid) { + return ErrTokenIdAlreadyExists + } + + return led.mintToken(to, tid) +} + +func (led *PrivateLedger) Burn(tid TokenID) error { + caller := std.PreviousRealm().Address() + if !led.isApprovedOrOwner(caller, tid) { + return ErrCallerIsNotOwnerOrApproved + } + + _, err := led.burnToken(tid) + return err +} + + +// BatchTransferFrom transfers multiple tokens from one address to another +func (led *PrivateLedger) BatchTransferFrom(from, to std.Address, tids []TokenID) error { + if len(tids) == 0 { + return ErrEmptyTokenIDList + } + + var err error + for _, tid := range tids { + err = led.TransferFrom(from, to, tid) + if err != nil { + return err + } + } + + return nil +} + +// BatchMint creates multiple new tokens +func (led *PrivateLedger) BatchMint(to std.Address, tids []TokenID) error { + if len(tids) == 0 { + return ErrEmptyTokenIDList + } + + var err error + for _, tid := range tids { + err = led.Mint(to, tid) + if err != nil { + return err + } + } + + return nil +} + +// BatchBurn destroys multiple tokens +func (led *PrivateLedger) BatchBurn(tids []TokenID) error { + if len(tids) == 0 { + return ErrEmptyTokenIDList + } + + var err error + for _, tid := range tids { + err = led.Burn(tid) + if err != nil { + return err + } + } + + return nil +} + +// BatchTransfer transfers multiple tokens to another address +func (led *PrivateLedger) BatchTransfer(to std.Address, tids []TokenID) error { + if len(tids) == 0 { + return ErrEmptyTokenIDList + } + + caller := std.PreviousRealm().Address() + var err error + + for _, tid := range tids { + err = led.TransferFrom(caller, to, tid) + if err != nil { + return err + } + } + + return nil +} + +// Helper functions + +// balanceOf returns the balance of the specified address. +func (led PrivateLedger) balanceOf(address std.Address) (uint64, error) { + if err := isValidAddress(address); err != nil { + return 0, err + } + + balance, found := led.balances.Get(address.String()) + if !found { + return 0, nil + } + + return balance.(uint64), nil +} + +// ownerOf returns the owner of the specified token ID. +func (led PrivateLedger) ownerOf(tid TokenID) (std.Address, error) { + owner, found := led.owners.Get(string(tid)) + if !found { + return "", ErrInvalidTokenId + } + + return owner.(std.Address), nil +} + +// tokenURI returns the URI of the specified token ID. +func (led PrivateLedger) tokenURI(tid TokenID) (string, error) { + uri, found := led.tokenURIs.Get(string(tid)) + if !found { + return "", ErrInvalidTokenId + } + + return uri.(string), nil +} + +// getApproved returns the approved address for the specified token ID. +func (led PrivateLedger) getApproved(tid TokenID) (std.Address, error) { + addr, found := led.tokenApprovals.Get(string(tid)) + if !found { + return zeroAddress, ErrTokenIdNotHasApproved + } + + return std.Address(addr.(string)), nil +} + +// isApprovedForAll returns whether the specified operator is approved for all tokens of the owner. +func (led PrivateLedger) isApprovedForAll(owner, operator std.Address) bool { + key := owner.String() + ":" + operator.String() + _, found := led.operatorApprovals.Get(key) + return found +} + +// Private helper function to transfer a single token +func (led *PrivateLedger) transferToken(from, to std.Address, tid TokenID) error { + led.tokenApprovals.Remove(string(tid)) + + led.owners.Set(string(tid), to) + + fromBalance, _ := led.balanceOf(from) + toBalance, _ := led.balanceOf(to) + fromBalance -= 1 + toBalance += 1 + led.balances.Set(from.String(), fromBalance) + led.balances.Set(to.String(), toBalance) + + std.Emit( + TransferEvent, + "from", string(from), + "to", string(to), + "tokenId", string(tid), + ) + + return nil +} + +// Private helper function to mint a single token +func (led *PrivateLedger) mintToken(to std.Address, tid TokenID) error { + led.owners.Set(string(tid), to) + + toBalance, _ := led.balanceOf(to) + toBalance += 1 + led.balances.Set(to.String(), toBalance) + + std.Emit( + MintEvent, + "to", string(to), + "tokenId", string(tid), + ) + + return nil +} + +// Private helper function to burn a single token +func (led *PrivateLedger) burnToken(tid TokenID) (std.Address, error) { + owner, err := led.ownerOf(tid) + if err != nil { + return "", err + } + + led.tokenApprovals.Remove(string(tid)) + + led.owners.Remove(string(tid)) + + led.tokenURIs.Remove(string(tid)) + + balance, _ := led.balanceOf(owner) + balance -= 1 + led.balances.Set(owner.String(), balance) + + std.Emit( + BurnEvent, + "from", string(owner), + "tokenId", string(tid), + ) + + return owner, nil +} + +// exists checks if a token exists +func (led *PrivateLedger) exists(tid TokenID) bool { + _, found := led.owners.Get(string(tid)) + return found +} + +// isApprovedOrOwner checks if an address is the owner or approved for a token +func (led *PrivateLedger) isApprovedOrOwner(addr std.Address, tid TokenID) bool { + owner, err := led.ownerOf(tid) + if err != nil { + return false + } + + if addr == owner || led.isApprovedForAll(owner, addr) { + return true + } + + approved, err := led.getApproved(tid) + if err != nil { + return false + } + + return addr == approved +} + +// Helper function to check if an address is valid +func isValidAddress(addr std.Address) error { + if !addr.IsValid() { + return ErrInvalidAddress + } + return nil +} + diff --git a/examples/gno.land/p/demo/grc/grc721-remake/tellers.gno b/examples/gno.land/p/demo/grc/grc721-remake/tellers.gno new file mode 100644 index 00000000000..817f46b8f71 --- /dev/null +++ b/examples/gno.land/p/demo/grc/grc721-remake/tellers.gno @@ -0,0 +1,186 @@ +package grc721remake + +import ( + "std" +) + +// CallerTeller returns a GRC721 compatible teller that checks the PreviousRealm +// caller for each call. It's usually safe to expose it publicly to let users +// manage their NFTs directly, or for realms to use their approvals. +func (nft *NFT) CallerTeller() NFTTeller { + if nft == nil { + panic("NFT cannot be nil") + } + + return &fnNFTTeller{ + accountFn: func() std.Address { + caller := std.PreviousRealm().Address() + return caller + }, + NFT: nft, + } +} + +// ReadonlyTeller is a GRC721 compatible teller that panics for any write operation. +// This is useful for providing read-only access to NFT information. +func (nft *NFT) ReadonlyTeller() NFTTeller { + if nft == nil { + panic("NFT cannot be nil") + } + + return &fnNFTTeller{ + accountFn: nil, + NFT: nft, + } +} + +// RealmTeller returns a GRC721 compatible teller that will store the +// caller realm permanently. Calling anything through this teller will +// result in approval or ownership changes for the realm that initialized the teller. +// The initializer of this teller should usually never share the resulting NFTTeller from +// this method except maybe for advanced delegation flows such as a DAO treasury +// management. +func (nft *NFT) RealmTeller() NFTTeller { + if nft == nil { + panic("NFT cannot be nil") + } + + caller := std.PreviousRealm().Address() + + return &fnNFTTeller{ + accountFn: func() std.Address { + return caller + }, + NFT: nft, + } +} + +// RealmSubTeller is like RealmTeller but uses the provided slug to derive a +// subaccount. +func (nft *NFT) RealmSubTeller(slug string) NFTTeller { + if nft == nil { + panic("NFT cannot be nil") + } + + caller := std.PreviousRealm().Address() + account := accountSlugAddr(caller, slug) + + return &fnNFTTeller{ + accountFn: func() std.Address { + return account + }, + NFT: nft, + } +} + +// ImpersonateTeller returns a GRC721 compatible teller that impersonates as a +// specified address. This allows operations to be performed as if they were +// executed by the given address, enabling the caller to manipulate NFTs on +// behalf of that address. +// +// It is particularly useful in scenarios where a contract needs to perform +// actions on behalf of a user or another account, without exposing the +// underlying logic or requiring direct access to the user's account. The +// returned teller will use the provided address for all operations, effectively +// masking the original caller. +// +// This method should be used with caution, as it allows for potentially +// sensitive operations to be performed under the guise of another address. +func (ledger *PrivateLedger) ImpersonateTeller(addr std.Address) NFTTeller { + if ledger == nil { + panic("Ledger cannot be nil") + } + + return &fnNFTTeller{ + accountFn: func() std.Address { + return addr + }, + NFT: ledger.nft, + } +} + +// Generic tellers methods implementation +// + +// Approve implements the NFTTeller interface +func (ft *fnNFTTeller) Approve(to std.Address, tid TokenID) error { + if ft.accountFn == nil { + return errors.New("readonly teller cannot approve") + } + + caller := ft.accountFn() + return ft.NFT.ledger.Approve(to, tid) +} + +// SetApprovalForAll implements the NFTTeller interface +func (ft *fnNFTTeller) SetApprovalForAll(operator std.Address, approved bool) error { + if ft.accountFn == nil { + return errors.New("readonly teller cannot set approval for all") + } + + caller := ft.accountFn() + return ft.NFT.ledger.SetApprovalForAll(operator, approved) +} + +// TransferFrom implements the NFTTeller interface +func (ft *fnNFTTeller) TransferFrom(from, to std.Address, tid TokenID) error { + if ft.accountFn == nil { + return errors.New("readonly teller cannot transfer") + } + + caller := ft.accountFn() + return ft.NFT.ledger.TransferFrom(from, to, tid) +} + +// BatchTransferFrom implements the NFTTeller interface +func (ft *fnNFTTeller) BatchTransferFrom(from, to std.Address, tids []TokenID) error { + if ft.accountFn == nil { + return errors.New("readonly teller cannot batch transfer") + } + + caller := ft.accountFn() + return ft.NFT.ledger.BatchTransferFrom(from, to, tids) +} + +// BatchMint implements the NFTTeller interface +func (ft *fnNFTTeller) BatchMint(to std.Address, tids []TokenID) error { + if ft.accountFn == nil { + return errors.New("readonly teller cannot batch mint") + } + + caller := ft.accountFn() + return ft.NFT.ledger.BatchMint(to, tids) +} + +// BatchBurn implements the NFTTeller interface +func (ft *fnNFTTeller) BatchBurn(tids []TokenID) error { + if ft.accountFn == nil { + return errors.New("readonly teller cannot batch burn") + } + + caller := ft.accountFn() + return ft.NFT.ledger.BatchBurn(tids) +} + +// BatchTransfer implements the NFTTeller interface +func (ft *fnNFTTeller) BatchTransfer(to std.Address, tids []TokenID) error { + if ft.accountFn == nil { + return errors.New("readonly teller cannot batch transfer") + } + + caller := ft.accountFn() + from := caller + return ft.NFT.ledger.BatchTransferFrom(from, to, tids) +} + +// helpers + +// accountSlugAddr returns the address derived from the specified address and slug. +func accountSlugAddr(addr std.Address, slug string) std.Address { + // XXX: use a new `std.XXX` call for this. + if slug == "" { + return addr + } + key := addr.String() + "/" + slug + return std.DerivePkgAddr(key) // temporarily using this helper +} diff --git a/examples/gno.land/p/demo/grc/grc721-remake/types.gno b/examples/gno.land/p/demo/grc/grc721-remake/types.gno new file mode 100644 index 00000000000..3cf4e717c7f --- /dev/null +++ b/examples/gno.land/p/demo/grc/grc721-remake/types.gno @@ -0,0 +1,144 @@ +package grc721remake + +import ( + "errors" + "std" + + "gno.land/p/demo/avl" +) + +// NFTTeller interface defines the methods that a GRC721 token must implement. +// It extends the TokenMetadata interface to include methods for managing NFT +// ownership, transfers, approvals, and querying token information. +// +// The NFTTeller interface ensures that any NFT adhering to this standard +// provides a consistent API for interacting with non-fungible tokens. +type NFTTeller interface { + // Returns the name of the NFT. + GetName() string + + // Returns the symbol of the NFT, usually a shorter version of the + // name. + GetSymbol() string + + // Returns the total number of tokens in existence. + TokenCount() uint64 + + // Returns the number of tokens owned by the specified address. + BalanceOf(address std.Address) (uint64, error) + + // Returns the owner of the specified token ID. + OwnerOf(tid TokenID) (std.Address, error) + + // Returns the URI for a given token ID. + TokenURI(tid TokenID) (string, error) + + // Returns the approved address for a token ID, or an error if no approval exists. + GetApproved(tid TokenID) (std.Address, error) + + // Returns whether an operator is approved for all tokens of an owner. + IsApprovedForAll(owner, operator std.Address) bool + + // Approves another address to transfer a specific token. + Approve(to std.Address, tid TokenID) error + + // Sets or unsets the approval of a given operator for all tokens owned by the caller. + SetApprovalForAll(operator std.Address, approved bool) error + + // Transfers a token from one address to another. + TransferFrom(from, to std.Address, tid TokenID) error + + // Safely transfers a token from one address to another, checking recipient compatibility. + SafeTransferFrom(from, to std.Address, tid TokenID) error + + // BatchTransferFrom transfers multiple tokens from one address to another. + BatchTransferFrom(from, to std.Address, tids []TokenID) error + + // BatchMint creates multiple new tokens. + BatchMint(to std.Address, tids []TokenID) error + + // BatchBurn destroys multiple tokens. + BatchBurn(tids []TokenID) error + + // BatchTransfer transfers multiple tokens owned by the caller to another address. + BatchTransfer(to std.Address, tids []TokenID) error +} + +// TokenID represents a unique identifier for an NFT +type TokenID string + +// TokenURI represents the URI for an NFT +type TokenURI string + +// NFT represents a non-fungible token with a name and symbol. +// It maintains a ledger for tracking ownership, approvals, and token URIs. +// +// The NFT struct provides methods for retrieving token metadata and +// interacting with the ledger, including checking ownership and approvals. +type NFT struct { + // Name of the NFT collection (e.g., "CryptoKitties"). + name string + // Symbol of the NFT collection (e.g., "CK"). + symbol string + // Pointer to the PrivateLedger that manages ownership and approvals. + ledger *PrivateLedger +} + +// NFTGetter is a function type that returns an NFT pointer. This type allows +// bypassing a limitation where we cannot directly pass NFT pointers between +// realms. Instead, we pass this function which can then be called to get the +// NFT pointer. +type NFTGetter func() *NFT + +// PrivateLedger manages the state of the NFT collection, including ownership, +// balances, approvals, and token URIs. It provides administrative functions +// for minting, burning, transferring tokens, and managing approvals. +// +// The PrivateLedger is not safe to expose publicly, as it contains sensitive +// information and allows direct access to all administrative functions. +type PrivateLedger struct { + // Pointer to the associated NFT struct + nft *NFT + // tokenId -> OwnerAddress + owners avl.Tree + // OwnerAddress -> TokenCount + balances avl.Tree + // TokenId -> ApprovedAddress + tokenApprovals avl.Tree + // TokenId -> URIs + tokenURIs avl.Tree + // "OwnerAddress:OperatorAddress" -> bool + operatorApprovals avl.Tree +} + +const ( + TransferEvent = "Transfer" + ApprovalEvent = "Approval" + ApprovalForAllEvent = "ApprovalForAll" + MintEvent = "Mint" + BurnEvent = "Burn" +) + +var ( + ErrInvalidAddress = errors.New("invalid address") + ErrInvalidTokenId = errors.New("invalid token id") + ErrCallerIsNotOwner = errors.New("caller is not owner") + ErrApprovalToCurrentOwner = errors.New("approval to current owner") + ErrCallerIsNotOwnerOrApproved = errors.New("caller is not owner or approved") + ErrTokenIdNotHasApproved = errors.New("token id not has approved") + ErrTransferToNonGRC721Receiver = errors.New("transfer to non GRC721 receiver") + ErrCannotTransferToSelf = errors.New("cannot transfer to self") + ErrTransferFromIncorrectOwner = errors.New("transfer from incorrect owner") + ErrTokenIdAlreadyExists = errors.New("token id already exists") + ErrEmptyTokenIDList = errors.New("empty token ID list") +) + +var zeroAddress std.Address + +type fnNFTTeller struct { + accountFn func() std.Address + *NFT +} + +// Ensure fnNFTTeller implements NFTTeller +var _ NFTTeller = (*fnNFTTeller)(nil) From e140f726cb96e658bf7f0df2d5b0409a0001691c Mon Sep 17 00:00:00 2001 From: matijamarjanovic Date: Thu, 6 Mar 2025 19:07:59 +0100 Subject: [PATCH 02/17] - rename package - modify tellers so they work how they are supposed to - fix imports --- .../{grc721-remake => grc721remake}/gno.mod | 0 .../{grc721-remake => grc721remake}/nft.gno | 49 ++++++++++------- .../tellers.gno | 52 ++++++++----------- .../{grc721-remake => grc721remake}/types.gno | 13 ++--- 4 files changed, 56 insertions(+), 58 deletions(-) rename examples/gno.land/p/demo/grc/{grc721-remake => grc721remake}/gno.mod (100%) rename examples/gno.land/p/demo/grc/{grc721-remake => grc721remake}/nft.gno (88%) rename examples/gno.land/p/demo/grc/{grc721-remake => grc721remake}/tellers.gno (80%) rename examples/gno.land/p/demo/grc/{grc721-remake => grc721remake}/types.gno (93%) diff --git a/examples/gno.land/p/demo/grc/grc721-remake/gno.mod b/examples/gno.land/p/demo/grc/grc721remake/gno.mod similarity index 100% rename from examples/gno.land/p/demo/grc/grc721-remake/gno.mod rename to examples/gno.land/p/demo/grc/grc721remake/gno.mod diff --git a/examples/gno.land/p/demo/grc/grc721-remake/nft.gno b/examples/gno.land/p/demo/grc/grc721remake/nft.gno similarity index 88% rename from examples/gno.land/p/demo/grc/grc721-remake/nft.gno rename to examples/gno.land/p/demo/grc/grc721remake/nft.gno index c4182654c1b..40bb3bbcbb2 100644 --- a/examples/gno.land/p/demo/grc/grc721-remake/nft.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/nft.gno @@ -1,11 +1,9 @@ -package grc721-remake +package grc721remake import ( - "errors" "std" "strconv" - "gno.land/p/demo/avl" ) // NewNFT creates a new NFT. @@ -93,7 +91,7 @@ func (led *PrivateLedger) SetTokenURI(tid TokenID, tURI TokenURI) error { } // Approve approves an address to transfer a specific token -func (led *PrivateLedger) Approve(to std.Address, tid TokenID) error { +func (led *PrivateLedger) Approve(caller std.Address, to std.Address, tid TokenID) error { if err := isValidAddress(to); err != nil { return err } @@ -102,12 +100,7 @@ func (led *PrivateLedger) Approve(to std.Address, tid TokenID) error { if err != nil { return err } - - if owner == to { - return ErrApprovalToCurrentOwner - } - caller := std.PreviousRealm().Address() if caller != owner && !led.isApprovedForAll(owner, caller) { return ErrCallerIsNotOwnerOrApproved } @@ -124,12 +117,11 @@ func (led *PrivateLedger) Approve(to std.Address, tid TokenID) error { } // SetApprovalForAll approves or removes an operator to transfer all tokens -func (led *PrivateLedger) SetApprovalForAll(operator std.Address, approved bool) error { +func (led *PrivateLedger) SetApprovalForAll(caller std.Address, operator std.Address, approved bool) error { if err := isValidAddress(operator); err != nil { return ErrInvalidAddress } - caller := std.PreviousRealm().Address() if caller == operator { return ErrApprovalToCurrentOwner } @@ -147,8 +139,31 @@ func (led *PrivateLedger) SetApprovalForAll(operator std.Address, approved bool) return nil } +// Transfer transfers a token directly from the caller to another address +func (led *PrivateLedger) Transfer(from, to std.Address, tid TokenID) error { + if err := isValidAddress(from); err != nil { + return err + } + if err := isValidAddress(to); err != nil { + return err + } + if from == to { + return ErrCannotTransferToSelf + } + + owner, err := led.ownerOf(tid) + if err != nil { + return err + } + if owner != from { + return ErrTransferFromIncorrectOwner + } + + return led.transferToken(from, to, tid) +} + // TransferFrom transfers a token from one address to another -func (led *PrivateLedger) TransferFrom(from, to std.Address, tid TokenID) error { +func (led *PrivateLedger) TransferFrom(caller, from, to std.Address, tid TokenID) error { if err := isValidAddress(from); err != nil { return err } @@ -160,7 +175,6 @@ func (led *PrivateLedger) TransferFrom(from, to std.Address, tid TokenID) error return ErrCannotTransferToSelf } - caller := std.PreviousRealm().Address() if !led.isApprovedOrOwner(caller, tid) { return ErrCallerIsNotOwnerOrApproved } @@ -201,14 +215,14 @@ func (led *PrivateLedger) Burn(tid TokenID) error { // BatchTransferFrom transfers multiple tokens from one address to another -func (led *PrivateLedger) BatchTransferFrom(from, to std.Address, tids []TokenID) error { +func (led *PrivateLedger) BatchTransferFrom(caller, from, to std.Address, tids []TokenID) error { if len(tids) == 0 { return ErrEmptyTokenIDList } var err error for _, tid := range tids { - err = led.TransferFrom(from, to, tid) + err = led.TransferFrom(caller, from, to, tid) if err != nil { return err } @@ -252,16 +266,15 @@ func (led *PrivateLedger) BatchBurn(tids []TokenID) error { } // BatchTransfer transfers multiple tokens to another address -func (led *PrivateLedger) BatchTransfer(to std.Address, tids []TokenID) error { +func (led *PrivateLedger) BatchTransfer(caller std.Address, to std.Address, tids []TokenID) error { if len(tids) == 0 { return ErrEmptyTokenIDList } - caller := std.PreviousRealm().Address() var err error for _, tid := range tids { - err = led.TransferFrom(caller, to, tid) + err = led.Transfer(caller, to, tid) if err != nil { return err } diff --git a/examples/gno.land/p/demo/grc/grc721-remake/tellers.gno b/examples/gno.land/p/demo/grc/grc721remake/tellers.gno similarity index 80% rename from examples/gno.land/p/demo/grc/grc721-remake/tellers.gno rename to examples/gno.land/p/demo/grc/grc721remake/tellers.gno index 817f46b8f71..9f0a60ca4c2 100644 --- a/examples/gno.land/p/demo/grc/grc721-remake/tellers.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/tellers.gno @@ -105,74 +105,64 @@ func (ledger *PrivateLedger) ImpersonateTeller(addr std.Address) NFTTeller { // Approve implements the NFTTeller interface func (ft *fnNFTTeller) Approve(to std.Address, tid TokenID) error { if ft.accountFn == nil { - return errors.New("readonly teller cannot approve") + return ErrReadOnly } caller := ft.accountFn() - return ft.NFT.ledger.Approve(to, tid) + return ft.NFT.ledger.Approve(caller, to, tid) } // SetApprovalForAll implements the NFTTeller interface func (ft *fnNFTTeller) SetApprovalForAll(operator std.Address, approved bool) error { if ft.accountFn == nil { - return errors.New("readonly teller cannot set approval for all") + return ErrReadOnly } caller := ft.accountFn() - return ft.NFT.ledger.SetApprovalForAll(operator, approved) + return ft.NFT.ledger.SetApprovalForAll(caller, operator, approved) } -// TransferFrom implements the NFTTeller interface -func (ft *fnNFTTeller) TransferFrom(from, to std.Address, tid TokenID) error { +// Transfer implements the NFTTeller interface +func (ft *fnNFTTeller) Transfer(to std.Address, tid TokenID) error { if ft.accountFn == nil { - return errors.New("readonly teller cannot transfer") + return ErrReadOnly } - - caller := ft.accountFn() - return ft.NFT.ledger.TransferFrom(from, to, tid) -} -// BatchTransferFrom implements the NFTTeller interface -func (ft *fnNFTTeller) BatchTransferFrom(from, to std.Address, tids []TokenID) error { - if ft.accountFn == nil { - return errors.New("readonly teller cannot batch transfer") - } - caller := ft.accountFn() - return ft.NFT.ledger.BatchTransferFrom(from, to, tids) + return ft.NFT.ledger.Transfer(caller, to, tid) } -// BatchMint implements the NFTTeller interface -func (ft *fnNFTTeller) BatchMint(to std.Address, tids []TokenID) error { +// TransferFrom implements the NFTTeller interface +func (ft *fnNFTTeller) TransferFrom(from, to std.Address, tid TokenID) error { if ft.accountFn == nil { - return errors.New("readonly teller cannot batch mint") + return ErrReadOnly } caller := ft.accountFn() - return ft.NFT.ledger.BatchMint(to, tids) + return ft.NFT.ledger.TransferFrom(caller, from, to, tid) } -// BatchBurn implements the NFTTeller interface -func (ft *fnNFTTeller) BatchBurn(tids []TokenID) error { +// BatchTransfer implements the NFTTeller interface +func (ft *fnNFTTeller) BatchTransfer(to std.Address, tids []TokenID) error { if ft.accountFn == nil { - return errors.New("readonly teller cannot batch burn") + return ErrReadOnly } caller := ft.accountFn() - return ft.NFT.ledger.BatchBurn(tids) + return ft.NFT.ledger.BatchTransfer(caller, to, tids) } -// BatchTransfer implements the NFTTeller interface -func (ft *fnNFTTeller) BatchTransfer(to std.Address, tids []TokenID) error { +// BatchTransferFrom implements the NFTTeller interface +func (ft *fnNFTTeller) BatchTransferFrom(from, to std.Address, tids []TokenID) error { if ft.accountFn == nil { - return errors.New("readonly teller cannot batch transfer") + return ErrReadOnly } caller := ft.accountFn() - from := caller - return ft.NFT.ledger.BatchTransferFrom(from, to, tids) + return ft.NFT.ledger.BatchTransferFrom(caller, from, to, tids) } + // helpers // accountSlugAddr returns the address derived from the specified address and slug. diff --git a/examples/gno.land/p/demo/grc/grc721-remake/types.gno b/examples/gno.land/p/demo/grc/grc721remake/types.gno similarity index 93% rename from examples/gno.land/p/demo/grc/grc721-remake/types.gno rename to examples/gno.land/p/demo/grc/grc721remake/types.gno index 3cf4e717c7f..93f32ab6e4a 100644 --- a/examples/gno.land/p/demo/grc/grc721-remake/types.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/types.gno @@ -45,21 +45,15 @@ type NFTTeller interface { // Sets or unsets the approval of a given operator for all tokens owned by the caller. SetApprovalForAll(operator std.Address, approved bool) error + // Transfers a token from the caller to another address. + Transfer(to std.Address, tid TokenID) error + // Transfers a token from one address to another. TransferFrom(from, to std.Address, tid TokenID) error - // Safely transfers a token from one address to another, checking recipient compatibility. - SafeTransferFrom(from, to std.Address, tid TokenID) error - // BatchTransferFrom transfers multiple tokens from one address to another. BatchTransferFrom(from, to std.Address, tids []TokenID) error - // BatchMint creates multiple new tokens. - BatchMint(to std.Address, tids []TokenID) error - - // BatchBurn destroys multiple tokens. - BatchBurn(tids []TokenID) error - // BatchTransfer transfers multiple tokens owned by the caller to another address. BatchTransfer(to std.Address, tids []TokenID) error } @@ -131,6 +125,7 @@ var ( ErrTransferFromIncorrectOwner = errors.New("transfer from incorrect owner") ErrTokenIdAlreadyExists = errors.New("token id already exists") ErrEmptyTokenIDList = errors.New("empty token ID list") + ErrReadOnly = errors.New("teller is readonly") ) var zeroAddress std.Address From c363b3cf1738f6a7d325be6f3e8d23981cf436b5 Mon Sep 17 00:00:00 2001 From: matijamarjanovic Date: Thu, 6 Mar 2025 19:35:37 +0100 Subject: [PATCH 03/17] - add teller tests --- .../gno.land/p/demo/grc/grc721remake/nft.gno | 253 +++++++++--------- .../p/demo/grc/grc721remake/tellers.gno | 13 +- .../p/demo/grc/grc721remake/tellers_test.gno | 170 ++++++++++++ .../p/demo/grc/grc721remake/types.gno | 4 +- 4 files changed, 303 insertions(+), 137 deletions(-) create mode 100644 examples/gno.land/p/demo/grc/grc721remake/tellers_test.gno diff --git a/examples/gno.land/p/demo/grc/grc721remake/nft.gno b/examples/gno.land/p/demo/grc/grc721remake/nft.gno index 40bb3bbcbb2..63a1c9fabdf 100644 --- a/examples/gno.land/p/demo/grc/grc721remake/nft.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/nft.gno @@ -3,7 +3,6 @@ package grc721remake import ( "std" "strconv" - ) // NewNFT creates a new NFT. @@ -80,12 +79,12 @@ func (led *PrivateLedger) SetTokenURI(tid TokenID, tURI TokenURI) error { if err != nil { return err } - + caller := std.PreviousRealm().Address() if caller != owner { return ErrCallerIsNotOwner } - + led.tokenURIs.Set(string(tid), string(tURI)) return nil } @@ -141,25 +140,25 @@ func (led *PrivateLedger) SetApprovalForAll(caller std.Address, operator std.Add // Transfer transfers a token directly from the caller to another address func (led *PrivateLedger) Transfer(from, to std.Address, tid TokenID) error { - if err := isValidAddress(from); err != nil { - return err - } - if err := isValidAddress(to); err != nil { - return err - } - if from == to { - return ErrCannotTransferToSelf - } - - owner, err := led.ownerOf(tid) - if err != nil { - return err - } - if owner != from { - return ErrTransferFromIncorrectOwner - } - - return led.transferToken(from, to, tid) + if err := isValidAddress(from); err != nil { + return err + } + if err := isValidAddress(to); err != nil { + return err + } + if from == to { + return ErrCannotTransferToSelf + } + + owner, err := led.ownerOf(tid) + if err != nil { + return err + } + if owner != from { + return ErrTransferFromIncorrectOwner + } + + return led.transferToken(from, to, tid) } // TransferFrom transfers a token from one address to another @@ -186,7 +185,7 @@ func (led *PrivateLedger) TransferFrom(caller, from, to std.Address, tid TokenID if owner != from { return ErrTransferFromIncorrectOwner } - + return led.transferToken(from, to, tid) } @@ -199,7 +198,7 @@ func (led *PrivateLedger) Mint(to std.Address, tid TokenID) error { if led.exists(tid) { return ErrTokenIdAlreadyExists } - + return led.mintToken(to, tid) } @@ -213,74 +212,73 @@ func (led *PrivateLedger) Burn(tid TokenID) error { return err } - // BatchTransferFrom transfers multiple tokens from one address to another func (led *PrivateLedger) BatchTransferFrom(caller, from, to std.Address, tids []TokenID) error { - if len(tids) == 0 { - return ErrEmptyTokenIDList - } - - var err error - for _, tid := range tids { - err = led.TransferFrom(caller, from, to, tid) - if err != nil { - return err - } - } - - return nil + if len(tids) == 0 { + return ErrEmptyTokenIDList + } + + var err error + for _, tid := range tids { + err = led.TransferFrom(caller, from, to, tid) + if err != nil { + return err + } + } + + return nil } // BatchMint creates multiple new tokens func (led *PrivateLedger) BatchMint(to std.Address, tids []TokenID) error { - if len(tids) == 0 { - return ErrEmptyTokenIDList - } + if len(tids) == 0 { + return ErrEmptyTokenIDList + } var err error - for _, tid := range tids { - err = led.Mint(to, tid) + for _, tid := range tids { + err = led.Mint(to, tid) if err != nil { return err } - } - - return nil + } + + return nil } // BatchBurn destroys multiple tokens func (led *PrivateLedger) BatchBurn(tids []TokenID) error { - if len(tids) == 0 { - return ErrEmptyTokenIDList - } - - var err error - for _, tid := range tids { - err = led.Burn(tid) - if err != nil { - return err - } - } - - return nil + if len(tids) == 0 { + return ErrEmptyTokenIDList + } + + var err error + for _, tid := range tids { + err = led.Burn(tid) + if err != nil { + return err + } + } + + return nil } // BatchTransfer transfers multiple tokens to another address func (led *PrivateLedger) BatchTransfer(caller std.Address, to std.Address, tids []TokenID) error { - if len(tids) == 0 { - return ErrEmptyTokenIDList - } - - var err error - - for _, tid := range tids { - err = led.Transfer(caller, to, tid) - if err != nil { - return err - } - } - - return nil + if len(tids) == 0 { + return ErrEmptyTokenIDList + } + + var err error + + for _, tid := range tids { + err = led.Transfer(caller, to, tid) + if err != nil { + return err + } + } + + return nil } // Helper functions @@ -338,68 +336,68 @@ func (led PrivateLedger) isApprovedForAll(owner, operator std.Address) bool { // Private helper function to transfer a single token func (led *PrivateLedger) transferToken(from, to std.Address, tid TokenID) error { - led.tokenApprovals.Remove(string(tid)) - - led.owners.Set(string(tid), to) - - fromBalance, _ := led.balanceOf(from) - toBalance, _ := led.balanceOf(to) - fromBalance -= 1 - toBalance += 1 - led.balances.Set(from.String(), fromBalance) - led.balances.Set(to.String(), toBalance) - - std.Emit( - TransferEvent, - "from", string(from), - "to", string(to), - "tokenId", string(tid), - ) - - return nil + led.tokenApprovals.Remove(string(tid)) + + led.owners.Set(string(tid), to) + + fromBalance, _ := led.balanceOf(from) + toBalance, _ := led.balanceOf(to) + fromBalance -= 1 + toBalance += 1 + led.balances.Set(from.String(), fromBalance) + led.balances.Set(to.String(), toBalance) + + std.Emit( + TransferEvent, + "from", string(from), + "to", string(to), + "tokenId", string(tid), + ) + + return nil } // Private helper function to mint a single token func (led *PrivateLedger) mintToken(to std.Address, tid TokenID) error { - led.owners.Set(string(tid), to) - - toBalance, _ := led.balanceOf(to) - toBalance += 1 - led.balances.Set(to.String(), toBalance) - - std.Emit( - MintEvent, - "to", string(to), - "tokenId", string(tid), - ) - - return nil + led.owners.Set(string(tid), to) + + toBalance, _ := led.balanceOf(to) + toBalance += 1 + led.balances.Set(to.String(), toBalance) + + std.Emit( + MintEvent, + "to", string(to), + "tokenId", string(tid), + ) + + return nil } // Private helper function to burn a single token func (led *PrivateLedger) burnToken(tid TokenID) (std.Address, error) { - owner, err := led.ownerOf(tid) - if err != nil { - return "", err - } - - led.tokenApprovals.Remove(string(tid)) - - led.owners.Remove(string(tid)) - - led.tokenURIs.Remove(string(tid)) - - balance, _ := led.balanceOf(owner) - balance -= 1 - led.balances.Set(owner.String(), balance) - - std.Emit( - BurnEvent, - "from", string(owner), - "tokenId", string(tid), - ) - - return owner, nil + owner, err := led.ownerOf(tid) + if err != nil { + return "", err + } + + led.tokenApprovals.Remove(string(tid)) + + led.owners.Remove(string(tid)) + + led.tokenURIs.Remove(string(tid)) + + balance, _ := led.balanceOf(owner) + balance -= 1 + led.balances.Set(owner.String(), balance) + + std.Emit( + BurnEvent, + "from", string(owner), + "tokenId", string(tid), + ) + + return owner, nil } // exists checks if a token exists @@ -434,4 +432,3 @@ func isValidAddress(addr std.Address) error { } return nil } - diff --git a/examples/gno.land/p/demo/grc/grc721remake/tellers.gno b/examples/gno.land/p/demo/grc/grc721remake/tellers.gno index 9f0a60ca4c2..b8604f7d300 100644 --- a/examples/gno.land/p/demo/grc/grc721remake/tellers.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/tellers.gno @@ -107,7 +107,7 @@ func (ft *fnNFTTeller) Approve(to std.Address, tid TokenID) error { if ft.accountFn == nil { return ErrReadOnly } - + caller := ft.accountFn() return ft.NFT.ledger.Approve(caller, to, tid) } @@ -117,7 +117,7 @@ func (ft *fnNFTTeller) SetApprovalForAll(operator std.Address, approved bool) er if ft.accountFn == nil { return ErrReadOnly } - + caller := ft.accountFn() return ft.NFT.ledger.SetApprovalForAll(caller, operator, approved) } @@ -137,7 +137,7 @@ func (ft *fnNFTTeller) TransferFrom(from, to std.Address, tid TokenID) error { if ft.accountFn == nil { return ErrReadOnly } - + caller := ft.accountFn() return ft.NFT.ledger.TransferFrom(caller, from, to, tid) } @@ -147,7 +147,7 @@ func (ft *fnNFTTeller) BatchTransfer(to std.Address, tids []TokenID) error { if ft.accountFn == nil { return ErrReadOnly } - + caller := ft.accountFn() return ft.NFT.ledger.BatchTransfer(caller, to, tids) } @@ -157,12 +157,11 @@ func (ft *fnNFTTeller) BatchTransferFrom(from, to std.Address, tids []TokenID) e if ft.accountFn == nil { return ErrReadOnly } - + caller := ft.accountFn() return ft.NFT.ledger.BatchTransferFrom(caller, from, to, tids) } - // helpers // accountSlugAddr returns the address derived from the specified address and slug. @@ -173,4 +172,4 @@ func accountSlugAddr(addr std.Address, slug string) std.Address { } key := addr.String() + "/" + slug return std.DerivePkgAddr(key) // temporarily using this helper -} +} diff --git a/examples/gno.land/p/demo/grc/grc721remake/tellers_test.gno b/examples/gno.land/p/demo/grc/grc721remake/tellers_test.gno new file mode 100644 index 00000000000..7391694a6e9 --- /dev/null +++ b/examples/gno.land/p/demo/grc/grc721remake/tellers_test.gno @@ -0,0 +1,170 @@ +package grc721remake + +import ( + "std" + "testing" + + "gno.land/p/demo/testutils" + "gno.land/p/demo/uassert" + "gno.land/p/demo/ufmt" + "gno.land/p/demo/urequire" +) + +func TestCallerTellerImpl(t *testing.T) { + nft, _ := NewNFT("Dummy NFT", "DNFT") + teller := nft.CallerTeller() + urequire.False(t, nft == nil) + var _ NFTTeller = teller +} + +func TestTeller(t *testing.T) { + var ( + alice = testutils.TestAddress("alice") + bob = testutils.TestAddress("bob") + carl = testutils.TestAddress("carl") + ) + + nft, ledger := NewNFT("Dummy NFT", "DNFT") + + checkBalances := func(aliceEB, bobEB, carlEB uint64) { + t.Helper() + exp := ufmt.Sprintf("alice=%d bob=%d carl=%d", aliceEB, bobEB, carlEB) + aliceGB, _ := nft.BalanceOf(alice) + bobGB, _ := nft.BalanceOf(bob) + carlGB, _ := nft.BalanceOf(carl) + got := ufmt.Sprintf("alice=%d bob=%d carl=%d", aliceGB, bobGB, carlGB) + uassert.Equal(t, got, exp, "invalid balances") + } + + checkOwnership := func(tid TokenID, expectedOwner std.Address) { + t.Helper() + owner, err := nft.OwnerOf(tid) + urequire.NoError(t, err) + uassert.Equal(t, owner, expectedOwner, "invalid owner") + } + + checkBalances(0, 0, 0) + + urequire.NoError(t, ledger.Mint(alice, "token1")) + urequire.NoError(t, ledger.Mint(alice, "token2")) + checkBalances(2, 0, 0) + checkOwnership("token1", alice) + checkOwnership("token2", alice) + + urequire.NoError(t, ledger.Approve(alice, bob, "token1")) + approved, err := nft.GetApproved("token1") + urequire.NoError(t, err) + uassert.Equal(t, approved, bob, "invalid approval") + + urequire.NoError(t, ledger.TransferFrom(bob, alice, carl, "token1")) + checkBalances(1, 0, 1) + checkOwnership("token1", carl) + checkOwnership("token2", alice) + + urequire.NoError(t, ledger.Transfer(alice, bob, "token2")) + checkBalances(0, 1, 1) + checkOwnership("token2", bob) + + urequire.NoError(t, ledger.SetApprovalForAll(bob, alice, true)) + uassert.True(t, nft.IsApprovedForAll(bob, alice), "approval for all not set") + + urequire.NoError(t, ledger.TransferFrom(alice, bob, carl, "token2")) + checkBalances(0, 0, 2) + checkOwnership("token2", carl) +} + +func TestCallerTeller(t *testing.T) { + alice := testutils.TestAddress("alice") + bob := testutils.TestAddress("bob") + carl := testutils.TestAddress("carl") + + nft, ledger := NewNFT("Dummy NFT", "DNFT") + teller := nft.CallerTeller() + + checkBalances := func(aliceEB, bobEB, carlEB uint64) { + t.Helper() + exp := ufmt.Sprintf("alice=%d bob=%d carl=%d", aliceEB, bobEB, carlEB) + aliceGB, _ := nft.BalanceOf(alice) + bobGB, _ := nft.BalanceOf(bob) + carlGB, _ := nft.BalanceOf(carl) + got := ufmt.Sprintf("alice=%d bob=%d carl=%d", aliceGB, bobGB, carlGB) + uassert.Equal(t, got, exp, "invalid balances") + } + + urequire.NoError(t, ledger.Mint(alice, "token1")) + checkBalances(1, 0, 0) + + std.TestSetOriginCaller(alice) + + urequire.NoError(t, teller.Approve(bob, "token1")) + approved, err := nft.GetApproved("token1") + urequire.NoError(t, err) + uassert.Equal(t, approved, bob, "invalid approval") + + std.TestSetOriginCaller(bob) + + urequire.NoError(t, teller.TransferFrom(alice, carl, "token1")) + checkBalances(0, 0, 1) + std.TestSetOriginCaller(carl) + + urequire.NoError(t, teller.Transfer(bob, "token1")) + checkBalances(0, 1, 0) +} + +func TestBatchOperations(t *testing.T) { + alice := testutils.TestAddress("alice") + bob := testutils.TestAddress("bob") + + nft, ledger := NewNFT("Dummy NFT", "DNFT") + teller := nft.CallerTeller() + + urequire.NoError(t, ledger.Mint(alice, "token1")) + urequire.NoError(t, ledger.Mint(alice, "token2")) + urequire.NoError(t, ledger.Mint(alice, "token3")) + + aliceBalance, _ := nft.BalanceOf(alice) + uassert.Equal(t, aliceBalance, uint64(3), "invalid balance after minting") + + std.TestSetOriginCaller(alice) + + urequire.NoError(t, teller.BatchTransfer(bob, []TokenID{"token1", "token2"})) + + aliceBalance, _ = nft.BalanceOf(alice) + bobBalance, _ := nft.BalanceOf(bob) + uassert.Equal(t, aliceBalance, uint64(1), "invalid alice balance after batch transfer") + uassert.Equal(t, bobBalance, uint64(2), "invalid bob balance after batch transfer") + + urequire.NoError(t, teller.Approve(bob, "token3")) + + std.TestSetOriginCaller(alice) + + urequire.NoError(t, teller.Transfer(bob, "token3")) + + aliceBalance, _ = nft.BalanceOf(alice) + bobBalance, _ = nft.BalanceOf(bob) + uassert.Equal(t, aliceBalance, uint64(0), "invalid alice balance after transferFrom") + uassert.Equal(t, bobBalance, uint64(3), "invalid bob balance after transferFrom") +} + +func TestReadonlyTeller(t *testing.T) { + alice := testutils.TestAddress("alice") + bob := testutils.TestAddress("bob") + + nft, ledger := NewNFT("Dummy NFT", "DNFT") + readonlyTeller := nft.ReadonlyTeller() + + urequire.NoError(t, ledger.Mint(alice, "token1")) + + err := readonlyTeller.Transfer(bob, "token1") + uassert.ErrorContains(t, err, ErrReadOnly.Error(), "readonly teller should not allow transfers") + + err = readonlyTeller.Approve(bob, "token1") + uassert.ErrorContains(t, err, ErrReadOnly.Error(), "readonly teller should not allow approvals") + + name := readonlyTeller.GetName() + uassert.Equal(t, name, "Dummy NFT", "readonly teller should allow reading name") + + balance, err := readonlyTeller.BalanceOf(alice) + urequire.NoError(t, err) + uassert.Equal(t, balance, uint64(1), "readonly teller should allow reading balances") +} diff --git a/examples/gno.land/p/demo/grc/grc721remake/types.gno b/examples/gno.land/p/demo/grc/grc721remake/types.gno index 93f32ab6e4a..54e682fda75 100644 --- a/examples/gno.land/p/demo/grc/grc721remake/types.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/types.gno @@ -53,7 +53,7 @@ type NFTTeller interface { // BatchTransferFrom transfers multiple tokens from one address to another. BatchTransferFrom(from, to std.Address, tids []TokenID) error - + // BatchTransfer transfers multiple tokens owned by the caller to another address. BatchTransfer(to std.Address, tids []TokenID) error } @@ -136,4 +136,4 @@ type fnNFTTeller struct { } // Ensure fnNFTTeller implements NFTTeller -var _ NFTTeller = (*fnNFTTeller)(nil) +var _ NFTTeller = (*fnNFTTeller)(nil) From 29ee4eeb08c13b2a6bdbc411bb78fccd01ee524a Mon Sep 17 00:00:00 2001 From: matijamarjanovic Date: Thu, 6 Mar 2025 19:55:33 +0100 Subject: [PATCH 04/17] add tests for nft.gno --- .../gno.land/p/demo/grc/grc721remake/nft.gno | 8 +- .../p/demo/grc/grc721remake/nft_test.gno | 130 ++++++++++++++++++ .../p/demo/grc/grc721remake/tellers.gno | 15 +- .../p/demo/grc/grc721remake/types.gno | 3 + 4 files changed, 142 insertions(+), 14 deletions(-) create mode 100644 examples/gno.land/p/demo/grc/grc721remake/nft_test.gno diff --git a/examples/gno.land/p/demo/grc/grc721remake/nft.gno b/examples/gno.land/p/demo/grc/grc721remake/nft.gno index 63a1c9fabdf..a0bdbbbcab3 100644 --- a/examples/gno.land/p/demo/grc/grc721remake/nft.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/nft.gno @@ -70,7 +70,7 @@ func (n *NFT) Getter() NFTGetter { } // SetTokenURI sets the URI for a token -func (led *PrivateLedger) SetTokenURI(tid TokenID, tURI TokenURI) error { +func (led *PrivateLedger) SetTokenURI(caller std.Address, tid TokenID, tURI TokenURI) error { if !led.exists(tid) { return ErrInvalidTokenId } @@ -80,7 +80,6 @@ func (led *PrivateLedger) SetTokenURI(tid TokenID, tURI TokenURI) error { return err } - caller := std.PreviousRealm().Address() if caller != owner { return ErrCallerIsNotOwner } @@ -203,11 +202,6 @@ func (led *PrivateLedger) Mint(to std.Address, tid TokenID) error { } func (led *PrivateLedger) Burn(tid TokenID) error { - caller := std.PreviousRealm().Address() - if !led.isApprovedOrOwner(caller, tid) { - return ErrCallerIsNotOwnerOrApproved - } - _, err := led.burnToken(tid) return err } diff --git a/examples/gno.land/p/demo/grc/grc721remake/nft_test.gno b/examples/gno.land/p/demo/grc/grc721remake/nft_test.gno new file mode 100644 index 00000000000..a837d463961 --- /dev/null +++ b/examples/gno.land/p/demo/grc/grc721remake/nft_test.gno @@ -0,0 +1,130 @@ +package grc721remake + +import ( + "std" + "testing" + + "gno.land/p/demo/testutils" + "gno.land/p/demo/uassert" + "gno.land/p/demo/ufmt" + "gno.land/p/demo/urequire" +) + +func TestTestImpl(t *testing.T) { + nft, _ := NewNFT("Dummy NFT", "DNFT") + urequire.False(t, nft == nil, "dummy should not be nil") +} + +func TestNFT(t *testing.T) { + var ( + alice = testutils.TestAddress("alice") + bob = testutils.TestAddress("bob") + carl = testutils.TestAddress("carl") + ) + + nft, ledger := NewNFT("Dummy NFT", "DNFT") + + checkBalances := func(aliceEB, bobEB, carlEB uint64) { + t.Helper() + exp := ufmt.Sprintf("alice=%d bob=%d carl=%d", aliceEB, bobEB, carlEB) + aliceGB, _ := nft.BalanceOf(alice) + bobGB, _ := nft.BalanceOf(bob) + carlGB, _ := nft.BalanceOf(carl) + got := ufmt.Sprintf("alice=%d bob=%d carl=%d", aliceGB, bobGB, carlGB) + uassert.Equal(t, got, exp, "invalid balances") + } + + checkOwnership := func(tid TokenID, expectedOwner std.Address) { + t.Helper() + owner, err := nft.OwnerOf(tid) + urequire.NoError(t, err) + uassert.Equal(t, owner, expectedOwner, "invalid owner for token "+string(tid)) + } + + checkApproval := func(tid TokenID, expectedApproved std.Address) { + t.Helper() + approved, err := nft.GetApproved(tid) + if err != nil && err != ErrTokenIdNotHasApproved { + t.Fatalf("unexpected error: %v", err) + } + if err == nil { + uassert.Equal(t, approved, expectedApproved, "invalid approval for token "+string(tid)) + } + } + + checkBalances(0, 0, 0) + uassert.Equal(t, nft.TokenCount(), uint64(0), "initial token count should be 0") + uassert.Equal(t, nft.KnownAccounts(), 0, "initial known accounts should be 0") + + urequire.NoError(t, ledger.Mint(alice, "token1")) + checkBalances(1, 0, 0) + checkOwnership("token1", alice) + uassert.Equal(t, nft.TokenCount(), uint64(1), "token count should be 1 after minting") + uassert.Equal(t, nft.KnownAccounts(), 1, "known accounts should be 1 after minting") + + urequire.NoError(t, ledger.BatchMint(alice, []TokenID{"token2", "token3"})) + checkBalances(3, 0, 0) + checkOwnership("token2", alice) + checkOwnership("token3", alice) + uassert.Equal(t, nft.TokenCount(), uint64(3), "token count should be 3 after batch minting") + + urequire.NoError(t, ledger.Approve(alice, bob, "token1")) + checkApproval("token1", bob) + + urequire.NoError(t, ledger.TransferFrom(bob, alice, carl, "token1")) + checkBalances(2, 0, 1) + checkOwnership("token1", carl) + + _, err := nft.GetApproved("token1") + uassert.ErrorContains(t, err, ErrTokenIdNotHasApproved.Error(), "approval should be cleared after transfer") + + urequire.NoError(t, ledger.Transfer(alice, bob, "token2")) + checkBalances(1, 1, 1) + checkOwnership("token2", bob) + + urequire.NoError(t, ledger.SetApprovalForAll(alice, bob, true)) + uassert.True(t, nft.IsApprovedForAll(alice, bob), "bob should be approved for all of alice's tokens") + + urequire.NoError(t, ledger.TransferFrom(bob, alice, carl, "token3")) + checkBalances(0, 1, 2) + checkOwnership("token3", carl) + + urequire.NoError(t, ledger.BatchMint(bob, []TokenID{"token4", "token5"})) + checkBalances(0, 3, 2) + urequire.NoError(t, ledger.BatchTransfer(bob, carl, []TokenID{"token4", "token5"})) + checkBalances(0, 1, 4) + checkOwnership("token4", carl) + checkOwnership("token5", carl) + + urequire.NoError(t, ledger.SetApprovalForAll(carl, bob, true)) + urequire.NoError(t, ledger.Burn("token1")) + checkBalances(0, 1, 3) + uassert.Equal(t, nft.TokenCount(), uint64(4), "token count should be 4 after burning") + + urequire.NoError(t, ledger.BatchBurn([]TokenID{"token3", "token4"})) + checkBalances(0, 1, 1) + uassert.Equal(t, nft.TokenCount(), uint64(2), "token count should be 2 after batch burning") + + err = ledger.Mint(alice, "token2") + uassert.ErrorContains(t, err, ErrTokenIdAlreadyExists.Error(), "should not be able to mint existing token") + + err = ledger.Transfer(alice, bob, "nonexistent") + uassert.ErrorContains(t, err, ErrInvalidTokenId.Error(), "should not be able to transfer non-existent token") + + err = ledger.Approve(alice, bob, "nonexistent") + uassert.ErrorContains(t, err, ErrInvalidTokenId.Error(), "should not be able to approve non-existent token") + + err = ledger.Transfer(alice, bob, "token5") + uassert.ErrorContains(t, err, ErrTransferFromIncorrectOwner.Error(), "should not be able to transfer token you don't own") +} + +func TestMetadata(t *testing.T) { + nft, _ := NewNFT("Dummy NFT", "DNFT") + + uassert.Equal(t, nft.GetName(), "Dummy NFT", "name should match") + uassert.Equal(t, nft.GetSymbol(), "DNFT", "symbol should match") + + getter := nft.Getter() + nftFromGetter := getter() + uassert.Equal(t, nftFromGetter.GetName(), "Dummy NFT", "name from getter should match") +} diff --git a/examples/gno.land/p/demo/grc/grc721remake/tellers.gno b/examples/gno.land/p/demo/grc/grc721remake/tellers.gno index b8604f7d300..34a36dddda8 100644 --- a/examples/gno.land/p/demo/grc/grc721remake/tellers.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/tellers.gno @@ -100,9 +100,15 @@ func (ledger *PrivateLedger) ImpersonateTeller(addr std.Address) NFTTeller { } // Generic tellers methods implementation -// -// Approve implements the NFTTeller interface +func (ft *fnNFTTeller) SetTokenURI(tid TokenID, tURI TokenURI) error { + if ft.accountFn == nil { + return ErrReadOnly + } + + caller := ft.accountFn() + return ft.NFT.ledger.SetTokenURI(caller, tid, tURI) +} func (ft *fnNFTTeller) Approve(to std.Address, tid TokenID) error { if ft.accountFn == nil { return ErrReadOnly @@ -112,7 +118,6 @@ func (ft *fnNFTTeller) Approve(to std.Address, tid TokenID) error { return ft.NFT.ledger.Approve(caller, to, tid) } -// SetApprovalForAll implements the NFTTeller interface func (ft *fnNFTTeller) SetApprovalForAll(operator std.Address, approved bool) error { if ft.accountFn == nil { return ErrReadOnly @@ -122,7 +127,6 @@ func (ft *fnNFTTeller) SetApprovalForAll(operator std.Address, approved bool) er return ft.NFT.ledger.SetApprovalForAll(caller, operator, approved) } -// Transfer implements the NFTTeller interface func (ft *fnNFTTeller) Transfer(to std.Address, tid TokenID) error { if ft.accountFn == nil { return ErrReadOnly @@ -132,7 +136,6 @@ func (ft *fnNFTTeller) Transfer(to std.Address, tid TokenID) error { return ft.NFT.ledger.Transfer(caller, to, tid) } -// TransferFrom implements the NFTTeller interface func (ft *fnNFTTeller) TransferFrom(from, to std.Address, tid TokenID) error { if ft.accountFn == nil { return ErrReadOnly @@ -142,7 +145,6 @@ func (ft *fnNFTTeller) TransferFrom(from, to std.Address, tid TokenID) error { return ft.NFT.ledger.TransferFrom(caller, from, to, tid) } -// BatchTransfer implements the NFTTeller interface func (ft *fnNFTTeller) BatchTransfer(to std.Address, tids []TokenID) error { if ft.accountFn == nil { return ErrReadOnly @@ -152,7 +154,6 @@ func (ft *fnNFTTeller) BatchTransfer(to std.Address, tids []TokenID) error { return ft.NFT.ledger.BatchTransfer(caller, to, tids) } -// BatchTransferFrom implements the NFTTeller interface func (ft *fnNFTTeller) BatchTransferFrom(from, to std.Address, tids []TokenID) error { if ft.accountFn == nil { return ErrReadOnly diff --git a/examples/gno.land/p/demo/grc/grc721remake/types.gno b/examples/gno.land/p/demo/grc/grc721remake/types.gno index 54e682fda75..2d59e952556 100644 --- a/examples/gno.land/p/demo/grc/grc721remake/types.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/types.gno @@ -33,6 +33,9 @@ type NFTTeller interface { // Returns the URI for a given token ID. TokenURI(tid TokenID) (string, error) + // Sets the URI for a token + SetTokenURI(tid TokenID, tURI TokenURI) error + // Returns the approved address for a token ID, or an error if no approval exists. GetApproved(tid TokenID) (std.Address, error) From 4e3d9a7a834326f33b0b5891970c9942c0121961 Mon Sep 17 00:00:00 2001 From: matijamarjanovic Date: Tue, 18 Mar 2025 20:16:20 +0100 Subject: [PATCH 05/17] minor changes --- examples/gno.land/p/demo/grc/grc721remake/nft.gno | 7 ++++--- examples/gno.land/p/demo/grc/grc721remake/tellers.gno | 1 - 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/gno.land/p/demo/grc/grc721remake/nft.gno b/examples/gno.land/p/demo/grc/grc721remake/nft.gno index a0bdbbbcab3..064b81d6b00 100644 --- a/examples/gno.land/p/demo/grc/grc721remake/nft.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/nft.gno @@ -7,6 +7,7 @@ import ( // NewNFT creates a new NFT. // It returns a pointer to the NFT and a pointer to the Ledger. +// Expected usage: NFT, ledger := NewNFT("DummyNFT", "DNFT") func NewNFT(name, symbol string) (*NFT, *PrivateLedger) { if name == "" { panic("name should not be empty") @@ -137,7 +138,7 @@ func (led *PrivateLedger) SetApprovalForAll(caller std.Address, operator std.Add return nil } -// Transfer transfers a token directly from the caller to another address +// Transfer transfers a nft directly from the caller to another address func (led *PrivateLedger) Transfer(from, to std.Address, tid TokenID) error { if err := isValidAddress(from); err != nil { return err @@ -160,7 +161,7 @@ func (led *PrivateLedger) Transfer(from, to std.Address, tid TokenID) error { return led.transferToken(from, to, tid) } -// TransferFrom transfers a token from one address to another +// TransferFrom transfers a nft from one address to another func (led *PrivateLedger) TransferFrom(caller, from, to std.Address, tid TokenID) error { if err := isValidAddress(from); err != nil { return err @@ -394,7 +395,7 @@ func (led *PrivateLedger) burnToken(tid TokenID) (std.Address, error) { return owner, nil } -// exists checks if a token exists +// exists checks if a nft exists func (led *PrivateLedger) exists(tid TokenID) bool { _, found := led.owners.Get(string(tid)) return found diff --git a/examples/gno.land/p/demo/grc/grc721remake/tellers.gno b/examples/gno.land/p/demo/grc/grc721remake/tellers.gno index 34a36dddda8..5f02030bc4f 100644 --- a/examples/gno.land/p/demo/grc/grc721remake/tellers.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/tellers.gno @@ -22,7 +22,6 @@ func (nft *NFT) CallerTeller() NFTTeller { } // ReadonlyTeller is a GRC721 compatible teller that panics for any write operation. -// This is useful for providing read-only access to NFT information. func (nft *NFT) ReadonlyTeller() NFTTeller { if nft == nil { panic("NFT cannot be nil") From 708837d300406459db155b4b31e6ece75475016e Mon Sep 17 00:00:00 2001 From: matijamarjanovic Date: Tue, 18 Mar 2025 21:37:18 +0100 Subject: [PATCH 06/17] add hooks to nft implementation for better ability to extend --- .../p/demo/grc/grc721remake/hooks.gno | 147 ++++++++++++ .../p/demo/grc/grc721remake/hooks_test.gno | 215 ++++++++++++++++++ .../gno.land/p/demo/grc/grc721remake/nft.gno | 64 +++++- .../p/demo/grc/grc721remake/types.gno | 2 + 4 files changed, 421 insertions(+), 7 deletions(-) create mode 100644 examples/gno.land/p/demo/grc/grc721remake/hooks.gno create mode 100644 examples/gno.land/p/demo/grc/grc721remake/hooks_test.gno diff --git a/examples/gno.land/p/demo/grc/grc721remake/hooks.gno b/examples/gno.land/p/demo/grc/grc721remake/hooks.gno new file mode 100644 index 00000000000..167e45ac0cc --- /dev/null +++ b/examples/gno.land/p/demo/grc/grc721remake/hooks.gno @@ -0,0 +1,147 @@ +package grc721remake + +import ( + "std" +) + +// HookType represents the type of operation a hook is associated with +type HookType string + +const ( + HookTransfer HookType = "transfer" + HookApproval HookType = "approval" + HookApprovalForAll HookType = "approvalForAll" + HookMint HookType = "mint" + HookBurn HookType = "burn" +) + +// HookTime represents when a hook should be executed +type HookTime string + +const ( + HookBefore HookTime = "before" + HookAfter HookTime = "after" +) + +// Hook is the base interface that all hooks must implement +type Hook interface { + GetHookType() HookType + GetHookTime() HookTime +} + +// TransferHook is implemented by hooks that want to intercept transfer operations +type TransferHook interface { + Hook + OnTransfer(from, to std.Address, tid TokenID) error +} + +// ApprovalHook is implemented by hooks that want to intercept approval operations +type ApprovalHook interface { + Hook + OnApproval(owner, approved std.Address, tid TokenID) error +} + +// ApprovalForAllHook is implemented by hooks that want to intercept approvalForAll operations +type ApprovalForAllHook interface { + Hook + OnApprovalForAll(owner, operator std.Address, approved bool) error +} + +// MintHook is implemented by hooks that want to intercept mint operations +type MintHook interface { + Hook + OnMint(to std.Address, tid TokenID) error +} + +// BurnHook is implemented by hooks that want to intercept burn operations +type BurnHook interface { + Hook + OnBurn(tid TokenID) error +} + +// HookRegistry manages a collection of hooks +type HookRegistry struct { + hooks []Hook +} + +// NewHookRegistry creates a new hook registry +func NewHookRegistry() *HookRegistry { + return &HookRegistry{ + hooks: make([]Hook, 0), + } +} + +// RegisterHook adds a hook to the registry +func (reg *HookRegistry) RegisterHook(hook Hook) { + reg.hooks = append(reg.hooks, hook) +} + +// TriggerTransferHooks executes all transfer hooks of the specified time +func (reg *HookRegistry) TriggerTransferHooks(time HookTime, from, to std.Address, tid TokenID) error { + for _, hook := range reg.hooks { + if hook.GetHookType() == HookTransfer && hook.GetHookTime() == time { + if transferHook, ok := hook.(TransferHook); ok { + if err := transferHook.OnTransfer(from, to, tid); err != nil { + return err + } + } + } + } + return nil +} + +// TriggerApprovalHooks executes all approval hooks of the specified time +func (reg *HookRegistry) TriggerApprovalHooks(time HookTime, owner, approved std.Address, tid TokenID) error { + for _, hook := range reg.hooks { + if hook.GetHookType() == HookApproval && hook.GetHookTime() == time { + if approvalHook, ok := hook.(ApprovalHook); ok { + if err := approvalHook.OnApproval(owner, approved, tid); err != nil { + return err + } + } + } + } + return nil +} + +// TriggerApprovalForAllHooks executes all approvalForAll hooks of the specified time +func (reg *HookRegistry) TriggerApprovalForAllHooks(time HookTime, owner, operator std.Address, approved bool) error { + for _, hook := range reg.hooks { + if hook.GetHookType() == HookApprovalForAll && hook.GetHookTime() == time { + if approvalForAllHook, ok := hook.(ApprovalForAllHook); ok { + if err := approvalForAllHook.OnApprovalForAll(owner, operator, approved); err != nil { + return err + } + } + } + } + return nil +} + +// TriggerMintHooks executes all mint hooks of the specified time +func (reg *HookRegistry) TriggerMintHooks(time HookTime, to std.Address, tid TokenID) error { + for _, hook := range reg.hooks { + if hook.GetHookType() == HookMint && hook.GetHookTime() == time { + if mintHook, ok := hook.(MintHook); ok { + if err := mintHook.OnMint(to, tid); err != nil { + return err + } + } + } + } + return nil +} + +// TriggerBurnHooks executes all burn hooks of the specified time +func (reg *HookRegistry) TriggerBurnHooks(time HookTime, tid TokenID) error { + for _, hook := range reg.hooks { + if hook.GetHookType() == HookBurn && hook.GetHookTime() == time { + if burnHook, ok := hook.(BurnHook); ok { + if err := burnHook.OnBurn(tid); err != nil { + return err + } + } + } + } + return nil +} diff --git a/examples/gno.land/p/demo/grc/grc721remake/hooks_test.gno b/examples/gno.land/p/demo/grc/grc721remake/hooks_test.gno new file mode 100644 index 00000000000..bae13b6ec9c --- /dev/null +++ b/examples/gno.land/p/demo/grc/grc721remake/hooks_test.gno @@ -0,0 +1,215 @@ +package grc721remake + +import ( + "std" + "testing" + + "gno.land/p/demo/testutils" + "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" + "gno.land/p/demo/ufmt" +) + +// PausableHook is an example hook that can pause transfers +type PausableHook struct { + paused bool +} + +func (h *PausableHook) GetHookType() HookType { + return HookTransfer +} + +func (h *PausableHook) GetHookTime() HookTime { + return HookBefore +} + +func (h *PausableHook) OnTransfer(from, to std.Address, tid TokenID) error { + if h.paused { + return ufmt.Errorf("transfers are paused") + } + return nil +} + +// RoyaltyHook is an example hook that can enforce royalties on transfers +type RoyaltyHook struct { + royaltyReceiver std.Address + royaltyPercent int +} + +func (h *RoyaltyHook) GetHookType() HookType { + return HookTransfer +} + +func (h *RoyaltyHook) GetHookTime() HookTime { + return HookBefore +} + +func (h *RoyaltyHook) OnTransfer(from, to std.Address, tid TokenID) error { + println("RoyaltyHook OnTransfer called") + return nil +} + +// LoggingHook is an example hook that logs all operations +type LoggingHook struct{} + +func (h *LoggingHook) GetHookType() HookType { + return HookMint +} + +func (h *LoggingHook) GetHookTime() HookTime { + return HookAfter +} + +func (h *LoggingHook) OnMint(to std.Address, tid TokenID) error { + println("LoggingHook OnMint called") + return nil +} + +// BurnLimitHook is an example hook that limits the number of tokens that can be burned +type BurnLimitHook struct { + burnCount int + maxBurns int +} + +func (h *BurnLimitHook) GetHookType() HookType { + return HookBurn +} + +func (h *BurnLimitHook) GetHookTime() HookTime { + return HookBefore +} + +func (h *BurnLimitHook) OnBurn(tid TokenID) error { + if h.burnCount >= h.maxBurns { + return ufmt.Errorf("burn limit reached: maximum %d burns allowed", h.maxBurns) + } + h.burnCount++ + return nil +} + +func TestPausableHook(t *testing.T) { + alice := testutils.TestAddress("alice") + bob := testutils.TestAddress("bob") + + nft, ledger := NewNFT("Dummy NFT", "DNFT") + + pausable := &PausableHook{paused: false} + nft.RegisterHook(pausable) + + urequire.NoError(t, ledger.Mint(alice, "token1")) + urequire.NoError(t, ledger.Transfer(alice, bob, "token1")) + + owner, err := nft.OwnerOf("token1") + urequire.NoError(t, err) + uassert.Equal(t, owner, bob, "token should be transferred to bob") + + pausable.paused = true + + urequire.NoError(t, ledger.Mint(bob, "token2")) + + err = ledger.Transfer(bob, alice, "token2") + uassert.Error(t, err, "transfer should fail when paused") + uassert.ErrorContains(t, err, "transfers are paused", "error should indicate transfers are paused") + + owner, err = nft.OwnerOf("token2") + urequire.NoError(t, err) + uassert.Equal(t, owner, bob, "token should still be owned by bob") + + pausable.paused = false + + urequire.NoError(t, ledger.Transfer(bob, alice, "token2")) + + owner, err = nft.OwnerOf("token2") + urequire.NoError(t, err) + uassert.Equal(t, owner, alice, "token should be transferred to alice") +} + +func TestRoyaltyHook(t *testing.T) { + alice := testutils.TestAddress("alice") + bob := testutils.TestAddress("bob") + creator := testutils.TestAddress("creator") + + nft, ledger := NewNFT("Royalty NFT", "RNFT") + + royalty := &RoyaltyHook{ + royaltyReceiver: creator, + royaltyPercent: 10, + } + nft.RegisterHook(royalty) + + urequire.NoError(t, ledger.Mint(alice, "token1")) + urequire.NoError(t, ledger.Transfer(alice, bob, "token1")) + + owner, err := nft.OwnerOf("token1") + urequire.NoError(t, err) + uassert.Equal(t, owner, bob, "token should be transferred to bob") +} + +func TestLoggingHook(t *testing.T) { + alice := testutils.TestAddress("alice") + + nft, ledger := NewNFT("Logging NFT", "LNFT") + + logger := &LoggingHook{} + nft.RegisterHook(logger) + + urequire.NoError(t, ledger.Mint(alice, "token1")) +} + +func TestBurnLimitHook(t *testing.T) { + alice := testutils.TestAddress("alice") + + nft, ledger := NewNFT("Burn Limit NFT", "BLNFT") + + burnLimit := &BurnLimitHook{ + burnCount: 0, + maxBurns: 2, + } + nft.RegisterHook(burnLimit) + + urequire.NoError(t, ledger.Mint(alice, "token1")) + urequire.NoError(t, ledger.Mint(alice, "token2")) + urequire.NoError(t, ledger.Mint(alice, "token3")) + + urequire.NoError(t, ledger.Burn("token1")) + urequire.NoError(t, ledger.Burn("token2")) + + err := ledger.Burn("token3") + uassert.Error(t, err, "burn should fail when limit reached") + uassert.ErrorContains(t, err, "burn limit reached", "error should indicate burn limit reached") + + _, err = nft.OwnerOf("token3") + uassert.NoError(t, err, "token3 should still exist") +} + +func TestMultipleHooks(t *testing.T) { + alice := testutils.TestAddress("alice") + bob := testutils.TestAddress("bob") + creator := testutils.TestAddress("creator") + + nft, ledger := NewNFT("Multi Hook NFT", "MHNFT") + + pausable := &PausableHook{paused: false} + royalty := &RoyaltyHook{ + royaltyReceiver: creator, + royaltyPercent: 10, + } + logger := &LoggingHook{} + + nft.RegisterHook(pausable) + nft.RegisterHook(royalty) + nft.RegisterHook(logger) + + urequire.NoError(t, ledger.Mint(alice, "token1")) + + urequire.NoError(t, ledger.Transfer(alice, bob, "token1")) + + pausable.paused = true + + err := ledger.Transfer(bob, alice, "token1") + uassert.Error(t, err, "transfer should fail when paused") + + owner, err := nft.OwnerOf("token1") + urequire.NoError(t, err) + uassert.Equal(t, owner, bob, "token should still be owned by bob") +} diff --git a/examples/gno.land/p/demo/grc/grc721remake/nft.gno b/examples/gno.land/p/demo/grc/grc721remake/nft.gno index 064b81d6b00..a2fbb37736f 100644 --- a/examples/gno.land/p/demo/grc/grc721remake/nft.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/nft.gno @@ -16,7 +16,9 @@ func NewNFT(name, symbol string) (*NFT, *PrivateLedger) { panic("symbol should not be empty") } - ledger := &PrivateLedger{} + ledger := &PrivateLedger{ + hookRegistry: NewHookRegistry(), + } nft := &NFT{ name: name, symbol: symbol, @@ -70,6 +72,11 @@ func (n *NFT) Getter() NFTGetter { } } +// RegisterHook registers a hook with the NFT's ledger +func (n *NFT) RegisterHook(hook Hook) { + n.ledger.hookRegistry.RegisterHook(hook) +} + // SetTokenURI sets the URI for a token func (led *PrivateLedger) SetTokenURI(caller std.Address, tid TokenID, tURI TokenURI) error { if !led.exists(tid) { @@ -104,6 +111,10 @@ func (led *PrivateLedger) Approve(caller std.Address, to std.Address, tid TokenI return ErrCallerIsNotOwnerOrApproved } + if err := led.hookRegistry.TriggerApprovalHooks(HookBefore, owner, to, tid); err != nil { + return err + } + led.tokenApprovals.Set(string(tid), to.String()) std.Emit( ApprovalEvent, @@ -112,7 +123,7 @@ func (led *PrivateLedger) Approve(caller std.Address, to std.Address, tid TokenI "tokenId", string(tid), ) - return nil + return led.hookRegistry.TriggerApprovalHooks(HookAfter, owner, to, tid) } // SetApprovalForAll approves or removes an operator to transfer all tokens @@ -125,6 +136,10 @@ func (led *PrivateLedger) SetApprovalForAll(caller std.Address, operator std.Add return ErrApprovalToCurrentOwner } + if err := led.hookRegistry.TriggerApprovalForAllHooks(HookBefore, caller, operator, approved); err != nil { + return err + } + key := caller.String() + ":" + operator.String() led.operatorApprovals.Set(key, approved) @@ -135,7 +150,7 @@ func (led *PrivateLedger) SetApprovalForAll(caller std.Address, operator std.Add "approved", strconv.FormatBool(approved), ) - return nil + return led.hookRegistry.TriggerApprovalForAllHooks(HookAfter, caller, operator, approved) } // Transfer transfers a nft directly from the caller to another address @@ -158,7 +173,16 @@ func (led *PrivateLedger) Transfer(from, to std.Address, tid TokenID) error { return ErrTransferFromIncorrectOwner } - return led.transferToken(from, to, tid) + if err := led.hookRegistry.TriggerTransferHooks(HookBefore, from, to, tid); err != nil { + return err + } + + err = led.transferToken(from, to, tid) + if err != nil { + return err + } + + return led.hookRegistry.TriggerTransferHooks(HookAfter, from, to, tid) } // TransferFrom transfers a nft from one address to another @@ -186,7 +210,16 @@ func (led *PrivateLedger) TransferFrom(caller, from, to std.Address, tid TokenID return ErrTransferFromIncorrectOwner } - return led.transferToken(from, to, tid) + if err := led.hookRegistry.TriggerTransferHooks(HookBefore, from, to, tid); err != nil { + return err + } + + err = led.transferToken(from, to, tid) + if err != nil { + return err + } + + return led.hookRegistry.TriggerTransferHooks(HookAfter, from, to, tid) } // Mint creates a new token @@ -199,12 +232,29 @@ func (led *PrivateLedger) Mint(to std.Address, tid TokenID) error { return ErrTokenIdAlreadyExists } - return led.mintToken(to, tid) + if err := led.hookRegistry.TriggerMintHooks(HookBefore, to, tid); err != nil { + return err + } + + err := led.mintToken(to, tid) + if err != nil { + return err + } + + return led.hookRegistry.TriggerMintHooks(HookAfter, to, tid) } func (led *PrivateLedger) Burn(tid TokenID) error { + if err := led.hookRegistry.TriggerBurnHooks(HookBefore, tid); err != nil { + return err + } + _, err := led.burnToken(tid) - return err + if err != nil { + return err + } + + return led.hookRegistry.TriggerBurnHooks(HookAfter, tid) } // BatchTransferFrom transfers multiple tokens from one address to another diff --git a/examples/gno.land/p/demo/grc/grc721remake/types.gno b/examples/gno.land/p/demo/grc/grc721remake/types.gno index 2d59e952556..5b49c9ba2ff 100644 --- a/examples/gno.land/p/demo/grc/grc721remake/types.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/types.gno @@ -106,6 +106,8 @@ type PrivateLedger struct { tokenURIs avl.Tree // "OwnerAddress:OperatorAddress" -> bool operatorApprovals avl.Tree + // Hook registry for extensibility + hookRegistry *HookRegistry } const ( From b7b80e8eca0681921705038d3233396e635c551c Mon Sep 17 00:00:00 2001 From: matijamarjanovic Date: Tue, 18 Mar 2025 21:47:50 +0100 Subject: [PATCH 07/17] add nft obj as an arg to hooks trigger funcs --- .../p/demo/grc/grc721remake/hooks.gno | 32 ++++----- .../p/demo/grc/grc721remake/hooks_test.gno | 66 +++++++++---------- .../gno.land/p/demo/grc/grc721remake/nft.gno | 24 +++---- .../p/demo/grc/grc721remake/nft_test.gno | 16 ++--- .../p/demo/grc/grc721remake/tellers_test.gno | 30 ++++----- 5 files changed, 84 insertions(+), 84 deletions(-) diff --git a/examples/gno.land/p/demo/grc/grc721remake/hooks.gno b/examples/gno.land/p/demo/grc/grc721remake/hooks.gno index 167e45ac0cc..33b85747a12 100644 --- a/examples/gno.land/p/demo/grc/grc721remake/hooks.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/hooks.gno @@ -32,31 +32,31 @@ type Hook interface { // TransferHook is implemented by hooks that want to intercept transfer operations type TransferHook interface { Hook - OnTransfer(from, to std.Address, tid TokenID) error + OnTransfer(nft *NFT, from, to std.Address, tid TokenID) error } // ApprovalHook is implemented by hooks that want to intercept approval operations type ApprovalHook interface { Hook - OnApproval(owner, approved std.Address, tid TokenID) error + OnApproval(nft *NFT, owner, approved std.Address, tid TokenID) error } // ApprovalForAllHook is implemented by hooks that want to intercept approvalForAll operations type ApprovalForAllHook interface { Hook - OnApprovalForAll(owner, operator std.Address, approved bool) error + OnApprovalForAll(nft *NFT, owner, operator std.Address, approved bool) error } // MintHook is implemented by hooks that want to intercept mint operations type MintHook interface { Hook - OnMint(to std.Address, tid TokenID) error + OnMint(nft *NFT, to std.Address, tid TokenID) error } // BurnHook is implemented by hooks that want to intercept burn operations type BurnHook interface { Hook - OnBurn(tid TokenID) error + OnBurn(nft *NFT, tid TokenID) error } // HookRegistry manages a collection of hooks @@ -77,11 +77,11 @@ func (reg *HookRegistry) RegisterHook(hook Hook) { } // TriggerTransferHooks executes all transfer hooks of the specified time -func (reg *HookRegistry) TriggerTransferHooks(time HookTime, from, to std.Address, tid TokenID) error { +func (reg *HookRegistry) TriggerTransferHooks(nft *NFT, time HookTime, from, to std.Address, tid TokenID) error { for _, hook := range reg.hooks { if hook.GetHookType() == HookTransfer && hook.GetHookTime() == time { if transferHook, ok := hook.(TransferHook); ok { - if err := transferHook.OnTransfer(from, to, tid); err != nil { + if err := transferHook.OnTransfer(nft, from, to, tid); err != nil { return err } } @@ -91,11 +91,11 @@ func (reg *HookRegistry) TriggerTransferHooks(time HookTime, from, to std.Addres } // TriggerApprovalHooks executes all approval hooks of the specified time -func (reg *HookRegistry) TriggerApprovalHooks(time HookTime, owner, approved std.Address, tid TokenID) error { +func (reg *HookRegistry) TriggerApprovalHooks(nft *NFT, time HookTime, owner, approved std.Address, tid TokenID) error { for _, hook := range reg.hooks { if hook.GetHookType() == HookApproval && hook.GetHookTime() == time { if approvalHook, ok := hook.(ApprovalHook); ok { - if err := approvalHook.OnApproval(owner, approved, tid); err != nil { + if err := approvalHook.OnApproval(nft, owner, approved, tid); err != nil { return err } } @@ -105,11 +105,11 @@ func (reg *HookRegistry) TriggerApprovalHooks(time HookTime, owner, approved std } // TriggerApprovalForAllHooks executes all approvalForAll hooks of the specified time -func (reg *HookRegistry) TriggerApprovalForAllHooks(time HookTime, owner, operator std.Address, approved bool) error { +func (reg *HookRegistry) TriggerApprovalForAllHooks(nft *NFT, time HookTime, owner, operator std.Address, approved bool) error { for _, hook := range reg.hooks { if hook.GetHookType() == HookApprovalForAll && hook.GetHookTime() == time { if approvalForAllHook, ok := hook.(ApprovalForAllHook); ok { - if err := approvalForAllHook.OnApprovalForAll(owner, operator, approved); err != nil { + if err := approvalForAllHook.OnApprovalForAll(nft, owner, operator, approved); err != nil { return err } } @@ -119,11 +119,11 @@ func (reg *HookRegistry) TriggerApprovalForAllHooks(time HookTime, owner, operat } // TriggerMintHooks executes all mint hooks of the specified time -func (reg *HookRegistry) TriggerMintHooks(time HookTime, to std.Address, tid TokenID) error { +func (reg *HookRegistry) TriggerMintHooks(nft *NFT, time HookTime, to std.Address, tid TokenID) error { for _, hook := range reg.hooks { if hook.GetHookType() == HookMint && hook.GetHookTime() == time { if mintHook, ok := hook.(MintHook); ok { - if err := mintHook.OnMint(to, tid); err != nil { + if err := mintHook.OnMint(nft, to, tid); err != nil { return err } } @@ -133,15 +133,15 @@ func (reg *HookRegistry) TriggerMintHooks(time HookTime, to std.Address, tid Tok } // TriggerBurnHooks executes all burn hooks of the specified time -func (reg *HookRegistry) TriggerBurnHooks(time HookTime, tid TokenID) error { +func (reg *HookRegistry) TriggerBurnHooks(nft *NFT, time HookTime, tid TokenID) error { for _, hook := range reg.hooks { if hook.GetHookType() == HookBurn && hook.GetHookTime() == time { if burnHook, ok := hook.(BurnHook); ok { - if err := burnHook.OnBurn(tid); err != nil { + if err := burnHook.OnBurn(nft, tid); err != nil { return err } } } } return nil -} +} diff --git a/examples/gno.land/p/demo/grc/grc721remake/hooks_test.gno b/examples/gno.land/p/demo/grc/grc721remake/hooks_test.gno index bae13b6ec9c..0957cac8af5 100644 --- a/examples/gno.land/p/demo/grc/grc721remake/hooks_test.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/hooks_test.gno @@ -6,8 +6,8 @@ import ( "gno.land/p/demo/testutils" "gno.land/p/demo/uassert" - "gno.land/p/demo/urequire" "gno.land/p/demo/ufmt" + "gno.land/p/demo/urequire" ) // PausableHook is an example hook that can pause transfers @@ -23,7 +23,7 @@ func (h *PausableHook) GetHookTime() HookTime { return HookBefore } -func (h *PausableHook) OnTransfer(from, to std.Address, tid TokenID) error { +func (h *PausableHook) OnTransfer(nft *NFT, from, to std.Address, tid TokenID) error { if h.paused { return ufmt.Errorf("transfers are paused") } @@ -44,7 +44,7 @@ func (h *RoyaltyHook) GetHookTime() HookTime { return HookBefore } -func (h *RoyaltyHook) OnTransfer(from, to std.Address, tid TokenID) error { +func (h *RoyaltyHook) OnTransfer(nft *NFT, from, to std.Address, tid TokenID) error { println("RoyaltyHook OnTransfer called") return nil } @@ -60,7 +60,7 @@ func (h *LoggingHook) GetHookTime() HookTime { return HookAfter } -func (h *LoggingHook) OnMint(to std.Address, tid TokenID) error { +func (h *LoggingHook) OnMint(nft *NFT, to std.Address, tid TokenID) error { println("LoggingHook OnMint called") return nil } @@ -79,7 +79,7 @@ func (h *BurnLimitHook) GetHookTime() HookTime { return HookBefore } -func (h *BurnLimitHook) OnBurn(tid TokenID) error { +func (h *BurnLimitHook) OnBurn(nft *NFT, tid TokenID) error { if h.burnCount >= h.maxBurns { return ufmt.Errorf("burn limit reached: maximum %d burns allowed", h.maxBurns) } @@ -92,33 +92,33 @@ func TestPausableHook(t *testing.T) { bob := testutils.TestAddress("bob") nft, ledger := NewNFT("Dummy NFT", "DNFT") - + pausable := &PausableHook{paused: false} nft.RegisterHook(pausable) - + urequire.NoError(t, ledger.Mint(alice, "token1")) urequire.NoError(t, ledger.Transfer(alice, bob, "token1")) - + owner, err := nft.OwnerOf("token1") urequire.NoError(t, err) uassert.Equal(t, owner, bob, "token should be transferred to bob") - + pausable.paused = true - + urequire.NoError(t, ledger.Mint(bob, "token2")) - + err = ledger.Transfer(bob, alice, "token2") uassert.Error(t, err, "transfer should fail when paused") uassert.ErrorContains(t, err, "transfers are paused", "error should indicate transfers are paused") - + owner, err = nft.OwnerOf("token2") urequire.NoError(t, err) uassert.Equal(t, owner, bob, "token should still be owned by bob") - + pausable.paused = false - + urequire.NoError(t, ledger.Transfer(bob, alice, "token2")) - + owner, err = nft.OwnerOf("token2") urequire.NoError(t, err) uassert.Equal(t, owner, alice, "token should be transferred to alice") @@ -130,16 +130,16 @@ func TestRoyaltyHook(t *testing.T) { creator := testutils.TestAddress("creator") nft, ledger := NewNFT("Royalty NFT", "RNFT") - + royalty := &RoyaltyHook{ royaltyReceiver: creator, royaltyPercent: 10, } nft.RegisterHook(royalty) - + urequire.NoError(t, ledger.Mint(alice, "token1")) urequire.NoError(t, ledger.Transfer(alice, bob, "token1")) - + owner, err := nft.OwnerOf("token1") urequire.NoError(t, err) uassert.Equal(t, owner, bob, "token should be transferred to bob") @@ -149,10 +149,10 @@ func TestLoggingHook(t *testing.T) { alice := testutils.TestAddress("alice") nft, ledger := NewNFT("Logging NFT", "LNFT") - + logger := &LoggingHook{} nft.RegisterHook(logger) - + urequire.NoError(t, ledger.Mint(alice, "token1")) } @@ -160,24 +160,24 @@ func TestBurnLimitHook(t *testing.T) { alice := testutils.TestAddress("alice") nft, ledger := NewNFT("Burn Limit NFT", "BLNFT") - + burnLimit := &BurnLimitHook{ burnCount: 0, maxBurns: 2, } nft.RegisterHook(burnLimit) - + urequire.NoError(t, ledger.Mint(alice, "token1")) urequire.NoError(t, ledger.Mint(alice, "token2")) urequire.NoError(t, ledger.Mint(alice, "token3")) - + urequire.NoError(t, ledger.Burn("token1")) urequire.NoError(t, ledger.Burn("token2")) - + err := ledger.Burn("token3") uassert.Error(t, err, "burn should fail when limit reached") uassert.ErrorContains(t, err, "burn limit reached", "error should indicate burn limit reached") - + _, err = nft.OwnerOf("token3") uassert.NoError(t, err, "token3 should still exist") } @@ -188,28 +188,28 @@ func TestMultipleHooks(t *testing.T) { creator := testutils.TestAddress("creator") nft, ledger := NewNFT("Multi Hook NFT", "MHNFT") - + pausable := &PausableHook{paused: false} royalty := &RoyaltyHook{ royaltyReceiver: creator, royaltyPercent: 10, } logger := &LoggingHook{} - + nft.RegisterHook(pausable) nft.RegisterHook(royalty) nft.RegisterHook(logger) - + urequire.NoError(t, ledger.Mint(alice, "token1")) - + urequire.NoError(t, ledger.Transfer(alice, bob, "token1")) - + pausable.paused = true - + err := ledger.Transfer(bob, alice, "token1") uassert.Error(t, err, "transfer should fail when paused") - + owner, err := nft.OwnerOf("token1") urequire.NoError(t, err) uassert.Equal(t, owner, bob, "token should still be owned by bob") -} +} diff --git a/examples/gno.land/p/demo/grc/grc721remake/nft.gno b/examples/gno.land/p/demo/grc/grc721remake/nft.gno index a2fbb37736f..cbbd0e3346c 100644 --- a/examples/gno.land/p/demo/grc/grc721remake/nft.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/nft.gno @@ -111,7 +111,7 @@ func (led *PrivateLedger) Approve(caller std.Address, to std.Address, tid TokenI return ErrCallerIsNotOwnerOrApproved } - if err := led.hookRegistry.TriggerApprovalHooks(HookBefore, owner, to, tid); err != nil { + if err := led.hookRegistry.TriggerApprovalHooks(led.nft, HookBefore, owner, to, tid); err != nil { return err } @@ -123,7 +123,7 @@ func (led *PrivateLedger) Approve(caller std.Address, to std.Address, tid TokenI "tokenId", string(tid), ) - return led.hookRegistry.TriggerApprovalHooks(HookAfter, owner, to, tid) + return led.hookRegistry.TriggerApprovalHooks(led.nft, HookAfter, owner, to, tid) } // SetApprovalForAll approves or removes an operator to transfer all tokens @@ -136,7 +136,7 @@ func (led *PrivateLedger) SetApprovalForAll(caller std.Address, operator std.Add return ErrApprovalToCurrentOwner } - if err := led.hookRegistry.TriggerApprovalForAllHooks(HookBefore, caller, operator, approved); err != nil { + if err := led.hookRegistry.TriggerApprovalForAllHooks(led.nft, HookBefore, caller, operator, approved); err != nil { return err } @@ -150,7 +150,7 @@ func (led *PrivateLedger) SetApprovalForAll(caller std.Address, operator std.Add "approved", strconv.FormatBool(approved), ) - return led.hookRegistry.TriggerApprovalForAllHooks(HookAfter, caller, operator, approved) + return led.hookRegistry.TriggerApprovalForAllHooks(led.nft, HookAfter, caller, operator, approved) } // Transfer transfers a nft directly from the caller to another address @@ -173,7 +173,7 @@ func (led *PrivateLedger) Transfer(from, to std.Address, tid TokenID) error { return ErrTransferFromIncorrectOwner } - if err := led.hookRegistry.TriggerTransferHooks(HookBefore, from, to, tid); err != nil { + if err := led.hookRegistry.TriggerTransferHooks(led.nft, HookBefore, from, to, tid); err != nil { return err } @@ -182,7 +182,7 @@ func (led *PrivateLedger) Transfer(from, to std.Address, tid TokenID) error { return err } - return led.hookRegistry.TriggerTransferHooks(HookAfter, from, to, tid) + return led.hookRegistry.TriggerTransferHooks(led.nft, HookAfter, from, to, tid) } // TransferFrom transfers a nft from one address to another @@ -210,7 +210,7 @@ func (led *PrivateLedger) TransferFrom(caller, from, to std.Address, tid TokenID return ErrTransferFromIncorrectOwner } - if err := led.hookRegistry.TriggerTransferHooks(HookBefore, from, to, tid); err != nil { + if err := led.hookRegistry.TriggerTransferHooks(led.nft, HookBefore, from, to, tid); err != nil { return err } @@ -219,7 +219,7 @@ func (led *PrivateLedger) TransferFrom(caller, from, to std.Address, tid TokenID return err } - return led.hookRegistry.TriggerTransferHooks(HookAfter, from, to, tid) + return led.hookRegistry.TriggerTransferHooks(led.nft, HookAfter, from, to, tid) } // Mint creates a new token @@ -232,7 +232,7 @@ func (led *PrivateLedger) Mint(to std.Address, tid TokenID) error { return ErrTokenIdAlreadyExists } - if err := led.hookRegistry.TriggerMintHooks(HookBefore, to, tid); err != nil { + if err := led.hookRegistry.TriggerMintHooks(led.nft, HookBefore, to, tid); err != nil { return err } @@ -241,11 +241,11 @@ func (led *PrivateLedger) Mint(to std.Address, tid TokenID) error { return err } - return led.hookRegistry.TriggerMintHooks(HookAfter, to, tid) + return led.hookRegistry.TriggerMintHooks(led.nft, HookAfter, to, tid) } func (led *PrivateLedger) Burn(tid TokenID) error { - if err := led.hookRegistry.TriggerBurnHooks(HookBefore, tid); err != nil { + if err := led.hookRegistry.TriggerBurnHooks(led.nft, HookBefore, tid); err != nil { return err } @@ -254,7 +254,7 @@ func (led *PrivateLedger) Burn(tid TokenID) error { return err } - return led.hookRegistry.TriggerBurnHooks(HookAfter, tid) + return led.hookRegistry.TriggerBurnHooks(led.nft, HookAfter, tid) } // BatchTransferFrom transfers multiple tokens from one address to another diff --git a/examples/gno.land/p/demo/grc/grc721remake/nft_test.gno b/examples/gno.land/p/demo/grc/grc721remake/nft_test.gno index a837d463961..48e0fc8d5f9 100644 --- a/examples/gno.land/p/demo/grc/grc721remake/nft_test.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/nft_test.gno @@ -74,7 +74,7 @@ func TestNFT(t *testing.T) { urequire.NoError(t, ledger.TransferFrom(bob, alice, carl, "token1")) checkBalances(2, 0, 1) checkOwnership("token1", carl) - + _, err := nft.GetApproved("token1") uassert.ErrorContains(t, err, ErrTokenIdNotHasApproved.Error(), "approval should be cleared after transfer") @@ -100,30 +100,30 @@ func TestNFT(t *testing.T) { urequire.NoError(t, ledger.Burn("token1")) checkBalances(0, 1, 3) uassert.Equal(t, nft.TokenCount(), uint64(4), "token count should be 4 after burning") - + urequire.NoError(t, ledger.BatchBurn([]TokenID{"token3", "token4"})) checkBalances(0, 1, 1) uassert.Equal(t, nft.TokenCount(), uint64(2), "token count should be 2 after batch burning") - + err = ledger.Mint(alice, "token2") uassert.ErrorContains(t, err, ErrTokenIdAlreadyExists.Error(), "should not be able to mint existing token") - + err = ledger.Transfer(alice, bob, "nonexistent") uassert.ErrorContains(t, err, ErrInvalidTokenId.Error(), "should not be able to transfer non-existent token") - + err = ledger.Approve(alice, bob, "nonexistent") uassert.ErrorContains(t, err, ErrInvalidTokenId.Error(), "should not be able to approve non-existent token") - + err = ledger.Transfer(alice, bob, "token5") uassert.ErrorContains(t, err, ErrTransferFromIncorrectOwner.Error(), "should not be able to transfer token you don't own") } func TestMetadata(t *testing.T) { nft, _ := NewNFT("Dummy NFT", "DNFT") - + uassert.Equal(t, nft.GetName(), "Dummy NFT", "name should match") uassert.Equal(t, nft.GetSymbol(), "DNFT", "symbol should match") - + getter := nft.Getter() nftFromGetter := getter() uassert.Equal(t, nftFromGetter.GetName(), "Dummy NFT", "name from getter should match") diff --git a/examples/gno.land/p/demo/grc/grc721remake/tellers_test.gno b/examples/gno.land/p/demo/grc/grc721remake/tellers_test.gno index 7391694a6e9..df65205e75f 100644 --- a/examples/gno.land/p/demo/grc/grc721remake/tellers_test.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/tellers_test.gno @@ -95,18 +95,18 @@ func TestCallerTeller(t *testing.T) { checkBalances(1, 0, 0) std.TestSetOriginCaller(alice) - + urequire.NoError(t, teller.Approve(bob, "token1")) approved, err := nft.GetApproved("token1") urequire.NoError(t, err) uassert.Equal(t, approved, bob, "invalid approval") std.TestSetOriginCaller(bob) - + urequire.NoError(t, teller.TransferFrom(alice, carl, "token1")) checkBalances(0, 0, 1) std.TestSetOriginCaller(carl) - + urequire.NoError(t, teller.Transfer(bob, "token1")) checkBalances(0, 1, 0) } @@ -121,25 +121,25 @@ func TestBatchOperations(t *testing.T) { urequire.NoError(t, ledger.Mint(alice, "token1")) urequire.NoError(t, ledger.Mint(alice, "token2")) urequire.NoError(t, ledger.Mint(alice, "token3")) - + aliceBalance, _ := nft.BalanceOf(alice) uassert.Equal(t, aliceBalance, uint64(3), "invalid balance after minting") std.TestSetOriginCaller(alice) - + urequire.NoError(t, teller.BatchTransfer(bob, []TokenID{"token1", "token2"})) - + aliceBalance, _ = nft.BalanceOf(alice) bobBalance, _ := nft.BalanceOf(bob) uassert.Equal(t, aliceBalance, uint64(1), "invalid alice balance after batch transfer") uassert.Equal(t, bobBalance, uint64(2), "invalid bob balance after batch transfer") - + urequire.NoError(t, teller.Approve(bob, "token3")) - + std.TestSetOriginCaller(alice) - + urequire.NoError(t, teller.Transfer(bob, "token3")) - + aliceBalance, _ = nft.BalanceOf(alice) bobBalance, _ = nft.BalanceOf(bob) uassert.Equal(t, aliceBalance, uint64(0), "invalid alice balance after transferFrom") @@ -154,17 +154,17 @@ func TestReadonlyTeller(t *testing.T) { readonlyTeller := nft.ReadonlyTeller() urequire.NoError(t, ledger.Mint(alice, "token1")) - + err := readonlyTeller.Transfer(bob, "token1") uassert.ErrorContains(t, err, ErrReadOnly.Error(), "readonly teller should not allow transfers") - + err = readonlyTeller.Approve(bob, "token1") uassert.ErrorContains(t, err, ErrReadOnly.Error(), "readonly teller should not allow approvals") - + name := readonlyTeller.GetName() uassert.Equal(t, name, "Dummy NFT", "readonly teller should allow reading name") - + balance, err := readonlyTeller.BalanceOf(alice) urequire.NoError(t, err) uassert.Equal(t, balance, uint64(1), "readonly teller should allow reading balances") -} +} From 4ee51cabbe89670116bbac2334e7589f697692a3 Mon Sep 17 00:00:00 2001 From: matijamarjanovic Date: Wed, 19 Mar 2025 10:44:52 +0100 Subject: [PATCH 08/17] update the package name in gno.mod --- examples/gno.land/p/demo/grc/grc721remake/gno.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/gno.land/p/demo/grc/grc721remake/gno.mod b/examples/gno.land/p/demo/grc/grc721remake/gno.mod index 62085e67928..931417edcd1 100644 --- a/examples/gno.land/p/demo/grc/grc721remake/gno.mod +++ b/examples/gno.land/p/demo/grc/grc721remake/gno.mod @@ -1 +1 @@ -module gno.land/p/demo/grc/grc721-remake +module gno.land/p/demo/grc/grc721remake From 87d680f8550e6b935be3adf0ba2b4bf8a610c6eb Mon Sep 17 00:00:00 2001 From: matijamarjanovic Date: Thu, 20 Mar 2025 22:43:20 +0100 Subject: [PATCH 09/17] make hook registry an avl.Tree and introduce GetHook method. make RegisterHook and GetHook methods over the ledger, as they are admin only --- .../p/demo/grc/grc721remake/hooks.gno | 93 +++++++++++++------ .../p/demo/grc/grc721remake/hooks_test.gno | 19 ++-- .../gno.land/p/demo/grc/grc721remake/nft.gno | 16 +++- .../p/demo/grc/grc721remake/types.gno | 1 + 4 files changed, 92 insertions(+), 37 deletions(-) diff --git a/examples/gno.land/p/demo/grc/grc721remake/hooks.gno b/examples/gno.land/p/demo/grc/grc721remake/hooks.gno index 33b85747a12..f53c307836e 100644 --- a/examples/gno.land/p/demo/grc/grc721remake/hooks.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/hooks.gno @@ -2,6 +2,8 @@ package grc721remake import ( "std" + + "gno.land/p/demo/avl" ) // HookType represents the type of operation a hook is associated with @@ -59,89 +61,128 @@ type BurnHook interface { OnBurn(nft *NFT, tid TokenID) error } -// HookRegistry manages a collection of hooks +// HookRegistry manages a collection of hooks using a tree structure type HookRegistry struct { - hooks []Hook + hooks *avl.Tree } // NewHookRegistry creates a new hook registry func NewHookRegistry() *HookRegistry { return &HookRegistry{ - hooks: make([]Hook, 0), + hooks: avl.NewTree(), } } -// RegisterHook adds a hook to the registry -func (reg *HookRegistry) RegisterHook(hook Hook) { - reg.hooks = append(reg.hooks, hook) +// RegisterHookWithKey adds a hook to the registry with a specified key +func (reg *HookRegistry) RegisterHook(key string, hook Hook) { + reg.hooks.Set(key, hook) +} + +// GetHook retrieves a hook by its key +func (reg *HookRegistry) GetHook(key string) (Hook, bool) { + value, exists := reg.hooks.Get(key) + if !exists { + return nil, false + } + return value.(Hook), true } // TriggerTransferHooks executes all transfer hooks of the specified time func (reg *HookRegistry) TriggerTransferHooks(nft *NFT, time HookTime, from, to std.Address, tid TokenID) error { - for _, hook := range reg.hooks { + var foundError error = nil + + reg.hooks.Iterate("", "", func(key string, value any) bool { + hook := value.(Hook) if hook.GetHookType() == HookTransfer && hook.GetHookTime() == time { if transferHook, ok := hook.(TransferHook); ok { if err := transferHook.OnTransfer(nft, from, to, tid); err != nil { - return err + foundError = err + return true } } } - } - return nil + return false + }) + + return foundError } // TriggerApprovalHooks executes all approval hooks of the specified time func (reg *HookRegistry) TriggerApprovalHooks(nft *NFT, time HookTime, owner, approved std.Address, tid TokenID) error { - for _, hook := range reg.hooks { + var foundError error = nil + + reg.hooks.Iterate("", "", func(key string, value any) bool { + hook := value.(Hook) if hook.GetHookType() == HookApproval && hook.GetHookTime() == time { if approvalHook, ok := hook.(ApprovalHook); ok { if err := approvalHook.OnApproval(nft, owner, approved, tid); err != nil { - return err + foundError = err + return true } } } - } - return nil + return false + }) + + return foundError } // TriggerApprovalForAllHooks executes all approvalForAll hooks of the specified time func (reg *HookRegistry) TriggerApprovalForAllHooks(nft *NFT, time HookTime, owner, operator std.Address, approved bool) error { - for _, hook := range reg.hooks { + var foundError error = nil + + reg.hooks.Iterate("", "", func(key string, value any) bool { + hook := value.(Hook) if hook.GetHookType() == HookApprovalForAll && hook.GetHookTime() == time { if approvalForAllHook, ok := hook.(ApprovalForAllHook); ok { if err := approvalForAllHook.OnApprovalForAll(nft, owner, operator, approved); err != nil { - return err + foundError = err + return true } } } - } - return nil + return false + }) + + return foundError } // TriggerMintHooks executes all mint hooks of the specified time func (reg *HookRegistry) TriggerMintHooks(nft *NFT, time HookTime, to std.Address, tid TokenID) error { - for _, hook := range reg.hooks { + var foundError error = nil + + reg.hooks.Iterate("", "", func(key string, value any) bool { + hook := value.(Hook) if hook.GetHookType() == HookMint && hook.GetHookTime() == time { if mintHook, ok := hook.(MintHook); ok { if err := mintHook.OnMint(nft, to, tid); err != nil { - return err + foundError = err + return true } } } - } - return nil + return false + }) + + return foundError } // TriggerBurnHooks executes all burn hooks of the specified time func (reg *HookRegistry) TriggerBurnHooks(nft *NFT, time HookTime, tid TokenID) error { - for _, hook := range reg.hooks { + var foundError error = nil + + reg.hooks.Iterate("", "", func(key string, value any) bool { + hook := value.(Hook) if hook.GetHookType() == HookBurn && hook.GetHookTime() == time { if burnHook, ok := hook.(BurnHook); ok { if err := burnHook.OnBurn(nft, tid); err != nil { - return err + foundError = err + return true } } } - } - return nil + return false + }) + + return foundError } diff --git a/examples/gno.land/p/demo/grc/grc721remake/hooks_test.gno b/examples/gno.land/p/demo/grc/grc721remake/hooks_test.gno index 0957cac8af5..6bde86884e5 100644 --- a/examples/gno.land/p/demo/grc/grc721remake/hooks_test.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/hooks_test.gno @@ -94,7 +94,7 @@ func TestPausableHook(t *testing.T) { nft, ledger := NewNFT("Dummy NFT", "DNFT") pausable := &PausableHook{paused: false} - nft.RegisterHook(pausable) + ledger.RegisterHook("pausable", pausable) urequire.NoError(t, ledger.Mint(alice, "token1")) urequire.NoError(t, ledger.Transfer(alice, bob, "token1")) @@ -103,7 +103,10 @@ func TestPausableHook(t *testing.T) { urequire.NoError(t, err) uassert.Equal(t, owner, bob, "token should be transferred to bob") - pausable.paused = true + retrievedHook, err := ledger.GetHook("pausable") + uassert.NoError(t, err) + pausableHook := retrievedHook.(*PausableHook) + pausableHook.paused = true urequire.NoError(t, ledger.Mint(bob, "token2")) @@ -135,7 +138,7 @@ func TestRoyaltyHook(t *testing.T) { royaltyReceiver: creator, royaltyPercent: 10, } - nft.RegisterHook(royalty) + ledger.RegisterHook("royalty", royalty) urequire.NoError(t, ledger.Mint(alice, "token1")) urequire.NoError(t, ledger.Transfer(alice, bob, "token1")) @@ -151,7 +154,7 @@ func TestLoggingHook(t *testing.T) { nft, ledger := NewNFT("Logging NFT", "LNFT") logger := &LoggingHook{} - nft.RegisterHook(logger) + ledger.RegisterHook("logging", logger) urequire.NoError(t, ledger.Mint(alice, "token1")) } @@ -165,7 +168,7 @@ func TestBurnLimitHook(t *testing.T) { burnCount: 0, maxBurns: 2, } - nft.RegisterHook(burnLimit) + ledger.RegisterHook("burnLimit", burnLimit) urequire.NoError(t, ledger.Mint(alice, "token1")) urequire.NoError(t, ledger.Mint(alice, "token2")) @@ -196,9 +199,9 @@ func TestMultipleHooks(t *testing.T) { } logger := &LoggingHook{} - nft.RegisterHook(pausable) - nft.RegisterHook(royalty) - nft.RegisterHook(logger) + ledger.RegisterHook("pausable", pausable) + ledger.RegisterHook("royalty", royalty) + ledger.RegisterHook("logging", logger) urequire.NoError(t, ledger.Mint(alice, "token1")) diff --git a/examples/gno.land/p/demo/grc/grc721remake/nft.gno b/examples/gno.land/p/demo/grc/grc721remake/nft.gno index cbbd0e3346c..aa129cc65f9 100644 --- a/examples/gno.land/p/demo/grc/grc721remake/nft.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/nft.gno @@ -72,9 +72,19 @@ func (n *NFT) Getter() NFTGetter { } } -// RegisterHook registers a hook with the NFT's ledger -func (n *NFT) RegisterHook(hook Hook) { - n.ledger.hookRegistry.RegisterHook(hook) +// RegisterHook registers a hook with a specific key (admin only) +func (led *PrivateLedger) RegisterHook(key string, hook Hook) error { + led.hookRegistry.RegisterHook(key, hook) + return nil +} + +// GetHook retrieves a hook by its key +func (led *PrivateLedger) GetHook(key string) (Hook, error) { + hook, found := led.hookRegistry.GetHook(key) + if !found { + return nil, ErrHookNotFound + } + return hook, nil } // SetTokenURI sets the URI for a token diff --git a/examples/gno.land/p/demo/grc/grc721remake/types.gno b/examples/gno.land/p/demo/grc/grc721remake/types.gno index 5b49c9ba2ff..5f0494e999e 100644 --- a/examples/gno.land/p/demo/grc/grc721remake/types.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/types.gno @@ -131,6 +131,7 @@ var ( ErrTokenIdAlreadyExists = errors.New("token id already exists") ErrEmptyTokenIDList = errors.New("empty token ID list") ErrReadOnly = errors.New("teller is readonly") + ErrHookNotFound = errors.New("hook not found") ) var zeroAddress std.Address From 7cba1fe707bf93a548074c3a5334f80a0e4cdf86 Mon Sep 17 00:00:00 2001 From: matijamarjanovic Date: Mon, 24 Mar 2025 15:37:10 +0100 Subject: [PATCH 10/17] - add universal hook - add /r/../pausablenft.gno as an example implementation --- .../p/demo/grc/grc721remake/hooks.gno | 72 +++++++++- .../grc721/pausablenft/gno.mod | 2 + .../grc721/pausablenft/hooks.gno | 26 ++++ .../grc721/pausablenft/pausablenft.gno | 74 ++++++++++ .../grc721/pausablenft/pausablenft_test.gno | 128 ++++++++++++++++++ 5 files changed, 297 insertions(+), 5 deletions(-) create mode 100644 examples/gno.land/r/matijamarjanovic/grc721/pausablenft/gno.mod create mode 100644 examples/gno.land/r/matijamarjanovic/grc721/pausablenft/hooks.gno create mode 100644 examples/gno.land/r/matijamarjanovic/grc721/pausablenft/pausablenft.gno create mode 100644 examples/gno.land/r/matijamarjanovic/grc721/pausablenft/pausablenft_test.gno diff --git a/examples/gno.land/p/demo/grc/grc721remake/hooks.gno b/examples/gno.land/p/demo/grc/grc721remake/hooks.gno index f53c307836e..ebcd394d2a7 100644 --- a/examples/gno.land/p/demo/grc/grc721remake/hooks.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/hooks.gno @@ -15,6 +15,7 @@ const ( HookApprovalForAll HookType = "approvalForAll" HookMint HookType = "mint" HookBurn HookType = "burn" + HookUniversal HookType = "universal" ) // HookTime represents when a hook should be executed @@ -61,6 +62,12 @@ type BurnHook interface { OnBurn(nft *NFT, tid TokenID) error } +// UniversalHook is implemented by hooks that want to intercept all operations +type UniversalHook interface { + Hook + OnAny(nft *NFT, operation string) error +} + // HookRegistry manages a collection of hooks using a tree structure type HookRegistry struct { hooks *avl.Tree @@ -93,13 +100,24 @@ func (reg *HookRegistry) TriggerTransferHooks(nft *NFT, time HookTime, from, to reg.hooks.Iterate("", "", func(key string, value any) bool { hook := value.(Hook) - if hook.GetHookType() == HookTransfer && hook.GetHookTime() == time { + if hook.GetHookTime() != time { + return false + } + + if hook.GetHookType() == HookTransfer { if transferHook, ok := hook.(TransferHook); ok { if err := transferHook.OnTransfer(nft, from, to, tid); err != nil { foundError = err return true } } + } else if hook.GetHookType() == HookUniversal { + if universalHook, ok := hook.(UniversalHook); ok { + if err := universalHook.OnAny(nft, "transfer"); err != nil { + foundError = err + return true + } + } } return false }) @@ -113,13 +131,24 @@ func (reg *HookRegistry) TriggerApprovalHooks(nft *NFT, time HookTime, owner, ap reg.hooks.Iterate("", "", func(key string, value any) bool { hook := value.(Hook) - if hook.GetHookType() == HookApproval && hook.GetHookTime() == time { + if hook.GetHookTime() != time { + return false + } + + if hook.GetHookType() == HookApproval { if approvalHook, ok := hook.(ApprovalHook); ok { if err := approvalHook.OnApproval(nft, owner, approved, tid); err != nil { foundError = err return true } } + } else if hook.GetHookType() == HookUniversal { + if universalHook, ok := hook.(UniversalHook); ok { + if err := universalHook.OnAny(nft, "approval"); err != nil { + foundError = err + return true + } + } } return false }) @@ -133,13 +162,24 @@ func (reg *HookRegistry) TriggerApprovalForAllHooks(nft *NFT, time HookTime, own reg.hooks.Iterate("", "", func(key string, value any) bool { hook := value.(Hook) - if hook.GetHookType() == HookApprovalForAll && hook.GetHookTime() == time { + if hook.GetHookTime() != time { + return false + } + + if hook.GetHookType() == HookApprovalForAll { if approvalForAllHook, ok := hook.(ApprovalForAllHook); ok { if err := approvalForAllHook.OnApprovalForAll(nft, owner, operator, approved); err != nil { foundError = err return true } } + } else if hook.GetHookType() == HookUniversal { + if universalHook, ok := hook.(UniversalHook); ok { + if err := universalHook.OnAny(nft, "approvalForAll"); err != nil { + foundError = err + return true + } + } } return false }) @@ -153,13 +193,24 @@ func (reg *HookRegistry) TriggerMintHooks(nft *NFT, time HookTime, to std.Addres reg.hooks.Iterate("", "", func(key string, value any) bool { hook := value.(Hook) - if hook.GetHookType() == HookMint && hook.GetHookTime() == time { + if hook.GetHookTime() != time { + return false + } + + if hook.GetHookType() == HookMint { if mintHook, ok := hook.(MintHook); ok { if err := mintHook.OnMint(nft, to, tid); err != nil { foundError = err return true } } + } else if hook.GetHookType() == HookUniversal { + if universalHook, ok := hook.(UniversalHook); ok { + if err := universalHook.OnAny(nft, "mint"); err != nil { + foundError = err + return true + } + } } return false }) @@ -173,13 +224,24 @@ func (reg *HookRegistry) TriggerBurnHooks(nft *NFT, time HookTime, tid TokenID) reg.hooks.Iterate("", "", func(key string, value any) bool { hook := value.(Hook) - if hook.GetHookType() == HookBurn && hook.GetHookTime() == time { + if hook.GetHookTime() != time { + return false + } + + if hook.GetHookType() == HookBurn { if burnHook, ok := hook.(BurnHook); ok { if err := burnHook.OnBurn(nft, tid); err != nil { foundError = err return true } } + } else if hook.GetHookType() == HookUniversal { + if universalHook, ok := hook.(UniversalHook); ok { + if err := universalHook.OnAny(nft, "burn"); err != nil { + foundError = err + return true + } + } } return false }) diff --git a/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/gno.mod b/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/gno.mod new file mode 100644 index 00000000000..f57fa8cc4cd --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/gno.mod @@ -0,0 +1,2 @@ +module gno.land/r/matijamarjanovic/grc721/pausablenft + diff --git a/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/hooks.gno b/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/hooks.gno new file mode 100644 index 00000000000..f49db485d35 --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/hooks.gno @@ -0,0 +1,26 @@ +package pausablenft + +import ( + "gno.land/p/demo/grc/grc721remake" + "gno.land/p/demo/ufmt" +) + +// Hook Definition +type PausableHook struct { + paused bool +} + +func (h *PausableHook) GetHookType() grc721remake.HookType { + return grc721remake.HookUniversal +} + +func (h *PausableHook) GetHookTime() grc721remake.HookTime { + return grc721remake.HookBefore +} + +func (h *PausableHook) OnAny(nft *grc721remake.NFT, operation string) error { + if h.paused { + return ufmt.Errorf("all operations are paused") + } + return nil +} diff --git a/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/pausablenft.gno b/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/pausablenft.gno new file mode 100644 index 00000000000..6c34fb8b177 --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/pausablenft.gno @@ -0,0 +1,74 @@ +package pausablenft + +import ( + "std" + "strconv" + + "gno.land/p/demo/grc/grc721remake" + "gno.land/p/demo/ufmt" + "gno.land/p/moul/authz" +) + +var ( + NFT, ledger = grc721remake.NewNFT("PausableNFT", "PNFT") + Teller = NFT.CallerTeller() + auth = authz.New() +) + +func init() { + pausableHook := &PausableHook{paused: false} + ledger.RegisterHook("pausable", pausableHook) +} + +// Hook Related Functions + +// SetPaused sets the paused state of the NFT collection +func SetPaused(paused bool) error { + return auth.Do("set_paused", func() error { + hook, err := ledger.GetHook("pausable") + if err != nil { + return err + } + + pausableHook := hook.(*PausableHook) + pausableHook.paused = paused + + std.Emit( + "SetPaused", + "paused", strconv.FormatBool(paused), + ) + return nil + }) +} + +// IsPaused returns whether the NFT collection is paused +func IsPaused() bool { + hook, err := ledger.GetHook("pausable") + if err != nil { + panic(err) + } + + pausableHook := hook.(*PausableHook) + return pausableHook.paused +} + +// Admin Functions (authz) + +func TransferAuthority(newAuthority authz.Authority) error { + return auth.Transfer(newAuthority) +} + +func AddAdmin(addr std.Address) error { + if memberAuth, ok := auth.Current().(*authz.MemberAuthority); ok { + return memberAuth.AddMember(addr) + } + return ufmt.Errorf("current authority is not a member authority") +} + +func RemoveAdmin(addr std.Address) error { + if memberAuth, ok := auth.Current().(*authz.MemberAuthority); ok { + return memberAuth.RemoveMember(addr) + } + return ufmt.Errorf("current authority is not a member authority") +} + diff --git a/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/pausablenft_test.gno b/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/pausablenft_test.gno new file mode 100644 index 00000000000..84fc4cd44fc --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/pausablenft_test.gno @@ -0,0 +1,128 @@ +package pausablenft + +import ( + "std" + "testing" + + "gno.land/p/demo/testutils" + "gno.land/p/demo/ufmt" + "gno.land/p/demo/uassert" + "gno.land/p/moul/authz" + "gno.land/p/demo/grc/grc721remake" +) + +func TestPausableNFT(t *testing.T) { + var ( + admin = testutils.TestAddress("admin") + alice = testutils.TestAddress("alice") + bob = testutils.TestAddress("bob") + ) + + initAuth := authz.NewMemberAuthority(admin) + auth.Transfer(initAuth) + + uassert.Equal(t, false, IsPaused()) + + std.TestSetOriginCaller(admin) + err := SetPaused(true) + uassert.NoError(t, err) + uassert.Equal(t, true, IsPaused()) + + err = SetPaused(false) + uassert.NoError(t, err) + uassert.Equal(t, false, IsPaused()) + + tokenID := "token1" + std.TestSetOriginCaller(admin) + err = ledger.Mint(admin, grc721remake.TokenID(tokenID)) + uassert.NoError(t, err) + + owner, err := NFT.OwnerOf(grc721remake.TokenID(tokenID)) + uassert.NoError(t, err) + uassert.Equal(t, admin, owner) + + err = ledger.TransferFrom(admin, admin, alice, grc721remake.TokenID(tokenID)) + uassert.NoError(t, err) + + owner, err = NFT.OwnerOf(grc721remake.TokenID(tokenID)) + uassert.NoError(t, err) + uassert.Equal(t, alice, owner) + + err = SetPaused(true) + uassert.NoError(t, err) + + tokenID2 := "token2" + err = ledger.Mint(admin, grc721remake.TokenID(tokenID2)) + if err == nil { + t.Error("Expected error when minting while paused") + } + + std.TestSetOriginCaller(alice) + err = ledger.TransferFrom(alice, alice, admin, grc721remake.TokenID(tokenID)) + if err == nil { + t.Error("Expected error when transferring while paused") + } +} + +func TestAdminFunctions(t *testing.T) { + var ( + admin = testutils.TestAddress("admin") + alice = testutils.TestAddress("alice") + bob = testutils.TestAddress("bob") + ) + + initAuth := authz.NewMemberAuthority(admin) + auth.Transfer(initAuth) + + std.TestSetOriginCaller(admin) + err := AddAdmin(alice) + uassert.NoError(t, err) + + std.TestSetOriginCaller(alice) + err = SetPaused(true) + uassert.NoError(t, err) + uassert.Equal(t, true, IsPaused()) + + std.TestSetOriginCaller(admin) + err = RemoveAdmin(alice) + uassert.NoError(t, err) + + std.TestSetOriginCaller(alice) + err = SetPaused(false) + if err == nil { + t.Error("Expected error when non-admin tries to set paused state") + } + + std.TestSetOriginCaller(admin) + err = SetPaused(false) + uassert.NoError(t, err) +} + +func TestTransferAuthority(t *testing.T) { + var ( + admin = testutils.TestAddress("admin") + alice = testutils.TestAddress("alice") + ) + + initAuth := authz.NewMemberAuthority(admin) + auth.Transfer(initAuth) + + newAuth := authz.NewMemberAuthority(alice) + + std.TestSetOriginCaller(admin) + err := TransferAuthority(newAuth) + uassert.NoError(t, err) + + err = SetPaused(true) + if err == nil { + t.Error("Expected error when old admin tries to set paused state") + } + + std.TestSetOriginCaller(alice) + err = SetPaused(true) + uassert.NoError(t, err) + uassert.Equal(t, true, IsPaused()) + + err = SetPaused(false) + uassert.NoError(t, err) +} From 55ea97643eb17f68f145f3e9ca23d407a913d24e Mon Sep 17 00:00:00 2001 From: matijamarjanovic Date: Mon, 24 Mar 2025 15:57:13 +0100 Subject: [PATCH 11/17] update tests & fix fmt --- .../p/demo/grc/grc721remake/tellers_test.gno | 10 ++-- .../grc721/pausablenft/pausablenft.gno | 11 ++-- .../grc721/pausablenft/pausablenft_test.gno | 55 +++++++++---------- 3 files changed, 37 insertions(+), 39 deletions(-) diff --git a/examples/gno.land/p/demo/grc/grc721remake/tellers_test.gno b/examples/gno.land/p/demo/grc/grc721remake/tellers_test.gno index df65205e75f..1ab3aaf564c 100644 --- a/examples/gno.land/p/demo/grc/grc721remake/tellers_test.gno +++ b/examples/gno.land/p/demo/grc/grc721remake/tellers_test.gno @@ -94,18 +94,18 @@ func TestCallerTeller(t *testing.T) { urequire.NoError(t, ledger.Mint(alice, "token1")) checkBalances(1, 0, 0) - std.TestSetOriginCaller(alice) + testing.SetOriginCaller(alice) urequire.NoError(t, teller.Approve(bob, "token1")) approved, err := nft.GetApproved("token1") urequire.NoError(t, err) uassert.Equal(t, approved, bob, "invalid approval") - std.TestSetOriginCaller(bob) + testing.SetOriginCaller(bob) urequire.NoError(t, teller.TransferFrom(alice, carl, "token1")) checkBalances(0, 0, 1) - std.TestSetOriginCaller(carl) + testing.SetOriginCaller(carl) urequire.NoError(t, teller.Transfer(bob, "token1")) checkBalances(0, 1, 0) @@ -125,7 +125,7 @@ func TestBatchOperations(t *testing.T) { aliceBalance, _ := nft.BalanceOf(alice) uassert.Equal(t, aliceBalance, uint64(3), "invalid balance after minting") - std.TestSetOriginCaller(alice) + testing.SetOriginCaller(alice) urequire.NoError(t, teller.BatchTransfer(bob, []TokenID{"token1", "token2"})) @@ -136,7 +136,7 @@ func TestBatchOperations(t *testing.T) { urequire.NoError(t, teller.Approve(bob, "token3")) - std.TestSetOriginCaller(alice) + testing.SetOriginCaller(alice) urequire.NoError(t, teller.Transfer(bob, "token3")) diff --git a/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/pausablenft.gno b/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/pausablenft.gno index 6c34fb8b177..32ab088510a 100644 --- a/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/pausablenft.gno +++ b/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/pausablenft.gno @@ -11,8 +11,8 @@ import ( var ( NFT, ledger = grc721remake.NewNFT("PausableNFT", "PNFT") - Teller = NFT.CallerTeller() - auth = authz.New() + Teller = NFT.CallerTeller() + auth = authz.New() ) func init() { @@ -29,10 +29,10 @@ func SetPaused(paused bool) error { if err != nil { return err } - + pausableHook := hook.(*PausableHook) pausableHook.paused = paused - + std.Emit( "SetPaused", "paused", strconv.FormatBool(paused), @@ -47,7 +47,7 @@ func IsPaused() bool { if err != nil { panic(err) } - + pausableHook := hook.(*PausableHook) return pausableHook.paused } @@ -71,4 +71,3 @@ func RemoveAdmin(addr std.Address) error { } return ufmt.Errorf("current authority is not a member authority") } - diff --git a/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/pausablenft_test.gno b/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/pausablenft_test.gno index 84fc4cd44fc..9943b23c357 100644 --- a/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/pausablenft_test.gno +++ b/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/pausablenft_test.gno @@ -1,14 +1,12 @@ package pausablenft import ( - "std" "testing" + "gno.land/p/demo/grc/grc721remake" "gno.land/p/demo/testutils" - "gno.land/p/demo/ufmt" "gno.land/p/demo/uassert" "gno.land/p/moul/authz" - "gno.land/p/demo/grc/grc721remake" ) func TestPausableNFT(t *testing.T) { @@ -23,7 +21,7 @@ func TestPausableNFT(t *testing.T) { uassert.Equal(t, false, IsPaused()) - std.TestSetOriginCaller(admin) + testing.SetOriginCaller(admin) err := SetPaused(true) uassert.NoError(t, err) uassert.Equal(t, true, IsPaused()) @@ -33,17 +31,17 @@ func TestPausableNFT(t *testing.T) { uassert.Equal(t, false, IsPaused()) tokenID := "token1" - std.TestSetOriginCaller(admin) + testing.SetOriginCaller(admin) err = ledger.Mint(admin, grc721remake.TokenID(tokenID)) uassert.NoError(t, err) - + owner, err := NFT.OwnerOf(grc721remake.TokenID(tokenID)) uassert.NoError(t, err) uassert.Equal(t, admin, owner) - + err = ledger.TransferFrom(admin, admin, alice, grc721remake.TokenID(tokenID)) uassert.NoError(t, err) - + owner, err = NFT.OwnerOf(grc721remake.TokenID(tokenID)) uassert.NoError(t, err) uassert.Equal(t, alice, owner) @@ -56,8 +54,8 @@ func TestPausableNFT(t *testing.T) { if err == nil { t.Error("Expected error when minting while paused") } - - std.TestSetOriginCaller(alice) + + testing.SetOriginCaller(alice) err = ledger.TransferFrom(alice, alice, admin, grc721remake.TokenID(tokenID)) if err == nil { t.Error("Expected error when transferring while paused") @@ -73,27 +71,28 @@ func TestAdminFunctions(t *testing.T) { initAuth := authz.NewMemberAuthority(admin) auth.Transfer(initAuth) - - std.TestSetOriginCaller(admin) + + testing.SetOriginCaller(admin) err := AddAdmin(alice) uassert.NoError(t, err) - - std.TestSetOriginCaller(alice) + + testing.SetOriginCaller(alice) err = SetPaused(true) uassert.NoError(t, err) uassert.Equal(t, true, IsPaused()) - - std.TestSetOriginCaller(admin) + + testing.SetOriginCaller(admin) err = RemoveAdmin(alice) uassert.NoError(t, err) - - std.TestSetOriginCaller(alice) + + testing.SetOriginCaller(alice) err = SetPaused(false) if err == nil { t.Error("Expected error when non-admin tries to set paused state") } - - std.TestSetOriginCaller(admin) + + // Reset state for other tests + testing.SetOriginCaller(admin) err = SetPaused(false) uassert.NoError(t, err) } @@ -103,26 +102,26 @@ func TestTransferAuthority(t *testing.T) { admin = testutils.TestAddress("admin") alice = testutils.TestAddress("alice") ) - + initAuth := authz.NewMemberAuthority(admin) auth.Transfer(initAuth) - + newAuth := authz.NewMemberAuthority(alice) - - std.TestSetOriginCaller(admin) + + testing.SetOriginCaller(admin) err := TransferAuthority(newAuth) uassert.NoError(t, err) - + err = SetPaused(true) if err == nil { t.Error("Expected error when old admin tries to set paused state") } - - std.TestSetOriginCaller(alice) + + testing.SetOriginCaller(alice) err = SetPaused(true) uassert.NoError(t, err) uassert.Equal(t, true, IsPaused()) - + err = SetPaused(false) uassert.NoError(t, err) } From 67686197e115fb42c3a0655e83a97f8023ef0b9a Mon Sep 17 00:00:00 2001 From: matijamarjanovic Date: Mon, 24 Mar 2025 16:05:46 +0100 Subject: [PATCH 12/17] mod tidy --- examples/gno.land/r/matijamarjanovic/grc721/pausablenft/gno.mod | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/gno.mod b/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/gno.mod index f57fa8cc4cd..72dc20e3e86 100644 --- a/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/gno.mod +++ b/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/gno.mod @@ -1,2 +1 @@ module gno.land/r/matijamarjanovic/grc721/pausablenft - From aa372b1d4536f524bbeebdf94805fa8ff0cb23e2 Mon Sep 17 00:00:00 2001 From: matijamarjanovic Date: Mon, 24 Mar 2025 20:27:29 +0100 Subject: [PATCH 13/17] add extended nft example implementation that implements royalty extension and metadata extension --- .../grc721/extendednft/gno.mod | 1 + .../grc721/extendednft/metadata_hook.gno | 62 +++++++ .../grc721/extendednft/nft.gno | 126 +++++++++++++++ .../grc721/extendednft/nft_test.gno | 151 ++++++++++++++++++ .../grc721/extendednft/royalty_hook.gno | 87 ++++++++++ .../grc721/extendednft/types.gno | 36 +++++ .../grc721/pausablenft/pausablenft.gno | 18 +++ 7 files changed, 481 insertions(+) create mode 100644 examples/gno.land/r/matijamarjanovic/grc721/extendednft/gno.mod create mode 100644 examples/gno.land/r/matijamarjanovic/grc721/extendednft/metadata_hook.gno create mode 100644 examples/gno.land/r/matijamarjanovic/grc721/extendednft/nft.gno create mode 100644 examples/gno.land/r/matijamarjanovic/grc721/extendednft/nft_test.gno create mode 100644 examples/gno.land/r/matijamarjanovic/grc721/extendednft/royalty_hook.gno create mode 100644 examples/gno.land/r/matijamarjanovic/grc721/extendednft/types.gno diff --git a/examples/gno.land/r/matijamarjanovic/grc721/extendednft/gno.mod b/examples/gno.land/r/matijamarjanovic/grc721/extendednft/gno.mod new file mode 100644 index 00000000000..b92de4f9a87 --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/grc721/extendednft/gno.mod @@ -0,0 +1 @@ +module gno.land/p/demo/grc/grc721/extendednft diff --git a/examples/gno.land/r/matijamarjanovic/grc721/extendednft/metadata_hook.gno b/examples/gno.land/r/matijamarjanovic/grc721/extendednft/metadata_hook.gno new file mode 100644 index 00000000000..fbb34033378 --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/grc721/extendednft/metadata_hook.gno @@ -0,0 +1,62 @@ +package extendednft + +import ( + "std" + + "gno.land/p/demo/avl" + "gno.land/p/demo/grc/grc721remake" + "gno.land/p/demo/ufmt" +) + +type MetadataHook struct { + tokenMetadata *avl.Tree // TokenID -> Metadata +} + +func NewMetadataHook() *MetadataHook { + return &MetadataHook{ + tokenMetadata: avl.NewTree(), + } +} + +func (h *MetadataHook) GetHookType() grc721remake.HookType { + return grc721remake.HookUniversal +} + +func (h *MetadataHook) GetHookTime() grc721remake.HookTime { + return grc721remake.HookAfter +} + +func (h *MetadataHook) OnAny(nft *grc721remake.NFT, operation string) error { + // This hook doesn't need to do anything on operations + // It just stores metadata that can be retrieved later + return nil +} + +func (h *MetadataHook) SetTokenMetadata(nft *grc721remake.NFT, tid TokenID, metadata Metadata, caller std.Address) error { + owner, err := nft.OwnerOf(tid) + if err != nil { + return ufmt.Errorf("token does not exist: %v", err) + } + + if owner != caller { + return ufmt.Errorf("caller is not token owner") + } + + h.tokenMetadata.Set(string(tid), metadata) + + std.Emit( + "MetadataUpdated", + "tokenId", string(tid), + "owner", string(owner), + ) + + return nil +} + +func (h *MetadataHook) GetTokenMetadata(tid TokenID) (Metadata, bool) { + val, found := h.tokenMetadata.Get(string(tid)) + if !found { + return Metadata{}, false + } + return val.(Metadata), true +} diff --git a/examples/gno.land/r/matijamarjanovic/grc721/extendednft/nft.gno b/examples/gno.land/r/matijamarjanovic/grc721/extendednft/nft.gno new file mode 100644 index 00000000000..1f9249d8f8c --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/grc721/extendednft/nft.gno @@ -0,0 +1,126 @@ +package extendednft + +import ( + "std" + + "gno.land/p/demo/grc/grc721remake" + "gno.land/p/demo/ufmt" + "gno.land/p/moul/authz" +) + +var ( + NFT, ledger = grc721remake.NewNFT("Extended NFT", "ENFT") + + Teller = NFT.CallerTeller() + + royaltyHook = NewRoyaltyHook(100) // Max 100% royalty + metadataHook = NewMetadataHook() + + auth = authz.New() +) + +func init() { + ledger.RegisterHook("royalty", royaltyHook) + ledger.RegisterHook("metadata", metadataHook) +} + +// Royalty-related functions + +func SetTokenRoyalty(tid TokenID, paymentAddress std.Address, percentage uint64) error { + caller := std.PreviousRealm().Address() + + owner, err := NFT.OwnerOf(tid) + if err != nil { + return ufmt.Errorf("token does not exist: %v", err) + } + + if owner != caller { + return ufmt.Errorf("caller is not token owner") + } + + return royaltyHook.SetTokenRoyalty(tid, RoyaltyInfo{ + PaymentAddress: paymentAddress, + Percentage: percentage, + }) +} + +func GetTokenRoyalty(tid TokenID) (RoyaltyInfo, error) { + info, found := royaltyHook.GetTokenRoyalty(tid) + if !found { + return RoyaltyInfo{}, ufmt.Errorf("no royalty info for token %s", tid) + } + return info, nil +} + +func CalculateRoyalty(tid TokenID, salePrice uint64) (std.Address, uint64, error) { + return royaltyHook.CalculateRoyaltyAmount(tid, salePrice) +} + +// Metadata-related functions + +func SetTokenMetadata(tid TokenID, metadata Metadata) error { + caller := std.PreviousRealm().Address() + return metadataHook.SetTokenMetadata(NFT, tid, metadata, caller) +} + +func GetTokenMetadata(tid TokenID) (Metadata, error) { + metadata, found := metadataHook.GetTokenMetadata(tid) + if !found { + return Metadata{}, ufmt.Errorf("no metadata for token %s", tid) + } + return metadata, nil +} + +// Admin functions + +func MintToken(to std.Address, tid TokenID) error { + return auth.Do("mint", func() error { + return ledger.Mint(to, tid) + }) +} + +func BatchMintTokens(to std.Address, tids []TokenID) error { + return auth.Do("batch_mint", func() error { + return ledger.BatchMint(to, tids) + }) +} + +func BurnToken(tid TokenID) error { + return auth.Do("burn", func() error { + return ledger.Burn(tid) + }) +} + +func TransferAuthority(newAuthority authz.Authority) error { + return auth.Transfer(newAuthority) +} + +func AddAdmin(addr std.Address) error { + if memberAuth, ok := auth.Current().(*authz.MemberAuthority); ok { + return memberAuth.AddMember(addr) + } + return ufmt.Errorf("current authority is not a member authority") +} + +func RemoveAdmin(addr std.Address) error { + if memberAuth, ok := auth.Current().(*authz.MemberAuthority); ok { + return memberAuth.RemoveMember(addr) + } + return ufmt.Errorf("current authority is not a member authority") +} + +func Render(path string) string { + if path == "" { + return renderHome() + } + + return "Not found" +} + +func renderHome() string { + output := "# " + NFT.GetName() + " (" + NFT.GetSymbol() + ")\n\n" + output += "An extended NFT collection with royalty and metadata support.\n\n" + output += "Total supply: " + ufmt.Sprintf("%d", NFT.TokenCount()) + "\n" + + return output +} diff --git a/examples/gno.land/r/matijamarjanovic/grc721/extendednft/nft_test.gno b/examples/gno.land/r/matijamarjanovic/grc721/extendednft/nft_test.gno new file mode 100644 index 00000000000..9651f866f15 --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/grc721/extendednft/nft_test.gno @@ -0,0 +1,151 @@ +package extendednft + +import ( + "testing" + + "gno.land/p/demo/testutils" + "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" + "gno.land/p/moul/authz" +) + +func TestExtendedNFT(t *testing.T) { + admin := testutils.TestAddress("admin") + alice := testutils.TestAddress("alice") + bob := testutils.TestAddress("bob") + royaltyReceiver := testutils.TestAddress("royalty") + + initAuth := authz.NewMemberAuthority(admin) + auth.Transfer(initAuth) + + testing.SetOriginCaller(admin) + err := MintToken(alice, "token1") + urequire.NoError(t, err) + + owner, err := NFT.OwnerOf("token1") + urequire.NoError(t, err) + uassert.Equal(t, alice, owner) + + testing.SetOriginCaller(alice) + metadata := Metadata{ + Name: "Test Token", + Description: "A test token with metadata", + Image: "https://example.com/image.png", + Attributes: []Trait{ + {TraitType: "Color", Value: "Blue"}, + {TraitType: "Size", Value: "Medium"}, + }, + } + + err = SetTokenMetadata("token1", metadata) + urequire.NoError(t, err) + + retrievedMetadata, err := GetTokenMetadata("token1") + urequire.NoError(t, err) + uassert.Equal(t, metadata.Name, retrievedMetadata.Name) + uassert.Equal(t, metadata.Description, retrievedMetadata.Description) + uassert.Equal(t, metadata.Image, retrievedMetadata.Image) + uassert.Equal(t, len(metadata.Attributes), len(retrievedMetadata.Attributes)) + + err = SetTokenRoyalty("token1", royaltyReceiver, 10) // 10% royalty + urequire.NoError(t, err) + + royaltyInfo, err := GetTokenRoyalty("token1") + urequire.NoError(t, err) + uassert.Equal(t, royaltyReceiver, royaltyInfo.PaymentAddress) + uassert.Equal(t, uint64(10), royaltyInfo.Percentage) + + receiver, amount, err := CalculateRoyalty("token1", 1000) + urequire.NoError(t, err) + uassert.Equal(t, royaltyReceiver, receiver) + uassert.Equal(t, uint64(100), amount) // 10% of 1000 = 100 + + err = Teller.Transfer(bob, "token1") + urequire.NoError(t, err) + + owner, err = NFT.OwnerOf("token1") + urequire.NoError(t, err) + uassert.Equal(t, bob, owner) + + testing.SetOriginCaller(alice) + err = SetTokenMetadata("token1", metadata) + if err == nil { + t.Error("Expected error when non-owner tries to set metadata") + } + + err = SetTokenRoyalty("token1", royaltyReceiver, 5) + if err == nil { + t.Error("Expected error when non-owner tries to set royalty") + } + + testing.SetOriginCaller(admin) + err = BatchMintTokens(alice, []TokenID{"token2", "token3"}) + urequire.NoError(t, err) + + balance, err := NFT.BalanceOf(alice) + urequire.NoError(t, err) + uassert.Equal(t, uint64(2), balance) + + err = BurnToken("token2") + urequire.NoError(t, err) + + _, err = NFT.OwnerOf("token2") + if err == nil { + t.Error("Expected error when querying burned token") + } +} + +func TestAdminFunctions(t *testing.T) { + admin := testutils.TestAddress("admin") + newAdmin := testutils.TestAddress("newadmin") + alice := testutils.TestAddress("alice") + + initAuth := authz.NewMemberAuthority(admin) + auth.Transfer(initAuth) + + testing.SetOriginCaller(admin) + err := AddAdmin(newAdmin) + urequire.NoError(t, err) + + testing.SetOriginCaller(newAdmin) + err = MintToken(alice, "token4") + urequire.NoError(t, err) + + testing.SetOriginCaller(admin) + err = RemoveAdmin(newAdmin) + urequire.NoError(t, err) + + testing.SetOriginCaller(newAdmin) + err = MintToken(alice, "token5") + if err == nil { + t.Error("Expected error when removed admin tries to mint") + } +} + +func TestTransferAuthority(t *testing.T) { + admin := testutils.TestAddress("admin") + alice := testutils.TestAddress("alice") + bob := testutils.TestAddress("bob") + + initAuth := authz.NewMemberAuthority(admin) + auth.Transfer(initAuth) + + newAuth := authz.NewMemberAuthority(alice) + + testing.SetOriginCaller(admin) + err := TransferAuthority(newAuth) + urequire.NoError(t, err) + + err = MintToken(bob, "token6") + if err == nil { + t.Error("Expected error when old admin tries to mint") + } + + testing.SetOriginCaller(alice) + err = MintToken(bob, "token6") + urequire.NoError(t, err) + + owner, err := NFT.OwnerOf("token6") + urequire.NoError(t, err) + uassert.Equal(t, bob, owner) +} diff --git a/examples/gno.land/r/matijamarjanovic/grc721/extendednft/royalty_hook.gno b/examples/gno.land/r/matijamarjanovic/grc721/extendednft/royalty_hook.gno new file mode 100644 index 00000000000..a2d1cefe26e --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/grc721/extendednft/royalty_hook.gno @@ -0,0 +1,87 @@ +package extendednft + +import ( + "std" + + "gno.land/p/demo/avl" + "gno.land/p/demo/grc/grc721remake" + "gno.land/p/demo/ufmt" +) + +type RoyaltyHook struct { + tokenRoyalties *avl.Tree // TokenID -> RoyaltyInfo + maxRoyaltyPercent uint64 // Maximum allowed royalty percentage +} + +func NewRoyaltyHook(maxRoyaltyPercent uint64) *RoyaltyHook { + return &RoyaltyHook{ + tokenRoyalties: avl.NewTree(), + maxRoyaltyPercent: maxRoyaltyPercent, + } +} + +func (h *RoyaltyHook) GetHookType() grc721remake.HookType { + return grc721remake.HookTransfer +} + +func (h *RoyaltyHook) GetHookTime() grc721remake.HookTime { + return grc721remake.HookAfter +} + +func (h *RoyaltyHook) OnTransfer(nft *grc721remake.NFT, from, to std.Address, tid TokenID) error { + // Skip minting and burning operations + if from == std.Address("") || to == std.Address("") { + return nil + } + + val, found := h.tokenRoyalties.Get(string(tid)) + if !found { + return nil + } + + royaltyInfo := val.(RoyaltyInfo) + + // XXX : this is where we would handle the actual royalty payment + // For this example, we just emit an event + std.Emit( + "RoyaltyPayment", + "tokenId", string(tid), + "from", string(from), + "to", string(to), + "royaltyReceiver", string(royaltyInfo.PaymentAddress), + "royaltyPercent", ufmt.Sprintf("%d", royaltyInfo.Percentage), + ) + + return nil +} + +func (h *RoyaltyHook) SetTokenRoyalty(tid TokenID, info RoyaltyInfo) error { + if info.Percentage > h.maxRoyaltyPercent { + return ufmt.Errorf("royalty percentage exceeds maximum allowed (%d)", h.maxRoyaltyPercent) + } + + if !info.PaymentAddress.IsValid() { + return ufmt.Errorf("invalid royalty payment address") + } + + h.tokenRoyalties.Set(string(tid), info) + return nil +} + +func (h *RoyaltyHook) GetTokenRoyalty(tid TokenID) (RoyaltyInfo, bool) { + val, found := h.tokenRoyalties.Get(string(tid)) + if !found { + return RoyaltyInfo{}, false + } + return val.(RoyaltyInfo), true +} + +func (h *RoyaltyHook) CalculateRoyaltyAmount(tid TokenID, salePrice uint64) (std.Address, uint64, error) { + royaltyInfo, found := h.GetTokenRoyalty(tid) + if !found { + return std.Address(""), 0, ufmt.Errorf("no royalty info for token %s", tid) + } + + royaltyAmount := (salePrice * royaltyInfo.Percentage) / 100 + return royaltyInfo.PaymentAddress, royaltyAmount, nil +} diff --git a/examples/gno.land/r/matijamarjanovic/grc721/extendednft/types.gno b/examples/gno.land/r/matijamarjanovic/grc721/extendednft/types.gno new file mode 100644 index 00000000000..add094dba50 --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/grc721/extendednft/types.gno @@ -0,0 +1,36 @@ +package extendednft + +import ( + "std" + + "gno.land/p/demo/grc/grc721remake" +) + +// RoyaltyInfo represents royalty information for a token +type RoyaltyInfo struct { + PaymentAddress std.Address // Address where royalty payment should be sent + Percentage uint64 // Royalty percentage (e.g., 10 means 10%) +} + +// Trait represents a metadata attribute +type Trait struct { + DisplayType string + TraitType string + Value string +} + +// Metadata follows the OpenSea metadata standard +type Metadata struct { + Image string // URL to the image + ImageData string // Raw SVG image data + ExternalURL string // URL that will appear below the asset + Description string // Human-readable description + Name string // Name of the item + Attributes []Trait // Attributes for the item + BackgroundColor string // Background color (hex without #) + AnimationURL string // URL to multimedia attachment + YoutubeURL string // URL to a YouTube video +} + +// TokenID is an alias for the NFT's token ID type +type TokenID = grc721remake.TokenID diff --git a/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/pausablenft.gno b/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/pausablenft.gno index 32ab088510a..16dfc0fd8f6 100644 --- a/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/pausablenft.gno +++ b/examples/gno.land/r/matijamarjanovic/grc721/pausablenft/pausablenft.gno @@ -54,6 +54,24 @@ func IsPaused() bool { // Admin Functions (authz) +func MintToken(to std.Address, tid grc721remake.TokenID) error { + return auth.Do("mint", func() error { + return ledger.Mint(to, tid) + }) +} + +func BatchMintTokens(to std.Address, tids []grc721remake.TokenID) error { + return auth.Do("batch_mint", func() error { + return ledger.BatchMint(to, tids) + }) +} + +func BurnToken(tid grc721remake.TokenID) error { + return auth.Do("burn", func() error { + return ledger.Burn(tid) + }) +} + func TransferAuthority(newAuthority authz.Authority) error { return auth.Transfer(newAuthority) } From d0f92235c23af1f4dcbbcdf39b159ede91bb31ef Mon Sep 17 00:00:00 2001 From: matijamarjanovic Date: Mon, 24 Mar 2025 20:29:20 +0100 Subject: [PATCH 14/17] rm unneccessary comment, fmt --- .../gno.land/r/matijamarjanovic/grc721/extendednft/types.gno | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/gno.land/r/matijamarjanovic/grc721/extendednft/types.gno b/examples/gno.land/r/matijamarjanovic/grc721/extendednft/types.gno index add094dba50..fa43eafe6ee 100644 --- a/examples/gno.land/r/matijamarjanovic/grc721/extendednft/types.gno +++ b/examples/gno.land/r/matijamarjanovic/grc721/extendednft/types.gno @@ -32,5 +32,4 @@ type Metadata struct { YoutubeURL string // URL to a YouTube video } -// TokenID is an alias for the NFT's token ID type type TokenID = grc721remake.TokenID From c0a7d0ab1edef693ebaceeaf83965cec439021b5 Mon Sep 17 00:00:00 2001 From: matijamarjanovic Date: Mon, 24 Mar 2025 21:11:03 +0100 Subject: [PATCH 15/17] rm com --- .../r/matijamarjanovic/grc721/extendednft/royalty_hook.gno | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/gno.land/r/matijamarjanovic/grc721/extendednft/royalty_hook.gno b/examples/gno.land/r/matijamarjanovic/grc721/extendednft/royalty_hook.gno index a2d1cefe26e..4cbcb747d90 100644 --- a/examples/gno.land/r/matijamarjanovic/grc721/extendednft/royalty_hook.gno +++ b/examples/gno.land/r/matijamarjanovic/grc721/extendednft/royalty_hook.gno @@ -29,7 +29,6 @@ func (h *RoyaltyHook) GetHookTime() grc721remake.HookTime { } func (h *RoyaltyHook) OnTransfer(nft *grc721remake.NFT, from, to std.Address, tid TokenID) error { - // Skip minting and burning operations if from == std.Address("") || to == std.Address("") { return nil } From 1f2560a61afbd9e1d8602db6611cb026ae3a9437 Mon Sep 17 00:00:00 2001 From: matijamarjanovic Date: Mon, 24 Mar 2025 21:11:25 +0100 Subject: [PATCH 16/17] rm com --- .../r/matijamarjanovic/grc721/extendednft/royalty_hook.gno | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/gno.land/r/matijamarjanovic/grc721/extendednft/royalty_hook.gno b/examples/gno.land/r/matijamarjanovic/grc721/extendednft/royalty_hook.gno index 4cbcb747d90..b265e91a3d9 100644 --- a/examples/gno.land/r/matijamarjanovic/grc721/extendednft/royalty_hook.gno +++ b/examples/gno.land/r/matijamarjanovic/grc721/extendednft/royalty_hook.gno @@ -10,7 +10,7 @@ import ( type RoyaltyHook struct { tokenRoyalties *avl.Tree // TokenID -> RoyaltyInfo - maxRoyaltyPercent uint64 // Maximum allowed royalty percentage + maxRoyaltyPercent uint64 } func NewRoyaltyHook(maxRoyaltyPercent uint64) *RoyaltyHook { From 67dcc3bea4de49a711ecc6a846f0e192d7b62d06 Mon Sep 17 00:00:00 2001 From: matijamarjanovic Date: Mon, 24 Mar 2025 21:15:46 +0100 Subject: [PATCH 17/17] fmt --- .../r/matijamarjanovic/grc721/extendednft/royalty_hook.gno | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/gno.land/r/matijamarjanovic/grc721/extendednft/royalty_hook.gno b/examples/gno.land/r/matijamarjanovic/grc721/extendednft/royalty_hook.gno index b265e91a3d9..f12d06210ea 100644 --- a/examples/gno.land/r/matijamarjanovic/grc721/extendednft/royalty_hook.gno +++ b/examples/gno.land/r/matijamarjanovic/grc721/extendednft/royalty_hook.gno @@ -10,7 +10,7 @@ import ( type RoyaltyHook struct { tokenRoyalties *avl.Tree // TokenID -> RoyaltyInfo - maxRoyaltyPercent uint64 + maxRoyaltyPercent uint64 } func NewRoyaltyHook(maxRoyaltyPercent uint64) *RoyaltyHook {