Skip to content

SmartContract: reprice static System.Runtime.* interops since Huyao - #4760

Merged
shargon merged 26 commits into
neo-project:master-n3from
Turalchik:change-runtime-interop-prices
Oct 15, 2026
Merged

shargon merged 26 commits into
neo-project:master-n3from
Turalchik:change-runtime-interop-prices

Conversation

@Turalchik

Copy link
Copy Markdown
Contributor

Description

Port the new fixed prices of the static System.Runtime.* interops, enabled since Huyao only. The price is declared via the InteropPrice attribute on the interop handler and is picked by OnSysCall instead of FixedPrice once the hardfork is enabled. New prices make interop pricing much more consistent, see nspcc-dev/neo-go#4281 (comment).

Change Log

  • Add File src/Neo/SmartContract/InteropPriceAttribute.cs
  • Add Class InteropPriceAttribute
  • Add Property InteropDescriptor.Prices
  • Update ApplicationEngine.Register to collect InteropPrice attributes from the handler
  • Update ApplicationEngine.OnSysCall to charge the price of the latest enabled hardfork
  • Add InteropPrice attributes for HF_Huyao to System.Runtime.* handlers: Platform, GetNetwork, GetAddressVersion, GetTrigger, GetTime, GetScriptContainer, GetExecutingScriptHash, GetCallingScriptHash, GetEntryScriptHash, GetInvocationCounter, GetRandom, Log, GasLeft, BurnGas
  • Disable the additional in-handler GetRandom fee since Huyao
  • Pin GasLeft and GetRandom fee unit tests to pre-Huyao behaviour

Related to nspcc-dev/neo-go#4281

Type of change

  • New feature (non-breaking change which adds functionality)

How Has This Been Tested?

  • Unit Testing

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

Signed-off-by: Tural Devrishev <tural@nspcc.ru>
@github-actions github-actions Bot added the N3 label Oct 2, 2026
@codecov

codecov Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.42857% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.93%. Comparing base (f7a212d) to head (94a5205).

Files with missing lines Patch % Lines
src/Neo/SmartContract/ApplicationEngine.cs 87.50% 1 Missing and 2 partials ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##           master-n3    #4760      +/-   ##
=============================================
- Coverage      83.96%   83.93%   -0.03%     
=============================================
  Files            240      241       +1     
  Lines          17128    17157      +29     
  Branches        2453     2458       +5     
=============================================
+ Hits           14381    14401      +20     
- Misses          1960     1968       +8     
- Partials         787      788       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread src/Neo/SmartContract/ApplicationEngine.cs Outdated
Comment thread src/Neo/SmartContract/ApplicationEngine.cs Outdated
Signed-off-by: Tural Devrishev <tural@nspcc.ru>
@Turalchik
Turalchik requested review from ajara87 and shargon October 5, 2026 10:53
shargon
shargon previously approved these changes Oct 5, 2026
@shargon

shargon commented Oct 5, 2026

Copy link
Copy Markdown
Member

This will be compatible with dynamic opcode price on syscalls?

@AnnaShaleva

Copy link
Copy Markdown
Member

This will be compatible with dynamic opcode price on syscalls?

Yes, it's a complement to dynamic syscall prices and should be enabled at the same Huyao hardfork. All prices are aligned and balanced.

ajara87
ajara87 previously approved these changes Oct 5, 2026
Comment thread tests/Neo.UnitTests/SmartContract/UT_Syscalls.cs Outdated
Comment thread src/Neo/SmartContract/InteropPriceAttribute.cs Outdated
Comment thread src/Neo/SmartContract/InteropDescriptor.cs Outdated
Comment thread src/Neo/SmartContract/ApplicationEngine.cs Outdated

@AnnaShaleva AnnaShaleva left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Turalchik write a unit-test for a single interop (pick any of them) that compares the price before Huyao with the price after Huyao, ensure the second price is lower. This UT is needed to ensure the basic mechanism of new prices works correctly.

@AnnaShaleva AnnaShaleva changed the title SmartContract: reprice static System.Runtime interops since Huyao SmartContract: reprice static System.Runtime.* interops since Huyao Oct 5, 2026
Tural Devrishev added 3 commits October 5, 2026 19:27
Signed-off-by: Tural Devrishev <tural@nspcc.ru>
Signed-off-by: Tural Devrishev <tural@nspcc.ru>
Signed-off-by: Tural Devrishev <tural@nspcc.ru>
for (int i = 0; i < descriptor.Prices.Count; i++)
{
var price = descriptor.Prices[i];
if (IsHardforkEnabled(price.Since))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] GetSysCallPrice treats Huyao as active by calling ApplicationEngine.IsHardforkEnabled. When PersistingBlock is null that helper returns ProtocolSettings.Hardforks.ContainsKey and ignores the configured height. VerifyWitness builds the verification engine with a null block (ApplicationEngine.Create(TriggerType.Verification, ..., null, settings, datoshi)), while opcode pricing in the constructor uses settings.IsHardforkEnabled(HF_Huyao, persistingIndex) and falls back to Ledger.CurrentIndex. As soon as HF_Huyao is listed at a future height, verification therefore charges the new coefficients (Log 4896, GetRandom 3148, and the rest) while block execution at the same height still charges FixedPrice. neo-go's IsHardforkEnabled compares persistingBlockHeight() (chain height + 1 when no block is set), so C# and neo-go disagree on verification gas — the network fee — for the whole pre-activation window. The same predicate at ApplicationEngine.Runtime.cs:334 also skips GetRandom's legacy 1<<4 / 1<<13 fee in that window, so a verify script pays only the new 3148 coefficient instead of the Aspidochelone in-handler fee. GetRandom is CallFlags.None, so it is legal in a verification script.

Suggestion: Select the price with the same height the constructor already uses for opcode pricing (PersistingBlock?.Index ?? Ledger.CurrentIndex(snapshot) passed to ProtocolSettings.IsHardforkEnabled), and gate GetRandom's legacy AddFee on that same check. Add a verification-trigger test whose settings map HF_Huyao to a future height and whose ledger index is still below it; it should observe the old FixedPrice (and the in-handler GetRandom fee), not the Huyao coefficient.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is a good catch, and it's actually a bug in master-n3.

{
Hardforks = TestProtocolSettings.Default.Hardforks
.SetItem(Hardfork.HF_Gorgon, 1)
.Remove(Hardfork.HF_Huyao)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The only fee tests touched by this PR pin the pre-Huyao schedule. System_Runtime_GasLeft strips HF_Huyao (and HF_Iara) so the expected stack values stay 99_999_490, 99_998_980, 99_998_410, and 1999999520, which are the old 1<<4 GasLeft price (480 datoshi at the default exec fee factor) with the fee already deducted before the getter runs. GetRandom_IncreasesFeeConsumed likewise removes Huyao and calls GetRandom() directly, so it never goes through OnSysCall and cannot observe coefficient 3148. Nothing asserts the Huyao femtoGAS charge (coefficient * _execFeeFactor, no OpcodePriceMultiplier) or the resulting GasLeft integer once the fork is actually enabled.

Suggestion: Add a case that leaves HF_Huyao enabled, runs System.Runtime.GasLeft and System.Runtime.GetRandom through SYSCALL, and locks FeeConsumed / the returned gas to 1725 and 3148 times the pico exec-fee factor. That also freezes whether GasLeft still sees its own fee already deducted (this PR charges in OnSysCall before the handler; neo-go PR 4413 currently charges Huyao interop fees after the handler returns).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Huyao charge via SYSCALL is already covered by System_Runtime_Log_PriceSinceHuyao. NeoGo will be aligned once the C# PRs on interop pricing are merged, and this one isn't the last of them.

Signed-off-by: Tural Devrishev <tural@nspcc.ru>
@Turalchik
Turalchik dismissed stale reviews from ajara87 and shargon via f3c5634 October 6, 2026 08:39
Signed-off-by: Tural Devrishev <tural@nspcc.ru>
Signed-off-by: Tural Devrishev <tural@nspcc.ru>
@Turalchik

Copy link
Copy Markdown
Contributor Author

@AnnaShaleva

Copy link
Copy Markdown
Member

@neo-project/core this PR is rebased onto fresh master and ready for review&merge. Please, check it out.

/// <returns>The price in the unit of femtoGAS.</returns>
private BigInteger GetSysCallPrice(InteropDescriptor descriptor)
{
for (int i = 0; i < descriptor.Prices.Count; i++)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not foreach (var (hardfork, price) in descriptor.Prices) ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

superboyiii
superboyiii previously approved these changes Oct 13, 2026

@superboyiii superboyiii left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's good and compatible before Huyao. But seems unit tests are not enough. I've added more boundary tests which were all passed. Maybe it's good to add.

// Copyright (C) 2015-2026 The Neo Project.
//
// UT_RuntimeInteropPriceBoundaries.cs file belongs to the neo project and is free
// software distributed under the MIT software license, see the accompanying file LICENSE in 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 Microsoft.VisualStudio.TestTools.UnitTesting;
using Neo.Network.P2P.Payloads;
using Neo.Persistence;
using Neo.SmartContract;
using Neo.SmartContract.Native;
using Neo.VM;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Numerics;
using System.Reflection;
using System.Runtime.ExceptionServices;

#nullable enable

namespace Neo.UnitTests.SmartContract
{
    [TestClass]
    public class UT_RuntimeInteropPriceBoundaries
    {
        /// <summary>Default exec fee factor, in datoshi. 1 datoshi = 1e-8 GAS.</summary>
        private const int ExecFeeFactor = 30;

        private const uint HuyaoHeight = 10;

        private DataCache _snapshot = null!;

        [TestInitialize]
        public void TestSetup()
        {
            _snapshot = TestBlockchain.GetTestSnapshotCache();
        }

        [TestMethod]
        public void EveryInteropPriceListHasUniqueForksAndAFallback()
        {
            Assert.IsGreaterThan(0, ApplicationEngine.Services.Count);
            foreach (var descriptor in ApplicationEngine.Services.Values)
            {
                Assert.IsGreaterThan(0, descriptor.Prices.Count, descriptor.Name);
                Assert.IsNull(descriptor.Prices[^1].Key, descriptor.Name);
                Assert.IsGreaterThanOrEqualTo(0, descriptor.Prices[^1].Value, descriptor.Name);

                var seen = new HashSet<Hardfork>();
                Hardfork? newer = null;
                for (int i = 0; i < descriptor.Prices.Count - 1; i++)
                {
                    var fork = descriptor.Prices[i].Key;
                    Assert.IsNotNull(fork, descriptor.Name);
                    Assert.IsTrue(seen.Add(fork!.Value), $"{descriptor.Name} prices {fork} twice.");
                    if (newer is not null)
                        Assert.IsTrue(newer.Value > fork.Value, $"{descriptor.Name} prices are not ordered from the latest hardfork.");
                    newer = fork;
                    Assert.IsGreaterThan(0, descriptor.Prices[i].Value, descriptor.Name);
                }
            }

            AssertPrice(ApplicationEngine.System_Runtime_GasLeft, 1 << 4, 8900);
            AssertPrice(ApplicationEngine.System_Runtime_GetRandom, 0, 15833);
            AssertPrice(ApplicationEngine.System_Runtime_GetScriptContainer, 1 << 3, 36533);
            AssertPrice(ApplicationEngine.System_Runtime_BurnGas, 1 << 4, 3067);
            // Not repriced: still a single pre-Huyao entry, so native-adjacent syscalls keep their old fee.
            Assert.HasCount(1, ApplicationEngine.System_Runtime_CheckWitness.Prices);
            Assert.HasCount(1, ApplicationEngine.System_Runtime_CurrentSigners.Prices);
            Assert.HasCount(1, ApplicationEngine.System_Contract_Call.Prices);
            Assert.HasCount(1, ApplicationEngine.System_Contract_CallNative.Prices);
        }

        [TestMethod]
        public void PriceSwitchesOnTheActivationBlockInclusive()
        {
            long before = Run(Syscall(ApplicationEngine.System_Runtime_GasLeft), Settings(HuyaoHeight), 1_000_000, BlockAt(HuyaoHeight - 1)).Fee;
            long onFork = Run(Syscall(ApplicationEngine.System_Runtime_GasLeft), Settings(HuyaoHeight), 1_000_000, BlockAt(HuyaoHeight)).Fee;
            long after = Run(Syscall(ApplicationEngine.System_Runtime_GasLeft), Settings(HuyaoHeight), 1_000_000, BlockAt(HuyaoHeight + 1)).Fee;

            Assert.AreEqual(LegacyDatoshi(1 << 4), before);
            Assert.AreEqual(HuyaoDatoshi(8900), onFork);
            Assert.AreEqual(onFork, after);
        }

        [TestMethod]
        public void VerificationUsesLedgerHeightPlusOne()
        {
            uint ledger = NativeContract.Ledger.CurrentIndex(_snapshot);
            uint persisting = ledger + 1;

            long before = Run(
                Syscall(ApplicationEngine.System_Runtime_GasLeft),
                Settings(persisting + 1),
                1_000_000,
                block: null,
                trigger: TriggerType.Verification).Fee;
            long activated = Run(
                Syscall(ApplicationEngine.System_Runtime_GasLeft),
                Settings(persisting),
                1_000_000,
                block: null,
                trigger: TriggerType.Verification).Fee;

            Assert.AreEqual(LegacyDatoshi(1 << 4), before);
            Assert.AreEqual(HuyaoDatoshi(8900), activated);
        }

        [TestMethod]
        public void GasLimitEqualToFeeHaltsAndOneDatoshiLessFaults()
        {
            AssertGasBoundary(ApplicationEngine.System_Runtime_GasLeft, LegacyDatoshi(1 << 4), huyao: false);
            AssertGasBoundary(ApplicationEngine.System_Runtime_GasLeft, HuyaoDatoshi(8900), huyao: true);

            // GetScriptContainer is one of the few runtime interops whose price goes up.
            // A limit that paid the old fee faults once Huyao is active, and nowhere earlier.
            Assert.AreEqual(VMState.HALT, Run(ScriptContainerScript(), Settings(HuyaoHeight), LegacyDatoshi(1 << 3), BlockAt(HuyaoHeight - 1), Container()).State);
            var tight = Run(ScriptContainerScript(), Settings(HuyaoHeight), LegacyDatoshi(1 << 3), BlockAt(HuyaoHeight), Container());
            Assert.AreEqual(VMState.FAULT, tight.State);
            Assert.Contains("Insufficient GAS.", tight.Fault);

            Assert.AreEqual(VMState.HALT, Run(ScriptContainerScript(), Settings(HuyaoHeight), HuyaoDatoshi(36533), BlockAt(HuyaoHeight), Container()).State);
        }

        [TestMethod]
        public void LowerPriceDoesNotFaultAScriptThatPaidTheOldFee()
        {
            long oldFee = LegacyDatoshi(1 << 4);
            var before = Run(Syscall(ApplicationEngine.System_Runtime_GasLeft), Settings(HuyaoHeight), oldFee, BlockAt(HuyaoHeight - 1));
            var after = Run(Syscall(ApplicationEngine.System_Runtime_GasLeft), Settings(HuyaoHeight), oldFee, BlockAt(HuyaoHeight));

            Assert.AreEqual(VMState.HALT, before.State, before.Fault);
            Assert.AreEqual(oldFee, before.Fee);
            Assert.AreEqual(VMState.HALT, after.State, after.Fault);
            Assert.AreEqual(HuyaoDatoshi(8900), after.Fee);
            Assert.IsGreaterThan(after.Fee, oldFee);
        }

        [TestMethod]
        public void GasLeftObservesItsOwnFeeAndAccountsExactly()
        {
            const long gas = 1_000_000;
            var before = Run(Syscall(ApplicationEngine.System_Runtime_GasLeft), Settings(HuyaoHeight), gas, BlockAt(HuyaoHeight - 1));
            var after = Run(Syscall(ApplicationEngine.System_Runtime_GasLeft), Settings(HuyaoHeight), gas, BlockAt(HuyaoHeight));

            Assert.AreEqual(VMState.HALT, before.State, before.Fault);
            Assert.AreEqual(gas - LegacyDatoshi(1 << 4), before.Top);
            Assert.AreEqual(gas, before.Top + before.Fee);

            Assert.AreEqual(VMState.HALT, after.State, after.Fault);
            Assert.AreEqual(gas - HuyaoDatoshi(8900), after.Top);
            Assert.AreEqual(gas, after.Top + after.Fee);
        }

        [TestMethod]
        public void ZeroGasFaultsInsteadOfThrowing()
        {
            var before = Run(Syscall(ApplicationEngine.System_Runtime_GasLeft), Settings(HuyaoHeight), gas: 0, BlockAt(HuyaoHeight - 1));
            var after = Run(Syscall(ApplicationEngine.System_Runtime_GasLeft), Settings(HuyaoHeight), gas: 0, BlockAt(HuyaoHeight));

            Assert.AreEqual(VMState.FAULT, before.State);
            Assert.AreEqual(VMState.FAULT, after.State);
            Assert.Contains("Insufficient GAS.", before.Fault);
            Assert.Contains("Insufficient GAS.", after.Fault);
            Assert.AreEqual(0, before.GasLeft);
            Assert.AreEqual(0, after.GasLeft);
        }

        [TestMethod]
        public void GetRandomChargesOnlyOneSchedule()
        {
            var before = Run(Syscall(ApplicationEngine.System_Runtime_GetRandom), Settings(HuyaoHeight), 10_000_000, BlockAt(HuyaoHeight - 1));
            var after = Run(Syscall(ApplicationEngine.System_Runtime_GetRandom), Settings(HuyaoHeight), 10_000_000, BlockAt(HuyaoHeight));

            Assert.AreEqual(VMState.HALT, before.State, before.Fault);
            Assert.AreEqual(VMState.HALT, after.State, after.Fault);
            // Pre-Huyao the descriptor price is 0 and the handler adds 1 << 13. Post-Huyao only the coefficient is charged.
            Assert.AreEqual(LegacyDatoshi(1 << 13), before.Fee);
            Assert.AreEqual(HuyaoDatoshi(15833), after.Fee);
            Assert.IsGreaterThan(after.Fee, before.Fee);
        }

        [TestMethod]
        public void HandlerFailureStillChargesTheSyscallOnce()
        {
            var before = Run(Syscall(ApplicationEngine.System_Runtime_GetTime), Settings(HuyaoHeight), 1_000_000, block: null, trigger: TriggerType.Verification);
            var after = Run(Syscall(ApplicationEngine.System_Runtime_GetTime), Settings(0), 1_000_000, block: null, trigger: TriggerType.Verification);

            Assert.AreEqual(VMState.FAULT, before.State);
            Assert.AreEqual(VMState.FAULT, after.State);
            Assert.Contains("GetTime can only be called with Application trigger.", before.Fault);
            Assert.Contains("GetTime can only be called with Application trigger.", after.Fault);
            Assert.AreEqual(LegacyDatoshi(1 << 3), before.Fee);
            Assert.AreEqual(HuyaoDatoshi(9033), after.Fee);

            var missingContainer = Run(ScriptContainerScript(), Settings(HuyaoHeight), 1_000_000, BlockAt(HuyaoHeight), container: null);
            Assert.AreEqual(VMState.FAULT, missingContainer.State);
            Assert.AreEqual(HuyaoDatoshi(36533), missingContainer.Fee);

            var burned = Run(BurnScript(0), Settings(HuyaoHeight), 1_000_000, BlockAt(HuyaoHeight));
            var burnedNegative = Run(BurnScript(-1), Settings(HuyaoHeight), 1_000_000, BlockAt(HuyaoHeight));
            Assert.AreEqual(VMState.FAULT, burned.State);
            Assert.AreEqual(VMState.FAULT, burnedNegative.State);
            Assert.Contains("GAS must be positive.", burned.Fault);
            Assert.Contains("GAS must be positive.", burnedNegative.Fault);
            Assert.AreEqual(burned.Fee, burnedNegative.Fee);
            Assert.DoesNotContain("AddFemtoGas can't be negative.", burnedNegative.Fault);
        }

        [TestMethod]
        public void BurnGasAddsTheRequestedAmountOnTopOfTheBaseFee()
        {
            var rejected = Run(BurnScript(0), Settings(HuyaoHeight), 1_000_000, BlockAt(HuyaoHeight));
            var accepted = Run(BurnScript(1), Settings(HuyaoHeight), 1_000_000, BlockAt(HuyaoHeight));

            Assert.AreEqual(VMState.FAULT, rejected.State);
            Assert.AreEqual(VMState.HALT, accepted.State, accepted.Fault);
            Assert.AreEqual(rejected.Fee + 1, accepted.Fee);
        }

        [TestMethod]
        public void WhitelistedContextSkipsTheHuyaoPrice()
        {
            const long gas = 1_000_000;
            var charged = Run(Syscall(ApplicationEngine.System_Runtime_GasLeft), Settings(HuyaoHeight), gas, BlockAt(HuyaoHeight));
            var skipped = Run(Syscall(ApplicationEngine.System_Runtime_GasLeft), Settings(HuyaoHeight), gas, BlockAt(HuyaoHeight), whitelist: true);

            Assert.AreEqual(VMState.HALT, skipped.State, skipped.Fault);
            Assert.AreEqual(0, skipped.Fee);
            Assert.AreEqual(gas, skipped.Top);
            Assert.IsGreaterThan(0, charged.Fee);
        }

        [TestMethod]
        public void DirectNativeCallsDoNotChargeOrFault()
        {
            var settings = Settings(HuyaoHeight);
            var block = BlockAt(HuyaoHeight);
            using var engine = ApplicationEngine.Create(TriggerType.Application, null, _snapshot.CloneCache(), block, settings, 1_000_000);
            engine.LoadScript(new byte[] { (byte)OpCode.RET });

            long fee = engine.FeeConsumed;
            Assert.AreEqual(block.Timestamp, engine.GetTime());
            Assert.AreEqual(settings.Network, engine.GetNetwork());
            Assert.AreEqual(1, engine.GetInvocationCounter());
            Assert.AreEqual(1, engine.GetInvocationCounter());
            _ = engine.GetRandom();
            Assert.AreEqual(fee, engine.FeeConsumed);

            using var verification = ApplicationEngine.Create(TriggerType.Verification, null, _snapshot.CloneCache(), null, settings, 1_000_000);
            Assert.ThrowsExactly<InvalidOperationException>(() => verification.GetTime());
            Assert.AreEqual(0, verification.FeeConsumed);
        }

        [TestMethod]
        public void UnpricedRuntimeSyscallsKeepTheSameFeeAcrossHuyao()
        {
            long before = Run(Syscall(ApplicationEngine.System_Runtime_CurrentSigners), Settings(HuyaoHeight), 1_000_000, BlockAt(HuyaoHeight - 1)).Fee;
            long after = Run(Syscall(ApplicationEngine.System_Runtime_CurrentSigners), Settings(HuyaoHeight), 1_000_000, BlockAt(HuyaoHeight)).Fee;

            Assert.AreEqual(LegacyDatoshi(1 << 4), before);
            Assert.AreEqual(before, after);
        }

        [TestMethod]
        public void RepricedRuntimeSyscallsHaltWhenGasIsSufficient()
        {
            var calls = new[]
            {
                ApplicationEngine.System_Runtime_Platform,
                ApplicationEngine.System_Runtime_GetNetwork,
                ApplicationEngine.System_Runtime_GetAddressVersion,
                ApplicationEngine.System_Runtime_GetTrigger,
                ApplicationEngine.System_Runtime_GetExecutingScriptHash,
                ApplicationEngine.System_Runtime_GetCallingScriptHash,
                ApplicationEngine.System_Runtime_GetEntryScriptHash,
                ApplicationEngine.System_Runtime_GetInvocationCounter,
                ApplicationEngine.System_Runtime_GetRandom,
                ApplicationEngine.System_Runtime_GasLeft,
                ApplicationEngine.System_Runtime_CurrentSigners,
            };

            foreach (bool huyao in new[] { false, true })
            {
                var block = BlockAt(huyao ? HuyaoHeight : HuyaoHeight - 1);
                foreach (var descriptor in calls)
                {
                    var result = Run(Syscall(descriptor), Settings(HuyaoHeight), 10_000_000, block);
                    Assert.AreEqual(VMState.HALT, result.State, $"{descriptor.Name} huyao={huyao}: {result.Fault}");
                    Assert.IsGreaterThan(0, result.Fee, descriptor.Name);
                }

                var time = Run(Syscall(ApplicationEngine.System_Runtime_GetTime), Settings(HuyaoHeight), 10_000_000, block);
                Assert.AreEqual(VMState.HALT, time.State, time.Fault);
                Assert.AreEqual((long)block.Timestamp, time.Top);

                var container = Run(ScriptContainerScript(), Settings(HuyaoHeight), 10_000_000, block, Container());
                Assert.AreEqual(VMState.HALT, container.State, container.Fault);
            }
        }

        [TestMethod]
        public void RegisterRejectsUnknownHandlerBeforePublishing()
        {
            int services = ApplicationEngine.Services.Count;

            var missing = Assert.ThrowsExactly<ArgumentException>(() => InvokeRegister("NoSuchInteropHandler"));
            Assert.AreEqual("handler", missing.ParamName);
            Assert.Contains("NoSuchInteropHandler", missing.Message);
            Assert.Contains("is not found", missing.Message);

            // The lookup throws before the descriptor is published.
            Assert.AreEqual(services, ApplicationEngine.Services.Count);
        }

        [TestMethod]
        public void GetSysCallPriceThrowsWhenNoEntryMatches()
        {
            PriceProbe();
            var handler = GetType().GetMethod(nameof(PriceProbe), BindingFlags.NonPublic | BindingFlags.Static)!;
            var settings = TestProtocolSettings.Default with
            {
                Hardforks = TestProtocolSettings.Default.Hardforks.Remove(Hardfork.HF_Huyao)
            };
            using var engine = ApplicationEngine.Create(TriggerType.Application, null, _snapshot.CloneCache(), null, settings, 1_000_000);

            var empty = new InteropDescriptor
            {
                Name = "Coverage.EmptyPrices",
                Handler = handler,
                Prices = [],
            };
            var disabled = new InteropDescriptor
            {
                Name = "Coverage.DisabledFork",
                Handler = handler,
                Prices = [new KeyValuePair<Hardfork?, long>(Hardfork.HF_Huyao, 1)],
            };

            var emptyEx = Assert.ThrowsExactly<UnreachableException>(() => InvokeGetSysCallPrice(engine, empty));
            Assert.Contains("Coverage.EmptyPrices", emptyEx.Message);
            var disabledEx = Assert.ThrowsExactly<UnreachableException>(() => InvokeGetSysCallPrice(engine, disabled));
            Assert.Contains("Coverage.DisabledFork", disabledEx.Message);
        }

        private void AssertGasBoundary(InteropDescriptor descriptor, long fee, bool huyao)
        {
            var block = BlockAt(huyao ? HuyaoHeight : HuyaoHeight - 1);
            var settings = Settings(HuyaoHeight);
            var script = Syscall(descriptor);

            var exact = Run(script, settings, fee, block);
            var shortByOne = Run(script, settings, fee - 1, block);

            Assert.AreEqual(VMState.HALT, exact.State, exact.Fault);
            Assert.AreEqual(fee, exact.Fee);
            Assert.AreEqual(VMState.FAULT, shortByOne.State);
            Assert.Contains("Insufficient GAS.", shortByOne.Fault);
            Assert.AreEqual(0, shortByOne.GasLeft);
        }

        private RunResult Run(byte[] script, ProtocolSettings settings, long gas, Block? block, IVerifiable? container = null, TriggerType trigger = TriggerType.Application, bool whitelist = false)
        {
            using var engine = ApplicationEngine.Create(trigger, container, _snapshot.CloneCache(), block, settings, gas);
            if (whitelist)
                engine.LoadScript(script, configureState: p => p.WhiteListed = true);
            else if (trigger == TriggerType.Verification)
                engine.LoadScript(script, configureState: p => p.CallFlags = CallFlags.ReadOnly);
            else
                engine.LoadScript(script);

            var state = engine.Execute();
            BigInteger? top = null;
            if (state == VMState.HALT && engine.ResultStack.Count == 1 && engine.ResultStack.Peek() is VM.Types.Integer)
                top = engine.ResultStack.Peek().GetInteger();
            return new RunResult(state, engine.FeeConsumed, engine.FaultException?.ToString() ?? "", engine.GasLeft, top);
        }

        private static void AssertPrice(InteropDescriptor descriptor, long fixedPrice, long huyaoCoefficient)
        {
            Assert.HasCount(2, descriptor.Prices, descriptor.Name);
            Assert.AreEqual(Hardfork.HF_Huyao, descriptor.Prices[0].Key);
            Assert.AreEqual(huyaoCoefficient, descriptor.Prices[0].Value);
            Assert.IsNull(descriptor.Prices[1].Key);
            Assert.AreEqual(fixedPrice * ApplicationEngine.OpcodePriceMultiplier, descriptor.Prices[1].Value);
        }

        private static ProtocolSettings Settings(uint huyaoHeight) => TestProtocolSettings.Default with
        {
            Hardforks = TestProtocolSettings.Default.Hardforks.SetItem(Hardfork.HF_Huyao, huyaoHeight)
        };

        private static Block BlockAt(uint index) => new()
        {
            Header = new Header
            {
                Index = index,
                Timestamp = 1_700_000_000_000,
                PrevHash = UInt256.Zero,
                MerkleRoot = UInt256.Zero,
                NextConsensus = UInt160.Zero,
                Witness = Witness.Empty,
            },
            Transactions = [],
        };

        private static Transaction Container() => new()
        {
            Script = new byte[] { 0x01 },
            Attributes = [],
            Signers = [new() { Account = UInt160.Zero }],
            NetworkFee = 0,
            SystemFee = 0,
            Nonce = 1,
            ValidUntilBlock = 100,
            Version = 0,
            Witnesses = [new() { VerificationScript = new byte[] { 0x01 } }],
        };

        private static byte[] Syscall(InteropDescriptor descriptor)
        {
            using var script = new ScriptBuilder();
            script.EmitSysCall(descriptor);
            return script.ToArray();
        }

        private static byte[] ScriptContainerScript() => Syscall(ApplicationEngine.System_Runtime_GetScriptContainer);

        private static byte[] BurnScript(long datoshi)
        {
            using var script = new ScriptBuilder();
            script.EmitPush(datoshi);
            script.EmitSysCall(ApplicationEngine.System_Runtime_BurnGas);
            return script.ToArray();
        }

        private static void InvokeRegister(string handler)
        {
            var method = typeof(ApplicationEngine).GetMethod("Register", BindingFlags.NonPublic | BindingFlags.Static)!;
            try
            {
                method.Invoke(null, ["Coverage." + handler, handler, 1L, CallFlags.None, null]);
            }
            catch (TargetInvocationException ex) when (ex.InnerException is not null)
            {
                ExceptionDispatchInfo.Capture(ex.InnerException).Throw();
            }
        }

        private static void InvokeGetSysCallPrice(ApplicationEngine engine, InteropDescriptor descriptor)
        {
            var method = typeof(ApplicationEngine).GetMethod("GetSysCallPrice", BindingFlags.NonPublic | BindingFlags.Instance)!;
            try
            {
                method.Invoke(engine, [descriptor]);
            }
            catch (TargetInvocationException ex) when (ex.InnerException is not null)
            {
                ExceptionDispatchInfo.Capture(ex.InnerException).Throw();
            }
        }

        private static void PriceProbe()
        {
        }

        private static long LegacyDatoshi(long fixedPrice) => fixedPrice * ExecFeeFactor;

        private static long HuyaoDatoshi(long coefficient) => (coefficient * ExecFeeFactor + 999) / 1000;

        private readonly record struct RunResult(VMState State, long Fee, string Fault, long GasLeft, BigInteger? Top);
    }
}

@Turalchik
Turalchik requested a review from Wi1l-B0t October 13, 2026 10:29
Signed-off-by: Tural Devrishev <tural@nspcc.ru>
AnnaShaleva
AnnaShaleva previously approved these changes Oct 13, 2026
Comment thread src/Neo/SmartContract/InteropPriceAttribute.cs
Comment thread src/Neo/SmartContract/ApplicationEngine.cs
Tural Devrishev added 2 commits October 13, 2026 21:49
Signed-off-by: Tural Devrishev <tural@nspcc.ru>
Signed-off-by: Tural Devrishev <tural@nspcc.ru>
Comment thread src/Neo/SmartContract/InteropPriceAttribute.cs
@shargon
shargon merged commit 8e5739c into neo-project:master-n3 Oct 15, 2026
Turalchik pushed a commit to Turalchik/neo that referenced this pull request Oct 15, 2026
ApplicationEngine.IsHardforkEnabled should always respect persisting
block index. Returning `true` based on the fact that hardfork is just
_present_ in the list of configured hardforks irrespective from the
current block index is misleading and will affect methods that don't set
persisting block (like VerifyWitness):
https://github.com/neo-project/neo/blob/99618f483a7e8bf783ad50af8a049752abd5ce4e/src/Neo/SmartContract/Helper.cs#L333

This problem will also lead to improper dynamic price calculation
introduced by neo-project#4760 as described
in neo-project#4760 (comment).

Signed-off-by: Anna Shaleva <shaleva.ann@nspcc.ru>
Turalchik pushed a commit to Turalchik/neo that referenced this pull request Oct 15, 2026
ApplicationEngine.IsHardforkEnabled should always respect persisting
block index. Returning `true` based on the fact that hardfork is just
_present_ in the list of configured hardforks irrespective from the
current block index is misleading and will affect methods that don't set
persisting block (like VerifyWitness):
https://github.com/neo-project/neo/blob/99618f483a7e8bf783ad50af8a049752abd5ce4e/src/Neo/SmartContract/Helper.cs#L333

This problem will also lead to improper dynamic price calculation
introduced by neo-project#4760 as described
in neo-project#4760 (comment).

Signed-off-by: Anna Shaleva <shaleva.ann@nspcc.ru>
Co-authored-by: Alvaro <amjarag@gmail.com>
Co-authored-by: Shargon <shargon@gmail.com>
shargon added a commit that referenced this pull request Oct 19, 2026
* SmartContract: reprice static System.Runtime interops since Huyao

Signed-off-by: Tural Devrishev <tural@nspcc.ru>

* avoid interface enumeration of interop prices

Signed-off-by: Tural Devrishev <tural@nspcc.ru>

* restore initial place of GasLeft test settings

Signed-off-by: Tural Devrishev <tural@nspcc.ru>

* clarify unit of InteropPrice coefficient

Signed-off-by: Tural Devrishev <tural@nspcc.ru>

* use LINQ to check duplicating interop prices

Signed-off-by: Tural Devrishev <tural@nspcc.ru>

* add UT comparing interop price before and after Huyao

Signed-off-by: Tural Devrishev <tural@nspcc.ru>

* update System.Runtime interop prices

Signed-off-by: Tural Devrishev <tural@nspcc.ru>

* select interop prices by persisting index

Signed-off-by: Tural Devrishev <tural@nspcc.ru>

* ApplicationEngine: fix IsHardforkEnabled behaviour

ApplicationEngine.IsHardforkEnabled should always respect persisting
block index. Returning `true` based on the fact that hardfork is just
_present_ in the list of configured hardforks irrespective from the
current block index is misleading and will affect methods that don't set
persisting block (like VerifyWitness):
https://github.com/neo-project/neo/blob/99618f483a7e8bf783ad50af8a049752abd5ce4e/src/Neo/SmartContract/Helper.cs#L333

This problem will also lead to improper dynamic price calculation
introduced by #4760 as described
in #4760 (comment).

Signed-off-by: Anna Shaleva <shaleva.ann@nspcc.ru>

* merge FixedPrice into InteropDescriptor.Prices

Signed-off-by: Tural Devrishev <tural@nspcc.ru>

* use IsHardforkEnabled to select interop prices

Signed-off-by: Tural Devrishev <tural@nspcc.ru>

* add dynamic price for System.Runtime.Notify since Huyao

Signed-off-by: Tural Devrishev <tural@nspcc.ru>

* fix undeclared price variable in RuntimeNotify

Signed-off-by: Tural Devrishev <tural@nspcc.ru>

* comment on scaling of fixed interop prices

Signed-off-by: Tural Devrishev <tural@nspcc.ru>

* throw if interop price isn't found

Signed-off-by: Tural Devrishev <tural@nspcc.ru>

* remove InteropDescriptor.FixedPrice

Signed-off-by: Tural Devrishev <tural@nspcc.ru>

* mention ExecFeeFactor in GetRandom test comment

Signed-off-by: Tural Devrishev <tural@nspcc.ru>

* check GetRandom verification price after Huyao

Signed-off-by: Tural Devrishev <tural@nspcc.ru>

* avoid allocating array

Signed-off-by: Tural Devrishev <tural@nspcc.ru>

* Revert "ApplicationEngine: fix IsHardforkEnabled behaviour"

This reverts commit 0b61521.

* add boundary tests for runtime interop prices

Signed-off-by: Tural Devrishev <tural@nspcc.ru>

* check InteropPrice coefficient is non-negative

Signed-off-by: Tural Devrishev <tural@nspcc.ru>

* cache interop prices in GetSysCallPrice

Signed-off-by: Tural Devrishev <tural@nspcc.ru>

* update Neo.VM to 3.10.2-CI0039

Signed-off-by: Tural Devrishev <tural@nspcc.ru>

* allow zero interop price in boundary tests

Signed-off-by: Tural Devrishev <tural@nspcc.ru>

---------

Signed-off-by: Tural Devrishev <tural@nspcc.ru>
Signed-off-by: Anna Shaleva <shaleva.ann@nspcc.ru>
Co-authored-by: Tural Devrishev <tural@nspcc.ru>
Co-authored-by: Shargon <shargon@gmail.com>
Co-authored-by: Anna Shaleva <shaleva.ann@nspcc.ru>
Co-authored-by: Owen <38493437+superboyiii@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants