diff --git a/examples/gno.land/p/demo/grc/grc721/errors.gno b/examples/gno.land/p/demo/grc/grc721/errors.gno index 08fb26b0cb5..2d512db350d 100644 --- a/examples/gno.land/p/demo/grc/grc721/errors.gno +++ b/examples/gno.land/p/demo/grc/grc721/errors.gno @@ -14,4 +14,9 @@ var ( ErrTransferToNonGRC721Receiver = errors.New("transfer to non GRC721Receiver implementer") ErrCallerIsNotOwnerOrApproved = errors.New("caller is not token owner or approved") ErrTokenIdAlreadyExists = errors.New("token id already exists") + + // ERC721Royalty + ErrInvalidRoyaltyPercentage = errors.New("invalid royalty percentage") + ErrInvalidRoyaltyPaymentAddress = errors.New("invalid royalty paymentAddress") + ErrCannotCalculateRoyaltyAmount = errors.New("cannot calculate royalty amount") ) diff --git a/examples/gno.land/p/demo/grc/grc721/grc721_metadata.gno b/examples/gno.land/p/demo/grc/grc721/grc721_metadata.gno new file mode 100644 index 00000000000..360f73ed106 --- /dev/null +++ b/examples/gno.land/p/demo/grc/grc721/grc721_metadata.gno @@ -0,0 +1,95 @@ +package grc721 + +import ( + "std" + + "gno.land/p/demo/avl" +) + +// metadataNFT represents an NFT with metadata extensions. +type metadataNFT struct { + *basicNFT // Embedded basicNFT struct for basic NFT functionality + extensions *avl.Tree // AVL tree for storing metadata extensions +} + +// Ensure that metadataNFT implements the IGRC721MetadataOnchain interface. +var _ IGRC721MetadataOnchain = (*metadataNFT)(nil) + +// NewNFTWithMetadata creates a new basic NFT with metadata extensions. +func NewNFTWithMetadata(name string, symbol string) *metadataNFT { + // Create a new basic NFT + nft := NewBasicNFT(name, symbol) + + // Return a metadataNFT with basicNFT embedded and an empty AVL tree for extensions + return &metadataNFT{ + basicNFT: nft, + extensions: avl.NewTree(), + } +} + +// SetTokenMetadata sets metadata for a given token ID. +func (s *metadataNFT) SetTokenMetadata(tid TokenID, metadata Metadata) error { + // Check if the caller is the owner of the token + owner, err := s.basicNFT.OwnerOf(tid) + if err != nil { + return err + } + caller := std.PrevRealm().Addr() + if caller != owner { + return ErrCallerIsNotOwner + } + + // Set the metadata for the token ID in the extensions AVL tree + s.extensions.Set(string(tid), metadata) + return nil +} + +// TokenMetadata retrieves metadata for a given token ID. +func (s *metadataNFT) TokenMetadata(tid TokenID) (Metadata, error) { + // Retrieve metadata from the extensions AVL tree + metadata, found := s.extensions.Get(string(tid)) + if !found { + return Metadata{}, ErrInvalidTokenId + } + + return metadata.(Metadata), nil +} + +// mint mints a new token and assigns it to the specified address. +func (s *metadataNFT) mint(to std.Address, tid TokenID) error { + // Check if the address is valid + if err := isValidAddress(to); err != nil { + return err + } + + // Check if the token ID already exists + if s.basicNFT.exists(tid) { + return ErrTokenIdAlreadyExists + } + + s.basicNFT.beforeTokenTransfer(zeroAddress, to, tid, 1) + + // Check if the token ID was minted by beforeTokenTransfer + if s.basicNFT.exists(tid) { + return ErrTokenIdAlreadyExists + } + + // Increment balance of the recipient address + toBalance, err := s.basicNFT.BalanceOf(to) + if err != nil { + return err + } + toBalance += 1 + s.basicNFT.balances.Set(to.String(), toBalance) + + // Set owner of the token ID to the recipient address + s.basicNFT.owners.Set(string(tid), to) + + // Emit transfer event + event := TransferEvent{zeroAddress, to, tid} + emit(&event) + + s.basicNFT.afterTokenTransfer(zeroAddress, to, tid, 1) + + return nil +} diff --git a/examples/gno.land/p/demo/grc/grc721/grc721_metadata_test.gno b/examples/gno.land/p/demo/grc/grc721/grc721_metadata_test.gno new file mode 100644 index 00000000000..b7ca6932fe1 --- /dev/null +++ b/examples/gno.land/p/demo/grc/grc721/grc721_metadata_test.gno @@ -0,0 +1,133 @@ +package grc721 + +import ( + "std" + "testing" + + "gno.land/p/demo/testutils" + "gno.land/p/demo/users" +) + +func TestSetMetadata(t *testing.T) { + // Create a new dummy NFT with metadata + dummy := NewNFTWithMetadata(dummyNFTName, dummyNFTSymbol) + if dummy == nil { + t.Errorf("should not be nil") + } + + // Define addresses for testing purposes + addr1 := testutils.TestAddress("alice") + addr2 := testutils.TestAddress("bob") + + // Define metadata attributes + name := "test" + description := "test" + image := "test" + imageData := "test" + externalURL := "test" + attributes := []Trait{} + backgroundColor := "test" + animationURL := "test" + youtubeURL := "test" + + // Set the original caller to addr1 + std.TestSetOrigCaller(addr1) // addr1 + + // Mint a new token for addr1 + dummy.mint(addr1, TokenID("1")) + + // Set metadata for token 1 + derr := dummy.SetTokenMetadata(TokenID("1"), Metadata{ + Name: name, + Description: description, + Image: image, + ImageData: imageData, + ExternalURL: externalURL, + Attributes: attributes, + BackgroundColor: backgroundColor, + AnimationURL: animationURL, + YoutubeURL: youtubeURL, + }) + + // Check if there was an error setting metadata + if derr != nil { + t.Errorf("Should not result in error : %s", derr.Error()) + } + + // Test case: Invalid token ID + err := dummy.SetTokenMetadata(TokenID("3"), Metadata{ + Name: name, + Description: description, + Image: image, + ImageData: imageData, + ExternalURL: externalURL, + Attributes: attributes, + BackgroundColor: backgroundColor, + AnimationURL: animationURL, + YoutubeURL: youtubeURL, + }) + + // Check if the error returned matches the expected error + if err != ErrInvalidTokenId { + t.Errorf("Expected error %s, got %s", ErrInvalidTokenId, err) + } + + // Set the original caller to addr2 + std.TestSetOrigCaller(addr2) // addr2 + + // Try to set metadata for token 1 from addr2 (should fail) + cerr := dummy.SetTokenMetadata(TokenID("1"), Metadata{ + Name: name, + Description: description, + Image: image, + ImageData: imageData, + ExternalURL: externalURL, + Attributes: attributes, + BackgroundColor: backgroundColor, + AnimationURL: animationURL, + YoutubeURL: youtubeURL, + }) + + // Check if the error returned matches the expected error + if cerr != ErrCallerIsNotOwner { + t.Errorf("Expected error %s, got %s", ErrCallerIsNotOwner, cerr) + } + + // Set the original caller back to addr1 + std.TestSetOrigCaller(addr1) // addr1 + + // Retrieve metadata for token 1 + dummyMetadata, err := dummy.TokenMetadata(TokenID("1")) + if err != nil { + t.Errorf("Metadata error: %s", err.Error()) + } + + // Check if metadata attributes match expected values + if dummyMetadata.Image != image { + t.Errorf("Expected Metadata's image %s, got %s", image, dummyMetadata.Image) + } + if dummyMetadata.ImageData != imageData { + t.Errorf("Expected Metadata's imageData %s, got %s", imageData, dummyMetadata.ImageData) + } + if dummyMetadata.ExternalURL != externalURL { + t.Errorf("Expected Metadata's externalURL %s, got %s", externalURL, dummyMetadata.ExternalURL) + } + if dummyMetadata.Description != description { + t.Errorf("Expected Metadata's description %s, got %s", description, dummyMetadata.Description) + } + if dummyMetadata.Name != name { + t.Errorf("Expected Metadata's name %s, got %s", name, dummyMetadata.Name) + } + if len(dummyMetadata.Attributes) != len(attributes) { + t.Errorf("Expected %d Metadata's attributes, got %d", len(attributes), len(dummyMetadata.Attributes)) + } + if dummyMetadata.BackgroundColor != backgroundColor { + t.Errorf("Expected Metadata's backgroundColor %s, got %s", backgroundColor, dummyMetadata.BackgroundColor) + } + if dummyMetadata.AnimationURL != animationURL { + t.Errorf("Expected Metadata's animationURL %s, got %s", animationURL, dummyMetadata.AnimationURL) + } + if dummyMetadata.YoutubeURL != youtubeURL { + t.Errorf("Expected Metadata's youtubeURL %s, got %s", youtubeURL, dummyMetadata.YoutubeURL) + } +} diff --git a/examples/gno.land/p/demo/grc/grc721/grc721_royalty.gno b/examples/gno.land/p/demo/grc/grc721/grc721_royalty.gno new file mode 100644 index 00000000000..9831c709121 --- /dev/null +++ b/examples/gno.land/p/demo/grc/grc721/grc721_royalty.gno @@ -0,0 +1,78 @@ +package grc721 + +import ( + "std" + + "gno.land/p/demo/avl" +) + +// royaltyNFT represents a non-fungible token (NFT) with royalty functionality. +type royaltyNFT struct { + *metadataNFT // Embedding metadataNFT for NFT functionality + tokenRoyaltyInfo *avl.Tree // AVL tree to store royalty information for each token + maxRoyaltyPercentage uint64 // maxRoyaltyPercentage represents the maximum royalty percentage that can be charged every sale +} + +// Ensure that royaltyNFT implements the IGRC2981 interface. +var _ IGRC2981 = (*royaltyNFT)(nil) + +// NewNFTWithRoyalty creates a new royalty NFT with the specified name, symbol, and royalty calculator. +func NewNFTWithRoyalty(name string, symbol string) *royaltyNFT { + // Create a new NFT with metadata + nft := NewNFTWithMetadata(name, symbol) + + return &royaltyNFT{ + metadataNFT: nft, + tokenRoyaltyInfo: avl.NewTree(), + maxRoyaltyPercentage: 100, + } +} + +// SetTokenRoyalty sets the royalty information for a specific token ID. +func (r *royaltyNFT) SetTokenRoyalty(tid TokenID, royaltyInfo RoyaltyInfo) error { + // Validate the payment address + if err := isValidAddress(royaltyInfo.PaymentAddress); err != nil { + return ErrInvalidRoyaltyPaymentAddress + } + + // Check if royalty percentage exceeds maxRoyaltyPercentage + if royaltyInfo.Percentage > r.maxRoyaltyPercentage { + return ErrInvalidRoyaltyPercentage + } + + // Check if the caller is the owner of the token + owner, err := r.metadataNFT.OwnerOf(tid) + if err != nil { + return err + } + caller := std.PrevRealm().Addr() + if caller != owner { + return ErrCallerIsNotOwner + } + + // Set royalty information for the token + r.tokenRoyaltyInfo.Set(string(tid), royaltyInfo) + + return nil +} + +// RoyaltyInfo returns the royalty information for the given token ID and sale price. +func (r *royaltyNFT) RoyaltyInfo(tid TokenID, salePrice uint64) (std.Address, uint64, error) { + // Retrieve royalty information for the token + val, found := r.tokenRoyaltyInfo.Get(string(tid)) + if !found { + return "", 0, ErrInvalidTokenId + } + + royaltyInfo := val.(RoyaltyInfo) + + // Calculate royalty amount + royaltyAmount, _ := r.calculateRoyaltyAmount(salePrice, royaltyInfo.Percentage) + + return royaltyInfo.PaymentAddress, royaltyAmount, nil +} + +func (r *royaltyNFT) calculateRoyaltyAmount(salePrice, percentage uint64) (uint64, error) { + royaltyAmount := (salePrice * percentage) / 100 + return royaltyAmount, nil +} diff --git a/examples/gno.land/p/demo/grc/grc721/grc721_royalty_test.gno b/examples/gno.land/p/demo/grc/grc721/grc721_royalty_test.gno new file mode 100644 index 00000000000..8c7bb33caa5 --- /dev/null +++ b/examples/gno.land/p/demo/grc/grc721/grc721_royalty_test.gno @@ -0,0 +1,93 @@ +package grc721 + +import ( + "std" + "testing" + + "gno.land/p/demo/testutils" + "gno.land/p/demo/ufmt" + "gno.land/p/demo/users" +) + +func TestSetTokenRoyalty(t *testing.T) { + dummy := NewNFTWithRoyalty(dummyNFTName, dummyNFTSymbol) + if dummy == nil { + t.Errorf("should not be nil") + } + + addr1 := testutils.TestAddress("alice") + addr2 := testutils.TestAddress("bob") + + paymentAddress := testutils.TestAddress("john") + percentage := uint64(10) // 10% + + salePrice := uint64(1000) + expectRoyaltyAmount := uint64(100) + + std.TestSetOrigCaller(addr1) // addr1 + + dummy.mint(addr1, TokenID("1")) + + derr := dummy.SetTokenRoyalty(TokenID("1"), RoyaltyInfo{ + PaymentAddress: paymentAddress, + Percentage: percentage, + }) + + if derr != nil { + t.Errorf("Should not result in error : %s", derr.Error()) + } + + // Test case: Invalid token ID + err := dummy.SetTokenRoyalty(TokenID("3"), RoyaltyInfo{ + PaymentAddress: paymentAddress, + Percentage: percentage, + }) + if err != ErrInvalidTokenId { + t.Errorf("Expected error %s, got %s", ErrInvalidTokenId, err) + } + + std.TestSetOrigCaller(addr2) // addr2 + + cerr := dummy.SetTokenRoyalty(TokenID("1"), RoyaltyInfo{ + PaymentAddress: paymentAddress, + Percentage: percentage, + }) + if cerr != ErrCallerIsNotOwner { + t.Errorf("Expected error %s, got %s", ErrCallerIsNotOwner, cerr) + } + + // Test case: Invalid payment address + aerr := dummy.SetTokenRoyalty(TokenID("4"), RoyaltyInfo{ + PaymentAddress: std.Address("###"), // invalid address + Percentage: percentage, + }) + if aerr != ErrInvalidRoyaltyPaymentAddress { + t.Errorf("Expected error %s, got %s", ErrInvalidRoyaltyPaymentAddress, aerr) + } + + // Test case: Invalid percentage + perr := dummy.SetTokenRoyalty(TokenID("5"), RoyaltyInfo{ + PaymentAddress: paymentAddress, + Percentage: uint64(200), // over maxRoyaltyPercentage + }) + + if perr != ErrInvalidRoyaltyPercentage { + t.Errorf("Expected error %s, got %s", ErrInvalidRoyaltyPercentage, perr) + } + + // Test case: Retrieving Royalty Info + std.TestSetOrigCaller(addr1) // addr1 + + dummyPaymentAddress, dummyRoyaltyAmount, rerr := dummy.RoyaltyInfo(TokenID("1"), salePrice) + if rerr != nil { + t.Errorf("RoyaltyInfo error: %s", rerr.Error()) + } + + if dummyPaymentAddress != paymentAddress { + t.Errorf("Expected RoyaltyPaymentAddress %s, got %s", paymentAddress, dummyPaymentAddress) + } + + if dummyRoyaltyAmount != expectRoyaltyAmount { + t.Errorf("Expected RoyaltyAmount %d, got %d", expectRoyaltyAmount, dummyRoyaltyAmount) + } +} diff --git a/examples/gno.land/p/demo/grc/grc721/igrc721_metadata.gno b/examples/gno.land/p/demo/grc/grc721/igrc721_metadata.gno new file mode 100644 index 00000000000..8a2be1ff75d --- /dev/null +++ b/examples/gno.land/p/demo/grc/grc721/igrc721_metadata.gno @@ -0,0 +1,38 @@ +package grc721 + +// IGRC721CollectionMetadata describes basic information about an NFT collection. +type IGRC721CollectionMetadata interface { + Name() string // Name returns the name of the collection. + Symbol() string // Symbol returns the symbol of the collection. +} + +// IGRC721Metadata follows the Ethereum standard +type IGRC721Metadata interface { + IGRC721CollectionMetadata + TokenURI(tid TokenID) (string, error) // TokenURI returns the URI of a specific token. +} + +// IGRC721Metadata follows the OpenSea metadata standard +type IGRC721MetadataOnchain interface { + IGRC721CollectionMetadata + TokenMetadata(tid TokenID) (Metadata, error) +} + +type Trait struct { + DisplayType string + TraitType string + Value string +} + +// see: https://docs.opensea.io/docs/metadata-standards +type Metadata struct { + Image string // URL to the image of the item. Can be any type of image (including SVGs, which will be cached into PNGs by OpenSea), IPFS or Arweave URLs or paths. We recommend using a minimum 3000 x 3000 image. + ImageData string // Raw SVG image data, if you want to generate images on the fly (not recommended). Only use this if you're not including the image parameter. + ExternalURL string // URL that will appear below the asset's image on OpenSea and will allow users to leave OpenSea and view the item on your site. + Description string // Human-readable description of the item. Markdown is supported. + Name string // Name of the item. + Attributes []Trait // Attributes for the item, which will show up on the OpenSea page for the item. + BackgroundColor string // Background color of the item on OpenSea. Must be a six-character hexadecimal without a pre-pended # + AnimationURL string // URL to a multimedia attachment for the item. Supported file extensions: GLTF, GLB, WEBM, MP4, M4V, OGV, OGG, MP3, WAV, OGA, HTML (for rich experiences and interactive NFTs using JavaScript canvas, WebGL, etc.). Scripts and relative paths within the HTML page are now supported. Access to browser extensions is not supported. + YoutubeURL string // URL to a YouTube video (only used if animation_url is not provided). +} diff --git a/examples/gno.land/p/demo/grc/grc721/igrc721_royalty.gno b/examples/gno.land/p/demo/grc/grc721/igrc721_royalty.gno new file mode 100644 index 00000000000..a8a74ea15cc --- /dev/null +++ b/examples/gno.land/p/demo/grc/grc721/igrc721_royalty.gno @@ -0,0 +1,18 @@ +package grc721 + +import ( + "std" +) + +// IGRC2981 follows the Ethereum standard +type IGRC2981 interface { + // RoyaltyInfo retrieves royalty information for a tokenID and salePrice. + // It returns the payment address, royalty amount, and an error if any. + RoyaltyInfo(tokenID TokenID, salePrice uint64) (std.Address, uint64, error) +} + +// RoyaltyInfo represents royalty information for a token. +type RoyaltyInfo struct { + PaymentAddress std.Address // PaymentAddress is the address where royalty payment should be sent. + Percentage uint64 // Percentage is the royalty percentage. It indicates the percentage of royalty to be paid for each sale. For example : Percentage = 10 => 10% +}