diff --git a/internal/testchain/transaction.go b/internal/testchain/transaction.go index 43b817d98d..1b33db59cb 100644 --- a/internal/testchain/transaction.go +++ b/internal/testchain/transaction.go @@ -25,6 +25,7 @@ import ( // Ledger is an interface that abstracts the implementation of the blockchain. type Ledger interface { BlockHeight() uint32 + CalculateAttributesFee(tx *transaction.Transaction) int64 FeePerByte() int64 GetBaseExecFee() int64 GetHeader(hash util.Uint256) (*block.Header, error) @@ -135,7 +136,7 @@ func signTxGeneric(bc Ledger, sign func(hash.Hashable) []byte, verif []byte, txs netFee, sizeDelta := fee.Calculate(bc.GetBaseExecFee(), verif) tx.NetworkFee += netFee size += sizeDelta - tx.NetworkFee += int64(size) * bc.FeePerByte() + tx.NetworkFee += int64(size)*bc.FeePerByte() + bc.CalculateAttributesFee(tx) tx.Scripts = []transaction.Witness{{ InvocationScript: sign(tx), VerificationScript: verif, diff --git a/pkg/compiler/native_test.go b/pkg/compiler/native_test.go index 8f3b8487b1..fd7794a92b 100644 --- a/pkg/compiler/native_test.go +++ b/pkg/compiler/native_test.go @@ -123,6 +123,14 @@ func TestLedgerVMStates(t *testing.T) { require.EqualValues(t, ledger.BreakState, vmstate.Break) } +func TestPolicyAttributeType(t *testing.T) { + require.EqualValues(t, policy.HighPriorityT, transaction.HighPriority) + require.EqualValues(t, policy.OracleResponseT, transaction.OracleResponseT) + require.EqualValues(t, policy.NotValidBeforeT, transaction.NotValidBeforeT) + require.EqualValues(t, policy.ConflictsT, transaction.ConflictsT) + require.EqualValues(t, policy.NotaryAssistedT, transaction.NotaryAssistedT) +} + type nativeTestCase struct { method string params []string @@ -179,6 +187,8 @@ func TestNativeHelpersCompile(t *testing.T) { {"setFeePerByte", []string{"42"}}, {"setStoragePrice", []string{"42"}}, {"unblockAccount", []string{u160}}, + {"getAttributeFee", []string{"1"}}, + {"setAttributeFee", []string{"1", "123"}}, }) runNativeTestCases(t, cs.Ledger.ContractMD, "ledger", []nativeTestCase{ {"currentHash", nil}, diff --git a/pkg/consensus/consensus.go b/pkg/consensus/consensus.go index 9a29f8c2d9..f0ab84ab8a 100644 --- a/pkg/consensus/consensus.go +++ b/pkg/consensus/consensus.go @@ -53,6 +53,7 @@ type Ledger interface { SubscribeForBlocks(ch chan *coreb.Block) UnsubscribeFromBlocks(ch chan *coreb.Block) GetBaseExecFee() int64 + CalculateAttributesFee(tx *transaction.Transaction) int64 interop.Ledger mempool.Feer } diff --git a/pkg/consensus/consensus_test.go b/pkg/consensus/consensus_test.go index 07eeb99edd..c095e8e3ea 100644 --- a/pkg/consensus/consensus_test.go +++ b/pkg/consensus/consensus_test.go @@ -592,7 +592,7 @@ func signTx(t *testing.T, bc Ledger, txs ...*transaction.Transaction) { netFee, sizeDelta := fee.Calculate(bc.GetBaseExecFee(), rawScript) tx.NetworkFee += +netFee size += sizeDelta - tx.NetworkFee += int64(size) * bc.FeePerByte() + tx.NetworkFee += int64(size)*bc.FeePerByte() + bc.CalculateAttributesFee(tx) buf := io.NewBufBinWriter() for _, key := range privNetKeys { diff --git a/pkg/core/blockchain.go b/pkg/core/blockchain.go index 54726d9b24..92e306d0e9 100644 --- a/pkg/core/blockchain.go +++ b/pkg/core/blockchain.go @@ -2485,7 +2485,7 @@ func (bc *Blockchain) verifyAndPoolTx(t *transaction.Transaction, pool *mempool. if size > transaction.MaxTransactionSize { return fmt.Errorf("%w: (%d > MaxTransactionSize %d)", ErrTxTooBig, size, transaction.MaxTransactionSize) } - needNetworkFee := int64(size) * bc.FeePerByte() + needNetworkFee := int64(size)*bc.FeePerByte() + bc.CalculateAttributesFee(t) if bc.P2PSigExtensionsEnabled() { attrs := t.GetAttributes(transaction.NotaryAssistedT) if len(attrs) != 0 { @@ -2508,7 +2508,7 @@ func (bc *Blockchain) verifyAndPoolTx(t *transaction.Transaction, pool *mempool. return err } } - err = bc.verifyTxWitnesses(t, nil, isPartialTx) + err = bc.verifyTxWitnesses(t, nil, isPartialTx, netFee) if err != nil { return err } @@ -2536,6 +2536,32 @@ func (bc *Blockchain) verifyAndPoolTx(t *transaction.Transaction, pool *mempool. return nil } +// CalculateAttributesFee returns network fee for all transaction attributes that should be +// paid according to native Policy. +func (bc *Blockchain) CalculateAttributesFee(tx *transaction.Transaction) int64 { + var ( + feeCache map[transaction.AttrType]int64 + feeSum int64 + ) + for _, attr := range tx.Attributes { + if feeCache == nil { + feeCache = make(map[transaction.AttrType]int64) + } + base, ok := feeCache[attr.Type] + if !ok { + base = bc.contracts.Policy.GetAttributeFeeInternal(bc.dao, attr.Type) + feeCache[attr.Type] = base + } + switch attr.Type { + case transaction.ConflictsT: + feeSum += base * int64(len(tx.Signers)) + default: + feeSum += base + } + } + return feeSum +} + func (bc *Blockchain) verifyTxAttributes(d *dao.Simple, tx *transaction.Transaction, isPartialTx bool) error { for i := range tx.Attributes { switch attrType := tx.Attributes[i].Type; attrType { @@ -2884,17 +2910,24 @@ func (bc *Blockchain) verifyHashAgainstScript(hash util.Uint160, witness *transa // transaction. It can reorder them by ScriptHash, because that's required to // match a slice of script hashes from the Blockchain. Block parameter // is used for easy interop access and can be omitted for transactions that are -// not yet added into any block. +// not yet added into any block. verificationFee argument can be provided to +// restrict the maximum amount of GAS allowed to spend on transaction +// verification. // Golang implementation of VerifyWitnesses method in C# (https://github.com/neo-project/neo/blob/master/neo/SmartContract/Helper.cs#L87). -func (bc *Blockchain) verifyTxWitnesses(t *transaction.Transaction, block *block.Block, isPartialTx bool) error { +func (bc *Blockchain) verifyTxWitnesses(t *transaction.Transaction, block *block.Block, isPartialTx bool, verificationFee ...int64) error { interopCtx := bc.newInteropContext(trigger.Verification, bc.dao, block, t) - gasLimit := t.NetworkFee - int64(t.Size())*bc.FeePerByte() - if bc.P2PSigExtensionsEnabled() { - attrs := t.GetAttributes(transaction.NotaryAssistedT) - if len(attrs) != 0 { - na := attrs[0].Value.(*transaction.NotaryAssisted) - gasLimit -= (int64(na.NKeys) + 1) * bc.contracts.Notary.GetNotaryServiceFeePerKey(bc.dao) + var gasLimit int64 + if len(verificationFee) == 0 { + gasLimit = t.NetworkFee - int64(t.Size())*bc.FeePerByte() - bc.CalculateAttributesFee(t) + if bc.P2PSigExtensionsEnabled() { + attrs := t.GetAttributes(transaction.NotaryAssistedT) + if len(attrs) != 0 { + na := attrs[0].Value.(*transaction.NotaryAssisted) + gasLimit -= (int64(na.NKeys) + 1) * bc.contracts.Notary.GetNotaryServiceFeePerKey(bc.dao) + } } + } else { + gasLimit = verificationFee[0] } for i := range t.Signers { gasConsumed, err := bc.verifyHashAgainstScript(t.Signers[i].Account, &t.Scripts[i], interopCtx, gasLimit) diff --git a/pkg/core/native/native_nep17.go b/pkg/core/native/native_nep17.go index 6a2c42c02d..50539981cb 100644 --- a/pkg/core/native/native_nep17.go +++ b/pkg/core/native/native_nep17.go @@ -356,6 +356,18 @@ func toUint32(s stackitem.Item) uint32 { return uint32(uint64Value) } +func toUint8(s stackitem.Item) uint8 { + bigInt := toBigInt(s) + if !bigInt.IsUint64() { + panic("bigint is not an uint64") + } + uint64Value := bigInt.Uint64() + if uint64Value > math.MaxUint8 { + panic("bigint does not fit into uint8") + } + return uint8(uint64Value) +} + func toInt64(s stackitem.Item) int64 { bigInt := toBigInt(s) if !bigInt.IsInt64() { diff --git a/pkg/core/native/native_test/policy_test.go b/pkg/core/native/native_test/policy_test.go index 3d4a2c469f..bb724ed674 100644 --- a/pkg/core/native/native_test/policy_test.go +++ b/pkg/core/native/native_test/policy_test.go @@ -7,8 +7,14 @@ import ( "github.com/nspcc-dev/neo-go/pkg/core/interop" "github.com/nspcc-dev/neo-go/pkg/core/native" "github.com/nspcc-dev/neo-go/pkg/core/native/nativenames" + "github.com/nspcc-dev/neo-go/pkg/core/transaction" + "github.com/nspcc-dev/neo-go/pkg/io" "github.com/nspcc-dev/neo-go/pkg/neotest" + "github.com/nspcc-dev/neo-go/pkg/smartcontract/callflag" "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neo-go/pkg/vm/emit" + "github.com/nspcc-dev/neo-go/pkg/vm/opcode" + "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" ) func newPolicyClient(t *testing.T) *neotest.ContractInvoker { @@ -39,6 +45,67 @@ func TestPolicy_StoragePriceCache(t *testing.T) { testGetSetCache(t, newPolicyClient(t), "StoragePrice", native.DefaultStoragePrice) } +func TestPolicy_AttributeFee(t *testing.T) { + c := newPolicyClient(t) + getName := "getAttributeFee" + setName := "setAttributeFee" + + randomInvoker := c.WithSigners(c.NewAccount(t)) + committeeInvoker := c.WithSigners(c.Committee) + + t.Run("set, not signed by committee", func(t *testing.T) { + randomInvoker.InvokeFail(t, "invalid committee signature", setName, byte(transaction.ConflictsT), 123) + }) + t.Run("get, unknown attribute", func(t *testing.T) { + randomInvoker.InvokeFail(t, "invalid attribute type: 84", getName, byte(0x54)) + }) + t.Run("get, default value", func(t *testing.T) { + randomInvoker.Invoke(t, 0, getName, byte(transaction.ConflictsT)) + }) + t.Run("set, too large value", func(t *testing.T) { + committeeInvoker.InvokeFail(t, "out of range", setName, byte(transaction.ConflictsT), 10_0000_0001) + }) + t.Run("set, unknown attribute", func(t *testing.T) { + committeeInvoker.InvokeFail(t, "invalid attribute type: 84", setName, 0x54, 5) + }) + t.Run("set, success", func(t *testing.T) { + // Set and get in the same block. + txSet := committeeInvoker.PrepareInvoke(t, setName, byte(transaction.ConflictsT), 1) + txGet := randomInvoker.PrepareInvoke(t, getName, byte(transaction.ConflictsT)) + c.AddNewBlock(t, txSet, txGet) + c.CheckHalt(t, txSet.Hash(), stackitem.Null{}) + c.CheckHalt(t, txGet.Hash(), stackitem.Make(1)) + // Get in the next block. + randomInvoker.Invoke(t, 1, getName, byte(transaction.ConflictsT)) + }) +} + +func TestPolicy_AttributeFeeCache(t *testing.T) { + c := newPolicyClient(t) + getName := "getAttributeFee" + setName := "setAttributeFee" + + committeeInvoker := c.WithSigners(c.Committee) + + // Change fee, abort the transaction and check that contract cache wasn't persisted + // for FAULTed tx at the same block. + w := io.NewBufBinWriter() + emit.AppCall(w.BinWriter, committeeInvoker.Hash, setName, callflag.All, byte(transaction.ConflictsT), 5) + emit.Opcodes(w.BinWriter, opcode.ABORT) + tx1 := committeeInvoker.PrepareInvocation(t, w.Bytes(), committeeInvoker.Signers) + tx2 := committeeInvoker.PrepareInvoke(t, getName, byte(transaction.ConflictsT)) + committeeInvoker.AddNewBlock(t, tx1, tx2) + committeeInvoker.CheckFault(t, tx1.Hash(), "ABORT") + committeeInvoker.CheckHalt(t, tx2.Hash(), stackitem.Make(0)) + + // Change fee and check that change is available for the next tx. + tx1 = committeeInvoker.PrepareInvoke(t, setName, byte(transaction.ConflictsT), 5) + tx2 = committeeInvoker.PrepareInvoke(t, getName, byte(transaction.ConflictsT)) + committeeInvoker.AddNewBlock(t, tx1, tx2) + committeeInvoker.CheckHalt(t, tx1.Hash()) + committeeInvoker.CheckHalt(t, tx2.Hash(), stackitem.Make(5)) +} + func TestPolicy_BlockedAccounts(t *testing.T) { c := newPolicyClient(t) e := c.Executor diff --git a/pkg/core/native/policy.go b/pkg/core/native/policy.go index d45972c733..207b1de320 100644 --- a/pkg/core/native/policy.go +++ b/pkg/core/native/policy.go @@ -1,6 +1,7 @@ package native import ( + "encoding/hex" "fmt" "math/big" "sort" @@ -11,6 +12,7 @@ import ( "github.com/nspcc-dev/neo-go/pkg/core/state" "github.com/nspcc-dev/neo-go/pkg/core/storage" "github.com/nspcc-dev/neo-go/pkg/core/transaction" + "github.com/nspcc-dev/neo-go/pkg/encoding/bigint" "github.com/nspcc-dev/neo-go/pkg/smartcontract" "github.com/nspcc-dev/neo-go/pkg/smartcontract/callflag" "github.com/nspcc-dev/neo-go/pkg/smartcontract/manifest" @@ -24,6 +26,8 @@ const ( defaultExecFeeFactor = interop.DefaultBaseExecFee defaultFeePerByte = 1000 defaultMaxVerificationGas = 1_50000000 + // defaultAttributeFee is a default fee for a transaction attribute those price wasn't set yet. + defaultAttributeFee = 0 // DefaultStoragePrice is the price to pay for 1 byte of storage. DefaultStoragePrice = 100000 @@ -33,9 +37,13 @@ const ( maxFeePerByte = 100_000_000 // maxStoragePrice is the maximum allowed price for a byte of storage. maxStoragePrice = 10000000 + // maxAttributeFee is the maximum allowed value for a transaction attribute fee. + maxAttributeFee = 10_00000000 // blockedAccountPrefix is a prefix used to store blocked account. blockedAccountPrefix = 15 + // attributeFeePrefix is a prefix used to store attribute fee. + attributeFeePrefix = 20 ) var ( @@ -59,6 +67,7 @@ type PolicyCache struct { feePerByte int64 maxVerificationGas int64 storagePrice uint32 + attributeFee map[transaction.AttrType]uint32 blockedAccounts []util.Uint160 } @@ -76,6 +85,10 @@ func (c *PolicyCache) Copy() dao.NativeContractCache { func copyPolicyCache(src, dst *PolicyCache) { *dst = *src + dst.attributeFee = make(map[transaction.AttrType]uint32, len(src.attributeFee)) + for t, v := range src.attributeFee { + dst.attributeFee[t] = v + } dst.blockedAccounts = make([]util.Uint160, len(src.blockedAccounts)) copy(dst.blockedAccounts, src.blockedAccounts) } @@ -112,6 +125,17 @@ func newPolicy() *Policy { md = newMethodAndPrice(p.setStoragePrice, 1<<15, callflag.States) p.AddMethod(md, desc) + desc = newDescriptor("getAttributeFee", smartcontract.IntegerType, + manifest.NewParameter("attributeType", smartcontract.IntegerType)) + md = newMethodAndPrice(p.getAttributeFee, 1<<15, callflag.ReadStates) + p.AddMethod(md, desc) + + desc = newDescriptor("setAttributeFee", smartcontract.VoidType, + manifest.NewParameter("attributeType", smartcontract.IntegerType), + manifest.NewParameter("value", smartcontract.IntegerType)) + md = newMethodAndPrice(p.setAttributeFee, 1<<15, callflag.States) + p.AddMethod(md, desc) + desc = newDescriptor("setFeePerByte", smartcontract.VoidType, manifest.NewParameter("value", smartcontract.IntegerType)) md = newMethodAndPrice(p.setFeePerByte, 1<<15, callflag.States) @@ -146,6 +170,7 @@ func (p *Policy) Initialize(ic *interop.Context) error { feePerByte: defaultFeePerByte, maxVerificationGas: defaultMaxVerificationGas, storagePrice: DefaultStoragePrice, + attributeFee: map[transaction.AttrType]uint32{}, blockedAccounts: make([]util.Uint160, 0), } ic.DAO.SetCache(p.ID, cache) @@ -183,6 +208,25 @@ func (p *Policy) fillCacheFromDAO(cache *PolicyCache, d *dao.Simple) error { if fErr != nil { return fmt.Errorf("failed to initialize blocked accounts: %w", fErr) } + + cache.attributeFee = make(map[transaction.AttrType]uint32) + d.Seek(p.ID, storage.SeekRange{Prefix: []byte{attributeFeePrefix}}, func(k, v []byte) bool { + if len(k) != 1 { + fErr = fmt.Errorf("unexpected attribute type len %d (%s)", len(k), hex.EncodeToString(k)) + return false + } + t := transaction.AttrType(k[0]) + value := bigint.FromBytes(v) + if value == nil { + fErr = fmt.Errorf("unexpected attribute value format: key=%s, value=%s", hex.EncodeToString(k), hex.EncodeToString(v)) + return false + } + cache.attributeFee[t] = uint32(value.Int64()) + return true + }) + if fErr != nil { + return fmt.Errorf("failed to initialize attribute fees: %w", fErr) + } return nil } @@ -290,6 +334,43 @@ func (p *Policy) setStoragePrice(ic *interop.Context, args []stackitem.Item) sta return stackitem.Null{} } +func (p *Policy) getAttributeFee(ic *interop.Context, args []stackitem.Item) stackitem.Item { + t := transaction.AttrType(toUint8(args[0])) + if !transaction.IsValidAttrType(ic.Chain.GetConfig().ReservedAttributes, t) { + panic(fmt.Errorf("invalid attribute type: %d", t)) + } + return stackitem.NewBigInteger(big.NewInt(p.GetAttributeFeeInternal(ic.DAO, t))) +} + +// GetAttributeFeeInternal returns required transaction's attribute fee. +func (p *Policy) GetAttributeFeeInternal(d *dao.Simple, t transaction.AttrType) int64 { + cache := d.GetROCache(p.ID).(*PolicyCache) + v, ok := cache.attributeFee[t] + if !ok { + // We may safely omit this part, but let it be here in case if defaultAttributeFee value is changed. + v = defaultAttributeFee + } + return int64(v) +} + +func (p *Policy) setAttributeFee(ic *interop.Context, args []stackitem.Item) stackitem.Item { + t := transaction.AttrType(toUint8(args[0])) + value := toUint32(args[1]) + if !transaction.IsValidAttrType(ic.Chain.GetConfig().ReservedAttributes, t) { + panic(fmt.Errorf("invalid attribute type: %d", t)) + } + if value > maxAttributeFee { + panic(fmt.Errorf("attribute value is out of range: %d", value)) + } + if !p.NEO.checkCommittee(ic) { + panic("invalid committee signature") + } + setIntWithKey(p.ID, ic.DAO, []byte{attributeFeePrefix, byte(t)}, int64(value)) + cache := ic.DAO.GetRWCache(p.ID).(*PolicyCache) + cache.attributeFee[t] = value + return stackitem.Null{} +} + // setFeePerByte is a Policy contract method that sets transaction's fee per byte. func (p *Policy) setFeePerByte(ic *interop.Context, args []stackitem.Item) stackitem.Item { value := toBigInt(args[0]).Int64() diff --git a/pkg/core/transaction/attrtype.go b/pkg/core/transaction/attrtype.go index c7a57f14c8..e31f3b3aca 100644 --- a/pkg/core/transaction/attrtype.go +++ b/pkg/core/transaction/attrtype.go @@ -21,6 +21,15 @@ const ( NotaryAssistedT AttrType = 0x22 // NotaryAssisted ) +// attrTypes contains a set of valid attribute types (does not include reserved attributes). +var attrTypes = map[AttrType]struct{}{ + HighPriority: {}, + OracleResponseT: {}, + NotValidBeforeT: {}, + ConflictsT: {}, + NotaryAssistedT: {}, +} + func (a AttrType) allowMultiple() bool { switch a { case ConflictsT: @@ -29,3 +38,11 @@ func (a AttrType) allowMultiple() bool { return false } } + +// IsValidAttrType returns whether the provided attribute type is valid. +func IsValidAttrType(reservedAttributesEnabled bool, attrType AttrType) bool { + if _, ok := attrTypes[attrType]; ok { + return true + } + return reservedAttributesEnabled && ReservedLowerBound <= attrType && attrType <= ReservedUpperBound +} diff --git a/pkg/interop/native/policy/attrtype.go b/pkg/interop/native/policy/attrtype.go new file mode 100644 index 0000000000..5c74d051d0 --- /dev/null +++ b/pkg/interop/native/policy/attrtype.go @@ -0,0 +1,14 @@ +package policy + +// AttributeType represents a transaction attribute type. +type AttributeType byte + +// List of valid transaction attribute types. +const ( + HighPriorityT AttributeType = 1 + OracleResponseT AttributeType = 0x11 + NotValidBeforeT AttributeType = 0x20 + ConflictsT AttributeType = 0x21 + // NotaryAssistedT is an extension of Neo protocol available on specifically configured NeoGo networks. + NotaryAssistedT AttributeType = 0x22 +) diff --git a/pkg/interop/native/policy/policy.go b/pkg/interop/native/policy/policy.go index b7aeeaca1b..6e0fdc78e1 100644 --- a/pkg/interop/native/policy/policy.go +++ b/pkg/interop/native/policy/policy.go @@ -43,6 +43,16 @@ func SetStoragePrice(value int) { neogointernal.CallWithTokenNoRet(Hash, "setStoragePrice", int(contract.States), value) } +// GetAttributeFee represents `getAttributeFee` method of Policy native contract. +func GetAttributeFee(t AttributeType) int { + return neogointernal.CallWithToken(Hash, "getAttributeFee", int(contract.ReadStates), t).(int) +} + +// SetAttributeFee represents `setAttributeFee` method of Policy native contract. +func SetAttributeFee(t AttributeType, value int) { + neogointernal.CallWithTokenNoRet(Hash, "setAttributeFee", int(contract.States), t, value) +} + // IsBlocked represents `isBlocked` method of Policy native contract. func IsBlocked(addr interop.Hash160) bool { return neogointernal.CallWithToken(Hash, "isBlocked", int(contract.ReadStates), addr).(bool) diff --git a/pkg/neotest/basic.go b/pkg/neotest/basic.go index d904f39ab4..4e5a7eb4d2 100644 --- a/pkg/neotest/basic.go +++ b/pkg/neotest/basic.go @@ -305,7 +305,7 @@ func AddNetworkFee(bc *core.Blockchain, tx *transaction.Transaction, signers ... tx.NetworkFee += netFee size += sizeDelta } - tx.NetworkFee += int64(size) * bc.FeePerByte() + tx.NetworkFee += int64(size)*bc.FeePerByte() + bc.CalculateAttributesFee(tx) } // NewUnsignedBlock creates a new unsigned block from txs. diff --git a/pkg/rpcclient/policy/policy.go b/pkg/rpcclient/policy/policy.go index 52dd6ba5b0..d578f62737 100644 --- a/pkg/rpcclient/policy/policy.go +++ b/pkg/rpcclient/policy/policy.go @@ -40,6 +40,7 @@ const ( execFeeSetter = "setExecFeeFactor" feePerByteSetter = "setFeePerByte" storagePriceSetter = "setStoragePrice" + attributeFeeSetter = "setAttributeFee" ) // ContractReader provides an interface to call read-only PolicyContract @@ -88,6 +89,12 @@ func (c *ContractReader) GetStoragePrice() (int64, error) { return unwrap.Int64(c.invoker.Call(Hash, "getStoragePrice")) } +// GetAttributeFee returns current fee for the specified attribute usage. Any +// contract saving data to the storage pays for it according to this value. +func (c *ContractReader) GetAttributeFee(t transaction.AttrType) (int64, error) { + return unwrap.Int64(c.invoker.Call(Hash, "getAttributeFee", byte(t))) +} + // IsBlocked checks if the given account is blocked in the PolicyContract. func (c *ContractReader) IsBlocked(account util.Uint160) (bool, error) { return unwrap.Bool(c.invoker.Call(Hash, "isBlocked", account)) @@ -158,6 +165,28 @@ func (c *Contract) SetStoragePriceUnsigned(value int64) (*transaction.Transactio return c.actor.MakeUnsignedCall(Hash, storagePriceSetter, nil, value) } +// SetAttributeFee creates and sends a transaction that sets the new attribute +// fee value for the specified attribute. The action is successful when +// transaction ends in HALT state. The returned values are transaction hash, its +// ValidUntilBlock value and an error if any. +func (c *Contract) SetAttributeFee(t transaction.AttrType, value int64) (util.Uint256, uint32, error) { + return c.actor.SendCall(Hash, attributeFeeSetter, byte(t), value) +} + +// SetAttributeFeeTransaction creates a transaction that sets the new attribute +// fee value for the specified attribute. This transaction is signed, but not +// sent to the network, instead it's returned to the caller. +func (c *Contract) SetAttributeFeeTransaction(t transaction.AttrType, value int64) (*transaction.Transaction, error) { + return c.actor.MakeCall(Hash, attributeFeeSetter, byte(t), value) +} + +// SetAttributeFeeUnsigned creates a transaction that sets the new attribute fee +// value for the specified attribute. This transaction is not signed and just +// returned to the caller. +func (c *Contract) SetAttributeFeeUnsigned(t transaction.AttrType, value int64) (*transaction.Transaction, error) { + return c.actor.MakeUnsignedCall(Hash, attributeFeeSetter, nil, byte(t), value) +} + // BlockAccount creates and sends a transaction that blocks an account on the // network (via `blockAccount` method), it fails (with FAULT state) if it's not // successful. The returned values are transaction hash, its diff --git a/pkg/rpcclient/policy/policy_test.go b/pkg/rpcclient/policy/policy_test.go index 458c77efdf..40935274c0 100644 --- a/pkg/rpcclient/policy/policy_test.go +++ b/pkg/rpcclient/policy/policy_test.go @@ -58,6 +58,8 @@ func TestReader(t *testing.T) { } _, err := pc.IsBlocked(util.Uint160{1, 2, 3}) require.Error(t, err) + _, err = pc.GetAttributeFee(transaction.ConflictsT) + require.Error(t, err) ta.err = nil ta.res = &result.Invoke{ @@ -71,6 +73,9 @@ func TestReader(t *testing.T) { require.NoError(t, err) require.Equal(t, int64(42), val) } + v, err := pc.GetAttributeFee(transaction.ConflictsT) + require.NoError(t, err) + require.Equal(t, int64(42), v) ta.res = &result.Invoke{ State: "HALT", Stack: []stackitem.Item{ diff --git a/pkg/services/rpcsrv/client_test.go b/pkg/services/rpcsrv/client_test.go index 7beb69f8bf..bae4db969e 100644 --- a/pkg/services/rpcsrv/client_test.go +++ b/pkg/services/rpcsrv/client_test.go @@ -803,12 +803,13 @@ func TestCalculateNetworkFee(t *testing.T) { require.NoError(t, err) require.NoError(t, c.Init()) + h, err := util.Uint160DecodeStringLE(verifyWithArgsContractHash) + require.NoError(t, err) + priv := testchain.PrivateKeyByID(0) + acc0 := wallet.NewAccountFromPrivateKey(priv) + t.Run("ContractWithArgs", func(t *testing.T) { check := func(t *testing.T, extraFee int64) { - h, err := util.Uint160DecodeStringLE(verifyWithArgsContractHash) - require.NoError(t, err) - priv := testchain.PrivateKeyByID(0) - acc0 := wallet.NewAccountFromPrivateKey(priv) tx := transaction.New([]byte{byte(opcode.PUSH1)}, 0) require.NoError(t, err) tx.ValidUntilBlock = chain.BlockHeight() + 10 @@ -862,6 +863,56 @@ func TestCalculateNetworkFee(t *testing.T) { check(t, -1) }) }) + t.Run("extra attribute fee", func(t *testing.T) { + const conflictsFee = 100 + + tx := transaction.New([]byte{byte(opcode.PUSH1)}, 0) + tx.ValidUntilBlock = chain.BlockHeight() + 10 + signer0 := transaction.Signer{ + Account: acc0.ScriptHash(), + Scopes: transaction.CalledByEntry, + } + tx.Signers = []transaction.Signer{signer0, {Account: util.Uint160{1, 2, 3}}} + tx.Attributes = []transaction.Attribute{ + { + Type: transaction.ConflictsT, + Value: &transaction.Conflicts{Hash: util.Uint256{1, 2, 3}}, + }, + } + tx.Scripts = []transaction.Witness{ + {VerificationScript: acc0.Contract.Script}, + {VerificationScript: acc0.Contract.Script}, + } + oldFee, err := c.CalculateNetworkFee(tx) + require.NoError(t, err) + + // Set fee per Conflicts attribute. + script, err := smartcontract.CreateCallScript(state.CreateNativeContractHash(nativenames.Policy), "setAttributeFee", byte(transaction.ConflictsT), conflictsFee) + require.NoError(t, err) + txSetFee := transaction.New(script, 1_0000_0000) + txSetFee.ValidUntilBlock = chain.BlockHeight() + 1 + txSetFee.Signers = []transaction.Signer{ + signer0, + { + Account: testchain.CommitteeScriptHash(), + Scopes: transaction.CalledByEntry, + }, + } + txSetFee.NetworkFee = 10_0000_0000 + require.NoError(t, acc0.SignTx(testchain.Network(), txSetFee)) + txSetFee.Scripts = append(txSetFee.Scripts, transaction.Witness{ + InvocationScript: testchain.SignCommittee(txSetFee), + VerificationScript: testchain.CommitteeVerificationScript(), + }) + require.NoError(t, chain.AddBlock(testchain.NewBlock(t, chain, 1, 0, txSetFee))) + + // Calculate network fee one more time with updated Conflicts price. + newFee, err := c.CalculateNetworkFee(tx) + require.NoError(t, err) + + expectedDiff := len(tx.Signers) * len(tx.GetAttributes(transaction.ConflictsT)) * conflictsFee + require.Equal(t, int64(expectedDiff), newFee-oldFee) + }) } func TestSignAndPushInvocationTx(t *testing.T) { chain, rpcSrv, httpSrv := initServerWithInMemoryChain(t) diff --git a/pkg/services/rpcsrv/server.go b/pkg/services/rpcsrv/server.go index 15519b20a6..fa60e267fd 100644 --- a/pkg/services/rpcsrv/server.go +++ b/pkg/services/rpcsrv/server.go @@ -65,6 +65,7 @@ type ( Ledger interface { AddBlock(block *block.Block) error BlockHeight() uint32 + CalculateAttributesFee(tx *transaction.Transaction) int64 CalculateClaimable(h util.Uint160, endHeight uint32) (*big.Int, error) CurrentBlockHash() util.Uint256 FeePerByte() int64 @@ -975,7 +976,7 @@ func (s *Server) calculateNetworkFee(reqParams params.Params) (any, *neorpc.Erro } } fee := s.chain.FeePerByte() - netFee += int64(size) * fee + netFee += int64(size)*fee + s.chain.CalculateAttributesFee(tx) return result.NetworkFee{Value: netFee}, nil } diff --git a/pkg/services/rpcsrv/server_test.go b/pkg/services/rpcsrv/server_test.go index d2793b6daa..68269165fd 100644 --- a/pkg/services/rpcsrv/server_test.go +++ b/pkg/services/rpcsrv/server_test.go @@ -87,7 +87,7 @@ const ( faultedTxHashLE = "82279bfe9bada282ca0f8cb8e0bb124b921af36f00c69a518320322c6f4fef60" faultedTxBlock uint32 = 23 invokescriptContractAVM = "VwIADBQBDAMOBQYMDQIODw0DDgcJAAAAAErZMCQE2zBwaEH4J+yMqiYEEUAMFA0PAwIJAAIBAwcDBAUCAQAOBgwJStkwJATbMHFpQfgn7IyqJgQSQBNA" - block20StateRootLE = "ae445869283f8d7e0debc3f455014c73cde21b9802db99e80248da9f393bce14" + block20StateRootLE = "f974457a4b883e42ee5522ed679c4b54f6b38d80f07385527ecb653b7bde2224" ) var (