diff --git a/docs/DynamicGas.md b/docs/DynamicGas.md new file mode 100644 index 0000000000..d72cba7a05 --- /dev/null +++ b/docs/DynamicGas.md @@ -0,0 +1,86 @@ +# Dynamic Gas Pricing + +This release replaces the legacy fixed fee schedule with a runtime-aware +resource model that charges contracts for the CPU, transient memory, and +persistent storage they actually consume. The change affects every opcode, +syscall, and native contract entrypoint. + +## Execution Model + +- **ResourceCost** – A new struct that tracks `cpuUnits`, `memoryBytes`, and + `storageBytes`. `ApplicationEngine` now exposes helpers (`ChargeCpu`, + `ChargeMemory`, `ChargeStorage`, and `AddResourceCost`) which translate + resource usage into datoshi using three policy-controlled multipliers: + `ExecFeeFactor`, `MemoryFeeFactor`, and `StoragePrice`. +- **Memory Fee Factor** – Managed alongside the existing execution and storage + multipliers by the native `PolicyContract`. The defaults are available even + on historical state, and governance can tune the factor at runtime through + the existing policy RPC surface (`PolicyAPI.GetMemoryFeeFactorAsync`). +- **Diagnostic Support** – Per-opcode resource contributions flow through the + existing `IDiagnostic` hook chain, enabling custom instrumentation and + trace tooling. + +## Opcode Accounting + +- `PreExecuteInstruction` now charges the static cost through `ChargeCpu` and + layers a dynamic adjustment via `CalculateDynamicOpcodeCost`. The estimator + inspects the evaluation stack to account for operand byte length, element + counts, and container sizes when executing string, buffer, and collection + opcodes (`PUSHDATA*`, `CAT`, `SUBSTR`, `LEFT/RIGHT`, `PACK`, `NEWARRAY*`, + `NEWBUFFER`, `MEMCPY`, etc.). +- Container mutations (`APPEND`, `SETITEM`, `REVERSEITEMS`, map projections) + add CPU and memory proportional to the element footprint using a small + heuristic (`AverageElementOverhead`). +- Iterator materialisation (`System.Iterator.Value`) bills the value size while + preserving immutability on the evaluation stack. +- Numeric, bitwise, and shift opcodes scale with operand size: the estimator + caps total cost to avoid runaway multiplications while still reflecting the + expense of big integer arithmetic, modular exponentiation, or wide bitfield + operations. + +## Syscalls & Native Contracts + +- Interop descriptors accept optional dynamic calculators. The engine captures + raw stack arguments before conversion and evaluates any additional resource + cost prior to dispatching the handler. +- Storage operations meter key lookups, value loads, iterator creation, and + writes: `System.Storage.Put`/`Delete` now charge CPU per-byte and convert the + existing storage delta logic to `ChargeStorage`; reads apply CPU + memory + proportional to the returned value size. +- Storage iterators charge per entry during enumeration, aligning the cost of + `Iterator.Next` with the key/value data retrieved from the underlying store. +- Runtime facilities (`System.Runtime.Log/Notify/LoadScript/CheckWitness`) are + instrumented to charge for serialized payloads, script size, and validation + inputs. Notifications charge per encoded byte at the point of serialization. +- Native contracts leverage the new helpers. Method invocation now maps the + descriptor metadata (`CpuFee`, `StorageFee`) into resource costs without + manual datoshi arithmetic. Contract deployment/update charges are expressed + in terms of storage bytes plus a make-whole component to honour the minimum + deployment fee. + +## Policy & Tooling Updates + +- `PolicyContract` persists the `MemoryFeeFactor` alongside existing policy + knobs. Setter/getter entrypoints and RPC bindings mirror the execution and + storage controls. +- `PolicyAPI` adds `GetMemoryFeeFactorAsync`, and unit/integration tests cover + the new RPC method. +- Documentation and CLI examples now reference the memory fee factor and + describe the conversion pipeline for all three resource classes. + +## Migration Guidance + +1. **Contract Authors** – Expect gas consumption to scale with payload size. + Re-run critical flows on testnet and ensure off-chain estimators use the + updated policy API before mainnet activation. Storage writes remain priced + per byte; transient allocations and large notifications are now metered. +2. **Node Operators** – Review policy multipliers (`exec`, `memory`, + `storage`) and adjust to reflect infrastructure costs. The defaults match + previous behaviour for small payloads and maintain deterministic execution. +3. **Tooling Vendors** – Update SDKs and wallets to query + `GetMemoryFeeFactorAsync` when modelling gas, and surface richer diagnostics + where available. + +Activation should be coordinated through a dedicated hardfork flag so legacy +state (which lacks the memory fee slot) absorbs the default without manual +migration. diff --git a/src/Neo/SmartContract/ApplicationEngine.Contract.cs b/src/Neo/SmartContract/ApplicationEngine.Contract.cs index 0ec783cc08..762b07a881 100644 --- a/src/Neo/SmartContract/ApplicationEngine.Contract.cs +++ b/src/Neo/SmartContract/ApplicationEngine.Contract.cs @@ -124,7 +124,7 @@ internal protected UInt160 CreateStandardAccount(ECPoint pubKey) long fee = IsHardforkEnabled(Hardfork.HF_Aspidochelone) ? CheckSigPrice : 1 << 8; - AddFee(fee * ExecFeeFactor); + ChargeCpu(fee); return Contract.CreateSignatureRedeemScript(pubKey).ToScriptHash(); } @@ -141,7 +141,7 @@ internal protected UInt160 CreateMultisigAccount(int m, ECPoint[] pubKeys) long fee = IsHardforkEnabled(Hardfork.HF_Aspidochelone) ? CheckSigPrice * pubKeys.Length : 1 << 8; - AddFee(fee * ExecFeeFactor); + ChargeCpu(fee); return Contract.CreateMultiSigRedeemScript(m, pubKeys).ToScriptHash(); } diff --git a/src/Neo/SmartContract/ApplicationEngine.Crypto.cs b/src/Neo/SmartContract/ApplicationEngine.Crypto.cs index a190cfdd0d..d6b2a1fbdb 100644 --- a/src/Neo/SmartContract/ApplicationEngine.Crypto.cs +++ b/src/Neo/SmartContract/ApplicationEngine.Crypto.cs @@ -69,7 +69,7 @@ protected internal bool CheckMultisig(byte[][] pubkeys, byte[][] signatures) if (n == 0) throw new ArgumentException("pubkeys array cannot be empty."); if (m == 0) throw new ArgumentException("signatures array cannot be empty."); if (m > n) throw new ArgumentException($"signatures count ({m}) cannot be greater than pubkeys count ({n})."); - AddFee(CheckSigPrice * n * ExecFeeFactor); + ChargeCpu(CheckSigPrice * n); try { for (int i = 0, j = 0; i < m && j < n;) diff --git a/src/Neo/SmartContract/ApplicationEngine.Fees.cs b/src/Neo/SmartContract/ApplicationEngine.Fees.cs new file mode 100644 index 0000000000..58484cf416 --- /dev/null +++ b/src/Neo/SmartContract/ApplicationEngine.Fees.cs @@ -0,0 +1,94 @@ +// Copyright (C) 2015-2025 The Neo Project. +// +// ApplicationEngine.Fees.cs file belongs to the neo project and is free +// software distributed under the MIT software license, see the +// accompanying file LICENSE in the main directory of the +// repository or http://www.opensource.org/licenses/mit-license.php +// for more details. +// +// Redistribution and use in source and binary forms with or without +// modifications are permitted. + +using System; + +namespace Neo.SmartContract +{ + public readonly struct ResourceCost + { + public long CpuUnits { get; } + public long MemoryBytes { get; } + public long StorageBytes { get; } + + public static ResourceCost Zero => default; + + public ResourceCost(long cpuUnits, long memoryBytes, long storageBytes) + { + if (cpuUnits < 0) throw new ArgumentOutOfRangeException(nameof(cpuUnits)); + if (memoryBytes < 0) throw new ArgumentOutOfRangeException(nameof(memoryBytes)); + if (storageBytes < 0) throw new ArgumentOutOfRangeException(nameof(storageBytes)); + + CpuUnits = cpuUnits; + MemoryBytes = memoryBytes; + StorageBytes = storageBytes; + } + + public ResourceCost Add(ResourceCost other) + { + checked + { + return new ResourceCost( + CpuUnits + other.CpuUnits, + MemoryBytes + other.MemoryBytes, + StorageBytes + other.StorageBytes); + } + } + + public bool IsZero => CpuUnits == 0 && MemoryBytes == 0 && StorageBytes == 0; + + public static ResourceCost FromCpu(long cpuUnits) => new(cpuUnits, 0, 0); + + public static ResourceCost FromMemory(long memoryBytes) => new(0, memoryBytes, 0); + + public static ResourceCost FromStorage(long storageBytes) => new(0, 0, storageBytes); + } + + partial class ApplicationEngine + { + protected internal void AddResourceCost(ResourceCost resourceCost) + { + if (resourceCost.IsZero) + return; + + checked + { + long datoshi = resourceCost.CpuUnits * ExecFeeFactor + + resourceCost.MemoryBytes * MemoryFeeFactor + + resourceCost.StorageBytes * StoragePrice; + if (datoshi != 0) + AddFee(datoshi); + } + } + + protected internal void ChargeCpu(long cpuUnits) + { + if (cpuUnits < 0) throw new ArgumentOutOfRangeException(nameof(cpuUnits)); + if (cpuUnits == 0) return; + AddResourceCost(ResourceCost.FromCpu(cpuUnits)); + } + + protected internal void ChargeMemory(long memoryBytes) + { + if (memoryBytes < 0) throw new ArgumentOutOfRangeException(nameof(memoryBytes)); + if (memoryBytes == 0) return; + AddResourceCost(ResourceCost.FromMemory(memoryBytes)); + } + + protected internal void ChargeStorage(long storageBytes) + { + if (storageBytes < 0) throw new ArgumentOutOfRangeException(nameof(storageBytes)); + if (storageBytes == 0) return; + AddResourceCost(ResourceCost.FromStorage(storageBytes)); + } + } +} + diff --git a/src/Neo/SmartContract/ApplicationEngine.Iterator.cs b/src/Neo/SmartContract/ApplicationEngine.Iterator.cs index faa5d0cd4f..631deb6aaf 100644 --- a/src/Neo/SmartContract/ApplicationEngine.Iterator.cs +++ b/src/Neo/SmartContract/ApplicationEngine.Iterator.cs @@ -47,7 +47,14 @@ internal protected static bool IteratorNext(IIterator iterator) /// The element in the collection at the current position of the iterator. internal protected StackItem IteratorValue(IIterator iterator) { - return iterator.Value(ReferenceCounter); + StackItem value = iterator.Value(ReferenceCounter); + long approximateSize = GetApproximateItemSize(value); + if (approximateSize > 0) + { + ChargeCpu(approximateSize); + ChargeMemory(approximateSize); + } + return value; } } } diff --git a/src/Neo/SmartContract/ApplicationEngine.Runtime.cs b/src/Neo/SmartContract/ApplicationEngine.Runtime.cs index bbdef2e7d4..20324b4faf 100644 --- a/src/Neo/SmartContract/ApplicationEngine.Runtime.cs +++ b/src/Neo/SmartContract/ApplicationEngine.Runtime.cs @@ -217,6 +217,12 @@ protected internal void RuntimeLoadScript(byte[] script, CallFlags callFlags, Ar if ((callFlags & ~CallFlags.All) != 0) throw new ArgumentOutOfRangeException(nameof(callFlags), $"Invalid call flags: {callFlags}"); + if (script is not null && script.Length > 0) + { + ChargeCpu(script.Length); + ChargeMemory(script.Length); + } + ExecutionContextState state = CurrentContext.GetState(); ExecutionContext context = LoadScript(new Script(script, true), configureState: p => { @@ -237,6 +243,8 @@ protected internal void RuntimeLoadScript(byte[] script, CallFlags callFlags, Ar /// if the account has witnessed the current transaction; otherwise, . protected internal bool CheckWitness(byte[] hashOrPubkey) { + if (hashOrPubkey is not null && hashOrPubkey.Length > 0) + ChargeCpu(hashOrPubkey.Length); UInt160 hash = hashOrPubkey.Length switch { 20 => new UInt160(hashOrPubkey), @@ -322,7 +330,7 @@ protected internal BigInteger GetRandom() buffer = nonceData = Cryptography.Helper.Murmur128(nonceData, ProtocolSettings.Network); price = 1 << 4; } - AddFee(price * ExecFeeFactor); + ChargeCpu(price); return new BigInteger(buffer, isUnsigned: true); } @@ -335,6 +343,11 @@ protected internal void RuntimeLog(byte[] state) { if (state.Length > MaxNotificationSize) throw new ArgumentException($"Notification size {state.Length} exceeds maximum allowed size of {MaxNotificationSize} bytes", nameof(state)); + if (state.Length > 0) + { + ChargeCpu(state.Length); + ChargeMemory(state.Length); + } try { string message = state.ToStrictUtf8String(); @@ -361,6 +374,8 @@ protected internal void RuntimeNotify(byte[] eventName, Array state) } if (eventName.Length > MaxEventName) throw new ArgumentException($"Event name size {eventName.Length} exceeds maximum allowed size of {MaxEventName} bytes", nameof(eventName)); + if (eventName.Length > 0) + ChargeCpu(eventName.Length); string name = eventName.ToStrictUtf8String(); ContractState contract = CurrentContext.GetState().Contract; @@ -380,6 +395,11 @@ protected internal void RuntimeNotify(byte[] eventName, Array state) using MemoryStream ms = new(MaxNotificationSize); using BinaryWriter writer = new(ms, Utility.StrictUTF8, true); BinarySerializer.Serialize(writer, state, MaxNotificationSize, Limits.MaxStackSize); + if (ms.Position > 0) + { + ChargeCpu((int)ms.Position); + ChargeMemory((int)ms.Position); + } SendNotification(CurrentScriptHash, name, state); } @@ -389,9 +409,16 @@ protected internal void RuntimeNotifyV1(byte[] eventName, Array state) throw new ArgumentException($"Event name size {eventName.Length} exceeds maximum allowed size of {MaxEventName} bytes", nameof(eventName)); if (CurrentContext.GetState().Contract is null) throw new InvalidOperationException("Notifications are not allowed in dynamic scripts."); + if (eventName.Length > 0) + ChargeCpu(eventName.Length); using MemoryStream ms = new(MaxNotificationSize); using BinaryWriter writer = new(ms, Utility.StrictUTF8, true); BinarySerializer.Serialize(writer, state, MaxNotificationSize, Limits.MaxStackSize); + if (ms.Position > 0) + { + ChargeCpu((int)ms.Position); + ChargeMemory((int)ms.Position); + } SendNotification(CurrentScriptHash, eventName.ToStrictUtf8String(), state); } diff --git a/src/Neo/SmartContract/ApplicationEngine.Storage.cs b/src/Neo/SmartContract/ApplicationEngine.Storage.cs index d04f5b8a8f..edfa64dee8 100644 --- a/src/Neo/SmartContract/ApplicationEngine.Storage.cs +++ b/src/Neo/SmartContract/ApplicationEngine.Storage.cs @@ -152,11 +152,24 @@ protected internal static StorageContext AsReadOnly(StorageContext context) /// The value of the entry. Or if the entry doesn't exist. protected internal ReadOnlyMemory? Get(StorageContext context, byte[] key) { - return SnapshotCache.TryGet(new StorageKey + if (key is not null && key.Length > 0) + ChargeCpu(key.Length); + + var storageItem = SnapshotCache.TryGet(new StorageKey { Id = context.Id, Key = key - })?.Value; + }); + if (storageItem is null) + return null; + + var value = storageItem.Value; + if (!value.IsEmpty) + { + ChargeCpu(value.Length); + ChargeMemory(value.Length); + } + return value; } /// @@ -201,9 +214,12 @@ protected internal IIterator Find(StorageContext context, byte[] prefix, FindOpt if ((options.HasFlag(FindOptions.PickField0) || options.HasFlag(FindOptions.PickField1)) && !options.HasFlag(FindOptions.DeserializeValues)) throw new ArgumentException("PickField0 or PickField1 requires DeserializeValues", nameof(options)); + if (prefix.Length > 0) + ChargeCpu(prefix.Length); + var prefixKey = StorageKey.CreateSearchPrefix(context.Id, prefix); var direction = options.HasFlag(FindOptions.Backwards) ? SeekDirection.Backward : SeekDirection.Forward; - return new StorageIterator(SnapshotCache.Find(prefixKey, direction).GetEnumerator(), prefix.Length, options); + return new StorageIterator(SnapshotCache.Find(prefixKey, direction).GetEnumerator(), prefix.Length, options, this); } /// @@ -256,7 +272,8 @@ protected internal void Put(StorageContext context, byte[] key, byte[] value) else newDataSize = (item.Value.Length - 1) / 4 + 1 + value.Length - item.Value.Length; } - AddFee(newDataSize * StoragePrice); + ChargeCpu(key.Length + value.Length); + ChargeStorage(newDataSize); item.Value = value; } @@ -281,6 +298,8 @@ protected internal void PutLocal(byte[] key, byte[] value) protected internal void Delete(StorageContext context, byte[] key) { if (context.IsReadOnly) throw new ArgumentException("StorageContext is read-only", nameof(context)); + if (key is not null && key.Length > 0) + ChargeCpu(key.Length); SnapshotCache.Delete(new StorageKey { Id = context.Id, diff --git a/src/Neo/SmartContract/ApplicationEngine.cs b/src/Neo/SmartContract/ApplicationEngine.cs index 6bbb909d22..b10c3dbb27 100644 --- a/src/Neo/SmartContract/ApplicationEngine.cs +++ b/src/Neo/SmartContract/ApplicationEngine.cs @@ -74,6 +74,7 @@ public partial class ApplicationEngine : ExecutionEngine private readonly Dictionary invocationCounter = new(); private readonly Dictionary contractTasks = new(); internal readonly uint ExecFeeFactor; + internal readonly uint MemoryFeeFactor; // In the unit of datoshi, 1 datoshi = 1e-8 GAS internal readonly uint StoragePrice; private byte[] nonceData; @@ -211,11 +212,13 @@ protected ApplicationEngine( if (snapshotCache is null || persistingBlock?.Index == 0) { ExecFeeFactor = PolicyContract.DefaultExecFeeFactor; + MemoryFeeFactor = PolicyContract.DefaultMemoryFeeFactor; StoragePrice = PolicyContract.DefaultStoragePrice; } else { ExecFeeFactor = NativeContract.Policy.GetExecFeeFactor(snapshotCache); + MemoryFeeFactor = NativeContract.Policy.GetMemoryFeeFactor(snapshotCache); StoragePrice = NativeContract.Policy.GetStoragePrice(snapshotCache); } @@ -657,11 +660,24 @@ internal protected void ValidateCallFlags(CallFlags requiredCallFlags) protected virtual void OnSysCall(InteropDescriptor descriptor) { ValidateCallFlags(descriptor.RequiredCallFlags); - AddFee(descriptor.FixedPrice * ExecFeeFactor); + if (descriptor.FixedPrice > 0) + ChargeCpu(descriptor.FixedPrice); - object[] parameters = new object[descriptor.Parameters.Count]; - for (int i = 0; i < parameters.Length; i++) - parameters[i] = Convert(Pop(), descriptor.Parameters[i]); + int parameterCount = descriptor.Parameters.Count; + StackItem[] rawArguments = parameterCount == 0 ? Array.Empty() : new StackItem[parameterCount]; + for (int i = 0; i < parameterCount; i++) + rawArguments[i] = Pop(); + + if (descriptor.DynamicCostCalculator is not null) + { + var extraCost = descriptor.DynamicCostCalculator(this, rawArguments); + if (!extraCost.IsZero) + AddResourceCost(extraCost); + } + + object[] parameters = new object[parameterCount]; + for (int i = 0; i < parameterCount; i++) + parameters[i] = Convert(rawArguments[i], descriptor.Parameters[i]); object returnValue = descriptor.Handler.Invoke(this, parameters); if (descriptor.Handler.ReturnType != typeof(void)) @@ -671,7 +687,13 @@ protected virtual void OnSysCall(InteropDescriptor descriptor) protected override void PreExecuteInstruction(Instruction instruction) { Diagnostic?.PreExecuteInstruction(instruction); - AddFee(ExecFeeFactor * OpCodePriceTable[(byte)instruction.OpCode]); + long baseCpuUnits = OpCodePriceTable[(byte)instruction.OpCode]; + if (baseCpuUnits > 0) + ChargeCpu(baseCpuUnits); + + var dynamicCost = CalculateDynamicOpcodeCost(instruction); + if (!dynamicCost.IsZero) + AddResourceCost(dynamicCost); } protected override void PostExecuteInstruction(Instruction instruction) @@ -680,6 +702,495 @@ protected override void PostExecuteInstruction(Instruction instruction) Diagnostic?.PostExecuteInstruction(instruction); } + private const long MaxDynamicCpuUnits = 1_000_000_000; // Guardrail to avoid overflow while still covering large payloads + + private ResourceCost CalculateDynamicOpcodeCost(Instruction instruction) + { + if (CurrentContext is null) + return ResourceCost.Zero; + + var stack = CurrentContext.EvaluationStack; + try + { + switch (instruction.OpCode) + { + case OpCode.PUSHDATA1: + case OpCode.PUSHDATA2: + case OpCode.PUSHDATA4: + return new ResourceCost(instruction.Operand.Length, instruction.Operand.Length, 0); + + case OpCode opCode when IsInlinePushBytesInstruction(opCode, instruction.Operand.Length): + return EstimatePushBytesCost(instruction); + + case OpCode.CAT: + return EstimateConcatCost(stack, 2); + + case OpCode.SUBSTR: + return EstimateSubstrCost(stack); + + case OpCode.LEFT: + return EstimateLeftRightCost(stack, isLeft: true); + + case OpCode.RIGHT: + return EstimateLeftRightCost(stack, isLeft: false); + + case OpCode.MEMCPY: + return EstimateMemcpyCost(stack); + + case OpCode.NEWBUFFER: + return EstimateNewBufferCost(stack); + + case OpCode.PACK: + case OpCode.PACKMAP: + case OpCode.PACKSTRUCT: + return EstimatePackCost(stack); + + case OpCode.NEWARRAY: + case OpCode.NEWARRAY_T: + case OpCode.NEWSTRUCT: + return EstimateNewCollectionCost(stack); + + case OpCode.APPEND: + case OpCode.SETITEM: + return EstimateSetItemCost(stack); + + case OpCode.PICKITEM: + return EstimatePickItemCost(stack); + + case OpCode.REVERSEITEMS: + return EstimateReverseItemsCost(stack); + + case OpCode.VALUES: + case OpCode.KEYS: + return EstimateKeyValueProjectionCost(stack, instruction.OpCode); + + case OpCode.ADD: + case OpCode.SUB: + case OpCode.MAX: + case OpCode.MIN: + return EstimateBinaryNumericCost(stack, NumericOperationKind.Linear); + + case OpCode.MUL: + case OpCode.DIV: + case OpCode.MOD: + case OpCode.MODMUL: + return EstimateBinaryNumericCost(stack, NumericOperationKind.Multiplicative); + + case OpCode.POW: + case OpCode.MODPOW: + return EstimateBinaryNumericCost(stack, NumericOperationKind.Exponential); + + case OpCode.SQRT: + case OpCode.ABS: + case OpCode.NEGATE: + case OpCode.INC: + case OpCode.DEC: + case OpCode.SIGN: + return EstimateUnaryNumericCost(stack); + + case OpCode.AND: + case OpCode.OR: + case OpCode.XOR: + case OpCode.INVERT: + case OpCode.BOOLAND: + case OpCode.BOOLOR: + return EstimateBitOperationCost(stack, instruction.OpCode); + + case OpCode.SHL: + case OpCode.SHR: + return EstimateShiftOperationCost(stack); + + case OpCode.EQUAL: + case OpCode.NOTEQUAL: + case OpCode.NUMEQUAL: + case OpCode.NUMNOTEQUAL: + case OpCode.LT: + case OpCode.LE: + case OpCode.GT: + case OpCode.GE: + case OpCode.WITHIN: + return EstimateComparisonCost(stack); + } + } + catch (Exception) + { + // Ignore estimation errors; actual opcode execution will handle invalid states + } + + return ResourceCost.Zero; + } + + private static bool IsInlinePushBytesInstruction(OpCode opCode, int operandLength) + { + byte value = (byte)opCode; + return value is >= 0x01 and <= 0x4B && operandLength == value; + } + + private static ResourceCost EstimatePushBytesCost(Instruction instruction) + { + int length = instruction.Operand.Length; + if (length <= 0) + return ResourceCost.Zero; + + return new ResourceCost(ClampDynamicCpu(length), length, 0); + } + + private static ResourceCost EstimateConcatCost(EvaluationStack stack, int itemCount) + { + if (stack.Count < itemCount) + return ResourceCost.Zero; + + long totalInput = 0; + for (int i = 0; i < itemCount; i++) + totalInput += GetSpanLength(stack.Peek(i)); + + if (totalInput == 0) + return ResourceCost.Zero; + + // Copying inputs plus creating the resulting buffer + return new ResourceCost(ClampDynamicCpu(totalInput * 2), totalInput, 0); + } + + private static ResourceCost EstimateSubstrCost(EvaluationStack stack) + { + if (stack.Count < 3) + return ResourceCost.Zero; + + var lengthItem = stack.Peek(0); + var startItem = stack.Peek(1); + var valueItem = stack.Peek(2); + + if (!TryGetInt(lengthItem, out long length) || length <= 0) + return ResourceCost.Zero; + + long available = GetSpanLength(valueItem); + long effective = Math.Min(Math.Max(length, 0), available); + if (effective <= 0) + return ResourceCost.Zero; + + return new ResourceCost(ClampDynamicCpu(effective * 2), effective, 0); + } + + private static ResourceCost EstimateLeftRightCost(EvaluationStack stack, bool isLeft) + { + if (stack.Count < 2) + return ResourceCost.Zero; + + var lengthItem = stack.Peek(0); + var valueItem = stack.Peek(1); + + if (!TryGetInt(lengthItem, out long length) || length <= 0) + return ResourceCost.Zero; + + long available = GetSpanLength(valueItem); + long effective = Math.Min(Math.Max(length, 0), available); + if (effective <= 0) + return ResourceCost.Zero; + + // Similar to substring; copying subset and allocating new bytes + return new ResourceCost(ClampDynamicCpu(effective * 2), effective, 0); + } + + private static ResourceCost EstimateMemcpyCost(EvaluationStack stack) + { + if (stack.Count < 3) + return ResourceCost.Zero; + + var countItem = stack.Peek(0); + if (!TryGetInt(countItem, out long length) || length <= 0) + return ResourceCost.Zero; + + return new ResourceCost(ClampDynamicCpu(length * 3), 0, 0); + } + + private static ResourceCost EstimateNewBufferCost(EvaluationStack stack) + { + if (stack.Count < 1) + return ResourceCost.Zero; + + if (!TryGetInt(stack.Peek(0), out long size) || size <= 0) + return ResourceCost.Zero; + + return new ResourceCost(ClampDynamicCpu(size), size, 0); + } + + private static ResourceCost EstimatePackCost(EvaluationStack stack) + { + if (stack.Count < 1) + return ResourceCost.Zero; + + if (!TryGetInt(stack.Peek(0), out long count) || count <= 0) + return ResourceCost.Zero; + + long cpu = ClampDynamicCpu(count * 2); + long memory = count * AverageElementOverhead; + return new ResourceCost(cpu, memory, 0); + } + + private static ResourceCost EstimateNewCollectionCost(EvaluationStack stack) + { + if (stack.Count < 1) + return ResourceCost.Zero; + + if (!TryGetInt(stack.Peek(0), out long count) || count <= 0) + return ResourceCost.Zero; + + long cpu = ClampDynamicCpu(count); + long memory = count * AverageElementOverhead; + return new ResourceCost(cpu, memory, 0); + } + + private static ResourceCost EstimateSetItemCost(EvaluationStack stack) + { + if (stack.Count < 3) + return ResourceCost.Zero; + + var container = stack.Peek(2); + var value = stack.Peek(0); + long valueSize = GetApproximateItemSize(value); + long cpu = ClampDynamicCpu(valueSize + AverageElementOverhead); + long memory = container.Type switch + { + StackItemType.Map => valueSize, + _ => 0 + }; + return new ResourceCost(Math.Max(cpu, 0), Math.Max(memory, 0), 0); + } + + private static ResourceCost EstimatePickItemCost(EvaluationStack stack) + { + if (stack.Count < 2) + return ResourceCost.Zero; + + var container = stack.Peek(1); + long elementSize = GetApproximateItemSize(container); + if (elementSize <= 0) + elementSize = AverageElementOverhead; + return new ResourceCost(elementSize, 0, 0); + } + + private static ResourceCost EstimateReverseItemsCost(EvaluationStack stack) + { + if (stack.Count < 1) + return ResourceCost.Zero; + + var arrayItem = stack.Peek(0); + if (arrayItem is VMArray array) + { + long count = array.Count; + return new ResourceCost(ClampDynamicCpu(count * 2), 0, 0); + } + return ResourceCost.Zero; + } + + private static ResourceCost EstimateKeyValueProjectionCost(EvaluationStack stack, OpCode opCode) + { + if (stack.Count < 1) + return ResourceCost.Zero; + + if (stack.Peek(0) is not Map map) + return ResourceCost.Zero; + + long count = map.Count; + long cpu = ClampDynamicCpu(count * 2); + long memory = count * AverageElementOverhead; + return new ResourceCost(cpu, memory, 0); + } + + private static bool TryGetInt(StackItem item, out long value) + { + try + { + value = (long)item.GetInteger(); + return true; + } + catch + { + value = 0; + return false; + } + } + + private const int AverageElementOverhead = 16; + + private static long GetSpanLength(StackItem item) + { + try + { + return item.Type switch + { + StackItemType.ByteString or StackItemType.Buffer => item.GetSpan().Length, + _ => 0 + }; + } + catch + { + return 0; + } + } + + private static long GetApproximateItemSize(StackItem item, int depth = 0) + { + if (item is null) + return 0; + + if (depth > 4) + return AverageElementOverhead; + + try + { + return item.Type switch + { + StackItemType.ByteString or StackItemType.Buffer => item.GetSpan().Length, + StackItemType.Array or StackItemType.Struct => item is VMArray array ? SumArrayFootprint(array, depth + 1) : AverageElementOverhead, + StackItemType.Map => item is Map map ? SumMapFootprint(map, depth + 1) : AverageElementOverhead, + StackItemType.Integer => item.GetSpan().Length, + _ => AverageElementOverhead + }; + } + catch + { + return AverageElementOverhead; + } + } + + private static long SumArrayFootprint(VMArray array, int depth) + { + long total = AverageElementOverhead; + foreach (var element in array) + { + total = AddWithCap(total, GetApproximateItemSize(element, depth)); + } + return total; + } + + private static long SumMapFootprint(Map map, int depth) + { + long total = AverageElementOverhead; + foreach (var (key, value) in map) + { + total = AddWithCap(total, GetApproximateItemSize(key, depth)); + total = AddWithCap(total, GetApproximateItemSize(value, depth)); + } + return total; + } + + private static ResourceCost EstimateBinaryNumericCost(EvaluationStack stack, NumericOperationKind kind) + { + if (stack.Count < 2) + return ResourceCost.Zero; + + long left = GetNumericOperandSize(stack.Peek(0)); + long right = GetNumericOperandSize(stack.Peek(1)); + + long cpu = kind switch + { + NumericOperationKind.Linear => Math.Max(left, right), + NumericOperationKind.Multiplicative => MultiplyWithCap(left, right), + NumericOperationKind.Exponential => + MultiplyWithCap(left, MultiplyWithCap(Math.Max(right, 1), Math.Max(right, 1))), + _ => 0 + }; + + return cpu > 0 ? ResourceCost.FromCpu(ClampDynamicCpu(cpu)) : ResourceCost.Zero; + } + + private static ResourceCost EstimateUnaryNumericCost(EvaluationStack stack) + { + if (stack.Count < 1) + return ResourceCost.Zero; + + long size = GetNumericOperandSize(stack.Peek(0)); + return size > 0 ? ResourceCost.FromCpu(ClampDynamicCpu(size)) : ResourceCost.Zero; + } + + private static ResourceCost EstimateBitOperationCost(EvaluationStack stack, OpCode opCode) + { + if (stack.Count < 1) + return ResourceCost.Zero; + + long operandSize = GetSpanLength(stack.Peek(0)); + if (opCode is OpCode.AND or OpCode.OR or OpCode.XOR) + { + if (stack.Count < 2) + return ResourceCost.Zero; + operandSize = Math.Max(GetSpanLength(stack.Peek(0)), GetSpanLength(stack.Peek(1))); + } + return operandSize > 0 ? ResourceCost.FromCpu(ClampDynamicCpu(operandSize)) : ResourceCost.Zero; + } + + private static ResourceCost EstimateShiftOperationCost(EvaluationStack stack) + { + if (stack.Count < 2) + return ResourceCost.Zero; + + long valueSize = GetNumericOperandSize(stack.Peek(0)); + long shift = Math.Max(0, GetNumericOperandSize(stack.Peek(1))); + long cpu = ClampDynamicCpu(MultiplyWithCap(valueSize, Math.Max(1, shift))); + return cpu > 0 ? ResourceCost.FromCpu(cpu) : ResourceCost.Zero; + } + + private static ResourceCost EstimateComparisonCost(EvaluationStack stack) + { + if (stack.Count < 2) + return ResourceCost.Zero; + + long left = GetApproximateItemSize(stack.Peek(0)); + long right = GetApproximateItemSize(stack.Peek(1)); + long cpu = ClampDynamicCpu(Math.Max(left, right)); + return cpu > 0 ? ResourceCost.FromCpu(cpu) : ResourceCost.Zero; + } + + private static long GetNumericOperandSize(StackItem item) + { + try + { + return item.Type switch + { + StackItemType.Integer => Math.Max(1, item.GetSpan().Length), + StackItemType.ByteString or StackItemType.Buffer => Math.Max(1, item.GetSpan().Length), + _ => AverageElementOverhead + }; + } + catch + { + return AverageElementOverhead; + } + } + + private static long ClampDynamicCpu(long cpu) + { + if (cpu <= 0) + return 0; + return Math.Min(cpu, MaxDynamicCpuUnits); + } + + private static long MultiplyWithCap(long left, long right) + { + if (left <= 0 || right <= 0) + return 0; + if (left > MaxDynamicCpuUnits) + return MaxDynamicCpuUnits; + if (right > MaxDynamicCpuUnits) + return MaxDynamicCpuUnits; + if (left > long.MaxValue / right) + return MaxDynamicCpuUnits; + return Math.Min(left * right, MaxDynamicCpuUnits); + } + + private static long AddWithCap(long left, long right) + { + long result = left + right; + return result < left ? MaxDynamicCpuUnits : Math.Min(result, MaxDynamicCpuUnits); + } + + private enum NumericOperationKind + { + Linear, + Multiplicative, + Exponential + } + private static Block CreateDummyBlock(IReadOnlyStore snapshot, ProtocolSettings settings) { UInt256 hash = NativeContract.Ledger.CurrentHash(snapshot); @@ -700,7 +1211,7 @@ private static Block CreateDummyBlock(IReadOnlyStore snapshot, ProtocolSettings }; } - protected static InteropDescriptor Register(string name, string handler, long fixedPrice, CallFlags requiredCallFlags, Hardfork? hardfork = null) + protected static InteropDescriptor Register(string name, string handler, long fixedPrice, CallFlags requiredCallFlags, Hardfork? hardfork = null, Func dynamicCost = null) { var flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; var method = typeof(ApplicationEngine).GetMethod(handler, flags) @@ -711,7 +1222,8 @@ protected static InteropDescriptor Register(string name, string handler, long fi Handler = method, Hardfork = hardfork, FixedPrice = fixedPrice, - RequiredCallFlags = requiredCallFlags + RequiredCallFlags = requiredCallFlags, + DynamicCostCalculator = dynamicCost }; services ??= []; services.Add(descriptor.Hash, descriptor); diff --git a/src/Neo/SmartContract/InteropDescriptor.cs b/src/Neo/SmartContract/InteropDescriptor.cs index 172dcfda12..47ddc315d7 100644 --- a/src/Neo/SmartContract/InteropDescriptor.cs +++ b/src/Neo/SmartContract/InteropDescriptor.cs @@ -10,6 +10,8 @@ // modifications are permitted. using Neo.Cryptography; +using Neo.VM.Types; +using System; using System.Buffers.Binary; using System.Collections.Generic; using System.Linq; @@ -58,6 +60,12 @@ public uint Hash /// public long FixedPrice { get; init; } + /// + /// An optional delegate used to compute dynamic resource cost for the interoperable service. + /// Receives the engine and the raw stack arguments popped for the call (top-first order). + /// + public Func DynamicCostCalculator { get; init; } + /// /// Required Hardfork to be active. /// diff --git a/src/Neo/SmartContract/Iterators/StorageIterator.cs b/src/Neo/SmartContract/Iterators/StorageIterator.cs index e2e69e7337..92efa915e5 100644 --- a/src/Neo/SmartContract/Iterators/StorageIterator.cs +++ b/src/Neo/SmartContract/Iterators/StorageIterator.cs @@ -22,12 +22,14 @@ internal class StorageIterator : IIterator private readonly IEnumerator<(StorageKey Key, StorageItem Value)> enumerator; private readonly int prefixLength; private readonly FindOptions options; + private readonly ApplicationEngine engine; - public StorageIterator(IEnumerator<(StorageKey, StorageItem)> enumerator, int prefixLength, FindOptions options) + public StorageIterator(IEnumerator<(StorageKey, StorageItem)> enumerator, int prefixLength, FindOptions options, ApplicationEngine engine = null) { this.enumerator = enumerator; this.prefixLength = prefixLength; this.options = options; + this.engine = engine; } public void Dispose() @@ -37,7 +39,16 @@ public void Dispose() public bool Next() { - return enumerator.MoveNext(); + bool moved = enumerator.MoveNext(); + if (moved && engine is not null) + { + ReadOnlyMemory key = enumerator.Current.Key.Key; + ReadOnlyMemory value = enumerator.Current.Value.Value; + int cpu = key.Length + value.Length; + if (cpu > 0) + engine.ChargeCpu(cpu); + } + return moved; } public StackItem Value(IReferenceCounter referenceCounter) diff --git a/src/Neo/SmartContract/Native/ContractManagement.cs b/src/Neo/SmartContract/Native/ContractManagement.cs index 7ea9c8cdb0..1eab9f95ca 100644 --- a/src/Neo/SmartContract/Native/ContractManagement.cs +++ b/src/Neo/SmartContract/Native/ContractManagement.cs @@ -240,10 +240,13 @@ private async ContractTask Deploy(ApplicationEngine engine, byte[ if (manifest.Length == 0) throw new ArgumentException($"Manifest length cannot be zero."); - engine.AddFee(Math.Max( - engine.StoragePrice * (nefFile.Length + manifest.Length), - GetMinimumDeploymentFee(engine.SnapshotCache) - )); + long storageBytes = nefFile.Length + manifest.Length; + long storageCost = storageBytes * engine.StoragePrice; + long minimumFee = GetMinimumDeploymentFee(engine.SnapshotCache); + if (storageBytes > 0) + engine.ChargeStorage(storageBytes); + if (minimumFee > storageCost) + engine.AddFee(minimumFee - storageCost); NefFile nef = nefFile.AsSerializable(); ContractManifest parsedManifest = ContractManifest.Parse(manifest); @@ -294,7 +297,9 @@ private ContractTask Update(ApplicationEngine engine, byte[] nefFile, byte[] man if (nefFile is null && manifest is null) throw new ArgumentException("NEF file and manifest cannot both be null."); - engine.AddFee(engine.StoragePrice * ((nefFile?.Length ?? 0) + (manifest?.Length ?? 0))); + long updateBytes = (nefFile?.Length ?? 0) + (manifest?.Length ?? 0); + if (updateBytes > 0) + engine.ChargeStorage(updateBytes); var contractState = engine.SnapshotCache.GetAndChange(CreateStorageKey(Prefix_Contract, engine.CallingScriptHash)) ?? throw new InvalidOperationException($"Updating Contract Does Not Exist: {engine.CallingScriptHash}"); diff --git a/src/Neo/SmartContract/Native/NativeContract.cs b/src/Neo/SmartContract/Native/NativeContract.cs index 4a22c91b53..418410b87f 100644 --- a/src/Neo/SmartContract/Native/NativeContract.cs +++ b/src/Neo/SmartContract/Native/NativeContract.cs @@ -428,7 +428,7 @@ internal async void Invoke(ApplicationEngine engine, byte version) if (!state.CallFlags.HasFlag(method.RequiredCallFlags)) throw new InvalidOperationException($"Cannot call this method with the flag {state.CallFlags}."); // In the unit of datoshi, 1 datoshi = 1e-8 GAS - engine.AddFee(method.CpuFee * engine.ExecFeeFactor + method.StorageFee * engine.StoragePrice); + engine.AddResourceCost(new ResourceCost(method.CpuFee, 0, method.StorageFee)); List parameters = new(); if (method.NeedApplicationEngine) parameters.Add(engine); if (method.NeedSnapshot) parameters.Add(engine.SnapshotCache); diff --git a/src/Neo/SmartContract/Native/PolicyContract.cs b/src/Neo/SmartContract/Native/PolicyContract.cs index 4cc1cf2105..7553399bb0 100644 --- a/src/Neo/SmartContract/Native/PolicyContract.cs +++ b/src/Neo/SmartContract/Native/PolicyContract.cs @@ -34,6 +34,11 @@ public sealed class PolicyContract : NativeContract /// public const uint DefaultStoragePrice = 100000; + /// + /// The default memory fee factor used for charging transient memory allocations. + /// + public const uint DefaultMemoryFeeFactor = 30; + /// /// The default network fee per byte of transactions. /// In the unit of datoshi, 1 datoshi = 1e-8 GAS @@ -65,6 +70,11 @@ public sealed class PolicyContract : NativeContract /// public const uint MaxStoragePrice = 10000000; + /// + /// The maximum memory fee factor that the committee can set. + /// + public const uint MaxMemoryFeeFactor = 1000; + /// /// The maximum block generation time that the committee can set in milliseconds. /// @@ -86,6 +96,7 @@ public sealed class PolicyContract : NativeContract private const byte Prefix_FeePerByte = 10; private const byte Prefix_ExecFeeFactor = 18; private const byte Prefix_StoragePrice = 19; + private const byte Prefix_MemoryFeeFactor = 24; private const byte Prefix_AttributeFee = 20; private const byte Prefix_MillisecondsPerBlock = 21; private const byte Prefix_MaxValidUntilBlockIncrement = 22; @@ -94,6 +105,7 @@ public sealed class PolicyContract : NativeContract private readonly StorageKey _feePerByte; private readonly StorageKey _execFeeFactor; private readonly StorageKey _storagePrice; + private readonly StorageKey _memoryFeeFactor; private readonly StorageKey _millisecondsPerBlock; private readonly StorageKey _maxValidUntilBlockIncrement; private readonly StorageKey _maxTraceableBlocks; @@ -112,6 +124,7 @@ internal PolicyContract() : base() _feePerByte = CreateStorageKey(Prefix_FeePerByte); _execFeeFactor = CreateStorageKey(Prefix_ExecFeeFactor); _storagePrice = CreateStorageKey(Prefix_StoragePrice); + _memoryFeeFactor = CreateStorageKey(Prefix_MemoryFeeFactor); _millisecondsPerBlock = CreateStorageKey(Prefix_MillisecondsPerBlock); _maxValidUntilBlockIncrement = CreateStorageKey(Prefix_MaxValidUntilBlockIncrement); _maxTraceableBlocks = CreateStorageKey(Prefix_MaxTraceableBlocks); @@ -124,6 +137,7 @@ internal override ContractTask InitializeAsync(ApplicationEngine engine, Hardfor engine.SnapshotCache.Add(_feePerByte, new StorageItem(DefaultFeePerByte)); engine.SnapshotCache.Add(_execFeeFactor, new StorageItem(DefaultExecFeeFactor)); engine.SnapshotCache.Add(_storagePrice, new StorageItem(DefaultStoragePrice)); + engine.SnapshotCache.Add(_memoryFeeFactor, new StorageItem(DefaultMemoryFeeFactor)); } if (hardfork == Hardfork.HF_Echidna) { @@ -168,6 +182,19 @@ public uint GetStoragePrice(IReadOnlyStore snapshot) return (uint)(BigInteger)snapshot[_storagePrice]; } + /// + /// Gets the memory fee factor. + /// + /// The snapshot used to read data. + /// The memory fee factor. + [ContractMethod(CpuFee = 1 << 15, RequiredCallFlags = CallFlags.ReadStates)] + public uint GetMemoryFeeFactor(IReadOnlyStore snapshot) + { + return snapshot.TryGet(_memoryFeeFactor, out var item) + ? (uint)(BigInteger)item + : DefaultMemoryFeeFactor; + } + /// /// Gets the block generation time in milliseconds. /// @@ -357,6 +384,15 @@ private void SetStoragePrice(ApplicationEngine engine, uint value) engine.SnapshotCache.GetAndChange(_storagePrice).Set(value); } + [ContractMethod(CpuFee = 1 << 15, RequiredCallFlags = CallFlags.States)] + private void SetMemoryFeeFactor(ApplicationEngine engine, uint value) + { + if (value == 0 || value > MaxMemoryFeeFactor) + throw new ArgumentOutOfRangeException(nameof(value), $"MemoryFeeFactor must be between [1, {MaxMemoryFeeFactor}], got {value}"); + if (!CheckCommittee(engine)) throw new InvalidOperationException(); + engine.SnapshotCache.GetAndChange(_memoryFeeFactor, () => new StorageItem(DefaultMemoryFeeFactor)).Set(value); + } + [ContractMethod(Hardfork.HF_Echidna, CpuFee = 1 << 15, RequiredCallFlags = CallFlags.States)] private void SetMaxValidUntilBlockIncrement(ApplicationEngine engine, uint value) { diff --git a/src/RpcClient/PolicyAPI.cs b/src/RpcClient/PolicyAPI.cs index e175feff8a..3a4e673327 100644 --- a/src/RpcClient/PolicyAPI.cs +++ b/src/RpcClient/PolicyAPI.cs @@ -48,6 +48,16 @@ public async Task GetStoragePriceAsync() return (uint)result.Stack.Single().GetInteger(); } + /// + /// Get Memory Fee Factor + /// + /// + public async Task GetMemoryFeeFactorAsync() + { + var result = await TestInvokeAsync(scriptHash, "getMemoryFeeFactor").ConfigureAwait(false); + return (uint)result.Stack.Single().GetInteger(); + } + /// /// Get Network Fee Per Byte /// diff --git a/tests/Neo.RpcClient.Tests/UT_PolicyAPI.cs b/tests/Neo.RpcClient.Tests/UT_PolicyAPI.cs index bf5d6de6ec..0d76e2f76e 100644 --- a/tests/Neo.RpcClient.Tests/UT_PolicyAPI.cs +++ b/tests/Neo.RpcClient.Tests/UT_PolicyAPI.cs @@ -57,6 +57,16 @@ public async Task TestGetStoragePrice() Assert.AreEqual(100000u, result); } + [TestMethod] + public async Task TestGetMemoryFeeFactor() + { + byte[] testScript = NativeContract.Policy.Hash.MakeScript("getMemoryFeeFactor"); + UT_TransactionManager.MockInvokeScript(rpcClientMock, testScript, new ContractParameter { Type = ContractParameterType.Integer, Value = new BigInteger(30) }); + + var result = await policyAPI.GetMemoryFeeFactorAsync(); + Assert.AreEqual(30u, result); + } + [TestMethod] public async Task TestGetFeePerByte() {