diff --git a/pkg/rpcclient/nep11/royalty.go b/pkg/rpcclient/nep11/royalty.go new file mode 100644 index 0000000000..9327e6f6c2 --- /dev/null +++ b/pkg/rpcclient/nep11/royalty.go @@ -0,0 +1,181 @@ +// Package nep11 provides RPC wrappers for NEP-11 contracts, including support for NEP-24 NFT royalties. +package nep11 + +import ( + "errors" + "fmt" + "math/big" + + "github.com/nspcc-dev/neo-go/pkg/neorpc/result" + "github.com/nspcc-dev/neo-go/pkg/rpcclient/unwrap" + "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" +) + +// RoyaltyInfoDetail contains information about the recipient and the royalty amount. +type RoyaltyInfoDetail struct { + RoyaltyRecipient util.Uint160 + RoyaltyAmount *big.Int +} + +// RoyaltiesTransferredEvent represents a RoyaltiesTransferred event as defined in the NEP-24 standard. +type RoyaltiesTransferredEvent struct { + RoyaltyToken util.Uint160 + RoyaltyRecipient util.Uint160 + Buyer util.Uint160 + TokenID []byte + Amount *big.Int +} + +// RoyaltyReader is an interface for contracts implementing NEP-24 royalties. +type RoyaltyReader struct { + BaseReader +} + +// RoyaltyWriter is an interface for state-changing methods related to NEP-24 royalties. +type RoyaltyWriter struct { + BaseWriter +} + +// Royalty is a full reader and writer interface for NEP-24 royalties. +type Royalty struct { + RoyaltyReader + RoyaltyWriter +} + +// NewRoyaltyReader creates an instance of RoyaltyReader for a contract with the given hash using the given invoker. +func NewRoyaltyReader(invoker Invoker, hash util.Uint160) *RoyaltyReader { + return &RoyaltyReader{*NewBaseReader(invoker, hash)} +} + +// NewRoyalty creates an instance of Royalty for a contract with the given hash using the given actor. +func NewRoyalty(actor Actor, hash util.Uint160) *Royalty { + return &Royalty{*NewRoyaltyReader(actor, hash), RoyaltyWriter{BaseWriter{hash, actor}}} +} + +// RoyaltyInfo retrieves the royalty information for a given token ID, including the recipient(s) and amount(s). +func (r *RoyaltyReader) RoyaltyInfo(tokenID []byte, royaltyToken util.Uint160, salePrice *big.Int) ([]*RoyaltyInfoDetail, error) { + items, err := unwrap.Array(r.invoker.Call(r.hash, "RoyaltyInfo", tokenID, royaltyToken, salePrice)) + fmt.Println(items) + if err != nil { + return nil, err + } + + royalties := make([]*RoyaltyInfoDetail, len(items)) + for i, item := range items { + royaltyDetail, err := itemToRoyaltyInfoDetail(item, nil) + if err != nil { + return nil, fmt.Errorf("failed to decode royalty detail %d: %w", i, err) + } + royalties[i] = royaltyDetail + } + return royalties, nil +} + +// itemToRoyaltyInfoDetail converts a stack item into a RoyaltyInfoDetail struct. +func itemToRoyaltyInfoDetail(item stackitem.Item, err error) (*RoyaltyInfoDetail, error) { + if err != nil { + return nil, err + } + fmt.Println("itemToRoyaltyInfoDetail item", item) + + arr, ok := item.Value().([]stackitem.Item) + if !ok || len(arr) != 2 { + return nil, fmt.Errorf("invalid RoyaltyInfoDetail structure: expected array of 2 items, got %T", item.Value()) + } + + recipientBytes, err := arr[0].TryBytes() + if err != nil { + return nil, fmt.Errorf("failed to decode RoyaltyRecipient: %w", err) + } + + recipient, err := util.Uint160DecodeBytesBE(recipientBytes) + if err != nil { + return nil, fmt.Errorf("invalid RoyaltyRecipient: %w", err) + } + + amount, err := arr[1].TryInteger() + if err != nil { + return nil, fmt.Errorf("failed to decode RoyaltyAmount: %w", err) + } + + return &RoyaltyInfoDetail{ + RoyaltyRecipient: recipient, + RoyaltyAmount: amount, + }, nil +} + +// RoyaltiesTransferredEventsFromApplicationLog retrieves all emitted RoyaltiesTransferredEvents from the provided [result.ApplicationLog]. +func RoyaltiesTransferredEventsFromApplicationLog(log *result.ApplicationLog) ([]*RoyaltiesTransferredEvent, error) { + if log == nil { + return nil, errors.New("nil application log") + } + var res []*RoyaltiesTransferredEvent + for i, ex := range log.Executions { + for j, e := range ex.Events { + if e.Name != "RoyaltiesTransferred" { + continue + } + event := new(RoyaltiesTransferredEvent) + err := event.FromStackItem(e.Item) + if err != nil { + return nil, fmt.Errorf("failed to decode event from stackitem (event #%d, execution #%d): %w", j, i, err) + } + res = append(res, event) + } + } + return res, nil +} + +// FromStackItem converts a stack item into a RoyaltiesTransferredEvent struct. +func (e *RoyaltiesTransferredEvent) FromStackItem(item *stackitem.Array) error { + if item == nil { + return errors.New("nil item") + } + arr, ok := item.Value().([]stackitem.Item) + if !ok { + return errors.New("not an array") + } + if len(arr) != 5 { + return errors.New("wrong number of event parameters") + } + + b, err := arr[0].TryBytes() + if err != nil { + return fmt.Errorf("invalid RoyaltyToken: %w", err) + } + e.RoyaltyToken, err = util.Uint160DecodeBytesBE(b) + if err != nil { + return fmt.Errorf("failed to decode RoyaltyToken: %w", err) + } + + b, err = arr[1].TryBytes() + if err != nil { + return fmt.Errorf("invalid RoyaltyRecipient: %w", err) + } + e.RoyaltyRecipient, err = util.Uint160DecodeBytesBE(b) + if err != nil { + return fmt.Errorf("failed to decode RoyaltyRecipient: %w", err) + } + + b, err = arr[2].TryBytes() + if err != nil { + return fmt.Errorf("invalid Buyer: %w", err) + } + e.Buyer, err = util.Uint160DecodeBytesBE(b) + if err != nil { + return fmt.Errorf("failed to decode Buyer: %w", err) + } + + e.TokenID, err = arr[3].TryBytes() + if err != nil { + return fmt.Errorf("invalid TokenID: %w", err) + } + + e.Amount, err = arr[4].TryInteger() + if err != nil { + return fmt.Errorf("invalid Amount: %w", err) + } + + return nil +} diff --git a/pkg/rpcclient/nep11/royalty_test.go b/pkg/rpcclient/nep11/royalty_test.go new file mode 100644 index 0000000000..564754ffc4 --- /dev/null +++ b/pkg/rpcclient/nep11/royalty_test.go @@ -0,0 +1,83 @@ +package nep11 + +import ( + "errors" + "math/big" + "testing" + + "github.com/nspcc-dev/neo-go/pkg/neorpc/result" + "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" + "github.com/stretchr/testify/require" +) + +// TestRoyaltyReaderRoyaltyInfo tests the RoyaltyInfo method in the RoyaltyReader. +func TestRoyaltyReaderRoyaltyInfo(t *testing.T) { + ta := new(testAct) + rr := NewRoyaltyReader(ta, util.Uint160{1, 2, 3}) + + tokenID := []byte{1, 2, 3} + royaltyToken := util.Uint160{4, 5, 6} + salePrice := big.NewInt(1000) + + tests := []struct { + name string + setupFunc func() + expectErr bool + expectedRI []RoyaltyInfoDetail + }{ + { + name: "error case", + setupFunc: func() { + ta.err = errors.New("some error") + }, + expectErr: true, + }, + { + name: "valid response", + setupFunc: func() { + ta.err = nil + recipient := util.Uint160{7, 8, 9} + amount := big.NewInt(100) + ta.res = &result.Invoke{ + State: "HALT", + Stack: []stackitem.Item{stackitem.Make([]stackitem.Item{ + stackitem.Make(recipient.BytesBE()), + stackitem.Make(amount), + })}, + } + }, + expectErr: false, + expectedRI: []RoyaltyInfoDetail{ + {RoyaltyRecipient: util.Uint160{7, 8, 9}, RoyaltyAmount: big.NewInt(100)}, + }, + }, + { + name: "invalid data response", + setupFunc: func() { + ta.res = &result.Invoke{ + State: "HALT", + Stack: []stackitem.Item{ + stackitem.Make([]stackitem.Item{ + stackitem.Make(util.Uint160{7, 8, 9}.BytesBE()), + }), + }, + } + }, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.setupFunc() + ri, err := rr.RoyaltyInfo(tokenID, royaltyToken, salePrice) + if tt.expectErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.expectedRI, ri) + } + }) + } +} diff --git a/pkg/smartcontract/manifest/standard/comply.go b/pkg/smartcontract/manifest/standard/comply.go index f8991c7933..f9048420c2 100644 --- a/pkg/smartcontract/manifest/standard/comply.go +++ b/pkg/smartcontract/manifest/standard/comply.go @@ -19,7 +19,7 @@ var ( ) var checks = map[string][]*Standard{ - manifest.NEP11StandardName: {Nep11NonDivisible, Nep11Divisible}, + manifest.NEP11StandardName: {Nep11NonDivisible, Nep11Divisible, Nep11WithRoyalty}, manifest.NEP17StandardName: {Nep17}, manifest.NEP11Payable: {Nep11Payable}, manifest.NEP17Payable: {Nep17Payable}, diff --git a/pkg/smartcontract/manifest/standard/nep24.go b/pkg/smartcontract/manifest/standard/nep24.go new file mode 100644 index 0000000000..c14a11cb3a --- /dev/null +++ b/pkg/smartcontract/manifest/standard/nep24.go @@ -0,0 +1,39 @@ +package standard + +import ( + "github.com/nspcc-dev/neo-go/pkg/smartcontract" + "github.com/nspcc-dev/neo-go/pkg/smartcontract/manifest" +) + +// Nep11WithRoyalty is a NEP-24 Standard for NFT royalties. +var Nep11WithRoyalty = &Standard{ + Base: Nep11Base, + Manifest: manifest.Manifest{ + ABI: manifest.ABI{ + Methods: []manifest.Method{ + { + Name: "RoyaltyInfo", + Parameters: []manifest.Parameter{ + {Name: "tokenId", Type: smartcontract.ByteArrayType}, + {Name: "royaltyToken", Type: smartcontract.Hash160Type}, + {Name: "salePrice", Type: smartcontract.IntegerType}, + }, + ReturnType: smartcontract.ArrayType, + Safe: true, + }, + }, + Events: []manifest.Event{ + { + Name: "RoyaltiesTransferred", + Parameters: []manifest.Parameter{ + {Name: "royaltyToken", Type: smartcontract.Hash160Type}, + {Name: "royaltyRecipient", Type: smartcontract.Hash160Type}, + {Name: "buyer", Type: smartcontract.Hash160Type}, + {Name: "tokenId", Type: smartcontract.ByteArrayType}, + {Name: "amount", Type: smartcontract.IntegerType}, + }, + }, + }, + }, + }, +} diff --git a/pkg/smartcontract/rpcbinding/binding.go b/pkg/smartcontract/rpcbinding/binding.go index 02f363496c..ad4d75d7d4 100644 --- a/pkg/smartcontract/rpcbinding/binding.go +++ b/pkg/smartcontract/rpcbinding/binding.go @@ -407,6 +407,9 @@ func Generate(cfg binding.Config) error { } else if standard.ComplyABI(cfg.Manifest, standard.Nep11NonDivisible) == nil { mfst.ABI.Methods = dropStdMethods(mfst.ABI.Methods, standard.Nep11NonDivisible) ctr.IsNep11ND = true + } else if standard.ComplyABI(cfg.Manifest, standard.Nep11WithRoyalty) == nil { + mfst.ABI.Methods = dropStdMethods(mfst.ABI.Methods, standard.Nep11WithRoyalty) + ctr.IsNep11D = true } mfst.ABI.Events = dropStdEvents(mfst.ABI.Events, standard.Nep11Base) break // Can't be NEP-17 at the same time.