Repository: nspcc-dev/neo-go
Pull request #3700 (2024-11-24 16:03:08)

NEO candidate registration via NEP-27 callback
See https://github.com/neo-project/neo/issues/3552. Depends on https://github.com/neo-project/neo/pull/3597.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/nspcc-dev/neo-go/pull/3700?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nspcc-dev) Report
Attention: Patch coverage is `86.84211%` with `5 lines` in your changes missing coverage. Please review.
> Project coverage is 83.01%. Comparing base [(`ff15e39`)](https://app.codecov.io/gh/nspcc-dev/neo-go/commit/ff15e39363ea44cbe337f493c434304de38b2382?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nspcc-dev) to head [(`d4d1ff2`)](https://app.codecov.io/gh/nspcc-dev/neo-go/commit/d4d1ff284b1efbd12970711e7dc81588dfa133fd?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nspcc-dev).

| [Files with missing lines](https://app.codecov.io/gh/nspcc-dev/neo-go/pull/3700?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nspcc-dev) | Patch % | Lines |
|---|---|---|
| [pkg/core/native/native\_neo.go](https://app.codecov.io/gh/nspcc-dev/neo-go/pull/3700?src=pr&el=tree&filepath=pkg%2Fcore%2Fnative%2Fnative_neo.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nspcc-dev#diff-cGtnL2NvcmUvbmF0aXZlL25hdGl2ZV9uZW8uZ28=) | 86.20% | [2 Missing and 2 partials :warning: ](https://app.codecov.io/gh/nspcc-dev/neo-go/pull/3700?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nspcc-dev) |
| [pkg/core/interop/context.go](https://app.codecov.io/gh/nspcc-dev/neo-go/pull/3700?src=pr&el=tree&filepath=pkg%2Fcore%2Finterop%2Fcontext.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nspcc-dev#diff-cGtnL2NvcmUvaW50ZXJvcC9jb250ZXh0Lmdv) | 88.88% | [0 Missing and 1 partial :warning: ](https://app.codecov.io/gh/nspcc-dev/neo-go/pull/3700?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nspcc-dev) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #3700      +/-   ##
==========================================
- Coverage   83.04%   83.01%   -0.04%     
==========================================
  Files         335      335              
  Lines       46719    46747      +28     
==========================================
+ Hits        38800    38806       +6     
- Misses       6327     6353      +26     
+ Partials     1592     1588       -4     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/nspcc-dev/neo-go/pull/3700?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nspcc-dev).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nspcc-dev).

----
🚨 Try these New Features:

- [Flaky Tests Detection](https://docs.codecov.com/docs/test-result-ingestion-beta) - Detect and resolve failed and flaky tests
AnnaShaleva: > don't we want to upgrade neo-go wallet candidate vote command as far? 

Ref. https://github.com/nspcc-dev/neo-go/issues/3775, this PR is only about contract-related changes.

Patch:
diff --git a/docs/node-configuration.md b/docs/node-configuration.md
index 3897dda6e..26565f9e7 100644
--- a/docs/node-configuration.md
+++ b/docs/node-configuration.md
@@ -470,7 +470,7 @@ in development and can change in an incompatible way.
 | `Basilisk`      | Enables strict smart contract script check against a set of JMP instructions and against method boundaries enabled on contract deploy or update. Increases `stackitem.Integer` JSON parsing precision up to the maximum value supported by the NeoVM. Enables strict check for notifications emitted by a contract to precisely match the events specified in the contract manifest. | https://github.com/nspcc-dev/neo-go/pull/3056 <br> https://github.com/neo-project/neo/pull/2881 <br> https://github.com/nspcc-dev/neo-go/pull/3080 <br> https://github.com/neo-project/neo/pull/2883 <br> https://github.com/nspcc-dev/neo-go/pull/3085 <br> https://github.com/neo-project/neo/pull/2810 |
 | `Cockatrice`    | Introduces the ability to update native contracts. Includes a couple of new native smart contract APIs: `keccak256` of native CryptoLib contract and `getCommitteeAddress` of native NeoToken contract. | https://github.com/nspcc-dev/neo-go/pull/3402 <br> https://github.com/neo-project/neo/pull/2942 <br> https://github.com/nspcc-dev/neo-go/pull/3301 <br> https://github.com/neo-project/neo/pull/2925 <br> https://github.com/nspcc-dev/neo-go/pull/3362 <br> https://github.com/neo-project/neo/pull/3154 |
 | `Domovoi`       | Makes node use executing contract state for the contract call permissions check instead of the state stored in the native Management contract. In C# also makes System.Runtime.GetNotifications interop properly count stack references of notification parameters which prevents users from creating objects that exceed MaxStackSize constraint, but NeoGo has never had this bug, thus proper behaviour is preserved even before HFDomovoi. It results in the fact that some T5 testnet transactions have different ApplicationLogs compared to the C# node, but the node states match. | https://github.com/nspcc-dev/neo-go/pull/3476 <br> https://github.com/neo-project/neo/pull/3290 <br> https://github.com/nspcc-dev/neo-go/pull/3473 <br> https://github.com/neo-project/neo/pull/3290 <br> https://github.com/neo-project/neo/pull/3301 <br> https://github.com/nspcc-dev/neo-go/pull/3485 |
-| `Echidna`       | Introduces `Designation` event extension with `Old` and `New` roles data to native RoleManagement contract. Adds support for `base64UrlEncode` and `base64UrlDecode` methods to native StdLib contract. Extends the list of required call flags for `registerCandidate`, `unregisterCandidate`and `vote` methods of native NeoToken contract with AllowNotify flag. | https://github.com/nspcc-dev/neo-go/pull/3554 <br> https://github.com/nspcc-dev/neo-go/pull/3761 |
+| `Echidna`       | Introduces `Designation` event extension with `Old` and `New` roles data to native RoleManagement contract. Adds support for `base64UrlEncode` and `base64UrlDecode` methods to native StdLib contract. Extends the list of required call flags for `registerCandidate`, `unregisterCandidate`and `vote` methods of native NeoToken contract with AllowNotify flag. Enables `onNEP17Payment` method of NEO contract for candidate registration. | https://github.com/nspcc-dev/neo-go/pull/3554 <br> https://github.com/nspcc-dev/neo-go/pull/3761 <br> https://github.com/nspcc-dev/neo-go/pull/3554 <br> https://github.com/neo-project/neo/pull/3597 <br> https://github.com/nspcc-dev/neo-go/pull/3700 |
 
 
 ## DB compatibility
diff --git a/pkg/core/native/native_neo.go b/pkg/core/native/native_neo.go
index d4e507826..93352bd7a 100644
--- a/pkg/core/native/native_neo.go
+++ b/pkg/core/native/native_neo.go
@@ -206,6 +206,13 @@ func newNEO(cfg config.ProtocolConfiguration) *NEO {
 	md = newMethodAndPrice(n.unregisterCandidate, 1<<16, callflag.States|callflag.AllowNotify, config.HFEchidna)
 	n.AddMethod(md, desc)
 
+	desc = newDescriptor("onNEP17Payment", smartcontract.VoidType,
+		manifest.NewParameter("from", smartcontract.Hash160Type),
+		manifest.NewParameter("amount", smartcontract.IntegerType),
+		manifest.NewParameter("data", smartcontract.AnyType))
+	md = newMethodAndPrice(n.onNEP17Payment, 1<<15, callflag.States|callflag.AllowNotify, config.HFEchidna)
+	n.AddMethod(md, desc)
+
 	desc = newDescriptor("vote", smartcontract.BoolType,
 		manifest.NewParameter("account", smartcontract.Hash160Type),
 		manifest.NewParameter("voteTo", smartcontract.PublicKeyType))
@@ -824,17 +831,52 @@ func (n *NEO) CalculateNEOHolderReward(d *dao.Simple, value *big.Int, start, end
 
 func (n *NEO) registerCandidate(ic *interop.Context, args []stackitem.Item) stackitem.Item {
 	pub := toPublicKey(args[0])
+	if !ic.IsHardforkEnabled(config.HFEchidna) {
+		ok, err := runtime.CheckKeyedWitness(ic, pub)
+		if err != nil {
+			panic(err)
+		} else if !ok {
+			return stackitem.NewBool(false)
+		}
+	}
+	if !ic.VM.AddGas(n.getRegisterPriceInternal(ic.DAO)) {
+		panic("insufficient gas")
+	}
+	var err = n.RegisterCandidateInternal(ic, pub)
+	return stackitem.NewBool(err == nil)
+}
+
+func (n *NEO) checkRegisterCandidate(ic *interop.Context, pub *keys.PublicKey) error {
 	ok, err := runtime.CheckKeyedWitness(ic, pub)
 	if err != nil {
 		panic(err)
 	} else if !ok {
-		return stackitem.NewBool(false)
+		return errors.New("not witnessed by the key owner")
 	}
-	if !ic.VM.AddGas(n.getRegisterPriceInternal(ic.DAO)) {
-		panic("insufficient gas")
+	return n.RegisterCandidateInternal(ic, pub)
+}
+
+func (n *NEO) onNEP17Payment(ic *interop.Context, args []stackitem.Item) stackitem.Item {
+	var (
+		caller   = ic.VM.GetCallingScriptHash()
+		_        = toUint160(args[0])
+		amount   = toBigInt(args[1])
+		pub      = toPublicKey(args[2])
+		regPrice = n.getRegisterPriceInternal(ic.DAO)
+	)
+
+	if caller != n.GAS.Hash {
+		panic("only GAS is accepted")
 	}
-	err = n.RegisterCandidateInternal(ic, pub)
-	return stackitem.NewBool(err == nil)
+	if !amount.IsInt64() || amount.Int64() != regPrice {
+		panic(fmt.Errorf("incorrect GAS amount for registration (expected %d)", regPrice))
+	}
+	var err = n.checkRegisterCandidate(ic, pub)
+	if err != nil {
+		panic(err)
+	}
+	n.GAS.burn(ic, n.Hash, amount)
+	return stackitem.Null{}
 }
 
 // RegisterCandidateInternal registers pub as a new candidate.


Test patch:
diff --git a/config/protocol.unit_testnet.yml b/config/protocol.unit_testnet.yml
index ff821d161..84e26a437 100644
--- a/config/protocol.unit_testnet.yml
+++ b/config/protocol.unit_testnet.yml
@@ -22,6 +22,7 @@ ProtocolConfiguration:
     Basilisk: 6
     Cockatrice: 9
     Domovoi: 12
+    Echidna: 13
 
 ApplicationConfiguration:
   SkipBlockVerification: false
diff --git a/pkg/core/blockchain_neotest_test.go b/pkg/core/blockchain_neotest_test.go
index 8b88ecf33..79490f838 100644
--- a/pkg/core/blockchain_neotest_test.go
+++ b/pkg/core/blockchain_neotest_test.go
@@ -272,7 +272,7 @@ func TestBlockchain_StartFromExistingDB(t *testing.T) {
 
 		_, _, _, err = chain.NewMultiWithCustomConfigAndStoreNoCheck(t, customConfig, cache)
 		require.Error(t, err)
-		require.True(t, strings.Contains(err.Error(), fmt.Sprintf("native %s: version mismatch for the latest hardfork Domovoi (stored contract state differs from autogenerated one)", nativenames.CryptoLib)), err)
+		require.True(t, strings.Contains(err.Error(), fmt.Sprintf("native %s: version mismatch for the latest hardfork Echidna (stored contract state differs from autogenerated one)", nativenames.CryptoLib)), err)
 	})
 
 	t.Run("good", func(t *testing.T) {
diff --git a/pkg/core/native/native_test/management_test.go b/pkg/core/native/native_test/management_test.go
index 09ef1b2c1..77f5d7195 100644
--- a/pkg/core/native/native_test/management_test.go
+++ b/pkg/core/native/native_test/management_test.go
@@ -57,6 +57,7 @@ var (
 		nativenames.Neo:       `{"id":-5,"hash":"0xef4073a0f2b305a38ec4050e4d3d28bc40ea63f5","nef":{"magic":860243278,"compiler":"neo-core-v3.0","source":"","tokens":[],"script":"EEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0A=","checksum":1325686241},"manifest":{"name":"NeoToken","abi":{"methods":[{"name":"balanceOf","offset":0,"parameters":[{"name":"account","type":"Hash160"}],"returntype":"Integer","safe":true},{"name":"decimals","offset":7,"parameters":[],"returntype":"Integer","safe":true},{"name":"getAccountState","offset":14,"parameters":[{"name":"account","type":"Hash160"}],"returntype":"Array","safe":true},{"name":"getAllCandidates","offset":21,"parameters":[],"returntype":"InteropInterface","safe":true},{"name":"getCandidateVote","offset":28,"parameters":[{"name":"pubKey","type":"PublicKey"}],"returntype":"Integer","safe":true},{"name":"getCandidates","offset":35,"parameters":[],"returntype":"Array","safe":true},{"name":"getCommittee","offset":42,"parameters":[],"returntype":"Array","safe":true},{"name":"getCommitteeAddress","offset":49,"parameters":[],"returntype":"Hash160","safe":true},{"name":"getGasPerBlock","offset":56,"parameters":[],"returntype":"Integer","safe":true},{"name":"getNextBlockValidators","offset":63,"parameters":[],"returntype":"Array","safe":true},{"name":"getRegisterPrice","offset":70,"parameters":[],"returntype":"Integer","safe":true},{"name":"registerCandidate","offset":77,"parameters":[{"name":"pubkey","type":"PublicKey"}],"returntype":"Boolean","safe":false},{"name":"setGasPerBlock","offset":84,"parameters":[{"name":"gasPerBlock","type":"Integer"}],"returntype":"Void","safe":false},{"name":"setRegisterPrice","offset":91,"parameters":[{"name":"registerPrice","type":"Integer"}],"returntype":"Void","safe":false},{"name":"symbol","offset":98,"parameters":[],"returntype":"String","safe":true},{"name":"totalSupply","offset":105,"parameters":[],"returntype":"Integer","safe":true},{"name":"transfer","offset":112,"parameters":[{"name":"from","type":"Hash160"},{"name":"to","type":"Hash160"},{"name":"amount","type":"Integer"},{"name":"data","type":"Any"}],"returntype":"Boolean","safe":false},{"name":"unclaimedGas","offset":119,"parameters":[{"name":"account","type":"Hash160"},{"name":"end","type":"Integer"}],"returntype":"Integer","safe":true},{"name":"unregisterCandidate","offset":126,"parameters":[{"name":"pubkey","type":"PublicKey"}],"returntype":"Boolean","safe":false},{"name":"vote","offset":133,"parameters":[{"name":"account","type":"Hash160"},{"name":"voteTo","type":"PublicKey"}],"returntype":"Boolean","safe":false}],"events":[{"name":"Transfer","parameters":[{"name":"from","type":"Hash160"},{"name":"to","type":"Hash160"},{"name":"amount","type":"Integer"}]},{"name":"CandidateStateChanged","parameters":[{"name":"pubkey","type":"PublicKey"},{"name":"registered","type":"Boolean"},{"name":"votes","type":"Integer"}]},{"name":"Vote","parameters":[{"name":"account","type":"Hash160"},{"name":"from","type":"PublicKey"},{"name":"to","type":"PublicKey"},{"name":"amount","type":"Integer"}]},{"name":"CommitteeChanged","parameters":[{"name":"old","type":"Array"},{"name":"new","type":"Array"}]}]},"features":{},"groups":[],"permissions":[{"contract":"*","methods":"*"}],"supportedstandards":["NEP-17"],"trusts":[],"extra":null},"updatecounter":0}`,
 	}
 	echidnaCSS = map[string]string{
+		nativenames.Neo:         `{"id":-5,"hash":"0xef4073a0f2b305a38ec4050e4d3d28bc40ea63f5","nef":{"magic":860243278,"compiler":"neo-core-v3.0","source":"","tokens":[],"script":"EEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dA","checksum":1991619121},"manifest":{"name":"NeoToken","abi":{"methods":[{"name":"balanceOf","offset":0,"parameters":[{"name":"account","type":"Hash160"}],"returntype":"Integer","safe":true},{"name":"decimals","offset":7,"parameters":[],"returntype":"Integer","safe":true},{"name":"getAccountState","offset":14,"parameters":[{"name":"account","type":"Hash160"}],"returntype":"Array","safe":true},{"name":"getAllCandidates","offset":21,"parameters":[],"returntype":"InteropInterface","safe":true},{"name":"getCandidateVote","offset":28,"parameters":[{"name":"pubKey","type":"PublicKey"}],"returntype":"Integer","safe":true},{"name":"getCandidates","offset":35,"parameters":[],"returntype":"Array","safe":true},{"name":"getCommittee","offset":42,"parameters":[],"returntype":"Array","safe":true},{"name":"getCommitteeAddress","offset":49,"parameters":[],"returntype":"Hash160","safe":true},{"name":"getGasPerBlock","offset":56,"parameters":[],"returntype":"Integer","safe":true},{"name":"getNextBlockValidators","offset":63,"parameters":[],"returntype":"Array","safe":true},{"name":"getRegisterPrice","offset":70,"parameters":[],"returntype":"Integer","safe":true},{"name":"onNEP17Payment","offset":77,"parameters":[{"name":"from","type":"Hash160"},{"name":"amount","type":"Integer"},{"name":"data","type":"Any"}],"returntype":"Void","safe":false},{"name":"registerCandidate","offset":84,"parameters":[{"name":"pubkey","type":"PublicKey"}],"returntype":"Boolean","safe":false},{"name":"setGasPerBlock","offset":91,"parameters":[{"name":"gasPerBlock","type":"Integer"}],"returntype":"Void","safe":false},{"name":"setRegisterPrice","offset":98,"parameters":[{"name":"registerPrice","type":"Integer"}],"returntype":"Void","safe":false},{"name":"symbol","offset":105,"parameters":[],"returntype":"String","safe":true},{"name":"totalSupply","offset":112,"parameters":[],"returntype":"Integer","safe":true},{"name":"transfer","offset":119,"parameters":[{"name":"from","type":"Hash160"},{"name":"to","type":"Hash160"},{"name":"amount","type":"Integer"},{"name":"data","type":"Any"}],"returntype":"Boolean","safe":false},{"name":"unclaimedGas","offset":126,"parameters":[{"name":"account","type":"Hash160"},{"name":"end","type":"Integer"}],"returntype":"Integer","safe":true},{"name":"unregisterCandidate","offset":133,"parameters":[{"name":"pubkey","type":"PublicKey"}],"returntype":"Boolean","safe":false},{"name":"vote","offset":140,"parameters":[{"name":"account","type":"Hash160"},{"name":"voteTo","type":"PublicKey"}],"returntype":"Boolean","safe":false}],"events":[{"name":"Transfer","parameters":[{"name":"from","type":"Hash160"},{"name":"to","type":"Hash160"},{"name":"amount","type":"Integer"}]},{"name":"CandidateStateChanged","parameters":[{"name":"pubkey","type":"PublicKey"},{"name":"registered","type":"Boolean"},{"name":"votes","type":"Integer"}]},{"name":"Vote","parameters":[{"name":"account","type":"Hash160"},{"name":"from","type":"PublicKey"},{"name":"to","type":"PublicKey"},{"name":"amount","type":"Integer"}]},{"name":"CommitteeChanged","parameters":[{"name":"old","type":"Array"},{"name":"new","type":"Array"}]}]},"features":{},"groups":[],"permissions":[{"contract":"*","methods":"*"}],"supportedstandards":["NEP-17"],"trusts":[],"extra":null},"updatecounter":0}`,
 		nativenames.StdLib:      `{"id":-2,"hash":"0xacce6fd80d44e1796aa0c2c625e9e4e0ce39efc0","nef":{"magic":860243278,"compiler":"neo-core-v3.0","source":"","tokens":[],"script":"EEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0A=","checksum":2681632925},"manifest":{"name":"StdLib","abi":{"methods":[{"name":"atoi","offset":0,"parameters":[{"name":"value","type":"String"}],"returntype":"Integer","safe":true},{"name":"atoi","offset":7,"parameters":[{"name":"value","type":"String"},{"name":"base","type":"Integer"}],"returntype":"Integer","safe":true},{"name":"base58CheckDecode","offset":14,"parameters":[{"name":"s","type":"String"}],"returntype":"ByteArray","safe":true},{"name":"base58CheckEncode","offset":21,"parameters":[{"name":"data","type":"ByteArray"}],"returntype":"String","safe":true},{"name":"base58Decode","offset":28,"parameters":[{"name":"s","type":"String"}],"returntype":"ByteArray","safe":true},{"name":"base58Encode","offset":35,"parameters":[{"name":"data","type":"ByteArray"}],"returntype":"String","safe":true},{"name":"base64Decode","offset":42,"parameters":[{"name":"s","type":"String"}],"returntype":"ByteArray","safe":true},{"name":"base64Encode","offset":49,"parameters":[{"name":"data","type":"ByteArray"}],"returntype":"String","safe":true},{"name":"base64UrlDecode","offset":56,"parameters":[{"name":"s","type":"String"}],"returntype":"ByteArray","safe":true},{"name":"base64UrlEncode","offset":63,"parameters":[{"name":"data","type":"ByteArray"}],"returntype":"String","safe":true},{"name":"deserialize","offset":70,"parameters":[{"name":"data","type":"ByteArray"}],"returntype":"Any","safe":true},{"name":"itoa","offset":77,"parameters":[{"name":"value","type":"Integer"}],"returntype":"String","safe":true},{"name":"itoa","offset":84,"parameters":[{"name":"value","type":"Integer"},{"name":"base","type":"Integer"}],"returntype":"String","safe":true},{"name":"jsonDeserialize","offset":91,"parameters":[{"name":"json","type":"ByteArray"}],"returntype":"Any","safe":true},{"name":"jsonSerialize","offset":98,"parameters":[{"name":"item","type":"Any"}],"returntype":"ByteArray","safe":true},{"name":"memoryCompare","offset":105,"parameters":[{"name":"str1","type":"ByteArray"},{"name":"str2","type":"ByteArray"}],"returntype":"Integer","safe":true},{"name":"memorySearch","offset":112,"parameters":[{"name":"mem","type":"ByteArray"},{"name":"value","type":"ByteArray"}],"returntype":"Integer","safe":true},{"name":"memorySearch","offset":119,"parameters":[{"name":"mem","type":"ByteArray"},{"name":"value","type":"ByteArray"},{"name":"start","type":"Integer"}],"returntype":"Integer","safe":true},{"name":"memorySearch","offset":126,"parameters":[{"name":"mem","type":"ByteArray"},{"name":"value","type":"ByteArray"},{"name":"start","type":"Integer"},{"name":"backward","type":"Boolean"}],"returntype":"Integer","safe":true},{"name":"serialize","offset":133,"parameters":[{"name":"item","type":"Any"}],"returntype":"ByteArray","safe":true},{"name":"strLen","offset":140,"parameters":[{"name":"str","type":"String"}],"returntype":"Integer","safe":true},{"name":"stringSplit","offset":147,"parameters":[{"name":"str","type":"String"},{"name":"separator","type":"String"}],"returntype":"Array","safe":true},{"name":"stringSplit","offset":154,"parameters":[{"name":"str","type":"String"},{"name":"separator","type":"String"},{"name":"removeEmptyEntries","type":"Boolean"}],"returntype":"Array","safe":true}],"events":[]},"features":{},"groups":[],"permissions":[{"contract":"*","methods":"*"}],"supportedstandards":[],"trusts":[],"extra":null},"updatecounter":0}`,
 		nativenames.Designation: `{"id":-8,"hash":"0x49cf4e5378ffcd4dec034fd98a174c5491e395e2","nef":{"magic":860243278,"compiler":"neo-core-v3.0","source":"","tokens":[],"script":"EEEa93tnQBBBGvd7Z0A=","checksum":983638438},"manifest":{"name":"RoleManagement","abi":{"methods":[{"name":"designateAsRole","offset":0,"parameters":[{"name":"role","type":"Integer"},{"name":"nodes","type":"Array"}],"returntype":"Void","safe":false},{"name":"getDesignatedByRole","offset":7,"parameters":[{"name":"role","type":"Integer"},{"name":"index","type":"Integer"}],"returntype":"Array","safe":true}],"events":[{"name":"Designation","parameters":[{"name":"Role","type":"Integer"},{"name":"BlockIndex","type":"Integer"},{"name":"Old","type":"Array"},{"name":"New","type":"Array"}]}]},"features":{},"groups":[],"permissions":[{"contract":"*","methods":"*"}],"supportedstandards":[],"trusts":[],"extra":null},"updatecounter":0}`,
 	}
diff --git a/pkg/core/native/native_test/neo_test.go b/pkg/core/native/native_test/neo_test.go
index 616208c9a..e5edf3919 100644
--- a/pkg/core/native/native_test/neo_test.go
+++ b/pkg/core/native/native_test/neo_test.go
@@ -740,6 +740,13 @@ func TestNEO_CalculateBonus(t *testing.T) {
 	accH := acc.Signers[0].ScriptHash()
 	rewardDistance := 10
 
+	// We have 11 blocks made by transactions above and we need block 13 to get Echidna.
+	// Otherwise this happens:
+	//    logger.go:146: 2024-12-24T17:52:18.160+0300 WARN    contract invocation failed      {"tx": "603d1b0e29e7aaf50513689be9d5bb946c7f7fec8836f0e90897c825fb762c13", "block": 13, "error": "at instruction 120 (SYSCALL): System.Contract.CallNative failed: gas limit exceeded"}
+	for range 3 {
+		e.AddNewBlock(t)
+	}
+
 	t.Run("Zero", func(t *testing.T) {
 		initialGASBalance := e.Chain.GetUtilityTokenBalance(accH)
 		for range rewardDistance {
@@ -900,3 +907,86 @@ func TestNEO_GetCandidates(t *testing.T) {
 	neoCommitteeInvoker.Invoke(t, expected, "getCandidates")
 	checkGetAllCandidates(t, expected)
 }
+
+func TestNEO_RegisterViaNEP27(t *testing.T) {
+	neoCommitteeInvoker := newNeoCommitteeClient(t, 100_0000_0000)
+	neoValidatorsInvoker := neoCommitteeInvoker.WithSigners(neoCommitteeInvoker.Validator)
+	e := neoCommitteeInvoker.Executor
+	neoHash := e.NativeHash(t, nativenames.Neo)
+
+	cfg := e.Chain.GetConfig()
+	candidatesCount := cfg.GetCommitteeSize(0) - 1
+
+	// Register a set of candidates and vote for them.
+	voters := make([]neotest.Signer, candidatesCount)
+	candidates := make([]neotest.Signer, candidatesCount)
+	for i := range candidatesCount {
+		voters[i] = e.NewAccount(t, 2000_0000_0000) // enough for one registration
+		candidates[i] = e.NewAccount(t, 2000_0000_0000)
+	}
+
+	stack, err := neoCommitteeInvoker.TestInvoke(t, "getRegisterPrice")
+	require.NoError(t, err)
+	registrationPrice, err := stack.Pop().Item().TryInteger()
+	require.NoError(t, err)
+
+	// We have 11 blocks made by transactions above and we need block 13 to get Echidna.
+	for range 3 {
+		e.AddNewBlock(t)
+	}
+
+	gasValidatorsInvoker := e.CommitteeInvoker(e.NativeHash(t, nativenames.Gas))
+	txes := make([]*transaction.Transaction, 0, candidatesCount*3)
+	for i := range candidatesCount {
+		transferTx := neoValidatorsInvoker.PrepareInvoke(t, "transfer", e.Validator.ScriptHash(), voters[i].(neotest.SingleSigner).Account().PrivateKey().GetScriptHash(), int64(candidatesCount+1-i)*1000000, nil)
+		txes = append(txes, transferTx)
+		registerTx := gasValidatorsInvoker.WithSigners(candidates[i]).PrepareInvoke(t, "transfer", candidates[i].(neotest.SingleSigner).Account().ScriptHash(), neoHash, registrationPrice, candidates[i].(neotest.SingleSigner).Account().PublicKey().Bytes())
+		txes = append(txes, registerTx)
+		voteTx := neoValidatorsInvoker.WithSigners(voters[i]).PrepareInvoke(t, "vote", voters[i].(neotest.SingleSigner).Account().PrivateKey().GetScriptHash(), candidates[i].(neotest.SingleSigner).Account().PublicKey().Bytes())
+		txes = append(txes, voteTx)
+	}
+
+	neoValidatorsInvoker.AddNewBlock(t, txes...)
+	for _, tx := range txes {
+		e.CheckHalt(t, tx.Hash(), stackitem.Make(true)) // luckily, both `transfer` and `vote` return boolean values
+	}
+
+	// Ensure NEO holds no GAS.
+	stack, err = gasValidatorsInvoker.TestInvoke(t, "balanceOf", neoHash)
+	require.NoError(t, err)
+	balance, err := stack.Pop().Item().TryInteger()
+	require.NoError(t, err)
+	require.Equal(t, 0, balance.Sign())
+
+	var expected = make([]stackitem.Item, candidatesCount)
+	for i := range expected {
+		pub := candidates[i].(neotest.SingleSigner).Account().PublicKey().Bytes()
+		v := stackitem.NewBigInteger(big.NewInt(int64(candidatesCount-i+1) * 1000000))
+		expected[i] = stackitem.NewStruct([]stackitem.Item{
+			stackitem.NewByteArray(pub),
+			v,
+		})
+		neoCommitteeInvoker.Invoke(t, v, "getCandidateVote", pub)
+	}
+
+	slices.SortFunc(expected, func(a, b stackitem.Item) int {
+		return bytes.Compare(a.Value().([]stackitem.Item)[0].Value().([]byte), b.Value().([]stackitem.Item)[0].Value().([]byte))
+	})
+
+	neoCommitteeInvoker.Invoke(t, stackitem.NewArray(expected), "getCandidates")
+
+	// Invalid cases.
+	var newCand = voters[0]
+
+	// Missing data.
+	gasValidatorsInvoker.WithSigners(newCand).InvokeFail(t, "invalid conversion", "transfer", newCand.(neotest.SingleSigner).Account().ScriptHash(), neoHash, registrationPrice, nil)
+	// Invalid data.
+	gasValidatorsInvoker.WithSigners(newCand).InvokeFail(t, "unexpected EOF", "transfer", newCand.(neotest.SingleSigner).Account().ScriptHash(), neoHash, registrationPrice, []byte{2, 2, 2})
+	// NEO transfer.
+	neoValidatorsInvoker.WithSigners(newCand).InvokeFail(t, "only GAS is accepted", "transfer", newCand.(neotest.SingleSigner).Account().ScriptHash(), neoHash, 1, newCand.(neotest.SingleSigner).Account().PublicKey().Bytes())
+	// Incorrect amount.
+	gasValidatorsInvoker.WithSigners(newCand).InvokeFail(t, "incorrect GAS amount", "transfer", newCand.(neotest.SingleSigner).Account().ScriptHash(), neoHash, 1, newCand.(neotest.SingleSigner).Account().PublicKey().Bytes())
+	// Incorrect witness.
+	var anotherAcc = e.NewAccount(t, 2000_0000_0000)
+	gasValidatorsInvoker.WithSigners(newCand).InvokeFail(t, "not witnessed by the key owner", "transfer", newCand.(neotest.SingleSigner).Account().ScriptHash(), neoHash, registrationPrice, anotherAcc.(neotest.SingleSigner).Account().PublicKey().Bytes())
+}
diff --git a/pkg/services/blockfetcher/blockfetcher_test.go b/pkg/services/blockfetcher/blockfetcher_test.go
index dedb07479..970a5d456 100644
--- a/pkg/services/blockfetcher/blockfetcher_test.go
+++ b/pkg/services/blockfetcher/blockfetcher_test.go
@@ -85,7 +85,7 @@ func TestServiceConstructor(t *testing.T) {
 			InternalService: config.InternalService{
 				Enabled: true,
 			},
-			Addresses: []string{"localhost:8080"},
+			Addresses: []string{"localhost:1"},
 		}
 		service, err := New(ledger, cfg, logger, mockPut.putBlock, shutdownCallback)
 		require.NoError(t, err)
diff --git a/pkg/services/rpcsrv/client_test.go b/pkg/services/rpcsrv/client_test.go
index 73a373928..2a1731be3 100644
--- a/pkg/services/rpcsrv/client_test.go
+++ b/pkg/services/rpcsrv/client_test.go
@@ -2461,6 +2461,7 @@ func TestClient_GetVersion_Hardforks(t *testing.T) {
 		config.HFBasilisk:      6,
 		config.HFCockatrice:    9,
 		config.HFDomovoi:       12,
+		config.HFEchidna:       13,
 	}
 	require.InDeltaMapValues(t, expected, v.Protocol.Hardforks, 0)
 }
diff --git a/pkg/services/rpcsrv/server_test.go b/pkg/services/rpcsrv/server_test.go
index 117bd56f4..da4f7beeb 100644
--- a/pkg/services/rpcsrv/server_test.go
+++ b/pkg/services/rpcsrv/server_test.go
@@ -121,7 +121,7 @@ const (
 	// not yet deployed to the testing basic chain.
 	invokescriptContractAVM = "VwIADBQBDAMOBQYMDQIODw0DDgcJAAAAAErZMCQE2zBwaEH4J+yMqiYEEUAMFA0PAwIJAAIBAwcDBAUCAQAOBgwJStkwJATbMHFpQfgn7IyqJgQSQBNA"
 	// block20StateRootLE is an LE stateroot of block #20 of basic testing chain.
-	block20StateRootLE = "570ba0814003f6e6a2a2e41d1b727f8af756e9c26d2453c8316868607b66da0a"
+	block20StateRootLE = "310acac4fd692ab7a90dbd7fcf6feaf1ac33aabeedf6592c4ddd08ff1dac15de"
 )
 
 var (