diff --git a/neo.sln b/neo.sln
index 3779ae8d78..e1de480f6e 100644
--- a/neo.sln
+++ b/neo.sln
@@ -1,4 +1,5 @@
-Microsoft Visual Studio Solution File, Format Version 12.00
+
+Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.2.32516.85
MinimumVisualStudioVersion = 10.0.40219.1
@@ -88,6 +89,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Neo.Cryptography.MPTTrie.Te
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Neo.Network.RPC.Tests", "tests\Neo.Network.RPC.Tests\Neo.Network.RPC.Tests.csproj", "{19B1CF1A-17F4-4E04-AB9C-55CE74952E11}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SignClient", "src\Plugins\SignClient\SignClient.csproj", "{CAD55942-48A3-4526-979D-7519FADF19FE}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Neo.Plugins.SignClient.Tests", "tests\Neo.Plugins.SignClient.Tests\Neo.Plugins.SignClient.Tests.csproj", "{E2CFEAA1-45F2-4075-94ED-866862C6863F}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -214,6 +219,10 @@ Global
{8C866DC8-2E55-4399-9563-2F47FD4602EC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8C866DC8-2E55-4399-9563-2F47FD4602EC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8C866DC8-2E55-4399-9563-2F47FD4602EC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {72997EAB-9B0C-4BC8-B797-955C219C2C97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {72997EAB-9B0C-4BC8-B797-955C219C2C97}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {72997EAB-9B0C-4BC8-B797-955C219C2C97}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {72997EAB-9B0C-4BC8-B797-955C219C2C97}.Release|Any CPU.Build.0 = Release|Any CPU
{77FDEE2E-9381-4BFC-B9E6-741EDBD6B90F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{77FDEE2E-9381-4BFC-B9E6-741EDBD6B90F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{77FDEE2E-9381-4BFC-B9E6-741EDBD6B90F}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -226,10 +235,6 @@ Global
{5F984D2B-793F-4683-B53A-80050E6E0286}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5F984D2B-793F-4683-B53A-80050E6E0286}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5F984D2B-793F-4683-B53A-80050E6E0286}.Release|Any CPU.Build.0 = Release|Any CPU
- {72997EAB-9B0C-4BC8-B797-955C219C2C97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {72997EAB-9B0C-4BC8-B797-955C219C2C97}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {72997EAB-9B0C-4BC8-B797-955C219C2C97}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {72997EAB-9B0C-4BC8-B797-955C219C2C97}.Release|Any CPU.Build.0 = Release|Any CPU
{9ADB4E11-8655-42C2-8A75-E4436F56F17A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9ADB4E11-8655-42C2-8A75-E4436F56F17A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9ADB4E11-8655-42C2-8A75-E4436F56F17A}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -246,6 +251,14 @@ Global
{19B1CF1A-17F4-4E04-AB9C-55CE74952E11}.Debug|Any CPU.Build.0 = Debug|Any CPU
{19B1CF1A-17F4-4E04-AB9C-55CE74952E11}.Release|Any CPU.ActiveCfg = Release|Any CPU
{19B1CF1A-17F4-4E04-AB9C-55CE74952E11}.Release|Any CPU.Build.0 = Release|Any CPU
+ {CAD55942-48A3-4526-979D-7519FADF19FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {CAD55942-48A3-4526-979D-7519FADF19FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CAD55942-48A3-4526-979D-7519FADF19FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {CAD55942-48A3-4526-979D-7519FADF19FE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E2CFEAA1-45F2-4075-94ED-866862C6863F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E2CFEAA1-45F2-4075-94ED-866862C6863F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E2CFEAA1-45F2-4075-94ED-866862C6863F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E2CFEAA1-45F2-4075-94ED-866862C6863F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -282,14 +295,16 @@ Global
{FF76D8A4-356B-461A-8471-BC1B83E57BBC} = {C2DC830A-327A-42A7-807D-295216D30DBB}
{5E4947F3-05D3-4806-B0F3-30DAC71B5986} = {C2DC830A-327A-42A7-807D-295216D30DBB}
{8C866DC8-2E55-4399-9563-2F47FD4602EC} = {7F257712-D033-47FF-B439-9D4320D06599}
+ {72997EAB-9B0C-4BC8-B797-955C219C2C97} = {7F257712-D033-47FF-B439-9D4320D06599}
{77FDEE2E-9381-4BFC-B9E6-741EDBD6B90F} = {EDE05FA8-8E73-4924-BC63-DD117127EEE1}
{B6CB2559-10F9-41AC-8D58-364BFEF9688B} = {C25EB0B0-0CAC-4CC1-8F36-F9229EFB99EC}
{5F984D2B-793F-4683-B53A-80050E6E0286} = {C25EB0B0-0CAC-4CC1-8F36-F9229EFB99EC}
- {72997EAB-9B0C-4BC8-B797-955C219C2C97} = {7F257712-D033-47FF-B439-9D4320D06599}
{9ADB4E11-8655-42C2-8A75-E4436F56F17A} = {B5339DF7-5D1D-43BA-B332-74B825E1770E}
{E384C5EF-493E-4ED6-813C-6364F968CEE8} = {B5339DF7-5D1D-43BA-B332-74B825E1770E}
{40A23D45-1E81-41A4-B587-16AF26630103} = {EDE05FA8-8E73-4924-BC63-DD117127EEE1}
{19B1CF1A-17F4-4E04-AB9C-55CE74952E11} = {EDE05FA8-8E73-4924-BC63-DD117127EEE1}
+ {CAD55942-48A3-4526-979D-7519FADF19FE} = {C2DC830A-327A-42A7-807D-295216D30DBB}
+ {E2CFEAA1-45F2-4075-94ED-866862C6863F} = {7F257712-D033-47FF-B439-9D4320D06599}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {BCBA19D9-F868-4C6D-8061-A2B91E06E3EC}
diff --git a/src/Neo/Sign/SignException.cs b/src/Neo/Sign/SignException.cs
index 36bb8cc504..e734b6fb66 100644
--- a/src/Neo/Sign/SignException.cs
+++ b/src/Neo/Sign/SignException.cs
@@ -22,6 +22,7 @@ public class SignException : Exception
/// Initializes a new instance of the class.
///
/// The message that describes the error.
- public SignException(string message) : base(message) { }
+ /// The cause of the exception.
+ public SignException(string message, Exception cause = null) : base(message, cause) { }
}
}
diff --git a/src/Plugins/DBFTPlugin/Consensus/ConsensusContext.MakePayload.cs b/src/Plugins/DBFTPlugin/Consensus/ConsensusContext.MakePayload.cs
index 769174ffa5..9849082a87 100644
--- a/src/Plugins/DBFTPlugin/Consensus/ConsensusContext.MakePayload.cs
+++ b/src/Plugins/DBFTPlugin/Consensus/ConsensusContext.MakePayload.cs
@@ -14,7 +14,6 @@
using Neo.Network.P2P.Payloads;
using Neo.Plugins.DBFTPlugin.Messages;
using Neo.Plugins.DBFTPlugin.Types;
-using Neo.Sign;
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
diff --git a/src/Plugins/SignClient/Settings.cs b/src/Plugins/SignClient/Settings.cs
new file mode 100644
index 0000000000..7660c76e87
--- /dev/null
+++ b/src/Plugins/SignClient/Settings.cs
@@ -0,0 +1,55 @@
+// Copyright (C) 2015-2025 The Neo Project.
+//
+// Settings.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 Microsoft.Extensions.Configuration;
+
+namespace Neo.Plugins.SignClient
+{
+ public class Settings : PluginSettings
+ {
+ public const string SectionName = "PluginConfiguration";
+ private const string DefaultEndpoint = "http://127.0.0.1:9991";
+
+ ///
+ /// The name of the sign client(i.e. Signer).
+ ///
+ public readonly string Name;
+
+ ///
+ /// The host of the sign client(i.e. Signer).
+ ///
+ public readonly string Endpoint;
+
+ public Settings(IConfigurationSection section) : base(section)
+ {
+ Name = section.GetValue("Name", "SignClient");
+
+ // Only support local host at present, so host always is "127.0.0.1" or "::1" now.
+ Endpoint = section.GetValue("Endpoint", DefaultEndpoint);
+ }
+
+ public static Settings Default
+ {
+ get
+ {
+ var section = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ [SectionName + ":Name"] = "SignClient",
+ [SectionName + ":Endpoint"] = DefaultEndpoint
+ })
+ .Build()
+ .GetSection(SectionName);
+ return new Settings(section);
+ }
+ }
+ }
+}
diff --git a/src/Plugins/SignClient/SignClient.cs b/src/Plugins/SignClient/SignClient.cs
new file mode 100644
index 0000000000..8667aaba5d
--- /dev/null
+++ b/src/Plugins/SignClient/SignClient.cs
@@ -0,0 +1,333 @@
+// Copyright (C) 2015-2025 The Neo Project.
+//
+// SignClient.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 Google.Protobuf;
+using Grpc.Core;
+using Grpc.Net.Client;
+using Grpc.Net.Client.Configuration;
+using Neo.ConsoleService;
+using Neo.Cryptography.ECC;
+using Neo.Extensions;
+using Neo.Network.P2P.Payloads;
+using Neo.Persistence;
+using Neo.Sign;
+using Neo.SmartContract;
+using Servicepb;
+using Signpb;
+using System.Diagnostics.CodeAnalysis;
+using static Neo.SmartContract.Helper;
+
+using ExtensiblePayload = Neo.Network.P2P.Payloads.ExtensiblePayload;
+
+namespace Neo.Plugins.SignClient
+{
+ ///
+ /// A signer that uses a client to sign transactions.
+ ///
+ public class SignClient : Plugin, ISigner
+ {
+ private GrpcChannel? _channel;
+
+ private SecureSign.SecureSignClient? _client;
+
+ private string _name = string.Empty;
+
+ public override string Description => "Signer plugin for signer service.";
+
+ public override string ConfigFile => System.IO.Path.Combine(RootPath, "SignClient.json");
+
+ public SignClient() { }
+
+ public SignClient(Settings settings)
+ {
+ Reset(settings);
+ }
+
+ // It's for test now.
+ internal SignClient(string name, SecureSign.SecureSignClient client)
+ {
+ Reset(name, client);
+ }
+
+ private void Reset(string name, SecureSign.SecureSignClient? client)
+ {
+ if (_client is not null) SignerManager.UnregisterSigner(_name);
+
+ _name = name;
+ _client = client;
+ if (!string.IsNullOrEmpty(_name)) SignerManager.RegisterSigner(_name, this);
+ }
+
+ private void Reset(Settings settings)
+ {
+ // _settings = settings;
+ var methodConfig = new MethodConfig
+ {
+ Names = { MethodName.Default },
+ RetryPolicy = new RetryPolicy
+ {
+ MaxAttempts = 3,
+ InitialBackoff = TimeSpan.FromMilliseconds(50),
+ MaxBackoff = TimeSpan.FromMilliseconds(200),
+ BackoffMultiplier = 1.5,
+ RetryableStatusCodes = {
+ StatusCode.Cancelled,
+ StatusCode.DeadlineExceeded,
+ StatusCode.ResourceExhausted,
+ StatusCode.Unavailable,
+ StatusCode.Aborted,
+ StatusCode.Internal,
+ StatusCode.DataLoss,
+ StatusCode.Unknown
+ }
+ }
+ };
+
+ var channel = GrpcChannel.ForAddress(settings.Endpoint, new GrpcChannelOptions
+ {
+ ServiceConfig = new ServiceConfig { MethodConfigs = { methodConfig } }
+ });
+
+ _channel?.Dispose();
+ _channel = channel;
+ Reset(settings.Name, new SecureSign.SecureSignClient(_channel));
+ }
+
+ ///
+ /// Get account status command
+ ///
+ /// The hex public key, compressed or uncompressed
+ [ConsoleCommand("get account status", Category = "Signer Commands", Description = "Get account status")]
+ public void AccountStatusCommand(string hexPublicKey)
+ {
+ if (_client is null)
+ {
+ ConsoleHelper.Error("No signer service is connected");
+ return;
+ }
+
+ try
+ {
+ var publicKey = ECPoint.DecodePoint(hexPublicKey.HexToBytes(), ECCurve.Secp256r1);
+ var output = _client.GetAccountStatus(new()
+ {
+ PublicKey = ByteString.CopyFrom(publicKey.EncodePoint(true))
+ });
+ ConsoleHelper.Info($"Account status: {output.Status}");
+ }
+ catch (RpcException rpcEx)
+ {
+ var message = rpcEx.StatusCode == StatusCode.Unavailable ?
+ "No available signer service" :
+ $"Failed to get account status: {rpcEx.StatusCode}: {rpcEx.Status.Detail}";
+ ConsoleHelper.Error(message);
+ }
+ catch (FormatException formatEx)
+ {
+ ConsoleHelper.Error($"Invalid public key: {formatEx.Message}");
+ }
+ }
+
+ private AccountStatus GetAccountStatus(ECPoint publicKey)
+ {
+ if (_client is null) throw new SignException("No signer service is connected");
+
+ try
+ {
+ var output = _client.GetAccountStatus(new()
+ {
+ PublicKey = ByteString.CopyFrom(publicKey.EncodePoint(true))
+ });
+ return output.Status;
+ }
+ catch (RpcException ex)
+ {
+ throw new SignException($"Get account status: {ex.Status}", ex);
+ }
+ }
+
+ ///
+ /// Check if the account is signable
+ ///
+ /// The public key
+ /// True if the account is signable, false otherwise
+ /// If no signer service is available, or other rpc error occurs.
+ public bool ContainsSignable(ECPoint publicKey)
+ {
+ var status = GetAccountStatus(publicKey);
+ return status == AccountStatus.Single || status == AccountStatus.Multiple;
+ }
+
+ private static bool TryDecodePublicKey(ByteString publicKey, [NotNullWhen(true)] out ECPoint? point)
+ {
+ try
+ {
+ point = ECPoint.DecodePoint(publicKey.Span, ECCurve.Secp256r1);
+ }
+ catch (FormatException) // add log later
+ {
+ point = null;
+ }
+ return point is not null;
+ }
+
+ internal Witness[] SignContext(ContractParametersContext context, IEnumerable signs)
+ {
+ var succeed = false;
+ foreach (var (accountSigns, scriptHash) in signs.Zip(context.ScriptHashes))
+ {
+ var accountStatus = accountSigns.Status;
+ if (accountStatus == AccountStatus.NoSuchAccount || accountStatus == AccountStatus.NoPrivateKey)
+ {
+ succeed |= context.AddWithScriptHash(scriptHash); // Same as Wallet.Sign(context)
+ continue;
+ }
+
+ var contract = accountSigns.Contract;
+ var accountContract = Contract.Create(
+ contract?.Parameters?.Select(p => (ContractParameterType)p).ToArray() ?? [],
+ contract?.Script?.ToByteArray() ?? []);
+ if (accountStatus == AccountStatus.Multiple)
+ {
+ if (!IsMultiSigContract(accountContract.Script, out int m, out ECPoint[] publicKeys))
+ throw new SignException("Sign context: multi-sign account but not multi-sign contract");
+
+ foreach (var sign in accountSigns.Signs)
+ {
+ if (!TryDecodePublicKey(sign.PublicKey, out var publicKey)) continue;
+
+ if (!publicKeys.Contains(publicKey))
+ throw new SignException($"Sign context: public key {publicKey} not in multi-sign contract");
+
+ var ok = context.AddSignature(accountContract, publicKey, sign.Signature.ToByteArray());
+ if (ok) m--;
+
+ succeed |= ok;
+ if (context.Completed || m <= 0) break;
+ }
+ }
+ else if (accountStatus == AccountStatus.Single)
+ {
+ if (accountSigns.Signs is null || accountSigns.Signs.Count != 1)
+ throw new SignException($"Sign context: single account but {accountSigns.Signs?.Count} signs");
+
+ var sign = accountSigns.Signs[0];
+ if (!TryDecodePublicKey(sign.PublicKey, out var publicKey)) continue;
+ succeed |= context.AddSignature(accountContract, publicKey, sign.Signature.ToByteArray());
+ }
+ }
+
+ if (!succeed) throw new SignException("Sign context: failed to sign");
+ return context.GetWitnesses();
+ }
+
+ ///
+ /// Signs the with the signer.
+ ///
+ /// The payload to sign
+ /// The data cache
+ /// The network
+ /// The witnesses
+ /// If no signer service is available, or other rpc error occurs.
+ public Witness SignExtensiblePayload(ExtensiblePayload payload, DataCache dataCache, uint network)
+ {
+ if (_client is null) throw new SignException("No signer service is connected");
+
+ try
+ {
+ var context = new ContractParametersContext(dataCache, payload, network);
+ var output = _client.SignExtensiblePayload(new()
+ {
+ Payload = new()
+ {
+ Category = payload.Category,
+ ValidBlockStart = payload.ValidBlockStart,
+ ValidBlockEnd = payload.ValidBlockEnd,
+ Sender = ByteString.CopyFrom(payload.Sender.GetSpan()),
+ Data = ByteString.CopyFrom(payload.Data.Span),
+ },
+ ScriptHashes = { context.ScriptHashes.Select(h160 => ByteString.CopyFrom(h160.GetSpan())) },
+ Network = network,
+ });
+
+ int signCount = output.Signs.Count, hashCount = context.ScriptHashes.Count;
+ if (signCount != hashCount)
+ {
+ throw new SignException($"Sign context: Signs.Count({signCount}) != Hashes.Count({hashCount})");
+ }
+
+ return SignContext(context, output.Signs)[0];
+ }
+ catch (RpcException ex)
+ {
+ throw new SignException($"Sign context: {ex.Status}", ex);
+ }
+ }
+
+ ///
+ /// Signs the specified data with the corresponding private key of the specified public key.
+ ///
+ /// The block to sign
+ /// The public key
+ /// The network
+ /// The signature
+ /// If no signer service is available, or other rpc error occurs.
+ public ReadOnlyMemory SignBlock(Block block, ECPoint publicKey, uint network)
+ {
+ if (_client is null) throw new SignException("No signer service is connected");
+
+ try
+ {
+ var output = _client.SignBlock(new()
+ {
+ Block = new()
+ {
+ Header = new()
+ {
+ Version = block.Version,
+ PrevHash = ByteString.CopyFrom(block.PrevHash.GetSpan()),
+ MerkleRoot = ByteString.CopyFrom(block.MerkleRoot.GetSpan()),
+ Timestamp = block.Timestamp,
+ Nonce = block.Nonce,
+ Index = block.Index,
+ PrimaryIndex = block.PrimaryIndex,
+ NextConsensus = ByteString.CopyFrom(block.NextConsensus.GetSpan()),
+ },
+ TxHashes = { block.Transactions.Select(tx => ByteString.CopyFrom(tx.Hash.GetSpan())) },
+ },
+ PublicKey = ByteString.CopyFrom(publicKey.EncodePoint(true)),
+ Network = network,
+ });
+
+ return output.Signature.Memory;
+ }
+ catch (RpcException ex)
+ {
+ throw new SignException($"Sign with public key: {ex.Status}", ex);
+ }
+ }
+
+ ///
+ protected override void Configure()
+ {
+ var config = GetConfiguration();
+ if (config is not null) Reset(new Settings(config));
+ }
+
+ ///
+ public override void Dispose()
+ {
+ Reset(string.Empty, null);
+ _channel?.Dispose();
+ base.Dispose();
+ }
+ }
+}
diff --git a/src/Plugins/SignClient/SignClient.csproj b/src/Plugins/SignClient/SignClient.csproj
new file mode 100644
index 0000000000..dce5c80e39
--- /dev/null
+++ b/src/Plugins/SignClient/SignClient.csproj
@@ -0,0 +1,34 @@
+
+
+
+ net9.0
+ latest
+ enable
+ enable
+
+
+
+
+ PreserveNewest
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Plugins/SignClient/SignClient.json b/src/Plugins/SignClient/SignClient.json
new file mode 100644
index 0000000000..dd9be7eb3e
--- /dev/null
+++ b/src/Plugins/SignClient/SignClient.json
@@ -0,0 +1,6 @@
+{
+ "PluginConfiguration": {
+ "Name": "SignClient",
+ "Endpoint": "http://127.0.0.1:9991"
+ }
+}
\ No newline at end of file
diff --git a/src/Plugins/SignClient/proto/servicepb.proto b/src/Plugins/SignClient/proto/servicepb.proto
new file mode 100644
index 0000000000..edc836b8dc
--- /dev/null
+++ b/src/Plugins/SignClient/proto/servicepb.proto
@@ -0,0 +1,64 @@
+// Copyright (C) 2015-2025 The Neo Project.
+ //
+ // servicepb.proto 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.
+
+syntax = "proto3";
+
+import "signpb.proto";
+
+package servicepb;
+
+message SignExtensiblePayloadRequest {
+ // the payload to be signed
+ signpb.ExtensiblePayload payload = 1;
+
+ // script hashes, H160 list
+ repeated bytes script_hashes = 2;
+
+ // the network id
+ uint32 network = 3;
+}
+
+message SignExtensiblePayloadResponse {
+ // script hash -> account signs, one to one mapping
+ repeated signpb.AccountSigns signs = 1;
+}
+
+message SignBlockRequest {
+ // the block header to be signed
+ signpb.TrimmedBlock block = 1;
+
+ // compressed or uncompressed public key
+ bytes public_key = 2;
+
+ // the network id
+ uint32 network = 3;
+}
+
+message SignBlockResponse {
+ bytes signature = 1;
+}
+
+message GetAccountStatusRequest {
+ // compressed or uncompressed public key
+ bytes public_key = 1;
+}
+
+message GetAccountStatusResponse {
+ signpb.AccountStatus status = 1;
+}
+
+service SecureSign {
+ rpc SignExtensiblePayload(SignExtensiblePayloadRequest) returns (SignExtensiblePayloadResponse) {}
+
+ rpc SignBlock(SignBlockRequest) returns (SignBlockResponse) {}
+
+ rpc GetAccountStatus(GetAccountStatusRequest) returns (GetAccountStatusResponse) {}
+}
diff --git a/src/Plugins/SignClient/proto/signpb.proto b/src/Plugins/SignClient/proto/signpb.proto
new file mode 100644
index 0000000000..5d73521901
--- /dev/null
+++ b/src/Plugins/SignClient/proto/signpb.proto
@@ -0,0 +1,92 @@
+// Copyright (C) 2015-2025 The Neo Project.
+ //
+ // signpb.proto 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.
+
+syntax = "proto3";
+
+package signpb;
+
+message BlockHeader {
+ uint32 version = 1;
+ bytes prev_hash = 2; // H256
+ bytes merkle_root = 3; // H256
+ uint64 timestamp = 4; // i.e unix milliseconds
+ uint64 nonce = 5;
+ uint32 index = 6;
+ uint32 primary_index = 7;
+ bytes next_consensus = 8; // H160
+}
+
+message TrimmedBlock {
+ BlockHeader header = 1;
+ repeated bytes tx_hashes = 2; // H256 list, tx hash list
+}
+
+message ExtensiblePayload {
+ string category = 1;
+ uint32 valid_block_start = 2;
+ uint32 valid_block_end = 3;
+ bytes sender = 4; // H160
+ bytes data = 5;
+}
+
+message AccountSign {
+ // the signature
+ bytes signature = 1;
+
+ // the compressed or uncompressed public key
+ bytes public_key = 2;
+}
+
+message AccountContract {
+ // the contract script
+ bytes script = 1;
+
+ // the contract parameters
+ repeated uint32 parameters = 2;
+
+ // if the contract is deployed
+ bool deployed = 3;
+}
+
+enum AccountStatus {
+ /// no such account
+ NoSuchAccount = 0;
+
+ /// no private key
+ NoPrivateKey = 1;
+
+ /// single sign
+ Single = 2;
+
+ /// multiple signs, aka. multisig
+ Multiple = 3;
+
+ /// this key-pair is locked
+ Locked = 4;
+}
+
+message AccountSigns {
+ // if the status is Single, there is only one sign
+ // if the status is Multiple, there are multiple signs
+ // if the status is NoSuchAccount, NoPrivateKey or Locked, there are no signs
+ repeated AccountSign signs = 1;
+
+ // the account contract
+ // If the account hasn't a contract, the contract is null
+ AccountContract contract = 2;
+
+ // the account status
+ AccountStatus status = 3;
+}
+
+message MultiAccountSigns {
+ repeated AccountSigns signs = 1;
+}
diff --git a/tests/Neo.Plugins.SignClient.Tests/Neo.Plugins.SignClient.Tests.csproj b/tests/Neo.Plugins.SignClient.Tests/Neo.Plugins.SignClient.Tests.csproj
new file mode 100644
index 0000000000..72e838c153
--- /dev/null
+++ b/tests/Neo.Plugins.SignClient.Tests/Neo.Plugins.SignClient.Tests.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ net9.0
+ latest
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Neo.Plugins.SignClient.Tests/UT_SignClient.cs b/tests/Neo.Plugins.SignClient.Tests/UT_SignClient.cs
new file mode 100644
index 0000000000..044be50570
--- /dev/null
+++ b/tests/Neo.Plugins.SignClient.Tests/UT_SignClient.cs
@@ -0,0 +1,206 @@
+// Copyright (C) 2015-2025 The Neo Project.
+//
+// UT_SignClient.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 Google.Protobuf;
+using Grpc.Core;
+using Microsoft.Extensions.Configuration;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+using Neo.Cryptography;
+using Neo.Cryptography.ECC;
+using Neo.Extensions;
+using Neo.Network.P2P;
+using Neo.Network.P2P.Payloads;
+using Neo.Persistence;
+using Neo.Persistence.Providers;
+using Neo.Sign;
+using Neo.SmartContract;
+using Neo.UnitTests;
+using Neo.Wallets;
+using Servicepb;
+using Signpb;
+
+using ExtensiblePayload = Neo.Network.P2P.Payloads.ExtensiblePayload;
+
+namespace Neo.Plugins.SignClient.Tests
+{
+ [TestClass]
+ public class UT_SignClient
+ {
+ const string PrivateKey = "0101010101010101010101010101010101010101010101010101010101010101";
+ const string PublicKey = "026ff03b949241ce1dadd43519e6960e0a85b41a69a05c328103aa2bce1594ca16";
+
+ private static readonly uint s_testNetwork = TestProtocolSettings.Default.Network;
+
+ private static readonly ECPoint s_publicKey = ECPoint.DecodePoint(PublicKey.HexToBytes(), ECCurve.Secp256r1);
+
+ private static SignClient NewClient(Block? block, ExtensiblePayload? payload)
+ {
+ // for test sign service, set SIGN_SERVICE_ENDPOINT env
+ var endpoint = Environment.GetEnvironmentVariable("SIGN_SERVICE_ENDPOINT");
+ if (endpoint is not null)
+ {
+ var section = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ [Settings.SectionName + ":Name"] = "SignClient",
+ [Settings.SectionName + ":Endpoint"] = endpoint
+ })
+ .Build()
+ .GetSection(Settings.SectionName);
+ return new SignClient(new Settings(section));
+ }
+
+ var mockClient = new Mock();
+
+ // setup GetAccountStatus
+ mockClient.Setup(c => c.GetAccountStatus(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny())
+ )
+ .Returns((req, _, _, _) =>
+ {
+ if (req.PublicKey.ToByteArray().ToHexString() == PublicKey)
+ return new() { Status = AccountStatus.Single };
+ return new() { Status = AccountStatus.NoSuchAccount };
+ });
+
+ // setup SignBlock
+ mockClient.Setup(c => c.SignBlock(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny())
+ )
+ .Returns((req, _, _, _) =>
+ {
+ if (req.PublicKey.ToByteArray().ToHexString() == PublicKey)
+ {
+ var sign = Crypto.Sign(block.GetSignData(s_testNetwork), PrivateKey.HexToBytes(), ECCurve.Secp256r1);
+ return new() { Signature = ByteString.CopyFrom(sign) };
+ }
+ throw new RpcException(new Status(StatusCode.NotFound, "no such account"));
+ });
+
+ // setup SignExtensiblePayload
+ mockClient.Setup(c => c.SignExtensiblePayload(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny())
+ )
+ .Returns((req, _, _, _) =>
+ {
+ var script = Contract.CreateSignatureRedeemScript(s_publicKey);
+ var res = new SignExtensiblePayloadResponse();
+ foreach (var scriptHash in req.ScriptHashes)
+ {
+ if (scriptHash.ToByteArray().ToHexString() == script.ToScriptHash().GetSpan().ToHexString())
+ {
+ var contract = new AccountContract() { Script = ByteString.CopyFrom(script) };
+ contract.Parameters.Add((uint)ContractParameterType.Signature);
+
+ var sign = Crypto.Sign(payload.GetSignData(s_testNetwork), PrivateKey.HexToBytes(), ECCurve.Secp256r1);
+ var signs = new AccountSigns() { Status = AccountStatus.Single, Contract = contract };
+ signs.Signs.Add(new AccountSign()
+ {
+ PublicKey = ByteString.CopyFrom(s_publicKey.EncodePoint(false).ToArray()),
+ Signature = ByteString.CopyFrom(sign)
+ });
+
+ res.Signs.Add(signs);
+ }
+ else
+ {
+ res.Signs.Add(new AccountSigns() { Status = AccountStatus.NoSuchAccount });
+ }
+ }
+ return res;
+ });
+
+ return new SignClient("TestSignClient", mockClient.Object);
+ }
+
+ [TestMethod]
+ public void TestSignBlock()
+ {
+ var snapshotCache = TestBlockchain.GetTestSnapshotCache();
+ var block = TestUtils.MakeBlock(snapshotCache, UInt256.Zero, 0);
+ using var signClient = NewClient(block, null);
+
+ // sign with public key
+ var signature = signClient.SignBlock(block, s_publicKey, s_testNetwork);
+ Assert.IsNotNull(signature);
+
+ // verify signature
+ var signData = block.GetSignData(s_testNetwork);
+ var verified = Crypto.VerifySignature(signData, signature.Span, s_publicKey);
+ Assert.IsTrue(verified);
+
+ var privateKey = Enumerable.Repeat((byte)0x0f, 32).ToArray();
+ var keypair = new KeyPair(privateKey);
+
+ // sign with a not exists private key
+ var action = () => { _ = signClient.SignBlock(block, keypair.PublicKey, s_testNetwork); };
+ Assert.ThrowsExactly(action);
+ }
+
+ [TestMethod]
+ public void TestSignExtensiblePayload()
+ {
+ var script = Contract.CreateSignatureRedeemScript(s_publicKey);
+ var signer = script.ToScriptHash();
+ var payload = new ExtensiblePayload()
+ {
+ Category = "test",
+ ValidBlockStart = 1,
+ ValidBlockEnd = 100,
+ Sender = signer,
+ Data = new byte[] { 1, 2, 3 },
+ };
+ using var signClient = NewClient(null, payload);
+ using var store = new MemoryStore();
+ using var snapshot = new StoreCache(store, false);
+
+ var witness = signClient.SignExtensiblePayload(payload, snapshot, s_testNetwork);
+ Assert.AreEqual(witness.VerificationScript.Span.ToHexString(), script.ToHexString());
+
+ var signature = witness.InvocationScript[^64..].ToArray();
+ var verified = Crypto.VerifySignature(payload.GetSignData(s_testNetwork), signature, s_publicKey);
+ Assert.IsTrue(verified);
+ }
+
+ [TestMethod]
+ public void TestGetAccountStatus()
+ {
+ using var signClient = NewClient(null, null);
+
+ // exists
+ var contains = signClient.ContainsSignable(s_publicKey);
+ Assert.IsTrue(contains);
+
+ var privateKey = Enumerable.Repeat((byte)0x0f, 32).ToArray();
+ var keypair = new KeyPair(privateKey);
+
+ // not exists
+ contains = signClient.ContainsSignable(keypair.PublicKey);
+ Assert.IsFalse(contains);
+
+ // exists
+ signClient.AccountStatusCommand(PublicKey);
+
+ // not exists
+ signClient.AccountStatusCommand(keypair.PublicKey.EncodePoint(true).ToHexString());
+ }
+ }
+}