diff --git a/src/Neo/SmartContract/Native/NativeContract.cs b/src/Neo/SmartContract/Native/NativeContract.cs index 85a58033a9..33fe6a3425 100644 --- a/src/Neo/SmartContract/Native/NativeContract.cs +++ b/src/Neo/SmartContract/Native/NativeContract.cs @@ -104,6 +104,11 @@ public CacheEntry GetAllowedMethods(NativeContract native, ApplicationEngine eng /// public static OracleContract Oracle { get; } = new(); + /// + /// Gets the instance of the class. + /// + public static Notary Notary { get; } = new(); + #endregion /// diff --git a/src/Neo/SmartContract/Native/Notary.cs b/src/Neo/SmartContract/Native/Notary.cs new file mode 100644 index 0000000000..7c3c89dc2c --- /dev/null +++ b/src/Neo/SmartContract/Native/Notary.cs @@ -0,0 +1,343 @@ +// Copyright (C) 2015-2025 The Neo Project. +// +// Notary.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. + +#nullable enable +#pragma warning disable IDE0051 + +using Neo.Cryptography; +using Neo.Cryptography.ECC; +using Neo.Extensions; +using Neo.Network.P2P; +using Neo.Network.P2P.Payloads; +using Neo.Persistence; +using Neo.SmartContract.Manifest; +using Neo.VM; +using Neo.VM.Types; +using System; +using System.Linq; +using System.Numerics; +using Array = Neo.VM.Types.Array; + +namespace Neo.SmartContract.Native +{ + /// + /// The Notary native contract used for multisignature transactions forming assistance. + /// + public sealed class Notary : NativeContract + { + /// + /// A default value for maximum allowed NotValidBeforeDelta. It is set to be + /// 20 rounds for 7 validators, a little more than half an hour for 15-seconds blocks. + /// + private const int DefaultMaxNotValidBeforeDelta = 140; + /// + /// A default value for deposit lock period. + /// + private const int DefaultDepositDeltaTill = 5760; + private const byte Prefix_Deposit = 1; + private const byte Prefix_MaxNotValidBeforeDelta = 10; + + internal Notary() : base() { } + + public override Hardfork? ActiveIn => Hardfork.HF_Echidna; + + internal override ContractTask InitializeAsync(ApplicationEngine engine, Hardfork? hardfork) + { + if (hardfork == ActiveIn) + { + engine.SnapshotCache.Add(CreateStorageKey(Prefix_MaxNotValidBeforeDelta), new StorageItem(DefaultMaxNotValidBeforeDelta)); + } + return ContractTask.CompletedTask; + } + + internal override async ContractTask OnPersistAsync(ApplicationEngine engine) + { + long nFees = 0; + ECPoint[]? notaries = null; + foreach (var tx in engine.PersistingBlock.Transactions) + { + var attr = tx.GetAttribute(); + if (attr is not null) + { + notaries ??= GetNotaryNodes(engine.SnapshotCache); + var nKeys = attr.NKeys; + nFees += (long)nKeys + 1; + if (tx.Sender == Hash) + { + var payer = tx.Signers[1]; + // Don't need to seal because Deposit is a fixed-sized interoperable, hence always can be serialized. + var balance = engine.SnapshotCache.GetAndChange(CreateStorageKey(Prefix_Deposit, payer.Account))?.GetInteroperable(); + if (balance != null) + { + balance.Amount -= tx.SystemFee + tx.NetworkFee; + if (balance.Amount.Sign == 0) RemoveDepositFor(engine.SnapshotCache, payer.Account); + } + } + } + } + if (nFees == 0) return; + if (notaries == null) return; + var singleReward = CalculateNotaryReward(engine.SnapshotCache, nFees, notaries.Length); + foreach (var notary in notaries) await GAS.Mint(engine, Contract.CreateSignatureRedeemScript(notary).ToScriptHash(), singleReward, false); + } + + protected override void OnManifestCompose(IsHardforkEnabledDelegate hfChecker, uint blockHeight, ContractManifest manifest) + { + manifest.SupportedStandards = ["NEP-27"]; + } + + /// + /// Verify checks whether the transaction is signed by one of the notaries and + /// ensures whether deposited amount of GAS is enough to pay the actual sender's fee. + /// + /// ApplicationEngine + /// Signature + /// Whether transaction is valid. + [ContractMethod(CpuFee = 1 << 15, RequiredCallFlags = CallFlags.ReadStates)] + private bool Verify(ApplicationEngine engine, byte[] signature) + { + if (signature is null || signature.Length != 64) return false; + var tx = engine.ScriptContainer as Transaction; + if (tx?.GetAttribute() is null) return false; + foreach (var signer in tx.Signers) + { + if (signer.Account == Hash) + { + if (signer.Scopes != WitnessScope.None) return false; + break; + } + } + if (tx.Sender == Hash) + { + if (tx.Signers.Length != 2) return false; + var payer = tx.Signers[1].Account; + var balance = GetDepositFor(engine.SnapshotCache, payer); + if (balance is null || balance.Amount.CompareTo(tx.NetworkFee + tx.SystemFee) < 0) return false; + } + var notaries = GetNotaryNodes(engine.SnapshotCache); + var hash = tx.GetSignData(engine.GetNetwork()); + return notaries.Any(n => Crypto.VerifySignature(hash, signature, n)); + } + + /// + /// OnNEP17Payment is a callback that accepts GAS transfer as Notary deposit. + /// It also sets the deposit's lock height after which deposit can be withdrawn. + /// + /// ApplicationEngine + /// GAS sender + /// The amount of GAS sent + /// Deposit-related data: optional To value (treated as deposit owner if set) and Till height after which deposit can be withdrawn + [ContractMethod(CpuFee = 1 << 15, RequiredCallFlags = CallFlags.States)] + private void OnNEP17Payment(ApplicationEngine engine, UInt160 from, BigInteger amount, StackItem data) + { + if (engine.CallingScriptHash != GAS.Hash) throw new InvalidOperationException(string.Format("only GAS can be accepted for deposit, got {0}", engine.CallingScriptHash.ToString())); + if (data is not Array additionalParams || additionalParams.Count != 2) throw new FormatException("`data` parameter should be an array of 2 elements"); + var to = from; + if (!additionalParams[0].Equals(StackItem.Null)) to = additionalParams[0].GetSpan().ToArray().AsSerializable(); + var till = (uint)additionalParams[1].GetInteger(); + var tx = (Transaction)engine.ScriptContainer; + var allowedChangeTill = tx.Sender == to; + var currentHeight = Ledger.CurrentIndex(engine.SnapshotCache); + if (till < currentHeight + 2) throw new ArgumentOutOfRangeException(string.Format("`till` shouldn't be less than the chain's height {0} + 1", currentHeight + 2)); + // Don't need to seal because Deposit is a fixed-sized interoperable, hence always can be serialized. + var deposit = engine.SnapshotCache.GetAndChange(CreateStorageKey(Prefix_Deposit, to))?.GetInteroperable(); + if (deposit != null && till < deposit.Till) throw new ArgumentOutOfRangeException(string.Format("`till` shouldn't be less than the previous value {0}", deposit.Till)); + if (deposit is null) + { + var feePerKey = Policy.GetAttributeFeeV1(engine.SnapshotCache, (byte)TransactionAttributeType.NotaryAssisted); + if ((long)amount < 2 * feePerKey) throw new ArgumentOutOfRangeException(string.Format("first deposit can not be less than {0}, got {1}", 2 * feePerKey, amount)); + deposit = new Deposit() { Amount = 0, Till = 0 }; + if (!allowedChangeTill) till = currentHeight + DefaultDepositDeltaTill; + } + else if (!allowedChangeTill) till = deposit.Till; + + deposit.Amount += amount; + deposit.Till = till; + PutDepositFor(engine, to, deposit); + } + + /// + /// Lock asset until the specified height is unlocked. + /// + /// ApplicationEngine + /// Account + /// specified height + /// Whether deposit lock height was successfully updated. + [ContractMethod(CpuFee = 1 << 15, RequiredCallFlags = CallFlags.States)] + public bool LockDepositUntil(ApplicationEngine engine, UInt160 account, uint till) + { + if (!engine.CheckWitnessInternal(account)) return false; + if (till < Ledger.CurrentIndex(engine.SnapshotCache) + 2) return false; // deposit must be valid at least until the next block after persisting block. + var deposit = GetDepositFor(engine.SnapshotCache, account); + if (deposit is null || till < deposit.Till) return false; + deposit.Till = till; + + PutDepositFor(engine, account, deposit); + return true; + } + + /// + /// ExpirationOf returns deposit lock height for specified address. + /// + /// DataCache + /// Account + /// Deposit lock height of the specified address. + [ContractMethod(CpuFee = 1 << 15, RequiredCallFlags = CallFlags.ReadStates)] + public uint ExpirationOf(DataCache snapshot, UInt160 account) + { + var deposit = GetDepositFor(snapshot, account); + if (deposit is null) return 0; + return deposit.Till; + } + + /// + /// BalanceOf returns deposited GAS amount for specified address. + /// + /// DataCache + /// Account + /// Deposit balance of the specified account. + [ContractMethod(CpuFee = 1 << 15, RequiredCallFlags = CallFlags.ReadStates)] + public BigInteger BalanceOf(DataCache snapshot, UInt160 account) + { + var deposit = GetDepositFor(snapshot, account); + if (deposit is null) return 0; + return deposit.Amount; + } + + /// + /// Withdraw sends all deposited GAS for "from" address to "to" address. If "to" + /// address is not specified, then "from" will be used as a sender. + /// + /// ApplicationEngine + /// From Account + /// To Account + /// Whether withdrawal was successfull. + [ContractMethod(CpuFee = 1 << 15, RequiredCallFlags = CallFlags.All)] + private async ContractTask Withdraw(ApplicationEngine engine, UInt160 from, UInt160 to) + { + if (!engine.CheckWitnessInternal(from)) return false; + var receive = to is null ? from : to; + var deposit = GetDepositFor(engine.SnapshotCache, from); + if (deposit is null) return false; + if (Ledger.CurrentIndex(engine.SnapshotCache) < deposit.Till) return false; + RemoveDepositFor(engine.SnapshotCache, from); + if (!await engine.CallFromNativeContractAsync(Hash, GAS.Hash, "transfer", Hash.ToArray(), receive.ToArray(), deposit.Amount, StackItem.Null)) + { + throw new InvalidOperationException(string.Format("Transfer to {0} has failed", receive.ToString())); + } + return true; + } + + /// + /// GetMaxNotValidBeforeDelta is Notary contract method and returns the maximum NotValidBefore delta. + /// + /// DataCache + /// NotValidBefore + [ContractMethod(CpuFee = 1 << 15, RequiredCallFlags = CallFlags.ReadStates)] + public uint GetMaxNotValidBeforeDelta(IReadOnlyStore snapshot) + { + return (uint)(BigInteger)snapshot[CreateStorageKey(Prefix_MaxNotValidBeforeDelta)]; + } + + /// + /// SetMaxNotValidBeforeDelta is Notary contract method and sets the maximum NotValidBefore delta. + /// + /// ApplicationEngine + /// Value + [ContractMethod(CpuFee = 1 << 15, RequiredCallFlags = CallFlags.States)] + private void SetMaxNotValidBeforeDelta(ApplicationEngine engine, uint value) + { + var maxVUBIncrement = engine.SnapshotCache.GetMaxValidUntilBlockIncrement(engine.ProtocolSettings); + if (value > maxVUBIncrement / 2 || value < ProtocolSettings.Default.ValidatorsCount) + throw new FormatException(string.Format("MaxNotValidBeforeDelta cannot be more than {0} or less than {1}", + maxVUBIncrement / 2, ProtocolSettings.Default.ValidatorsCount)); + if (!CheckCommittee(engine)) throw new InvalidOperationException(); + engine.SnapshotCache.GetAndChange(CreateStorageKey(Prefix_MaxNotValidBeforeDelta))!.Set(value); + } + + /// + /// GetNotaryNodes returns public keys of notary nodes. + /// + /// DataCache + /// Public keys of notary nodes. + private static ECPoint[] GetNotaryNodes(DataCache snapshot) + { + return RoleManagement.GetDesignatedByRole(snapshot, Role.P2PNotary, Ledger.CurrentIndex(snapshot) + 1); + } + + /// + /// GetDepositFor returns Deposit for the specified account or nil in case if deposit + /// is not found in storage. + /// + /// + /// + /// Deposit for the specified account. + private Deposit? GetDepositFor(DataCache snapshot, UInt160 acc) + { + return snapshot.TryGet(CreateStorageKey(Prefix_Deposit, acc))?.GetInteroperable(); + } + + /// + /// PutDepositFor puts deposit on the balance of the specified account in the storage. + /// + /// ApplicationEngine + /// Account + /// deposit + private void PutDepositFor(ApplicationEngine engine, UInt160 acc, Deposit deposit) + { + // Don't need to seal because Deposit is a fixed-sized interoperable, hence always can be serialized. + var indeposit = engine.SnapshotCache.GetAndChange(CreateStorageKey(Prefix_Deposit, acc), () => new StorageItem(deposit)); + indeposit!.Value = new StorageItem(deposit).Value; + } + + /// + /// RemoveDepositFor removes deposit from the storage. + /// + /// DataCache + /// Account + private void RemoveDepositFor(DataCache snapshot, UInt160 acc) + { + snapshot.Delete(CreateStorageKey(Prefix_Deposit, acc)); + } + + /// + /// CalculateNotaryReward calculates the reward for a single notary node based on FEE's count and Notary nodes count. + /// + /// DataCache + /// + /// + /// result + private static long CalculateNotaryReward(IReadOnlyStore snapshot, long nFees, int notariesCount) + { + return nFees * Policy.GetAttributeFeeV1(snapshot, (byte)TransactionAttributeType.NotaryAssisted) / notariesCount; + } + + public class Deposit : IInteroperable + { + public BigInteger Amount { get; set; } + public uint Till { get; set; } + + public void FromStackItem(StackItem stackItem) + { + var @struct = (Struct)stackItem; + Amount = @struct[0].GetInteger(); + Till = (uint)@struct[1].GetInteger(); + } + + public StackItem ToStackItem(IReferenceCounter referenceCounter) + { + return new Struct(referenceCounter) { Amount, Till }; + } + } + } +} + +#nullable disable diff --git a/tests/Neo.UnitTests/Extensions/NativeContractExtensions.cs b/tests/Neo.UnitTests/Extensions/NativeContractExtensions.cs index acfb835944..2dad5dc458 100644 --- a/tests/Neo.UnitTests/Extensions/NativeContractExtensions.cs +++ b/tests/Neo.UnitTests/Extensions/NativeContractExtensions.cs @@ -132,6 +132,11 @@ public static StackItem Call(this NativeContract contract, DataCache snapshot, s public static StackItem Call(this NativeContract contract, DataCache snapshot, IVerifiable container, Block persistingBlock, string method, params ContractParameter[] args) { using var engine = ApplicationEngine.Create(TriggerType.Application, container, snapshot, persistingBlock, settings: TestProtocolSettings.Default); + return Call(contract, engine, method, args); + } + + public static StackItem Call(this NativeContract contract, ApplicationEngine engine, string method, params ContractParameter[] args) + { using var script = new ScriptBuilder(); script.EmitDynamicCall(contract.Hash, method, args); engine.LoadScript(script.ToArray()); diff --git a/tests/Neo.UnitTests/Extensions/Nep17NativeContractExtensions.cs b/tests/Neo.UnitTests/Extensions/Nep17NativeContractExtensions.cs index fab7d9a544..e2b9b89f9f 100644 --- a/tests/Neo.UnitTests/Extensions/Nep17NativeContractExtensions.cs +++ b/tests/Neo.UnitTests/Extensions/Nep17NativeContractExtensions.cs @@ -52,12 +52,38 @@ public void SerializeUnsigned(BinaryWriter writer) { } } public static bool Transfer(this NativeContract contract, DataCache snapshot, byte[] from, byte[] to, BigInteger amount, bool signFrom, Block persistingBlock) + { + return Transfer(contract, snapshot, from, to, amount, signFrom, persistingBlock, null); + } + + public static bool Transfer(this NativeContract contract, DataCache snapshot, byte[] from, byte[] to, BigInteger amount, bool signFrom, Block persistingBlock, object data) { using var engine = ApplicationEngine.Create(TriggerType.Application, new ManualWitness(signFrom ? new UInt160(from) : null), snapshot, persistingBlock, settings: TestProtocolSettings.Default); using var script = new ScriptBuilder(); - script.EmitDynamicCall(contract.Hash, "transfer", from, to, amount, null); + script.EmitDynamicCall(contract.Hash, "transfer", from, to, amount, data); + engine.LoadScript(script.ToArray()); + + if (engine.Execute() == VMState.FAULT) + { + throw engine.FaultException; + } + + var result = engine.ResultStack.Pop(); + Assert.IsInstanceOfType(result, typeof(Boolean)); + + return result.GetBoolean(); + } + + public static bool TransferWithTransaction(this NativeContract contract, DataCache snapshot, byte[] from, byte[] to, BigInteger amount, bool signFrom, Block persistingBlock, object data) + { + using var engine = ApplicationEngine.Create(TriggerType.Application, + new Transaction() { Signers = [new() { Account = signFrom ? new(from) : null, Scopes = WitnessScope.Global }], Attributes = [] }, + snapshot, persistingBlock, settings: TestProtocolSettings.Default); + + using var script = new ScriptBuilder(); + script.EmitDynamicCall(contract.Hash, "transfer", from, to, amount, data); engine.LoadScript(script.ToArray()); if (engine.Execute() == VMState.FAULT) diff --git a/tests/Neo.UnitTests/SmartContract/Native/UT_GasToken.cs b/tests/Neo.UnitTests/SmartContract/Native/UT_GasToken.cs index 0a7bec5b20..d395022d37 100644 --- a/tests/Neo.UnitTests/SmartContract/Native/UT_GasToken.cs +++ b/tests/Neo.UnitTests/SmartContract/Native/UT_GasToken.cs @@ -20,10 +20,10 @@ using Neo.VM; using Neo.Wallets; using System; +using System.Collections.Generic; using System.Linq; using System.Numerics; using System.Threading.Tasks; -using VMTypes = Neo.VM.Types; namespace Neo.UnitTests.SmartContract.Native { @@ -154,61 +154,5 @@ internal static StorageKey CreateStorageKey(byte prefix, byte[] key = null) Key = buffer }; } - - [TestMethod] - public void Check_OnPersist_NotaryAssisted() - { - // Hardcode test values. - const uint defaultNotaryssestedFeePerKey = 1000_0000; - const byte NKeys1 = 4; - const byte NKeys2 = 6; - - // Generate two transactions with NotaryAssisted attributes with hardcoded NKeys values. - var from = Contract.GetBFTAddress(TestProtocolSettings.Default.StandbyValidators); - var tx1 = TestUtils.GetTransaction(from); - tx1.Attributes = new TransactionAttribute[] { new NotaryAssisted() { NKeys = NKeys1 } }; - var netFee1 = 1_0000_0000; - tx1.NetworkFee = netFee1; - var tx2 = TestUtils.GetTransaction(from); - tx2.Attributes = new TransactionAttribute[] { new NotaryAssisted() { NKeys = NKeys2 } }; - var netFee2 = 2_0000_0000; - tx2.NetworkFee = netFee2; - - // Calculate expected Notary nodes reward. - var expectedNotaryReward = (NKeys1 + 1) * defaultNotaryssestedFeePerKey + (NKeys2 + 1) * defaultNotaryssestedFeePerKey; - - // Build block to check transaction fee distribution during Gas OnPersist. - var persistingBlock = new Block - { - Header = new Header - { - Index = (uint)TestProtocolSettings.Default.CommitteeMembersCount, - MerkleRoot = UInt256.Zero, - NextConsensus = UInt160.Zero, - PrevHash = UInt256.Zero, - Witness = Witness.Empty - }, - Transactions = [tx1, tx2], - }; - var snapshot = _snapshotCache.CloneCache(); - var script = new ScriptBuilder(); - script.EmitSysCall(ApplicationEngine.System_Contract_NativeOnPersist); - var engine = ApplicationEngine.Create(TriggerType.OnPersist, null, snapshot, persistingBlock, settings: TestProtocolSettings.Default); - - // Check that block's Primary balance is 0. - ECPoint[] validators = NativeContract.NEO.GetNextBlockValidators(engine.SnapshotCache, engine.ProtocolSettings.ValidatorsCount); - var primary = Contract.CreateSignatureRedeemScript(validators[engine.PersistingBlock.PrimaryIndex]).ToScriptHash(); - Assert.AreEqual(0, NativeContract.GAS.BalanceOf(engine.SnapshotCache, primary)); - - // Execute OnPersist script. - engine.LoadScript(script.ToArray()); - Assert.AreEqual(VMState.HALT, engine.Execute()); - - // Check that proper amount of GAS was minted to block's Primary and the rest - // will be minted to Notary nodes as a reward once Notary contract is implemented. - Assert.AreEqual(2 + 1, engine.Notifications.Count()); // burn tx1 and tx2 network fee + mint primary reward - Assert.AreEqual(netFee1 + netFee2 - expectedNotaryReward, engine.Notifications[2].State[2]); - Assert.AreEqual(netFee1 + netFee2 - expectedNotaryReward, NativeContract.GAS.BalanceOf(engine.SnapshotCache, primary)); - } } } diff --git a/tests/Neo.UnitTests/SmartContract/Native/UT_NativeContract.cs b/tests/Neo.UnitTests/SmartContract/Native/UT_NativeContract.cs index 5baf6575f3..e70dccf622 100644 --- a/tests/Neo.UnitTests/SmartContract/Native/UT_NativeContract.cs +++ b/tests/Neo.UnitTests/SmartContract/Native/UT_NativeContract.cs @@ -49,6 +49,7 @@ public void TestSetup() {"PolicyContract", """{"id":-7,"updatecounter":0,"hash":"0xcc5e4edd9f5f8dba8bb65734541df7a1c081c67b","nef":{"magic":860243278,"compiler":"neo-core-v3.0","source":"","tokens":[],"script":"EEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0A=","checksum":588003825},"manifest":{"name":"PolicyContract","groups":[],"features":{},"supportedstandards":[],"abi":{"methods":[{"name":"blockAccount","parameters":[{"name":"account","type":"Hash160"}],"returntype":"Boolean","offset":0,"safe":false},{"name":"getAttributeFee","parameters":[{"name":"attributeType","type":"Integer"}],"returntype":"Integer","offset":7,"safe":true},{"name":"getExecFeeFactor","parameters":[],"returntype":"Integer","offset":14,"safe":true},{"name":"getFeePerByte","parameters":[],"returntype":"Integer","offset":21,"safe":true},{"name":"getMaxTraceableBlocks","parameters":[],"returntype":"Integer","offset":28,"safe":true},{"name":"getMaxValidUntilBlockIncrement","parameters":[],"returntype":"Integer","offset":35,"safe":true},{"name":"getMillisecondsPerBlock","parameters":[],"returntype":"Integer","offset":42,"safe":true},{"name":"getStoragePrice","parameters":[],"returntype":"Integer","offset":49,"safe":true},{"name":"isBlocked","parameters":[{"name":"account","type":"Hash160"}],"returntype":"Boolean","offset":56,"safe":true},{"name":"setAttributeFee","parameters":[{"name":"attributeType","type":"Integer"},{"name":"value","type":"Integer"}],"returntype":"Void","offset":63,"safe":false},{"name":"setExecFeeFactor","parameters":[{"name":"value","type":"Integer"}],"returntype":"Void","offset":70,"safe":false},{"name":"setFeePerByte","parameters":[{"name":"value","type":"Integer"}],"returntype":"Void","offset":77,"safe":false},{"name":"setMaxTraceableBlocks","parameters":[{"name":"value","type":"Integer"}],"returntype":"Void","offset":84,"safe":false},{"name":"setMaxValidUntilBlockIncrement","parameters":[{"name":"value","type":"Integer"}],"returntype":"Void","offset":91,"safe":false},{"name":"setMillisecondsPerBlock","parameters":[{"name":"value","type":"Integer"}],"returntype":"Void","offset":98,"safe":false},{"name":"setStoragePrice","parameters":[{"name":"value","type":"Integer"}],"returntype":"Void","offset":105,"safe":false},{"name":"unblockAccount","parameters":[{"name":"account","type":"Hash160"}],"returntype":"Boolean","offset":112,"safe":false}],"events":[{"name":"MillisecondsPerBlockChanged","parameters":[{"name":"old","type":"Integer"},{"name":"new","type":"Integer"}]}]},"permissions":[{"contract":"*","methods":"*"}],"trusts":[],"extra":null}}"""}, {"RoleManagement", """{"id":-8,"updatecounter":0,"hash":"0x49cf4e5378ffcd4dec034fd98a174c5491e395e2","nef":{"magic":860243278,"compiler":"neo-core-v3.0","source":"","tokens":[],"script":"EEEa93tnQBBBGvd7Z0A=","checksum":983638438},"manifest":{"name":"RoleManagement","groups":[],"features":{},"supportedstandards":[],"abi":{"methods":[{"name":"designateAsRole","parameters":[{"name":"role","type":"Integer"},{"name":"nodes","type":"Array"}],"returntype":"Void","offset":0,"safe":false},{"name":"getDesignatedByRole","parameters":[{"name":"role","type":"Integer"},{"name":"index","type":"Integer"}],"returntype":"Array","offset":7,"safe":true}],"events":[{"name":"Designation","parameters":[{"name":"Role","type":"Integer"},{"name":"BlockIndex","type":"Integer"},{"name":"Old","type":"Array"},{"name":"New","type":"Array"}]}]},"permissions":[{"contract":"*","methods":"*"}],"trusts":[],"extra":null}}"""}, {"OracleContract", """{"id":-9,"updatecounter":0,"hash":"0xfe924b7cfe89ddd271abaf7210a80a7e11178758","nef":{"magic":860243278,"compiler":"neo-core-v3.0","source":"","tokens":[],"script":"EEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0A=","checksum":2663858513},"manifest":{"name":"OracleContract","groups":[],"features":{},"supportedstandards":[],"abi":{"methods":[{"name":"finish","parameters":[],"returntype":"Void","offset":0,"safe":false},{"name":"getPrice","parameters":[],"returntype":"Integer","offset":7,"safe":true},{"name":"request","parameters":[{"name":"url","type":"String"},{"name":"filter","type":"String"},{"name":"callback","type":"String"},{"name":"userData","type":"Any"},{"name":"gasForResponse","type":"Integer"}],"returntype":"Void","offset":14,"safe":false},{"name":"setPrice","parameters":[{"name":"price","type":"Integer"}],"returntype":"Void","offset":21,"safe":false},{"name":"verify","parameters":[],"returntype":"Boolean","offset":28,"safe":true}],"events":[{"name":"OracleRequest","parameters":[{"name":"Id","type":"Integer"},{"name":"RequestContract","type":"Hash160"},{"name":"Url","type":"String"},{"name":"Filter","type":"String"}]},{"name":"OracleResponse","parameters":[{"name":"Id","type":"Integer"},{"name":"OriginalTx","type":"Hash256"}]}]},"permissions":[{"contract":"*","methods":"*"}],"trusts":[],"extra":null}}"""}, + {"Notary", """{"id":-10,"updatecounter":0,"hash":"0xc1e14f19c3e60d0b9244d06dd7ba9b113135ec3b","nef":{"magic":860243278,"compiler":"neo-core-v3.0","source":"","tokens":[],"script":"EEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0A=","checksum":1110259869},"manifest":{"name":"Notary","groups":[],"features":{},"supportedstandards":["NEP-27"],"abi":{"methods":[{"name":"balanceOf","parameters":[{"name":"account","type":"Hash160"}],"returntype":"Integer","offset":0,"safe":true},{"name":"expirationOf","parameters":[{"name":"account","type":"Hash160"}],"returntype":"Integer","offset":7,"safe":true},{"name":"getMaxNotValidBeforeDelta","parameters":[],"returntype":"Integer","offset":14,"safe":true},{"name":"lockDepositUntil","parameters":[{"name":"account","type":"Hash160"},{"name":"till","type":"Integer"}],"returntype":"Boolean","offset":21,"safe":false},{"name":"onNEP17Payment","parameters":[{"name":"from","type":"Hash160"},{"name":"amount","type":"Integer"},{"name":"data","type":"Any"}],"returntype":"Void","offset":28,"safe":false},{"name":"setMaxNotValidBeforeDelta","parameters":[{"name":"value","type":"Integer"}],"returntype":"Void","offset":35,"safe":false},{"name":"verify","parameters":[{"name":"signature","type":"ByteArray"}],"returntype":"Boolean","offset":42,"safe":true},{"name":"withdraw","parameters":[{"name":"from","type":"Hash160"},{"name":"to","type":"Hash160"}],"returntype":"Boolean","offset":49,"safe":false}],"events":[]},"permissions":[{"contract":"*","methods":"*"}],"trusts":[],"extra":null}}"""} }; } diff --git a/tests/Neo.UnitTests/SmartContract/Native/UT_Notary.cs b/tests/Neo.UnitTests/SmartContract/Native/UT_Notary.cs new file mode 100644 index 0000000000..6474360af4 --- /dev/null +++ b/tests/Neo.UnitTests/SmartContract/Native/UT_Notary.cs @@ -0,0 +1,623 @@ +// Copyright (C) 2015-2025 The Neo Project. +// +// UT_Notary.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 FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Neo.Cryptography.ECC; +using Neo.Extensions; +using Neo.Network.P2P.Payloads; +using Neo.Persistence; +using Neo.SmartContract; +using Neo.SmartContract.Native; +using Neo.UnitTests.Extensions; +using Neo.VM; +using Neo.Wallets; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Numerics; + +namespace Neo.UnitTests.SmartContract.Native +{ + [TestClass] + public class UT_Notary + { + private DataCache _snapshot; + private Block _persistingBlock; + + [TestInitialize] + public void TestSetup() + { + _snapshot = TestBlockchain.GetTestSnapshotCache(); + _persistingBlock = new Block { Header = new Header() }; + } + + [TestMethod] + public void Check_Name() => NativeContract.Notary.Name.Should().Be(nameof(Notary)); + + [TestMethod] + public void Check_OnNEP17Payment() + { + var snapshot = _snapshot.CloneCache(); + var persistingBlock = new Block { Header = new Header { Index = 1000 } }; + byte[] from = Contract.GetBFTAddress(TestProtocolSettings.Default.StandbyValidators).ToArray(); + byte[] to = NativeContract.Notary.Hash.ToArray(); + + // Set proper current index for deposit's Till parameter check. + var storageKey = new KeyBuilder(NativeContract.Ledger.Id, 12); + snapshot.Add(storageKey, new StorageItem(new HashIndexState { Hash = UInt256.Zero, Index = persistingBlock.Index - 1 })); + + // Non-GAS transfer should fail. + Assert.ThrowsExactly(() => NativeContract.NEO.Transfer(snapshot, from, to, BigInteger.Zero, true, persistingBlock)); + + // GAS transfer with invalid data format should fail. + Assert.ThrowsExactly(() => NativeContract.GAS.Transfer(snapshot, from, to, BigInteger.Zero, true, persistingBlock, 5)); + + // GAS transfer with wrong number of data elements should fail. + var data = new ContractParameter { Type = ContractParameterType.Array, Value = new List() { new ContractParameter { Type = ContractParameterType.Boolean, Value = true } } }; + Assert.ThrowsExactly(() => NativeContract.GAS.Transfer(snapshot, from, to, BigInteger.Zero, true, persistingBlock, data)); + + // Gas transfer with invalid Till parameter should fail. + data = new ContractParameter { Type = ContractParameterType.Array, Value = new List() { new ContractParameter { Type = ContractParameterType.Any }, new ContractParameter { Type = ContractParameterType.Integer, Value = persistingBlock.Index } } }; + Assert.ThrowsExactly(() => NativeContract.GAS.TransferWithTransaction(snapshot, from, to, BigInteger.Zero, true, persistingBlock, data)); + + // Insufficient first deposit. + data = new ContractParameter { Type = ContractParameterType.Array, Value = new List() { new ContractParameter { Type = ContractParameterType.Any }, new ContractParameter { Type = ContractParameterType.Integer, Value = persistingBlock.Index + 100 } } }; + Assert.ThrowsExactly(() => NativeContract.GAS.TransferWithTransaction(snapshot, from, to, 2 * 1000_0000 - 1, true, persistingBlock, data)); + + // Good deposit. + data = new ContractParameter { Type = ContractParameterType.Array, Value = new List() { new ContractParameter { Type = ContractParameterType.Any }, new ContractParameter { Type = ContractParameterType.Integer, Value = persistingBlock.Index + 100 } } }; + Assert.IsTrue(NativeContract.GAS.TransferWithTransaction(snapshot, from, to, 2 * 1000_0000 + 1, true, persistingBlock, data)); + } + + [TestMethod] + public void Check_ExpirationOf() + { + var snapshot = _snapshot.CloneCache(); + var persistingBlock = new Block { Header = new Header { Index = 1000 } }; + byte[] from = Contract.GetBFTAddress(TestProtocolSettings.Default.StandbyValidators).ToArray(); + byte[] ntr = NativeContract.Notary.Hash.ToArray(); + + // Set proper current index for deposit's Till parameter check. + var storageKey = new KeyBuilder(NativeContract.Ledger.Id, 12); + snapshot.Add(storageKey, new StorageItem(new HashIndexState { Hash = UInt256.Zero, Index = persistingBlock.Index - 1 })); + + // Check that 'till' of an empty deposit is 0 by default. + Call_ExpirationOf(snapshot, from, persistingBlock).Should().Be(0); + + // Make initial deposit. + var till = persistingBlock.Index + 123; + var data = new ContractParameter { Type = ContractParameterType.Array, Value = new List() { new ContractParameter { Type = ContractParameterType.Any }, new ContractParameter { Type = ContractParameterType.Integer, Value = till } } }; + Assert.IsTrue(NativeContract.GAS.TransferWithTransaction(snapshot, from, ntr, 2 * 1000_0000 + 1, true, persistingBlock, data)); + + // Ensure deposit's 'till' value is properly set. + Call_ExpirationOf(snapshot, from, persistingBlock).Should().Be(till); + + // Make one more deposit with updated 'till' parameter. + till += 5; + data = new ContractParameter { Type = ContractParameterType.Array, Value = new List() { new ContractParameter { Type = ContractParameterType.Any }, new ContractParameter { Type = ContractParameterType.Integer, Value = till } } }; + Assert.IsTrue(NativeContract.GAS.TransferWithTransaction(snapshot, from, ntr, 5, true, persistingBlock, data)); + + // Ensure deposit's 'till' value is properly updated. + Call_ExpirationOf(snapshot, from, persistingBlock).Should().Be(till); + + // Make deposit to some side account with custom 'till' value. + UInt160 to = UInt160.Parse("01ff00ff00ff00ff00ff00ff00ff00ff00ff00a4"); + data = new ContractParameter { Type = ContractParameterType.Array, Value = new List() { new ContractParameter { Type = ContractParameterType.Hash160, Value = to }, new ContractParameter { Type = ContractParameterType.Integer, Value = till } } }; + Assert.IsTrue(NativeContract.GAS.TransferWithTransaction(snapshot, from, ntr, 2 * 1000_0000 + 1, true, persistingBlock, data)); + + // Default 'till' value should be set for to's deposit. + var defaultDeltaTill = 5760; + Call_ExpirationOf(snapshot, to.ToArray(), persistingBlock).Should().Be(persistingBlock.Index - 1 + defaultDeltaTill); + + // Withdraw own deposit. + persistingBlock.Header.Index = till + 1; + var currentBlock = snapshot.GetAndChange(storageKey, () => new StorageItem(new HashIndexState())); + currentBlock.GetInteroperable().Index = till + 1; + Call_Withdraw(snapshot, from, from, persistingBlock); + + // Check that 'till' value is properly updated. + Call_ExpirationOf(snapshot, from, persistingBlock).Should().Be(0); + } + + [TestMethod] + public void Check_LockDepositUntil() + { + var snapshot = _snapshot.CloneCache(); + var persistingBlock = new Block { Header = new Header { Index = 1000 } }; + byte[] from = Contract.GetBFTAddress(TestProtocolSettings.Default.StandbyValidators).ToArray(); + + // Set proper current index for deposit's Till parameter check. + var storageKey = new KeyBuilder(NativeContract.Ledger.Id, 12); + snapshot.Add(storageKey, new StorageItem(new HashIndexState { Hash = UInt256.Zero, Index = persistingBlock.Index - 1 })); + + // Check that 'till' of an empty deposit is 0 by default. + Call_ExpirationOf(snapshot, from, persistingBlock).Should().Be(0); + + // Update `till` value of an empty deposit should fail. + Call_LockDepositUntil(snapshot, from, 123, persistingBlock).Should().Be(false); + Call_ExpirationOf(snapshot, from, persistingBlock).Should().Be(0); + + // Make initial deposit. + var till = persistingBlock.Index + 123; + var data = new ContractParameter { Type = ContractParameterType.Array, Value = new List() { new ContractParameter { Type = ContractParameterType.Any }, new ContractParameter { Type = ContractParameterType.Integer, Value = till } } }; + Assert.IsTrue(NativeContract.GAS.TransferWithTransaction(snapshot, from, NativeContract.Notary.Hash.ToArray(), 2 * 1000_0000 + 1, true, persistingBlock, data)); + + // Ensure deposit's 'till' value is properly set. + Call_ExpirationOf(snapshot, from, persistingBlock).Should().Be(till); + + // Update deposit's `till` value for side account should fail. + UInt160 other = UInt160.Parse("01ff00ff00ff00ff00ff00ff00ff00ff00ff00a4"); + Call_LockDepositUntil(snapshot, other.ToArray(), till + 10, persistingBlock).Should().Be(false); + Call_ExpirationOf(snapshot, from, persistingBlock).Should().Be(till); + + // Decrease deposit's `till` value should fail. + Call_LockDepositUntil(snapshot, from, till - 1, persistingBlock).Should().Be(false); + Call_ExpirationOf(snapshot, from, persistingBlock).Should().Be(till); + + // Good. + till += 10; + Call_LockDepositUntil(snapshot, from, till, persistingBlock).Should().Be(true); + Call_ExpirationOf(snapshot, from, persistingBlock).Should().Be(till); + } + + [TestMethod] + public void Check_BalanceOf() + { + var snapshot = _snapshot.CloneCache(); + var persistingBlock = new Block { Header = new Header { Index = 1000 } }; + UInt160 fromAddr = Contract.GetBFTAddress(TestProtocolSettings.Default.StandbyValidators); + byte[] from = fromAddr.ToArray(); + byte[] ntr = NativeContract.Notary.Hash.ToArray(); + + // Set proper current index for deposit expiration. + var storageKey = new KeyBuilder(NativeContract.Ledger.Id, 12); + snapshot.Add(storageKey, new StorageItem(new HashIndexState { Hash = UInt256.Zero, Index = persistingBlock.Index - 1 })); + + // Ensure that default deposit is 0. + Call_BalanceOf(snapshot, from, persistingBlock).Should().Be(0); + + // Make initial deposit. + var till = persistingBlock.Index + 123; + var deposit1 = 2 * 1_0000_0000; + var data = new ContractParameter { Type = ContractParameterType.Array, Value = new List() { new ContractParameter { Type = ContractParameterType.Any }, new ContractParameter { Type = ContractParameterType.Integer, Value = till } } }; + Assert.IsTrue(NativeContract.GAS.TransferWithTransaction(snapshot, from, ntr, deposit1, true, persistingBlock, data)); + + // Ensure value is deposited. + Call_BalanceOf(snapshot, from, persistingBlock).Should().Be(deposit1); + + // Make one more deposit with updated 'till' parameter. + var deposit2 = 5; + data = new ContractParameter { Type = ContractParameterType.Array, Value = new List() { new ContractParameter { Type = ContractParameterType.Any }, new ContractParameter { Type = ContractParameterType.Integer, Value = till } } }; + Assert.IsTrue(NativeContract.GAS.TransferWithTransaction(snapshot, from, ntr, deposit2, true, persistingBlock, data)); + + // Ensure deposit's 'till' value is properly updated. + Call_BalanceOf(snapshot, from, persistingBlock).Should().Be(deposit1 + deposit2); + + // Make deposit to some side account. + UInt160 to = UInt160.Parse("01ff00ff00ff00ff00ff00ff00ff00ff00ff00a4"); + data = new ContractParameter { Type = ContractParameterType.Array, Value = new List() { new ContractParameter { Type = ContractParameterType.Hash160, Value = to }, new ContractParameter { Type = ContractParameterType.Integer, Value = till } } }; + Assert.IsTrue(NativeContract.GAS.TransferWithTransaction(snapshot, from, ntr, deposit1, true, persistingBlock, data)); + + Call_BalanceOf(snapshot, to.ToArray(), persistingBlock).Should().Be(deposit1); + + // Process some Notary transaction and check that some deposited funds have been withdrawn. + var tx1 = TestUtils.GetTransaction(NativeContract.Notary.Hash, fromAddr); + tx1.Attributes = new TransactionAttribute[] { new NotaryAssisted() { NKeys = 4 } }; + tx1.NetworkFee = 1_0000_0000; + + // Build block to check transaction fee distribution during Gas OnPersist. + persistingBlock = new Block + { + Header = new Header + { + Index = (uint)TestProtocolSettings.Default.CommitteeMembersCount, + MerkleRoot = UInt256.Zero, + NextConsensus = UInt160.Zero, + PrevHash = UInt256.Zero, + Witness = new Witness() { InvocationScript = Array.Empty(), VerificationScript = Array.Empty() } + }, + Transactions = new Transaction[] { tx1 } + }; + // Designate Notary node. + byte[] privateKey1 = new byte[32]; + var rng = System.Security.Cryptography.RandomNumberGenerator.Create(); + rng.GetBytes(privateKey1); + KeyPair key1 = new KeyPair(privateKey1); + UInt160 committeeMultiSigAddr = NativeContract.NEO.GetCommitteeAddress(snapshot); + var ret = NativeContract.RoleManagement.Call( + snapshot, + new Nep17NativeContractExtensions.ManualWitness(committeeMultiSigAddr), + new Block { Header = new Header() }, + "designateAsRole", + new ContractParameter(ContractParameterType.Integer) { Value = new BigInteger((int)Role.P2PNotary) }, + new ContractParameter(ContractParameterType.Array) + { + Value = new List(){ + new ContractParameter(ContractParameterType.ByteArray){Value = key1.PublicKey.ToArray()}, + } + } + ); + snapshot.Commit(); + + // Execute OnPersist script. + var script = new ScriptBuilder(); + script.EmitSysCall(ApplicationEngine.System_Contract_NativeOnPersist); + var engine = ApplicationEngine.Create(TriggerType.OnPersist, null, snapshot, persistingBlock, settings: TestProtocolSettings.Default); + engine.LoadScript(script.ToArray()); + Assert.IsTrue(engine.Execute() == VMState.HALT); + snapshot.Commit(); + + // Check that transaction's fees were paid by from's deposit. + Call_BalanceOf(snapshot, from, persistingBlock).Should().Be(deposit1 + deposit2 - tx1.NetworkFee - tx1.SystemFee); + + // Withdraw own deposit. + persistingBlock.Header.Index = till + 1; + var currentBlock = snapshot.GetAndChange(storageKey, () => new StorageItem(new HashIndexState())); + currentBlock.GetInteroperable().Index = till + 1; + Call_Withdraw(snapshot, from, from, persistingBlock); + + // Check that no deposit is left. + Call_BalanceOf(snapshot, from, persistingBlock).Should().Be(0); + } + + [TestMethod] + public void Check_Withdraw() + { + var snapshot = _snapshot.CloneCache(); + var persistingBlock = new Block { Header = new Header { Index = 1000 } }; + UInt160 fromAddr = Contract.GetBFTAddress(TestProtocolSettings.Default.StandbyValidators); + byte[] from = fromAddr.ToArray(); + + // Set proper current index to get proper deposit expiration height. + var storageKey = new KeyBuilder(NativeContract.Ledger.Id, 12); + snapshot.Add(storageKey, new StorageItem(new HashIndexState { Hash = UInt256.Zero, Index = persistingBlock.Index - 1 })); + + // Ensure that default deposit is 0. + Call_BalanceOf(snapshot, from, persistingBlock).Should().Be(0); + + // Make initial deposit. + var till = persistingBlock.Index + 123; + var deposit1 = 2 * 1_0000_0000; + var data = new ContractParameter { Type = ContractParameterType.Array, Value = new List() { new ContractParameter { Type = ContractParameterType.Any }, new ContractParameter { Type = ContractParameterType.Integer, Value = till } } }; + Assert.IsTrue(NativeContract.GAS.TransferWithTransaction(snapshot, from, NativeContract.Notary.Hash.ToArray(), deposit1, true, persistingBlock, data)); + + // Ensure value is deposited. + Call_BalanceOf(snapshot, from, persistingBlock).Should().Be(deposit1); + + // Unwitnessed withdraw should fail. + UInt160 sideAccount = UInt160.Parse("01ff00ff00ff00ff00ff00ff00ff00ff00ff00a4"); + Call_Withdraw(snapshot, from, sideAccount.ToArray(), persistingBlock, false).Should().Be(false); + + // Withdraw missing (zero) deposit should fail. + Call_Withdraw(snapshot, sideAccount.ToArray(), sideAccount.ToArray(), persistingBlock).Should().Be(false); + + // Withdraw before deposit expiration should fail. + Call_Withdraw(snapshot, from, from, persistingBlock).Should().Be(false); + + // Good. + persistingBlock.Header.Index = till + 1; + var currentBlock = snapshot.GetAndChange(storageKey, () => new StorageItem(new HashIndexState())); + currentBlock.GetInteroperable().Index = till + 1; + Call_Withdraw(snapshot, from, from, persistingBlock).Should().Be(true); + + // Check that no deposit is left. + Call_BalanceOf(snapshot, from, persistingBlock).Should().Be(0); + } + + internal static BigInteger Call_BalanceOf(DataCache snapshot, byte[] address, Block persistingBlock) + { + using var engine = ApplicationEngine.Create(TriggerType.Application, null, snapshot, persistingBlock, settings: TestProtocolSettings.Default); + + using var script = new ScriptBuilder(); + script.EmitDynamicCall(NativeContract.Notary.Hash, "balanceOf", address); + engine.LoadScript(script.ToArray()); + + engine.Execute().Should().Be(VMState.HALT); + + var result = engine.ResultStack.Pop(); + result.Should().BeOfType(typeof(VM.Types.Integer)); + + return result.GetInteger(); + } + + internal static BigInteger Call_ExpirationOf(DataCache snapshot, byte[] address, Block persistingBlock) + { + using var engine = ApplicationEngine.Create(TriggerType.Application, null, snapshot, persistingBlock, settings: TestProtocolSettings.Default); + + using var script = new ScriptBuilder(); + script.EmitDynamicCall(NativeContract.Notary.Hash, "expirationOf", address); + engine.LoadScript(script.ToArray()); + + engine.Execute().Should().Be(VMState.HALT); + + var result = engine.ResultStack.Pop(); + result.Should().BeOfType(typeof(VM.Types.Integer)); + + return result.GetInteger(); + } + + internal static bool Call_LockDepositUntil(DataCache snapshot, byte[] address, uint till, Block persistingBlock) + { + using var engine = ApplicationEngine.Create(TriggerType.Application, new Transaction() { Signers = new Signer[] { new Signer() { Account = new UInt160(address), Scopes = WitnessScope.Global } }, Attributes = System.Array.Empty() }, snapshot, persistingBlock, settings: TestProtocolSettings.Default); + + using var script = new ScriptBuilder(); + script.EmitDynamicCall(NativeContract.Notary.Hash, "lockDepositUntil", address, till); + engine.LoadScript(script.ToArray()); + + engine.Execute().Should().Be(VMState.HALT); + + var result = engine.ResultStack.Pop(); + result.Should().BeOfType(typeof(VM.Types.Boolean)); + + return result.GetBoolean(); + } + + internal static bool Call_Withdraw(DataCache snapshot, byte[] from, byte[] to, Block persistingBlock, bool witnessedByFrom = true) + { + var accFrom = UInt160.Zero; + if (witnessedByFrom) + { + accFrom = new UInt160(from); + } + using var engine = ApplicationEngine.Create(TriggerType.Application, new Transaction() { Signers = new Signer[] { new Signer() { Account = accFrom, Scopes = WitnessScope.Global } }, Attributes = System.Array.Empty() }, snapshot, persistingBlock, settings: TestProtocolSettings.Default); + + using var script = new ScriptBuilder(); + script.EmitDynamicCall(NativeContract.Notary.Hash, "withdraw", from, to); + engine.LoadScript(script.ToArray()); + + if (engine.Execute() != VMState.HALT) + { + throw engine.FaultException; + } + + var result = engine.ResultStack.Pop(); + result.Should().BeOfType(typeof(VM.Types.Boolean)); + + return result.GetBoolean(); + } + + [TestMethod] + public void Check_GetMaxNotValidBeforeDelta() + { + const int defaultMaxNotValidBeforeDelta = 140; + NativeContract.Notary.GetMaxNotValidBeforeDelta(_snapshot).Should().Be(defaultMaxNotValidBeforeDelta); + } + + [TestMethod] + public void Check_SetMaxNotValidBeforeDelta() + { + var snapshot = _snapshot.CloneCache(); + var persistingBlock = new Block { Header = new Header { Index = 1000 } }; + UInt160 committeeAddress = NativeContract.NEO.GetCommitteeAddress(snapshot); + + using var engine = ApplicationEngine.Create(TriggerType.Application, new Nep17NativeContractExtensions.ManualWitness(committeeAddress), snapshot, persistingBlock, settings: TestProtocolSettings.Default); + using var script = new ScriptBuilder(); + script.EmitDynamicCall(NativeContract.Notary.Hash, "setMaxNotValidBeforeDelta", 100); + engine.LoadScript(script.ToArray()); + VMState vMState = engine.Execute(); + vMState.Should().Be(VMState.HALT); + NativeContract.Notary.GetMaxNotValidBeforeDelta(snapshot).Should().Be(100); + } + + [TestMethod] + public void Check_OnPersist_FeePerKeyUpdate() + { + // Hardcode test values. + const uint defaultNotaryAssistedFeePerKey = 1000_0000; + const uint newNotaryAssistedFeePerKey = 5000_0000; + const byte NKeys = 4; + + // Generate one transaction with NotaryAssisted attribute with hardcoded NKeys values. + var from = Contract.GetBFTAddress(TestProtocolSettings.Default.StandbyValidators); + var tx2 = TestUtils.GetTransaction(from); + tx2.Attributes = new TransactionAttribute[] { new NotaryAssisted() { NKeys = NKeys } }; + var netFee = 1_0000_0000; // enough to cover defaultNotaryAssistedFeePerKey, but not enough to cover newNotaryAssistedFeePerKey. + tx2.NetworkFee = netFee; + tx2.SystemFee = 1000_0000; + + // Calculate overall expected Notary nodes reward. + var expectedNotaryReward = (NKeys + 1) * defaultNotaryAssistedFeePerKey; + + // Build block to check transaction fee distribution during Gas OnPersist. + var persistingBlock = new Block + { + Header = new Header + { + Index = 10, + MerkleRoot = UInt256.Zero, + NextConsensus = UInt160.Zero, + PrevHash = UInt256.Zero, + Witness = new Witness() { InvocationScript = Array.Empty(), VerificationScript = Array.Empty() } + }, + Transactions = new Transaction[] { tx2 } + }; + var snapshot = _snapshot.CloneCache(); + + // Designate Notary node. + byte[] privateKey1 = new byte[32]; + var rng = System.Security.Cryptography.RandomNumberGenerator.Create(); + rng.GetBytes(privateKey1); + KeyPair key1 = new KeyPair(privateKey1); + UInt160 committeeMultiSigAddr = NativeContract.NEO.GetCommitteeAddress(snapshot); + var ret = NativeContract.RoleManagement.Call( + snapshot, + new Nep17NativeContractExtensions.ManualWitness(committeeMultiSigAddr), + new Block { Header = new Header() }, + "designateAsRole", + new ContractParameter(ContractParameterType.Integer) { Value = new BigInteger((int)Role.P2PNotary) }, + new ContractParameter(ContractParameterType.Array) + { + Value = new List(){ + new ContractParameter(ContractParameterType.ByteArray){Value = key1.PublicKey.ToArray()} + } + } + ); + snapshot.Commit(); + + // Create engine with custom settings (HF_Echidna should be enabled to properly interact with NotaryAssisted attribute). + var settings = ProtocolSettings.Default with + { + Network = 0x334F454Eu, + StandbyCommittee = + [ + ECPoint.Parse("03b209fd4f53a7170ea4444e0cb0a6bb6a53c2bd016926989cf85f9b0fba17a70c", ECCurve.Secp256r1), + ECPoint.Parse("02df48f60e8f3e01c48ff40b9b7f1310d7a8b2a193188befe1c2e3df740e895093", ECCurve.Secp256r1), + ECPoint.Parse("03b8d9d5771d8f513aa0869b9cc8d50986403b78c6da36890638c3d46a5adce04a", ECCurve.Secp256r1), + ECPoint.Parse("02ca0e27697b9c248f6f16e085fd0061e26f44da85b58ee835c110caa5ec3ba554", ECCurve.Secp256r1), + ECPoint.Parse("024c7b7fb6c310fccf1ba33b082519d82964ea93868d676662d4a59ad548df0e7d", ECCurve.Secp256r1), + ECPoint.Parse("02aaec38470f6aad0042c6e877cfd8087d2676b0f516fddd362801b9bd3936399e", ECCurve.Secp256r1), + ECPoint.Parse("02486fd15702c4490a26703112a5cc1d0923fd697a33406bd5a1c00e0013b09a70", ECCurve.Secp256r1) + ], + ValidatorsCount = 7, + Hardforks = new Dictionary{ + {Hardfork.HF_Aspidochelone, 1}, + {Hardfork.HF_Basilisk, 2}, + {Hardfork.HF_Cockatrice, 3}, + {Hardfork.HF_Domovoi, 4}, + {Hardfork.HF_Echidna, 5} + }.ToImmutableDictionary() + }; + + // Imitate Blockchain's Persist behaviour: OnPersist + transactions processing. + // Execute OnPersist firstly: + var script = new ScriptBuilder(); + script.EmitSysCall(ApplicationEngine.System_Contract_NativeOnPersist); + var engine = ApplicationEngine.Create(TriggerType.OnPersist, new Nep17NativeContractExtensions.ManualWitness(committeeMultiSigAddr), snapshot, persistingBlock, settings: settings); + engine.LoadScript(script.ToArray()); + Assert.IsTrue(engine.Execute() == VMState.HALT, engine.FaultException?.ToString()); + snapshot.Commit(); + + // Process transaction that changes NotaryServiceFeePerKey after OnPersist. + ret = NativeContract.Policy.Call(engine, + "setAttributeFee", new ContractParameter(ContractParameterType.Integer) { Value = (BigInteger)(byte)TransactionAttributeType.NotaryAssisted }, new ContractParameter(ContractParameterType.Integer) { Value = newNotaryAssistedFeePerKey }); + Assert.IsNull(ret); + snapshot.Commit(); + + // Process tx2 with NotaryAssisted attribute. + engine = ApplicationEngine.Create(TriggerType.Application, tx2, snapshot, persistingBlock, settings: TestProtocolSettings.Default, tx2.SystemFee); + engine.LoadScript(tx2.Script); + Assert.IsTrue(engine.Execute() == VMState.HALT); + snapshot.Commit(); + + // Ensure that Notary reward is distributed based on the old value of NotaryAssisted price + // and no underflow happens during GAS distribution. + ECPoint[] validators = NativeContract.NEO.GetNextBlockValidators(engine.SnapshotCache, engine.ProtocolSettings.ValidatorsCount); + var primary = Contract.CreateSignatureRedeemScript(validators[engine.PersistingBlock.PrimaryIndex]).ToScriptHash(); + NativeContract.GAS.BalanceOf(snapshot, primary).Should().Be(netFee - expectedNotaryReward); + NativeContract.GAS.BalanceOf(engine.SnapshotCache, Contract.CreateSignatureRedeemScript(key1.PublicKey).ToScriptHash()).Should().Be(expectedNotaryReward); + } + + [TestMethod] + public void Check_OnPersist_NotaryRewards() + { + // Hardcode test values. + const uint defaultNotaryssestedFeePerKey = 1000_0000; + const byte NKeys1 = 4; + const byte NKeys2 = 6; + + // Generate two transactions with NotaryAssisted attributes with hardcoded NKeys values. + var from = Contract.GetBFTAddress(TestProtocolSettings.Default.StandbyValidators); + var tx1 = TestUtils.GetTransaction(from); + tx1.Attributes = new TransactionAttribute[] { new NotaryAssisted() { NKeys = NKeys1 } }; + var netFee1 = 1_0000_0000; + tx1.NetworkFee = netFee1; + var tx2 = TestUtils.GetTransaction(from); + tx2.Attributes = new TransactionAttribute[] { new NotaryAssisted() { NKeys = NKeys2 } }; + var netFee2 = 2_0000_0000; + tx2.NetworkFee = netFee2; + + // Calculate overall expected Notary nodes reward. + var expectedNotaryReward = (NKeys1 + 1) * defaultNotaryssestedFeePerKey + (NKeys2 + 1) * defaultNotaryssestedFeePerKey; + + // Build block to check transaction fee distribution during Gas OnPersist. + var persistingBlock = new Block + { + Header = new Header + { + Index = (uint)TestProtocolSettings.Default.CommitteeMembersCount, + MerkleRoot = UInt256.Zero, + NextConsensus = UInt160.Zero, + PrevHash = UInt256.Zero, + Witness = new Witness() { InvocationScript = Array.Empty(), VerificationScript = Array.Empty() } + }, + Transactions = new Transaction[] { tx1, tx2 } + }; + var snapshot = _snapshot.CloneCache(); + + // Designate several Notary nodes. + byte[] privateKey1 = new byte[32]; + var rng = System.Security.Cryptography.RandomNumberGenerator.Create(); + rng.GetBytes(privateKey1); + KeyPair key1 = new KeyPair(privateKey1); + byte[] privateKey2 = new byte[32]; + rng.GetBytes(privateKey2); + KeyPair key2 = new KeyPair(privateKey2); + UInt160 committeeMultiSigAddr = NativeContract.NEO.GetCommitteeAddress(snapshot); + var ret = NativeContract.RoleManagement.Call( + snapshot, + new Nep17NativeContractExtensions.ManualWitness(committeeMultiSigAddr), + new Block { Header = new Header() }, + "designateAsRole", + new ContractParameter(ContractParameterType.Integer) { Value = new BigInteger((int)Role.P2PNotary) }, + new ContractParameter(ContractParameterType.Array) + { + Value = new List(){ + new ContractParameter(ContractParameterType.ByteArray){Value = key1.PublicKey.ToArray()}, + new ContractParameter(ContractParameterType.ByteArray){Value = key2.PublicKey.ToArray()}, + } + } + ); + snapshot.Commit(); + + var script = new ScriptBuilder(); + script.EmitSysCall(ApplicationEngine.System_Contract_NativeOnPersist); + var engine = ApplicationEngine.Create(TriggerType.OnPersist, null, snapshot, persistingBlock, settings: TestProtocolSettings.Default); + + // Check that block's Primary balance is 0. + ECPoint[] validators = NativeContract.NEO.GetNextBlockValidators(engine.SnapshotCache, engine.ProtocolSettings.ValidatorsCount); + var primary = Contract.CreateSignatureRedeemScript(validators[engine.PersistingBlock.PrimaryIndex]).ToScriptHash(); + NativeContract.GAS.BalanceOf(engine.SnapshotCache, primary).Should().Be(0); + + // Execute OnPersist script. + engine.LoadScript(script.ToArray()); + Assert.IsTrue(engine.Execute() == VMState.HALT); + + // Check that proper amount of GAS was minted to block's Primary and the rest + // is evenly devided between designated Notary nodes as a reward. + Assert.AreEqual(2 + 1 + 2, engine.Notifications.Count()); // burn tx1 and tx2 network fee + mint primary reward + transfer reward to Notary1 and Notary2 + Assert.AreEqual(netFee1 + netFee2 - expectedNotaryReward, engine.Notifications[2].State[2]); + NativeContract.GAS.BalanceOf(engine.SnapshotCache, primary).Should().Be(netFee1 + netFee2 - expectedNotaryReward); + Assert.AreEqual(expectedNotaryReward / 2, engine.Notifications[3].State[2]); + NativeContract.GAS.BalanceOf(engine.SnapshotCache, Contract.CreateSignatureRedeemScript(key1.PublicKey).ToScriptHash()).Should().Be(expectedNotaryReward / 2); + Assert.AreEqual(expectedNotaryReward / 2, engine.Notifications[4].State[2]); + NativeContract.GAS.BalanceOf(engine.SnapshotCache, Contract.CreateSignatureRedeemScript(key2.PublicKey).ToScriptHash()).Should().Be(expectedNotaryReward / 2); + } + + internal static StorageKey CreateStorageKey(byte prefix, uint key) + { + return CreateStorageKey(prefix, BitConverter.GetBytes(key)); + } + + internal static StorageKey CreateStorageKey(byte prefix, byte[] key = null) + { + byte[] buffer = GC.AllocateUninitializedArray(sizeof(byte) + (key?.Length ?? 0)); + buffer[0] = prefix; + key?.CopyTo(buffer.AsSpan(1)); + return new() + { + Id = NativeContract.GAS.Id, + Key = buffer + }; + } + } +} diff --git a/tests/Neo.UnitTests/TestUtils.Transaction.cs b/tests/Neo.UnitTests/TestUtils.Transaction.cs index 8ebdeb3a58..8b305a370f 100644 --- a/tests/Neo.UnitTests/TestUtils.Transaction.cs +++ b/tests/Neo.UnitTests/TestUtils.Transaction.cs @@ -115,6 +115,47 @@ public static Transaction GetTransaction(UInt160 sender) }; } + public static Transaction GetTransaction(UInt160 sender, UInt160 signer) + { + return new Transaction + { + Script = new[] { (byte)OpCode.PUSH2 }, + Attributes = [], + Signers = + [ + new Signer + { + Account = sender, + Scopes = WitnessScope.CalledByEntry, + AllowedContracts = [], + AllowedGroups = [], + Rules = [], + }, + new Signer + { + Account = signer, + Scopes = WitnessScope.CalledByEntry, + AllowedContracts = [], + AllowedGroups = [], + Rules = [], + } + ], + Witnesses = + [ + new Witness + { + InvocationScript = Memory.Empty, + VerificationScript = Memory.Empty, + }, + new Witness + { + InvocationScript = Memory.Empty, + VerificationScript = Memory.Empty, + } + ] + }; + } + public static Transaction CreateInvalidTransaction(DataCache snapshot, NEP6Wallet wallet, WalletAccount account, InvalidTransactionType type, UInt256 conflict = null) { var rand = new Random();