diff --git a/.github/workflows/dotnet-tests.yml b/.github/workflows/dotnet-tests.yml
new file mode 100644
index 0000000..edcc14e
--- /dev/null
+++ b/.github/workflows/dotnet-tests.yml
@@ -0,0 +1,24 @@
+name: .NET tests
+
+on:
+ pull_request:
+ push:
+ branches:
+ - master
+
+jobs:
+ seed-tests:
+ name: Seed tests
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Set up .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Run tests
+ run: dotnet test OneGateApp.Tests/OneGateApp.Tests.csproj --configuration Release --verbosity normal
diff --git a/OneGateApp.Tests/OneGateApp.Tests.csproj b/OneGateApp.Tests/OneGateApp.Tests.csproj
new file mode 100644
index 0000000..db296aa
--- /dev/null
+++ b/OneGateApp.Tests/OneGateApp.Tests.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OneGateApp.Tests/RawResourceTests.cs b/OneGateApp.Tests/RawResourceTests.cs
new file mode 100644
index 0000000..4770c27
--- /dev/null
+++ b/OneGateApp.Tests/RawResourceTests.cs
@@ -0,0 +1,69 @@
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace OneGateApp.Tests;
+
+public sealed partial class RawResourceTests
+{
+ [GeneratedRegex("^0x[0-9a-fA-F]{40}$", RegexOptions.CultureInvariant)]
+ private static partial Regex UInt160HashPattern();
+
+ [Theory]
+ [InlineData("protocol.json")]
+ [InlineData("tokens.json")]
+ [InlineData("nft.json")]
+ public void RawJsonResourcesAreValidJson(string fileName)
+ {
+ using JsonDocument document = LoadRawJson(fileName);
+
+ Assert.NotEqual(JsonValueKind.Undefined, document.RootElement.ValueKind);
+ }
+
+ [Fact]
+ public void ProtocolResourceContainsExpectedNeoMainnetConfiguration()
+ {
+ using JsonDocument document = LoadRawJson("protocol.json");
+ JsonElement configuration = document.RootElement.GetProperty("ProtocolConfiguration");
+
+ Assert.Equal(860833102, configuration.GetProperty("Network").GetInt32());
+ Assert.Equal(53, configuration.GetProperty("AddressVersion").GetInt32());
+ Assert.True(configuration.GetProperty("MillisecondsPerBlock").GetInt32() > 0);
+ Assert.True(configuration.GetProperty("StandbyCommittee").GetArrayLength() >= 7);
+ Assert.True(configuration.GetProperty("SeedList").GetArrayLength() > 0);
+ }
+
+ [Theory]
+ [InlineData("tokens.json", true)]
+ [InlineData("nft.json", false)]
+ public void TokenCatalogEntriesHaveRequiredFields(string fileName, bool requiresDecimals)
+ {
+ using JsonDocument document = LoadRawJson(fileName);
+ JsonElement catalog = document.RootElement;
+
+ Assert.Equal(JsonValueKind.Array, catalog.ValueKind);
+ Assert.True(catalog.GetArrayLength() > 0);
+
+ foreach (JsonElement token in catalog.EnumerateArray())
+ {
+ string hash = token.GetProperty("hash").GetString()!;
+ string name = token.GetProperty("name").GetString()!;
+ string symbol = token.GetProperty("symbol").GetString()!;
+
+ Assert.Matches(UInt160HashPattern(), hash);
+ Assert.False(string.IsNullOrWhiteSpace(name));
+ Assert.False(string.IsNullOrWhiteSpace(symbol));
+
+ if (requiresDecimals)
+ {
+ int decimals = token.GetProperty("decimals").GetInt32();
+ Assert.InRange(decimals, 0, 18);
+ }
+ }
+ }
+
+ static JsonDocument LoadRawJson(string fileName)
+ {
+ string path = Path.Combine(TestPaths.RepositoryRoot, "OneGateApp", "Resources", "Raw", fileName);
+ return JsonDocument.Parse(File.ReadAllText(path));
+ }
+}
diff --git a/OneGateApp.Tests/ResourceParityTests.cs b/OneGateApp.Tests/ResourceParityTests.cs
new file mode 100644
index 0000000..f0d5994
--- /dev/null
+++ b/OneGateApp.Tests/ResourceParityTests.cs
@@ -0,0 +1,37 @@
+using System.Xml.Linq;
+
+namespace OneGateApp.Tests;
+
+public sealed class ResourceParityTests
+{
+ [Fact]
+ public void LocalizedStringResourcesExposeSameKeysAsNeutralResource()
+ {
+ string resourceDirectory = Path.Combine(TestPaths.RepositoryRoot, "OneGateApp", "Properties");
+ string neutralPath = Path.Combine(resourceDirectory, "Strings.resx");
+ string[] localizedPaths = Directory.GetFiles(resourceDirectory, "Strings.*.resx");
+
+ SortedSet neutralKeys = LoadResourceKeys(neutralPath);
+ Assert.NotEmpty(localizedPaths);
+
+ foreach (string localizedPath in localizedPaths)
+ {
+ SortedSet localizedKeys = LoadResourceKeys(localizedPath);
+ string[] missing = neutralKeys.Except(localizedKeys).ToArray();
+ string[] extra = localizedKeys.Except(neutralKeys).ToArray();
+
+ Assert.True(missing.Length == 0 && extra.Length == 0,
+ $"{Path.GetFileName(localizedPath)} resource keys differ. Missing: {string.Join(", ", missing)}. Extra: {string.Join(", ", extra)}.");
+ }
+ }
+
+ static SortedSet LoadResourceKeys(string path)
+ {
+ XDocument document = XDocument.Load(path);
+ IEnumerable keys = document.Root!
+ .Elements("data")
+ .Select(element => element.Attribute("name")?.Value)
+ .Where(name => !string.IsNullOrWhiteSpace(name))!;
+ return new SortedSet(keys, StringComparer.Ordinal);
+ }
+}
diff --git a/OneGateApp.Tests/TestPaths.cs b/OneGateApp.Tests/TestPaths.cs
new file mode 100644
index 0000000..1f76636
--- /dev/null
+++ b/OneGateApp.Tests/TestPaths.cs
@@ -0,0 +1,21 @@
+namespace OneGateApp.Tests;
+
+static class TestPaths
+{
+ public static string RepositoryRoot { get; } = FindRepositoryRoot();
+
+ static string FindRepositoryRoot()
+ {
+ DirectoryInfo? directory = new(AppContext.BaseDirectory);
+ while (directory is not null)
+ {
+ string projectPath = Path.Combine(directory.FullName, "OneGateApp", "OneGateApp.csproj");
+ if (File.Exists(projectPath))
+ return directory.FullName;
+
+ directory = directory.Parent;
+ }
+
+ throw new DirectoryNotFoundException("Could not locate the OneGateApp repository root.");
+ }
+}