manifest: add NEP-24

Close #3451

Signed-off-by: Ekaterina Pavlova <ekt@morphbits.io>
This commit is contained in:
Ekaterina Pavlova 2024-08-19 11:09:53 +03:00
parent d9a6a7cd3f
commit 3a9fdda478
5 changed files with 510 additions and 1 deletions

View file

@ -0,0 +1,176 @@
// 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))
if err != nil {
return nil, err
}
royaltyDetail, err := itemToRoyaltyInfoDetail(items)
if err != nil {
return nil, fmt.Errorf("failed to decode royalty detail: %w", err)
}
return []RoyaltyInfoDetail{*royaltyDetail}, nil
}
// itemToRoyaltyInfoDetail converts an array of stack items into a RoyaltyInfoDetail struct.
func itemToRoyaltyInfoDetail(items []stackitem.Item) (*RoyaltyInfoDetail, error) {
if len(items) != 2 {
return nil, fmt.Errorf("invalid structure: expected 2 items, got %d", len(items))
}
recipientBytes, err := items[0].TryBytes()
if err != nil {
return nil, fmt.Errorf("failed to decode RoyaltyRecipient: %w", err)
}
// Validate recipient byte size (should be 20 bytes for Uint160)
if len(recipientBytes) != 20 {
return nil, fmt.Errorf("invalid RoyaltyRecipient: expected byte size of 20, got %d", len(recipientBytes))
}
recipient, err := util.Uint160DecodeBytesBE(recipientBytes)
if err != nil {
return nil, fmt.Errorf("invalid RoyaltyRecipient: %w", err)
}
amountBigInt, err := items[1].TryInteger()
if err != nil {
return nil, fmt.Errorf("failed to decode RoyaltyAmount: %w", err)
}
amount := big.NewInt(0).Set(amountBigInt)
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 || len(arr) != 5 {
return errors.New("invalid event structure: expected array of 5 items")
}
b, err := arr[0].TryBytes()
if err != nil {
return fmt.Errorf("failed to decode RoyaltyToken: %w", err)
}
e.RoyaltyToken, err = util.Uint160DecodeBytesBE(b)
if err != nil {
return fmt.Errorf("invalid RoyaltyToken: %w", err)
}
b, err = arr[1].TryBytes()
if err != nil {
return fmt.Errorf("failed to decode RoyaltyRecipient: %w", err)
}
e.RoyaltyRecipient, err = util.Uint160DecodeBytesBE(b)
if err != nil {
return fmt.Errorf("invalid RoyaltyRecipient: %w", err)
}
b, err = arr[2].TryBytes()
if err != nil {
return fmt.Errorf("failed to decode Buyer: %w", err)
}
e.Buyer, err = util.Uint160DecodeBytesBE(b)
if err != nil {
return fmt.Errorf("invalid Buyer: %w", err)
}
e.TokenID, err = arr[3].TryBytes()
if err != nil {
return fmt.Errorf("failed to decode TokenID: %w", err)
}
if _, ok := arr[4].Value().(*big.Int); !ok {
return fmt.Errorf("invalid type for Amount: expected Integer, got %T", arr[4].Value())
}
e.Amount, err = arr[4].TryInteger()
if err != nil {
return fmt.Errorf("failed to decode Amount: %w", err)
}
return nil
}

View file

@ -0,0 +1,291 @@
package nep11
import (
"errors"
"math/big"
"testing"
"github.com/nspcc-dev/neo-go/pkg/core/state"
"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"
)
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)
}
})
}
}
func TestItemToRoyaltyInfoDetail(t *testing.T) {
tests := []struct {
name string
items []stackitem.Item
expectErr bool
expected *RoyaltyInfoDetail
}{
{
name: "valid input",
items: []stackitem.Item{
stackitem.Make(util.Uint160{7, 8, 9}.BytesBE()),
stackitem.Make(big.NewInt(100)),
},
expectErr: false,
expected: &RoyaltyInfoDetail{
RoyaltyRecipient: util.Uint160{7, 8, 9},
RoyaltyAmount: big.NewInt(100),
},
},
{
name: "invalid number of items",
items: []stackitem.Item{
stackitem.Make(util.Uint160{7, 8, 9}.BytesBE()),
},
expectErr: true,
},
{
name: "invalid recipient size",
items: []stackitem.Item{
stackitem.Make([]byte{1, 2}),
stackitem.Make(big.NewInt(100)),
},
expectErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ri, err := itemToRoyaltyInfoDetail(tt.items)
if tt.expectErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tt.expected, ri)
}
})
}
}
func TestFromStackItem(t *testing.T) {
tests := []struct {
name string
item *stackitem.Array
expectErr bool
expected *RoyaltiesTransferredEvent
}{
{
name: "valid stack item",
item: stackitem.NewArray([]stackitem.Item{
stackitem.Make(util.Uint160{1, 2, 3}.BytesBE()), // RoyaltyToken
stackitem.Make(util.Uint160{4, 5, 6}.BytesBE()), // RoyaltyRecipient
stackitem.Make(util.Uint160{7, 8, 9}.BytesBE()), // Buyer
stackitem.Make([]byte{1, 2, 3}), // TokenID
stackitem.Make(big.NewInt(100)), // Amount
}),
expectErr: false,
expected: &RoyaltiesTransferredEvent{
RoyaltyToken: util.Uint160{1, 2, 3},
RoyaltyRecipient: util.Uint160{4, 5, 6},
Buyer: util.Uint160{7, 8, 9},
TokenID: []byte{1, 2, 3},
Amount: big.NewInt(100),
},
},
{
name: "invalid number of items",
item: stackitem.NewArray([]stackitem.Item{
stackitem.Make(util.Uint160{1, 2, 3}.BytesBE()), // Only one item
}),
expectErr: true,
},
{
name: "invalid recipient size",
item: stackitem.NewArray([]stackitem.Item{
stackitem.Make(util.Uint160{1, 2, 3}.BytesBE()), // RoyaltyToken
stackitem.Make([]byte{1, 2}), // Invalid RoyaltyRecipient
stackitem.Make(util.Uint160{7, 8, 9}.BytesBE()), // Buyer
stackitem.Make([]byte{1, 2, 3}), // TokenID
stackitem.Make(big.NewInt(100)), // Amount
}),
expectErr: true,
},
{
name: "invalid integer amount",
item: stackitem.NewArray([]stackitem.Item{
stackitem.Make(util.Uint160{1, 2, 3}.BytesBE()), // RoyaltyToken
stackitem.Make(util.Uint160{4, 5, 6}.BytesBE()), // RoyaltyRecipient
stackitem.Make(util.Uint160{7, 8, 9}.BytesBE()), // Buyer
stackitem.Make([]byte{1, 2, 3}), // TokenID
stackitem.Make(stackitem.NewBool(true)), // Invalid integer for Amount
}),
expectErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
event := new(RoyaltiesTransferredEvent)
err := event.FromStackItem(tt.item)
if tt.expectErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tt.expected, event)
}
})
}
}
func TestRoyaltiesTransferredEventsFromApplicationLog(t *testing.T) {
createEvent := func(token, recipient, buyer util.Uint160, tokenID []byte, amount *big.Int) state.NotificationEvent {
return state.NotificationEvent{
ScriptHash: util.Uint160{1, 2, 3}, // Any contract address.
Name: "RoyaltiesTransferred",
Item: stackitem.NewArray([]stackitem.Item{
stackitem.Make(token.BytesBE()), // RoyaltyToken
stackitem.Make(recipient.BytesBE()), // RoyaltyRecipient
stackitem.Make(buyer.BytesBE()), // Buyer
stackitem.Make(tokenID), // TokenID
stackitem.Make(amount), // Amount
}),
}
}
tests := []struct {
name string
log *result.ApplicationLog
expectErr bool
expected []*RoyaltiesTransferredEvent
}{
{
name: "valid log with one event",
log: &result.ApplicationLog{
Executions: []state.Execution{
{
Events: []state.NotificationEvent{
createEvent(
util.Uint160{1, 2, 3}, // RoyaltyToken
util.Uint160{4, 5, 6}, // RoyaltyRecipient
util.Uint160{7, 8, 9}, // Buyer
[]byte{1, 2, 3}, // TokenID
big.NewInt(100), // Amount
),
},
},
},
},
expectErr: false,
expected: []*RoyaltiesTransferredEvent{
{
RoyaltyToken: util.Uint160{1, 2, 3},
RoyaltyRecipient: util.Uint160{4, 5, 6},
Buyer: util.Uint160{7, 8, 9},
TokenID: []byte{1, 2, 3},
Amount: big.NewInt(100),
},
},
},
{
name: "invalid event structure (missing fields)",
log: &result.ApplicationLog{
Executions: []state.Execution{
{
Events: []state.NotificationEvent{
{
Name: "RoyaltiesTransferred",
Item: stackitem.NewArray([]stackitem.Item{
stackitem.Make(util.Uint160{1, 2, 3}.BytesBE()), // RoyaltyToken
// Missing other fields
}),
},
},
},
},
},
expectErr: true,
},
{
name: "empty log",
log: &result.ApplicationLog{},
expectErr: false,
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
events, err := RoyaltiesTransferredEventsFromApplicationLog(tt.log)
if tt.expectErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tt.expected, events)
}
})
}
}

View file

@ -19,7 +19,7 @@ var (
) )
var checks = map[string][]*Standard{ var checks = map[string][]*Standard{
manifest.NEP11StandardName: {Nep11NonDivisible, Nep11Divisible}, manifest.NEP11StandardName: {Nep11NonDivisible, Nep11Divisible, Nep11WithRoyalty},
manifest.NEP17StandardName: {Nep17}, manifest.NEP17StandardName: {Nep17},
manifest.NEP11Payable: {Nep11Payable}, manifest.NEP11Payable: {Nep11Payable},
manifest.NEP17Payable: {Nep17Payable}, manifest.NEP17Payable: {Nep17Payable},

View file

@ -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},
},
},
},
},
},
}

View file

@ -407,6 +407,9 @@ func Generate(cfg binding.Config) error {
} else if standard.ComplyABI(cfg.Manifest, standard.Nep11NonDivisible) == nil { } else if standard.ComplyABI(cfg.Manifest, standard.Nep11NonDivisible) == nil {
mfst.ABI.Methods = dropStdMethods(mfst.ABI.Methods, standard.Nep11NonDivisible) mfst.ABI.Methods = dropStdMethods(mfst.ABI.Methods, standard.Nep11NonDivisible)
ctr.IsNep11ND = true 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) mfst.ABI.Events = dropStdEvents(mfst.ABI.Events, standard.Nep11Base)
break // Can't be NEP-17 at the same time. break // Can't be NEP-17 at the same time.