Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions docs/DynamicGas.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions src/Neo/SmartContract/ApplicationEngine.Contract.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand All @@ -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();
}

Expand Down
2 changes: 1 addition & 1 deletion src/Neo/SmartContract/ApplicationEngine.Crypto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;)
Expand Down
94 changes: 94 additions & 0 deletions src/Neo/SmartContract/ApplicationEngine.Fees.cs
Original file line number Diff line number Diff line change
@@ -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));
}
}
}

9 changes: 8 additions & 1 deletion src/Neo/SmartContract/ApplicationEngine.Iterator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,14 @@ internal protected static bool IteratorNext(IIterator iterator)
/// <returns>The element in the collection at the current position of the iterator.</returns>
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;
}
}
}
29 changes: 28 additions & 1 deletion src/Neo/SmartContract/ApplicationEngine.Runtime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExecutionContextState>();
ExecutionContext context = LoadScript(new Script(script, true), configureState: p =>
{
Expand All @@ -237,6 +243,8 @@ protected internal void RuntimeLoadScript(byte[] script, CallFlags callFlags, Ar
/// <returns><see langword="true"/> if the account has witnessed the current transaction; otherwise, <see langword="false"/>.</returns>
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),
Expand Down Expand Up @@ -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);
}

Expand All @@ -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();
Expand All @@ -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<ExecutionContextState>().Contract;
Expand All @@ -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);
}

Expand All @@ -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<ExecutionContextState>().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);
}

Expand Down
27 changes: 23 additions & 4 deletions src/Neo/SmartContract/ApplicationEngine.Storage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,24 @@ protected internal static StorageContext AsReadOnly(StorageContext context)
/// <returns>The value of the entry. Or <see langword="null"/> if the entry doesn't exist.</returns>
protected internal ReadOnlyMemory<byte>? 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;
}

/// <summary>
Expand Down Expand Up @@ -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);
}

/// <summary>
Expand Down Expand Up @@ -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;
}
Expand All @@ -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,
Expand Down
Loading
Loading