diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
new file mode 100644
index 0000000..8d74243
--- /dev/null
+++ b/.config/dotnet-tools.json
@@ -0,0 +1,13 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "csharpier": {
+ "version": "1.2.6",
+ "commands": [
+ "csharpier"
+ ],
+ "rollForward": false
+ }
+ }
+}
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..ba824e2
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,84 @@
+# EditorConfig: https://editorconfig.org
+root = true
+
+##########################################
+# All files
+##########################################
+[*]
+charset = utf-8
+end_of_line = lf
+indent_style = space
+indent_size = 4
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{yml,yaml,json,md}]
+indent_size = 2
+
+[*.{csproj,props,targets,xml}]
+indent_size = 2
+
+##########################################
+# C# files
+##########################################
+[*.cs]
+indent_size = 4
+
+# Organize usings
+dotnet_sort_system_directives_first = true
+dotnet_separate_import_directive_groups = false
+csharp_using_directive_placement = outside_namespace:warning
+
+# Namespace does not need to match folder structure.
+# Also avoids a dotnet-format crash: its IDE0130 fixer attempts file moves that
+# MSBuildWorkspace doesn't support (NotSupportedException: Changing document properties).
+dotnet_diagnostic.IDE0130.severity = none
+
+# this. qualification
+dotnet_style_qualification_for_field = false:warning
+dotnet_style_qualification_for_property = false:warning
+dotnet_style_qualification_for_method = false:warning
+dotnet_style_qualification_for_event = false:warning
+
+# Language keywords vs BCL types
+dotnet_style_predefined_type_for_locals_parameters_members = true:warning
+dotnet_style_predefined_type_for_member_access = true:warning
+
+# Modifier preferences
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
+csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:warning
+
+# var preferences
+csharp_style_var_for_built_in_types = true:suggestion
+csharp_style_var_when_type_is_apparent = true:suggestion
+csharp_style_var_elsewhere = true:suggestion
+
+# Expression-bodied members
+csharp_style_expression_bodied_methods = when_on_single_line:suggestion
+csharp_style_expression_bodied_properties = when_on_single_line:suggestion
+csharp_style_expression_bodied_accessors = when_on_single_line:suggestion
+
+# Newline preferences (Allman braces)
+csharp_new_line_before_open_brace = all
+csharp_new_line_before_else = true
+csharp_new_line_before_catch = true
+csharp_new_line_before_finally = true
+csharp_new_line_before_members_in_object_initializers = true
+csharp_new_line_before_members_in_anonymous_types = true
+csharp_new_line_between_query_expression_clauses = true
+
+# Indentation
+csharp_indent_case_contents = true
+csharp_indent_switch_labels = true
+csharp_indent_labels = flush_left
+
+# Spacing
+csharp_space_after_cast = false
+csharp_space_after_keywords_in_control_flow_statements = true
+csharp_space_between_method_declaration_parameter_list_parentheses = false
+csharp_space_between_method_call_parameter_list_parentheses = false
+csharp_space_between_parentheses = false
+
+# Wrapping
+csharp_preserve_single_line_statements = false
+csharp_preserve_single_line_blocks = true
diff --git a/.env.example b/.env.example
deleted file mode 100644
index 1baa4cf..0000000
--- a/.env.example
+++ /dev/null
@@ -1,7 +0,0 @@
-PORT=3000
-JWT_SECRET=
-ENJIN_API_URL=https://platform.canary.enjin.io/graphql
-ENJIN_API_KEY=
-
-DAEMON_WALLET_ADDRESS=
-ENJIN_COLLECTION_ID=
\ No newline at end of file
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..e9f91f9
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,56 @@
+name: Build & Verify
+
+on:
+ push:
+ branches:
+ - master
+ tags:
+ - '*'
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 9.0.x
+
+ - name: Cache NuGet Packages
+ uses: actions/cache@v4
+ with:
+ path: ~/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-
+
+ - name: Restore Main Project
+ run: dotnet restore ./PlatformSampleGameServer.csproj
+
+ - name: Restore Tools Project
+ run: dotnet restore ./tools/Ss58SelfTest/Ss58SelfTest.csproj
+
+ - name: Restore .NET Tools
+ run: dotnet tool restore
+
+ - name: Verify Formatting (CSharpier)
+ run: dotnet csharpier check .
+
+ - name: Verify Formatting (dotnet format - main)
+ run: dotnet format ./PlatformSampleGameServer.csproj --verify-no-changes --no-restore
+
+ - name: Verify Formatting (dotnet format - tools)
+ run: dotnet format ./tools/Ss58SelfTest/Ss58SelfTest.csproj --verify-no-changes --no-restore
+
+ - name: Build Main Project
+ run: dotnet build ./PlatformSampleGameServer.csproj --configuration Release --no-restore
+
+ - name: Build Tools Project
+ run: dotnet build ./tools/Ss58SelfTest/Ss58SelfTest.csproj --configuration Release --no-restore
diff --git a/.gitignore b/.gitignore
index 8afb0d3..922ba49 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,14 @@
-node_modules/
-.env
-npm-debug.log
-.DS_Store
\ No newline at end of file
+.DS_Store
+
+# .NET
+bin/
+obj/
+*.user
+
+# Local-only runtime state (persisted bootstrap collection id, etc.)
+state.json
+
+# Local-only secrets (URL, API key, JWT secret). Use appsettings.Development.json
+# or environment variables.
+appsettings.Development.json
+appsettings.Local.json
diff --git a/Endpoints/AuthEndpoints.cs b/Endpoints/AuthEndpoints.cs
new file mode 100644
index 0000000..3622697
--- /dev/null
+++ b/Endpoints/AuthEndpoints.cs
@@ -0,0 +1,69 @@
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Mvc;
+using PlatformSampleGameServer.Models;
+using PlatformSampleGameServer.Services;
+
+namespace PlatformSampleGameServer.Endpoints;
+
+public static class AuthEndpoints
+{
+ public static void MapAuthEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/api/auth");
+
+ // Anonymous - the Unity client polls this to confirm connectivity.
+ group
+ .MapGet("/health-check", () => Results.Ok(new HealthCheckResponse("OK")))
+ .AllowAnonymous();
+
+ // Register-or-login. The Unity client uses this single endpoint for both
+ // first-time signup and returning sessions; behaviour matches the original
+ // Node.js sample (src/routes/auth.js).
+ group
+ .MapPost(
+ "/register",
+ async (
+ AuthRequest body,
+ AuthService auth,
+ EnjinService enjin,
+ ILoggerFactory loggerFactory,
+ CancellationToken ct
+ ) =>
+ {
+ var log = loggerFactory.CreateLogger(
+ "PlatformSampleGameServer.Endpoints.AuthEndpoints"
+ );
+ try
+ {
+ var (token, email) = auth.RegisterOrLogin(body.Email, body.Password);
+
+ // Ensure a managed wallet exists for this player so subsequent
+ // operations (mint/melt/transfer) can target it immediately.
+ string? wallet = null;
+ try
+ {
+ wallet = await enjin.EnsureManagedWalletAsync(email, ct);
+ }
+ catch (Exception ex)
+ {
+ // Don't fail registration on a wallet-provisioning hiccup;
+ // the client can retry against /api/wallet/get-tokens later.
+ wallet = null;
+ log.LogWarning(ex, "EnsureManagedWallet failed for {Email}", email);
+ }
+
+ return Results.Ok(new AuthResponse(email, wallet, token));
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ return Results.Json(new BoolResponse(false, ex.Message), statusCode: 401);
+ }
+ catch (Exception ex)
+ {
+ return Results.Json(new BoolResponse(false, ex.Message), statusCode: 400);
+ }
+ }
+ )
+ .AllowAnonymous();
+ }
+}
diff --git a/Endpoints/SetupEndpoints.cs b/Endpoints/SetupEndpoints.cs
new file mode 100644
index 0000000..c927edb
--- /dev/null
+++ b/Endpoints/SetupEndpoints.cs
@@ -0,0 +1,45 @@
+using PlatformSampleGameServer.Models;
+using PlatformSampleGameServer.Services;
+
+namespace PlatformSampleGameServer.Endpoints;
+
+///
+/// Setup-time endpoints. Called by the Unity Editor (and any other tooling)
+/// once when standing the game up against a new server / new canary state, to
+/// bake server-allocated identifiers into client-side configuration assets.
+///
+/// Intentionally unauthenticated: setup happens before any player account
+/// exists, the values exposed here are not secret (they end up in shipped
+/// client builds anyway), and gating this behind a JWT would be a chicken-
+/// and-egg problem for fresh installs.
+///
+public static class SetupEndpoints
+{
+ public static void MapSetupEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/api/setup");
+
+ // GET /api/setup/collection-id - returns the on-chain collection id
+ // this server has bootstrapped. The Unity Editor menu "Enjin > Stamp
+ // Collection ID onto EnjinItem Assets" calls this and writes the
+ // value onto every EnjinItem ScriptableObject.
+ group.MapGet(
+ "/collection-id",
+ (EnjinService enjin) =>
+ {
+ var id = enjin.CollectionId;
+ if (id is null)
+ {
+ return Results.Json(
+ new BoolResponse(
+ false,
+ "Collection id not initialised. " + "Did the server finish bootstrap?"
+ ),
+ statusCode: 503
+ );
+ }
+ return Results.Ok(new CollectionIdResponse(id.Value.ToString()));
+ }
+ );
+ }
+}
diff --git a/Endpoints/TokenEndpoints.cs b/Endpoints/TokenEndpoints.cs
new file mode 100644
index 0000000..10a6c7f
--- /dev/null
+++ b/Endpoints/TokenEndpoints.cs
@@ -0,0 +1,139 @@
+using System.Numerics;
+using System.Security.Claims;
+using PlatformSampleGameServer.Models;
+using PlatformSampleGameServer.Services;
+
+namespace PlatformSampleGameServer.Endpoints;
+
+public static class TokenEndpoints
+{
+ public static void MapTokenEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/api/token").RequireAuthorization();
+
+ group.MapPost(
+ "/mint",
+ async (
+ MintRequest req,
+ ClaimsPrincipal user,
+ EnjinService enjin,
+ CancellationToken ct
+ ) =>
+ await Run(
+ "mint",
+ req.TokenId,
+ req.Amount,
+ user,
+ async (tokenId, amount, email) =>
+ {
+ // Mint to the player's wallet. The daemon signs implicitly.
+ var address = await enjin.EnsureManagedWalletAsync(email, ct);
+ await enjin.MintTokenAsync(tokenId, amount, address, ct);
+ }
+ )
+ );
+
+ group.MapPost(
+ "/melt",
+ async (
+ MeltRequest req,
+ ClaimsPrincipal user,
+ EnjinService enjin,
+ CancellationToken ct
+ ) =>
+ await Run(
+ "melt",
+ req.TokenId,
+ req.Amount,
+ user,
+ async (tokenId, amount, email) =>
+ {
+ // Burn from the player's wallet; daemon signs on their behalf via externalId.
+ await enjin.MeltTokenAsync(tokenId, amount, email, ct);
+ }
+ )
+ );
+
+ group.MapPost(
+ "/transfer",
+ async (
+ TransferRequest req,
+ ClaimsPrincipal user,
+ EnjinService enjin,
+ CancellationToken ct
+ ) =>
+ await Run(
+ "transfer",
+ req.TokenId,
+ req.Amount,
+ user,
+ async (tokenId, amount, email) =>
+ {
+ if (string.IsNullOrWhiteSpace(req.Recipient))
+ throw new ArgumentException("Recipient is required.");
+ await enjin.TransferTokenAsync(tokenId, amount, req.Recipient, email, ct);
+ }
+ )
+ );
+ }
+
+ private static async Task Run(
+ string operation,
+ string tokenIdString,
+ string amountString,
+ ClaimsPrincipal user,
+ Func action
+ )
+ {
+ var email = user.FindFirst(AuthService.EmailClaim)?.Value;
+ if (string.IsNullOrEmpty(email))
+ return Results.Unauthorized();
+
+ if (!BigInteger.TryParse(tokenIdString, out var tokenId))
+ return Results.Json(
+ new BoolResponse(false, $"Invalid tokenId '{tokenIdString}'."),
+ statusCode: 400
+ );
+ if (!TryParseAmount(amountString, out var amount, out var amountError))
+ return Results.Json(new BoolResponse(false, amountError), statusCode: 400);
+
+ try
+ {
+ await action(tokenId, amount, email);
+ return Results.Ok(new BoolResponse(true));
+ }
+ catch (Exception ex)
+ {
+ return Results.Json(new BoolResponse(false, ex.Message), statusCode: 500);
+ }
+ }
+
+ ///
+ /// Parses a token amount supplied as a decimal string. Token amounts are
+ /// BigIntegers on-chain, so the wire format carries them as decimal strings
+ /// to allow values above int/long range. Returns false with a user-facing
+ /// when the string is not a positive integer.
+ ///
+ public static bool TryParseAmount(
+ string? amountString,
+ out BigInteger amount,
+ out string? error
+ )
+ {
+ if (!BigInteger.TryParse(amountString, out amount))
+ {
+ amount = BigInteger.Zero;
+ error = $"Invalid amount '{amountString}'.";
+ return false;
+ }
+ if (amount <= 0)
+ {
+ amount = BigInteger.Zero;
+ error = "Amount must be positive.";
+ return false;
+ }
+
+ error = null;
+ return true;
+ }
+}
diff --git a/Endpoints/WalletEndpoints.cs b/Endpoints/WalletEndpoints.cs
new file mode 100644
index 0000000..d708eb8
--- /dev/null
+++ b/Endpoints/WalletEndpoints.cs
@@ -0,0 +1,43 @@
+using System.Security.Claims;
+using PlatformSampleGameServer.Models;
+using PlatformSampleGameServer.Services;
+
+namespace PlatformSampleGameServer.Endpoints;
+
+public static class WalletEndpoints
+{
+ public static void MapWalletEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/api/wallet").RequireAuthorization();
+
+ // GET /api/wallet/get-tokens - returns the player's managed wallet account
+ // plus the tokens they hold in our collection. Shape matches the Unity
+ // client's PlatformModels.ManagedWalletAccount.
+ group.MapGet(
+ "/get-tokens",
+ async (ClaimsPrincipal user, EnjinService enjin, CancellationToken ct) =>
+ {
+ var email = user.FindFirst(AuthService.EmailClaim)?.Value;
+ if (string.IsNullOrEmpty(email))
+ return Results.Unauthorized();
+
+ try
+ {
+ var account = await enjin.GetManagedWalletTokensAsync(email, ct);
+ if (account is null)
+ {
+ return Results.Json(
+ new BoolResponse(false, $"No managed wallet for {email}"),
+ statusCode: 404
+ );
+ }
+ return Results.Ok(account);
+ }
+ catch (Exception ex)
+ {
+ return Results.Json(new BoolResponse(false, ex.Message), statusCode: 500);
+ }
+ }
+ );
+ }
+}
diff --git a/Models/Dtos.cs b/Models/Dtos.cs
new file mode 100644
index 0000000..3d1832f
--- /dev/null
+++ b/Models/Dtos.cs
@@ -0,0 +1,52 @@
+namespace PlatformSampleGameServer.Models;
+
+// Wire-format DTOs. Field names use camelCase via System.Text.Json
+// default policy + explicit JsonPropertyName where needed, to match the
+// shapes the Unity client (Assets/Enjin Integration/Scripts/...) expects.
+// BigInteger-valued fields are serialized as decimal strings so the
+// client's SerializableBigInteger wrapper can parse them.
+
+// ---- Request bodies ----
+
+public sealed record AuthRequest(string Email, string Password);
+
+public sealed record MintRequest(string TokenId, string Amount);
+
+public sealed record MeltRequest(string TokenId, string Amount);
+
+public sealed record TransferRequest(string TokenId, string Amount, string Recipient);
+
+// ---- Response bodies ----
+
+public sealed record HealthCheckResponse(string Status);
+
+public sealed record AuthResponse(string Email, string? Wallet, string Token);
+
+public sealed record BoolResponse(bool Success, string? Message = null);
+
+// Mirrors PlatformModels.ManagedWalletAccount on the Unity side.
+public sealed record ManagedWalletAccountDto(
+ AccountDto Account,
+ IReadOnlyList TokenAccounts
+);
+
+// Returned by GET /api/setup/collection-id. The Unity Editor calls this once
+// during studio setup to stamp the on-chain collection id onto each
+// EnjinItem ScriptableObject; the running game itself never calls it. The
+// server allocates the collection during bootstrap (Program.cs) and persists
+// the id in state.json, so this endpoint is a stable read after that.
+public sealed record CollectionIdResponse(string CollectionId);
+
+public sealed record AccountDto(string PublicKey, string Address);
+
+public sealed record TokenAccountDto(string Balance, TokenDto Token);
+
+public sealed record TokenDto(
+ CollectionDto Collection,
+ string TokenId,
+ IReadOnlyList Attributes
+);
+
+public sealed record CollectionDto(string CollectionId);
+
+public sealed record AttributeDto(string Key, string Value);
diff --git a/Models/User.cs b/Models/User.cs
new file mode 100644
index 0000000..fbec802
--- /dev/null
+++ b/Models/User.cs
@@ -0,0 +1,6 @@
+namespace PlatformSampleGameServer.Models;
+
+// In-memory user record. Matches the original Node.js sample's in-memory store
+// (src/models/user.js). Persistence is intentionally omitted: this is sample
+// code, not a production user database.
+public sealed record User(string Email, string PasswordHash);
diff --git a/PlatformSampleGameServer.csproj b/PlatformSampleGameServer.csproj
new file mode 100644
index 0000000..11592a7
--- /dev/null
+++ b/PlatformSampleGameServer.csproj
@@ -0,0 +1,27 @@
+
+
+ net9.0
+ enable
+ enable
+ PlatformSampleGameServer
+ PlatformSampleGameServer
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Program.cs b/Program.cs
new file mode 100644
index 0000000..cb8eeca
--- /dev/null
+++ b/Program.cs
@@ -0,0 +1,179 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Text;
+using System.Text.Json;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.IdentityModel.Tokens;
+using PlatformSampleGameServer.Endpoints;
+using PlatformSampleGameServer.Services;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Don't remap inbound JWT claim names (e.g. "email" -> long XMLSOAP URI).
+// We want claims to round-trip with their original short names so endpoints
+// can read them by their well-known JWT names.
+JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
+
+// ----- Configuration -----
+builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true);
+builder.Configuration.AddEnvironmentVariables();
+
+builder.Services.Configure(builder.Configuration.GetSection("Server"));
+builder.Services.Configure(builder.Configuration.GetSection("Jwt"));
+builder.Services.Configure(builder.Configuration.GetSection("Enjin"));
+
+// ----- Services -----
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+
+builder.Services.AddCors(o =>
+ o.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())
+);
+
+// JSON: camelCase property names + serialize BigInteger-as-string so the
+// Unity client's SerializableBigInteger wrapper can parse it. Records with
+// PascalCase properties will be emitted as camelCase by default; this is
+// what the Unity client expects (its JsonUtility uses the field name verbatim,
+// and its fields are camelCase).
+builder.Services.ConfigureHttpJsonOptions(o =>
+{
+ o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
+ o.SerializerOptions.PropertyNameCaseInsensitive = true;
+});
+
+// ----- JWT auth -----
+var jwt = builder.Configuration.GetSection("Jwt").Get() ?? new JwtOptions();
+if (string.IsNullOrWhiteSpace(jwt.Secret))
+{
+ // Emit a generated dev secret if none is configured so the server still boots
+ // for local development. Production must set Jwt:Secret explicitly.
+ jwt.Secret = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N");
+ Console.WriteLine(
+ "[warn] Jwt:Secret not configured; generated a transient dev secret. "
+ + "Set Jwt:Secret in appsettings or env for stable sessions across restarts."
+ );
+
+ // Propagate the generated secret into the bound JwtOptions so AuthService
+ // (which resolves IOptions) signs tokens with the same key that
+ // the JwtBearer middleware validates against. Without this, AuthService
+ // would either throw on an empty Secret or sign with a different key.
+ builder.Services.PostConfigure(o =>
+ {
+ if (string.IsNullOrWhiteSpace(o.Secret))
+ o.Secret = jwt.Secret;
+ });
+}
+
+builder
+ .Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer(opts =>
+ {
+ opts.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = !string.IsNullOrEmpty(jwt.Issuer),
+ ValidIssuer = jwt.Issuer,
+ ValidateAudience = !string.IsNullOrEmpty(jwt.Audience),
+ ValidAudience = jwt.Audience,
+ ValidateLifetime = true,
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Secret)),
+ ClockSkew = TimeSpan.FromMinutes(1),
+ };
+ });
+builder.Services.AddAuthorization();
+
+// ----- Kestrel: port + request timeout -----
+var serverOptions =
+ builder.Configuration.GetSection("Server").Get() ?? new ServerOptions();
+builder.WebHost.ConfigureKestrel(k =>
+{
+ k.ListenAnyIP(serverOptions.Port);
+});
+
+// Long-running on-chain operations (mint/melt/transfer) can legitimately take
+// several minutes while we poll for finalization. Configure a per-request
+// execution timeout via the ASP.NET Core RequestTimeouts middleware so the
+// server cancels requests that exceed Server:RequestTimeoutSeconds instead of
+// letting them run indefinitely. (KeepAliveTimeout is intentionally left at
+// its default; it controls idle connection lifetime, not request duration.)
+builder.Services.AddRequestTimeouts(o =>
+{
+ o.DefaultPolicy = new Microsoft.AspNetCore.Http.Timeouts.RequestTimeoutPolicy
+ {
+ Timeout = TimeSpan.FromSeconds(serverOptions.RequestTimeoutSeconds),
+ };
+});
+
+var app = builder.Build();
+
+app.UseCors();
+app.UseRequestTimeouts();
+app.UseAuthentication();
+app.UseAuthorization();
+
+// ----- Routes -----
+app.MapAuthEndpoints();
+app.MapWalletEndpoints();
+app.MapTokenEndpoints();
+app.MapSetupEndpoints();
+
+// ----- Bootstrap the collection + resource tokens before serving any requests -----
+// Pass --skip-bootstrap (or set Enjin:SkipBootstrap=true) to start the server
+// without creating/verifying the on-chain collection. Useful for local smoke tests
+// where you don't want to mutate canary.
+var skipBootstrap =
+ args.Contains("--skip-bootstrap")
+ || string.Equals(
+ builder.Configuration["Enjin:SkipBootstrap"],
+ "true",
+ StringComparison.OrdinalIgnoreCase
+ );
+
+using (var scope = app.Services.CreateScope())
+{
+ var sp = scope.ServiceProvider;
+ var state = sp.GetRequiredService();
+ // Allow the operator to seed state.json on first run by supplying a
+ // collection id via configuration. Configuration:AddEnvironmentVariables
+ // accepts the standard double-underscore form (Enjin__CollectionId), but
+ // the legacy Node.js sample used the flat ENJIN_COLLECTION_ID name, so
+ // we accept that too as a fallback for operators migrating from the old
+ // server.
+ var collectionIdSeed =
+ builder.Configuration["Enjin:CollectionId"]
+ ?? Environment.GetEnvironmentVariable("ENJIN_COLLECTION_ID");
+ state.OverrideFromConfig(collectionIdSeed);
+
+ var log = sp.GetRequiredService>();
+
+ if (skipBootstrap)
+ {
+ log.LogWarning(
+ "Bootstrap skipped (--skip-bootstrap). On-chain operations may fail until a collection ID is provided."
+ );
+ }
+ else
+ {
+ var enjin = sp.GetRequiredService();
+ log.LogInformation(
+ "Preparing collection and resource tokens. This may take a few minutes on first run."
+ );
+ try
+ {
+ await enjin.PrepareCollectionAsync(CancellationToken.None);
+ }
+ catch (Exception ex)
+ {
+ log.LogCritical(ex, "Failed to prepare collection. Server will not start.");
+ return;
+ }
+ }
+
+ log.LogInformation("----------------------------------------");
+ log.LogInformation("Collection ID: {Id}", state.CollectionId?.ToString() ?? "(unset)");
+ log.LogInformation("Server listening on http://0.0.0.0:{Port}", serverOptions.Port);
+ log.LogInformation("----------------------------------------");
+}
+
+await app.RunAsync();
diff --git a/README.md b/README.md
index df314d7..7fd0ecb 100644
--- a/README.md
+++ b/README.md
@@ -1,75 +1,152 @@
-# Introduction
-
-This is a NodeJS Express RESTful server for a sample Unity game [Enjin Farmer](https://github.com/enjin/platform-sample-game-client-unity), designed to demonstrate how to easily integrate NFTs into a project using the Enjin Platform.
-
-## Overview
-
-This server provides the backend functionality for managing NFTs within the sample Unity game. It handles user authentication, wallet management, and token operations (minting, melting, and transferring) using the Enjin Platform API.
-
-## Features
-
-* User registration and login
-* Managed wallet creation and retrieval
-* Token minting, melting, and transfer
-* Health check endpoint
+# Platform Sample Game Server (C#)
+
+REST backend for the **Enjin Farmer** Unity sample
+([platform-sample-game-client-unity](https://github.com/enjin/platform-sample-game-client-unity)),
+demonstrating how to integrate NFTs into a game using the
+[Enjin Platform C# SDK](https://github.com/enjin/platform-csharp-sdk).
+
+This is a rewrite of the previous Node.js server in .NET 9, preserving the
+route paths and JWT handling. One deliberate wire change came with SDK v3:
+token `amount` fields on the mint / melt / transfer endpoints are sent as
+decimal strings (the SDK uses `BigInteger` for token amounts), so the matching
+Unity client sends them as strings too.
+
+## What this server does
+
+- Holds the Enjin Platform API key. The Unity client never sees it.
+- Bootstraps an on-chain **collection** and a fixed set of **resource tokens**
+ the game uses (Gold Coin, Gold Coin Blue, Green Gem) on first run.
+- Issues players a JWT after `/api/auth/register` so subsequent calls can
+ identify them.
+- Creates a managed wallet per player (one managed wallet per `externalId`,
+ where `externalId == email`) and drips a small amount of ENJ to it so it
+ can pay transaction fees.
+- Brokers mint / melt / transfer mutations to the platform and waits for
+ on-chain finalization before responding to the client.
## Prerequisites
-* Node.js and npm installed
-* An Enjin Platform account and API key
-* Wallet Daemon
-* Aquire cENJ from the [Canary Faucet](https://faucet.canary.enjin.io/) to fund the Wallet Daemon account
-
-## Setup Instructions
-
-1. **Clone the repository:**
-
- ```bash
- git clone https://github.com/enjin/platform-sample-game-server.git
- cd platform-sample-game-server
- ```
-
-2. **Install dependencies:**
-
- ```bash
- npm install
- ```
-
-3. **Configure environment variables:**
-
- Duplicate the `.env.example` file and rename the copy to `.env`.
- Open the `.env` file and fill in the following variables:
- - `PORT=3000` (You can change this if port 3000 is already in use).
- - `JWT_SECRET`: Generate a secure, random string. This is used for authenticating players.
- - `ENJIN_API_URL`: Keep the default `https://platform.canary.enjin.io/graphql` for testing on the Canary network.
- - `ENJIN_API_KEY`: Paste the **API Key Token** from your Enjin Platform account.
- - `DAEMON_WALLET_ADDRESS`: Paste the wallet address you copied from the Wallet Daemon UI.
- - `ENJIN_COLLECTION_ID`: Leave this blank, it will be automatically populated once the collection is created.
-
-4. **Run the server:**
-
- ```bash
- npm start
- ```
-
- On the first server launch, a collection will be created, along with the resources tokens.
- Once these are created, the server will run on port 3000.
-
-## API Endpoints
-
-- `/api/auth/health-check`: Perform a health check to ensure the server is running and authentication is working.
-- `/api/auth/register`: User registration
-- `/api/auth/login`: User login
-- `/api/wallet/create`: Create a managed wallet
-- `/api/wallet/get`: Get a managed wallet
-- `/api/wallet/get-tokens`: Get a managed wallet and its tokens
-- `/api/token/mint`: Mint token
-- `/api/token/melt`: Melt token
-- `/api/token/transfer`: Transfer token
-
-## Full Documentation
-
-For more in-depth information about the Enjin Platform and its features, please refer to the official documentation:
-
-* [Setup Guide](https://docs.enjin.io/guides/platform/enjin-farmer-sample-game/setup-guide)
-* [Implementation Breakdown](https://docs.enjin.io/guides/platform/enjin-farmer-sample-game/implementation-breakdown)
\ No newline at end of file
+- [.NET 9 SDK](https://dotnet.microsoft.com/download).
+- The [Enjin Platform C# SDK](https://github.com/enjin/platform-csharp-sdk)
+ v3.0.0 or later, pulled from NuGet automatically on `dotnet restore`.
+ (For local development against an unreleased SDK, swap the
+ `PackageReference` in `PlatformSampleGameServer.csproj` for the commented-out
+ `ProjectReference` and check the SDK out as a sibling directory.)
+- An Enjin Platform account and a generated API token.
+- A running [Wallet Daemon](https://docs.enjin.io/products/wallet-daemon)
+ configured with the same API token; its SS58 address is what you'll set as
+ `DaemonWalletAddress` below.
+- cENJ in the daemon's wallet (use the
+ [Canary Faucet](https://faucet.canary.enjin.io/)) to pay for collection
+ creation, mints, and drips to new players.
+
+## Setup
+
+1. **Clone the repo:**
+
+ ```bash
+ git clone https://github.com/enjin/platform-sample-game-server.git
+ cd platform-sample-game-server
+ ```
+
+2. **Create a local config:**
+
+ Copy `appsettings.Sample.json` to `appsettings.Local.json` (gitignored)
+ and fill in:
+
+ - `Jwt.Secret` — any long random string (32+ chars).
+ - `Enjin.ApiToken` — your platform API token.
+ - `Enjin.DaemonWalletAddress` — the SS58 address of your running
+ wallet daemon, on the same network/chain (default Canary Matrix; uses
+ SS58 prefix 9030).
+
+ You can leave the rest of `appsettings.json` alone. Notable defaults:
+
+ | Setting | Default | Meaning |
+ |---|---|---|
+ | `Server.Port` | `3000` | HTTP listen port. The Unity client expects 3000 by default. |
+ | `Enjin.ApiUrl` | Canary GraphQL | Switch to production when you ship. |
+ | `Enjin.Network` / `Enjin.Chain` | `Canary` / `Matrix` | |
+ | `Enjin.ResourceTokens` | three entries | The Unity client has matching `EnjinItem` assets for `Id` 1, 2, 3. |
+ | `Enjin.CollectionName` | `Enjin Sample Game` | Used to find or reuse an existing collection so you don't create a new one every run. |
+ | `Enjin.Ss58Prefix` | `9030` | Matrix prefix; change if you target a different chain. |
+ | `Enjin.DripEnjEnabled` / `Enjin.DripEnjAmount` | `true` / `"1"` | Each new managed wallet gets 1 ENJ from the daemon so it can pay fees. |
+
+3. **Run the server:**
+
+ ```bash
+ dotnet run
+ ```
+
+ On first launch the server:
+
+ 1. Looks for an existing collection owned by the daemon and named
+ `Enjin.CollectionName`. If absent, creates one and waits for the
+ `CreateCollection` transaction to finalize (~10–20s).
+ 2. Creates each entry in `Enjin.ResourceTokens` as a token in that
+ collection. (Skipped on subsequent runs.)
+ 3. Persists the resulting `collectionId` to `state.json` so subsequent
+ runs reuse it.
+
+ When you see `Server listening on http://0.0.0.0:3000` it's ready.
+
+4. **Stamp the collection ID into the Unity client.**
+
+ In the Unity Editor, run:
+
+ > Enjin → Stamp Collection ID onto EnjinItem Assets
+
+ The Editor calls `GET /api/setup/collection-id`, then writes the value
+ onto every `EnjinItem` ScriptableObject (Gold Coin, Gold Coin Blue,
+ Green Gem). Commit those `.asset` files. **Do this once after first
+ server bootstrap, and again if the canary state ever resets and forces a
+ new collection.**
+
+ If you skip this step, the Unity backpack UI will show no tokens because
+ it filters by `(collectionId, tokenId)` against your wallet's holdings.
+
+## Optional flags
+
+- `dotnet run -- --skip-bootstrap` — start the HTTP server without
+ trying to allocate a collection. Useful when canary is misbehaving and you
+ just want to inspect requests.
+
+## API endpoints
+
+| Method | Path | Auth | Purpose |
+|---|---|---|---|
+| GET | `/api/auth/health-check` | none | Liveness probe. |
+| POST | `/api/auth/register` | none | Register *or* log in (same call). Returns a JWT. |
+| GET | `/api/wallet/get-tokens` | JWT | Player's managed wallet + the resource-token balances they hold. |
+| POST | `/api/token/mint` | JWT | Daemon-signed mint of `tokenId` into the caller's managed wallet. |
+| POST | `/api/token/melt` | JWT | Player-signed burn of `tokenId` from the caller's managed wallet. |
+| POST | `/api/token/transfer` | JWT | Player-signed transfer of `tokenId` to an SS58 address. |
+| GET | `/api/setup/collection-id` | none | One-shot read used by the Unity Editor setup menu. Returns `{"collectionId":"..."}`. |
+
+Every JWT-protected route reads the `email` claim (the player identifier)
+and uses it as the `externalId` when talking to the Enjin Platform's
+managed-wallet APIs.
+
+## Source map
+
+| Path | Purpose |
+|---|---|
+| `Program.cs` | Host setup, DI registration, JWT configuration, bootstrap orchestration, port binding. |
+| `Services/EnjinService.cs` | Every SDK call: collection bootstrap, managed wallet resolution, mint/melt/transfer, transaction polling, ENJ drip. Also defines `ServerState`, the persisted state (`state.json`) holding the collection id and the set of `externalId`s already dripped. |
+| `Services/AuthService.cs` | Bcrypt password hashing, JWT issuance (`sub` + `email` claims). |
+| `Services/SubstrateAddress.cs` | SS58 encoder (Blake2b + base58check) used to convert managed-wallet public keys returned by the platform into SS58 addresses. |
+| `Services/Options.cs` | Strongly-typed config classes bound from `appsettings*.json`. |
+| `Endpoints/AuthEndpoints.cs` | `/api/auth/*` minimal-API routes. |
+| `Endpoints/WalletEndpoints.cs` | `/api/wallet/*` routes. |
+| `Endpoints/TokenEndpoints.cs` | `/api/token/*` routes. |
+| `Endpoints/SetupEndpoints.cs` | `/api/setup/*` routes (called by Unity Editor tooling). |
+| `Models/Dtos.cs` | Request/response records (`*Dto`, `*Request`, `*Response`). |
+| `tools/Ss58SelfTest/` | Tiny console app that verifies the SS58 encoder against published Polkadot/Substrate test vectors. Run with `dotnet run --project tools/Ss58SelfTest`. |
+
+`appsettings.Local.json`, `state.json`, and `bin/` `obj/` are gitignored.
+
+## Further reading
+
+- [Enjin Platform C# SDK](https://github.com/enjin/platform-csharp-sdk)
+- [Enjin Farmer setup guide](https://docs.enjin.io/guides/platform/enjin-farmer-sample-game/setup-guide)
+- [Implementation breakdown](https://docs.enjin.io/guides/platform/enjin-farmer-sample-game/implementation-breakdown)
diff --git a/Services/AuthService.cs b/Services/AuthService.cs
new file mode 100644
index 0000000..4e0737e
--- /dev/null
+++ b/Services/AuthService.cs
@@ -0,0 +1,78 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using System.Text;
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.Tokens;
+using PlatformSampleGameServer.Models;
+
+namespace PlatformSampleGameServer.Services;
+
+// Player-facing authentication: bcrypt-hashed passwords + a short-lived JWT
+// bearer token returned to the Unity client. Mirrors the original Node.js
+// services/authService.js (bcryptjs + jsonwebtoken).
+public sealed class AuthService
+{
+ public const string EmailClaim = "email";
+
+ private readonly UserStore _users;
+ private readonly JwtOptions _jwt;
+
+ public AuthService(UserStore users, IOptions jwt)
+ {
+ _users = users;
+ _jwt = jwt.Value;
+ }
+
+ // Register-or-login: if the email already exists, fall through to login.
+ // This matches the original Node.js behaviour where /api/auth/register is
+ // also the login endpoint the Unity client calls.
+ public (string Token, string Email) RegisterOrLogin(string email, string password)
+ {
+ if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password))
+ {
+ throw new ArgumentException("Email and password are required.");
+ }
+
+ var existing = _users.FindByEmail(email);
+ if (existing is not null)
+ {
+ if (!BCrypt.Net.BCrypt.Verify(password, existing.PasswordHash))
+ {
+ throw new UnauthorizedAccessException("Invalid password.");
+ }
+ return (GenerateToken(existing), existing.Email);
+ }
+
+ var hash = BCrypt.Net.BCrypt.HashPassword(password, workFactor: 10);
+ var created = _users.Create(email, hash);
+ return (GenerateToken(created), created.Email);
+ }
+
+ private string GenerateToken(User user)
+ {
+ if (string.IsNullOrWhiteSpace(_jwt.Secret))
+ {
+ throw new InvalidOperationException(
+ "Jwt:Secret is not configured. Set it in appsettings.Local.json or an env var."
+ );
+ }
+
+ var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.Secret));
+ var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
+
+ var token = new JwtSecurityToken(
+ issuer: _jwt.Issuer,
+ audience: _jwt.Audience,
+ claims: new[]
+ {
+ new Claim(JwtRegisteredClaimNames.Sub, user.Email),
+ new Claim(JwtRegisteredClaimNames.Email, user.Email),
+ new Claim(EmailClaim, user.Email),
+ },
+ expires: DateTime.UtcNow.AddHours(_jwt.ExpiryHours),
+ signingCredentials: creds
+ );
+
+ return new JwtSecurityTokenHandler().WriteToken(token);
+ }
+}
diff --git a/Services/EnjinService.cs b/Services/EnjinService.cs
new file mode 100644
index 0000000..7cafad2
--- /dev/null
+++ b/Services/EnjinService.cs
@@ -0,0 +1,911 @@
+using System.Numerics;
+using System.Text.Json;
+using Enjin.Platform.Sdk;
+using Microsoft.Extensions.Options;
+
+namespace PlatformSampleGameServer.Services;
+
+// All Enjin Platform interaction lives in this single service so the sample is
+// easy to read top-to-bottom.
+//
+// Architecture notes (matters for newcomers reading the sample):
+//
+// - The Enjin Platform API token is held by this server only. The Unity
+// client authenticates against /api/auth and gets a player-scoped JWT;
+// the API token is never exposed to clients.
+//
+// - All blockchain mutations in v3 are submitted via the single
+// CreateTransaction mutation with a TransactionInput union populated to
+// one of its 46 method fields (MintToken, BurnToken, TransferToken, ...).
+//
+// - There are no event subscriptions in v3. After submitting a transaction
+// we poll GetTransaction until State is terminal
+// (Finalized | Failed | Abandoned | Timeout).
+//
+// - The daemon wallet (configured DaemonWalletAddress) owns the collection
+// and mints tokens to player wallets. Per-player melts and transfers are
+// signed by the daemon on behalf of the player's managed wallet, addressed
+// by the player's email as externalId.
+public sealed class EnjinService : IAsyncDisposable
+{
+ private readonly PlatformClient _client;
+ private readonly EnjinOptions _opts;
+ private readonly ServerState _state;
+ private readonly ILogger _log;
+ private readonly Network _network;
+ private readonly Chain _chain;
+
+ ///
+ /// On-chain collection id allocated (or reused) during bootstrap. Null
+ /// until PrepareCollectionAsync has run.
+ ///
+ public BigInteger? CollectionId => _state.CollectionId;
+
+ public EnjinService(IOptions opts, ServerState state, ILogger log)
+ {
+ _opts = opts.Value;
+ _state = state;
+ _log = log;
+ _network = _opts.Network;
+ _chain = _opts.Chain;
+
+ if (string.IsNullOrWhiteSpace(_opts.ApiUrl))
+ throw new InvalidOperationException("Enjin:ApiUrl is not configured.");
+ if (string.IsNullOrWhiteSpace(_opts.ApiToken))
+ throw new InvalidOperationException("Enjin:ApiToken is not configured.");
+ if (string.IsNullOrWhiteSpace(_opts.DaemonWalletAddress))
+ throw new InvalidOperationException(
+ "Enjin:DaemonWalletAddress is not configured. Set it to the SS58 address of your running wallet daemon."
+ );
+ if (string.IsNullOrWhiteSpace(_opts.CollectionName))
+ throw new InvalidOperationException(
+ "Enjin:CollectionName is not configured. PrepareCollection needs a name to find or create a collection."
+ );
+ if (_opts.ResourceTokens is null || _opts.ResourceTokens.Count == 0)
+ throw new InvalidOperationException(
+ "Enjin:ResourceTokens is empty. Configure at least one resource token definition (id, name, media)."
+ );
+
+ _client = new PlatformClient(
+ new Uri(_opts.ApiUrl),
+ userAgent: "platform-sample-game-server/1.0"
+ );
+ _client.Auth(_opts.ApiToken);
+ }
+
+ // ------------------------------------------------------------------
+ // Bootstrap: ensure the sample game's collection + resource tokens exist.
+ // Invoked once at server startup before any HTTP request is served.
+ // ------------------------------------------------------------------
+ public async Task PrepareCollectionAsync(CancellationToken ct)
+ {
+ var collectionId = _state.CollectionId;
+ if (collectionId is null)
+ {
+ // Avoid creating duplicate collections on canary if state.json was lost.
+ // First look for an existing collection owned by the daemon wallet whose
+ // "name" attribute matches our configured collection name.
+ _log.LogInformation(
+ "No collection ID on file. Checking for an existing '{Name}' collection owned by {Owner}...",
+ _opts.CollectionName,
+ _opts.DaemonWalletAddress
+ );
+
+ var existing = await FindExistingCollectionAsync(ct);
+ if (existing is not null)
+ {
+ _log.LogInformation("Found existing collection {Id}; reusing.", existing);
+ collectionId = existing;
+ }
+ else
+ {
+ _log.LogInformation(
+ "No matching collection found. Creating new '{Name}' collection...",
+ _opts.CollectionName
+ );
+ collectionId = await CreateCollectionAsync(ct);
+ _log.LogInformation("Created collection with ID {Id}", collectionId);
+ }
+
+ _state.SetCollectionId(collectionId.Value);
+ }
+ else
+ {
+ _log.LogInformation("Using existing collection ID {Id}", collectionId);
+ }
+
+ // Create any missing resource tokens. Order matters less than correctness;
+ // we run them sequentially because the daemon wallet's nonce is shared.
+ foreach (var token in _opts.ResourceTokens)
+ {
+ if (await TokenExistsAsync(collectionId.Value, token.Id, ct))
+ {
+ _log.LogInformation(
+ "Resource token #{Id} '{Name}' already exists",
+ token.Id,
+ token.Name
+ );
+ continue;
+ }
+
+ _log.LogInformation("Creating resource token #{Id} '{Name}'...", token.Id, token.Name);
+ await CreateResourceTokenAsync(collectionId.Value, token, ct);
+ _log.LogInformation("Resource token #{Id} '{Name}' ready", token.Id, token.Name);
+ }
+ }
+
+ // Returns the BigInteger id of an existing collection owned by the daemon
+ // wallet whose "name" attribute matches the configured collection name, or
+ // null if no such collection exists. Used both before creating a new
+ // collection (to avoid duplicates) and after creation (to retrieve the
+ // new id, since v3 Transaction does not surface emitted events).
+ private async Task FindExistingCollectionAsync(CancellationToken ct)
+ {
+ var query = new QueryQueryBuilder().WithGetCollections(
+ new CollectionQueryBuilder()
+ .WithId()
+ .WithAttributes(new AttributeQueryBuilder().WithKey().WithValue()),
+ _network,
+ _chain,
+ ids: null,
+ address: _opts.DaemonWalletAddress
+ );
+
+ var resp = await _client.SendQuery(query);
+ EnsureSuccess(resp, "GetCollections");
+
+ var match = (resp.Result.Data?.GetCollections ?? Array.Empty())
+ .Where(c => c is not null)
+ .Where(c =>
+ c!.Attributes?.Any(a =>
+ a is not null && a.Key == "name" && a.Value == _opts.CollectionName
+ ) == true
+ )
+ .OrderByDescending(c => c!.Id)
+ .FirstOrDefault();
+
+ return match?.Id;
+ }
+
+ private async Task CreateCollectionAsync(CancellationToken ct)
+ {
+ var transaction = new TransactionInput
+ {
+ CreateCollection = new CreateCollectionInput
+ {
+ ForceCollapsingSupply = false,
+ Attributes = new List
+ {
+ new() { Key = "name", Value = _opts.CollectionName },
+ new() { Key = "banner_image", Value = _opts.CollectionBannerImage },
+ new() { Key = "media", Value = _opts.CollectionMedia },
+ },
+ },
+ };
+
+ var submitted = await CreateTransactionAsync(transaction, signerExternalId: null, ct);
+ await WaitForFinalizationAsync(submitted.Uuid!, "collection creation", ct);
+
+ // v3 Transaction does not surface emitted events; locate the new collection by
+ // listing those owned by the daemon wallet and matching on the "name" attribute.
+ var found = await FindExistingCollectionAsync(ct);
+ if (found is null)
+ {
+ throw new InvalidOperationException(
+ $"CreateCollection finalized but no collection named '{_opts.CollectionName}' "
+ + $"found owned by {_opts.DaemonWalletAddress}."
+ );
+ }
+
+ return found.Value;
+ }
+
+ // Checks whether a token entry exists in our collection by querying it directly.
+ // GetToken returns null (not an error) when the (collection, token) pair is unknown.
+ private async Task TokenExistsAsync(
+ BigInteger collectionId,
+ BigInteger tokenId,
+ CancellationToken ct
+ )
+ {
+ var query = new QueryQueryBuilder().WithGetToken(
+ new TokenQueryBuilder().WithTokenId(),
+ _network,
+ _chain,
+ collectionId: collectionId,
+ tokenId: tokenId
+ );
+
+ var resp = await _client.SendQuery(query);
+ EnsureSuccess(resp, "GetToken");
+ return resp.Result.Data?.GetToken is not null;
+ }
+
+ private async Task CreateResourceTokenAsync(
+ BigInteger collectionId,
+ ResourceTokenDefinition token,
+ CancellationToken ct
+ )
+ {
+ var transaction = new TransactionInput
+ {
+ CreateToken = new CreateTokenInput
+ {
+ Recipient = _opts.DaemonWalletAddress,
+ CollectionId = collectionId,
+ TokenId = new BigInteger(token.Id),
+ InitialSupply = BigInteger.One,
+ Attributes = new List
+ {
+ new() { Key = "name", Value = token.Name },
+ new() { Key = "media", Value = token.Media },
+ },
+ },
+ };
+
+ var submitted = await CreateTransactionAsync(transaction, signerExternalId: null, ct);
+ await WaitForFinalizationAsync(
+ submitted.Uuid!,
+ $"create token #{token.Id} '{token.Name}'",
+ ct
+ );
+ }
+
+ // ------------------------------------------------------------------
+ // Managed wallets
+ // ------------------------------------------------------------------
+
+ public async Task GetManagedWalletAddressAsync(string externalId, CancellationToken ct)
+ {
+ var query = new QueryQueryBuilder().WithGetManagedWallet(
+ new ManagedWalletQueryBuilder().WithPublicKey().WithExternalId(),
+ _network,
+ _chain,
+ externalId: externalId
+ );
+
+ var resp = await _client.SendQuery(query);
+ EnsureSuccess(resp, "GetManagedWallet");
+ // PublicKey on a managed wallet IS the account public key (hex); we resolve to
+ // SS58 by calling GetAccount.
+ var publicKey = resp.Result.Data?.GetManagedWallet?.PublicKey;
+ if (publicKey is null)
+ return null;
+ return await ResolveAddressAsync(publicKey, ct);
+ }
+
+ // Ensures the wallet exists; returns the SS58 address. Idempotent: if the
+ // wallet already exists, we just resolve and return it.
+ public async Task EnsureManagedWalletAsync(string externalId, CancellationToken ct)
+ {
+ var existing = await GetManagedWalletAddressAsync(externalId, ct);
+ if (existing is not null)
+ {
+ // Even for pre-existing wallets, drip once if we haven't yet.
+ // (Covers wallets created before drip was enabled.)
+ await DripIfNeededAsync(externalId, existing, ct);
+ return existing;
+ }
+
+ var mutation = new MutationQueryBuilder().WithCreateManagedWallet(externalId);
+ var resp = await _client.SendMutation(mutation);
+ EnsureSuccess(resp, "CreateManagedWallet");
+
+ // The platform creates the wallet asynchronously: poll until it's queryable.
+ for (var attempt = 1; attempt <= _opts.ManagedWalletPollMaxAttempts; attempt++)
+ {
+ var address = await GetManagedWalletAddressAsync(externalId, ct);
+ if (address is not null)
+ {
+ await DripIfNeededAsync(externalId, address, ct);
+ return address;
+ }
+ await Task.Delay(TimeSpan.FromSeconds(_opts.ManagedWalletPollIntervalSeconds), ct);
+ }
+
+ throw new InvalidOperationException(
+ $"CreateManagedWallet for externalId '{externalId}' did not become queryable "
+ + $"after {_opts.ManagedWalletPollMaxAttempts} attempts."
+ );
+ }
+
+ ///
+ /// Transfer a fixed amount of ENJ from the daemon wallet to a managed wallet,
+ /// once per externalId. Required so the managed wallet can pay transaction fees
+ /// and the storage reserve for token holding records (canary platform has no
+ /// fuel-tank API to do this in-band).
+ ///
+ private async Task DripIfNeededAsync(
+ string externalId,
+ string recipientAddress,
+ CancellationToken ct
+ )
+ {
+ if (!_opts.DripEnjEnabled)
+ return;
+ if (_state.HasDripped(externalId))
+ return;
+ if (
+ !BigInteger.TryParse(_opts.DripEnjAmount, out var amount)
+ || amount.IsZero
+ || amount.Sign < 0
+ )
+ {
+ _log.LogWarning(
+ "DripEnjAmount '{Amount}' is invalid; skipping drip for {ExternalId}.",
+ _opts.DripEnjAmount,
+ externalId
+ );
+ return;
+ }
+
+ // Atomic guard: only one concurrent caller proceeds per externalId.
+ // Without this, two simultaneous EnsureManagedWalletAsync calls for the
+ // same player could both pass HasDripped, both await TransferEnj, and
+ // double-drip. TryBeginDrip marks the externalId as "in progress"
+ // synchronously; we clear it on failure so a later attempt can retry.
+ if (!_state.TryBeginDrip(externalId))
+ {
+ _log.LogDebug(
+ "Drip already in progress or completed for {ExternalId}; skipping.",
+ externalId
+ );
+ return;
+ }
+
+ _log.LogInformation(
+ "Dripping {Amount} ENJ from daemon to managed wallet {Address} (externalId={ExternalId}).",
+ amount,
+ recipientAddress,
+ externalId
+ );
+
+ var input = new TransactionInput
+ {
+ TransferEnj = new TransferEnjInput { Recipient = recipientAddress, Amount = amount },
+ };
+
+ try
+ {
+ // Signed by the daemon (signerExternalId: null).
+ await SubmitAndWaitAsync(
+ input,
+ signerExternalId: null,
+ $"drip ENJ to {externalId}",
+ ct
+ );
+ _state.RecordDripped(externalId);
+ }
+ catch
+ {
+ // Clear the in-progress flag so a later attempt can retry.
+ _state.CancelDrip(externalId);
+ throw;
+ }
+ }
+
+ private Task ResolveAddressAsync(string publicKey, CancellationToken ct)
+ {
+ // The v3 platform's GetAccount(address:) requires an SS58 string; ManagedWallet
+ // only exposes the public key as hex. There is no platform-side helper, so we
+ // SS58-encode locally using the configured network prefix.
+ // (Enjin Matrix Canary = 9030, Mainnet Matrix = 1110.)
+ var address = SubstrateAddress.Encode(publicKey, _opts.Ss58Prefix);
+ return Task.FromResult(address);
+ }
+
+ // ------------------------------------------------------------------
+ // Wallet token listing (used by Unity backpack UI)
+ // ------------------------------------------------------------------
+ public async Task GetManagedWalletTokensAsync(
+ string externalId,
+ CancellationToken ct
+ )
+ {
+ // Step 1: locate the managed wallet's public key.
+ var mwQuery = new QueryQueryBuilder().WithGetManagedWallet(
+ new ManagedWalletQueryBuilder().WithPublicKey().WithExternalId(),
+ _network,
+ _chain,
+ externalId: externalId
+ );
+
+ var mwResp = await _client.SendQuery(mwQuery);
+ EnsureSuccess(mwResp, "GetManagedWallet");
+ var wallet = mwResp.Result.Data?.GetManagedWallet;
+ if (wallet?.PublicKey is null)
+ return null;
+
+ var collectionId =
+ _state.CollectionId
+ ?? throw new InvalidOperationException(
+ "Collection ID not initialised; PrepareCollection has not run."
+ );
+
+ var ss58Address = SubstrateAddress.Encode(wallet.PublicKey, _opts.Ss58Prefix);
+
+ // Step 2: for each resource token we know exists in the collection, query
+ // the token's holder list and look for this player's address.
+ //
+ // The Enjin canary platform exposes `holders` only via the singular
+ // `GetToken(collectionId, tokenId)` query; the same field returns null
+ // when nested under `GetTokens` or `Account.tokens`. So we issue one
+ // query per resource token. With ~3 tokens this is fine; we run them
+ // in parallel.
+ var holderTasks = _opts
+ .ResourceTokens.Select(rt =>
+ FetchTokenForHolderAsync(collectionId, new BigInteger(rt.Id), ss58Address, ct)
+ )
+ .ToList();
+ var tokenAccountResults = await Task.WhenAll(holderTasks);
+
+ var tokenAccounts = tokenAccountResults.Where(t => t is not null).Select(t => t!).ToList();
+
+ return new Models.ManagedWalletAccountDto(
+ Account: new Models.AccountDto(PublicKey: wallet.PublicKey, Address: ss58Address),
+ TokenAccounts: tokenAccounts
+ );
+ }
+
+ private async Task FetchTokenForHolderAsync(
+ BigInteger collectionId,
+ BigInteger tokenId,
+ string holderAddress,
+ CancellationToken ct
+ )
+ {
+ var query = new QueryQueryBuilder().WithGetToken(
+ new TokenQueryBuilder()
+ .WithTokenId()
+ .WithCollection(new CollectionQueryBuilder().WithId())
+ .WithAttributes(new AttributeQueryBuilder().WithKey().WithValue())
+ .WithHolders(
+ new TokenHolderQueryBuilder().WithAddress().WithAmount(),
+ limit: 50,
+ page: 1
+ ),
+ _network,
+ _chain,
+ id: null,
+ collectionId: collectionId,
+ tokenId: tokenId
+ );
+
+ var resp = await _client.SendQuery(query);
+ EnsureSuccess(resp, $"GetToken(collection={collectionId}, token={tokenId})");
+ var token = resp.Result.Data?.GetToken;
+ if (token is null)
+ return null;
+
+ var holderBalance =
+ token
+ .Holders?.Where(h =>
+ string.Equals(h.Address, holderAddress, StringComparison.OrdinalIgnoreCase)
+ )
+ .Select(h => h.Amount)
+ .FirstOrDefault()
+ ?? BigInteger.Zero;
+
+ if (holderBalance.IsZero)
+ return null;
+
+ var attrs = (token.Attributes ?? Enumerable.Empty())
+ .Select(a => new Models.AttributeDto(a.Key ?? "", a.Value ?? ""))
+ .ToList();
+
+ return new Models.TokenAccountDto(
+ Balance: holderBalance.ToString(),
+ Token: new Models.TokenDto(
+ Collection: new Models.CollectionDto(
+ token.Collection?.Id.ToString() ?? collectionId.ToString()
+ ),
+ TokenId: token.TokenId ?? tokenId.ToString(),
+ Attributes: attrs
+ )
+ );
+ }
+
+ // ------------------------------------------------------------------
+ // Mint / Burn / Transfer (all wait for chain finalization)
+ // ------------------------------------------------------------------
+
+ public Task MintTokenAsync(
+ BigInteger tokenId,
+ BigInteger amount,
+ string recipientAddress,
+ CancellationToken ct
+ )
+ {
+ var input = new TransactionInput
+ {
+ MintToken = new MintTokenInput
+ {
+ Recipient = recipientAddress,
+ CollectionId = RequireCollectionId(),
+ TokenId = tokenId,
+ Amount = amount,
+ },
+ };
+ return SubmitAndWaitAsync(input, signerExternalId: null, $"mint token #{tokenId}", ct);
+ }
+
+ public Task MeltTokenAsync(
+ BigInteger tokenId,
+ BigInteger amount,
+ string signerExternalId,
+ CancellationToken ct
+ )
+ {
+ var input = new TransactionInput
+ {
+ BurnToken = new BurnTokenInput
+ {
+ CollectionId = RequireCollectionId(),
+ TokenId = tokenId,
+ Amount = amount,
+ },
+ };
+ return SubmitAndWaitAsync(input, signerExternalId, $"burn token #{tokenId}", ct);
+ }
+
+ public Task TransferTokenAsync(
+ BigInteger tokenId,
+ BigInteger amount,
+ string recipientAddress,
+ string signerExternalId,
+ CancellationToken ct
+ )
+ {
+ var input = new TransactionInput
+ {
+ TransferToken = new TransferTokenInput
+ {
+ Recipient = recipientAddress,
+ CollectionId = RequireCollectionId(),
+ TokenId = tokenId,
+ Amount = amount,
+ },
+ };
+ return SubmitAndWaitAsync(input, signerExternalId, $"transfer token #{tokenId}", ct);
+ }
+
+ // ------------------------------------------------------------------
+ // Transaction submission + polling
+ // ------------------------------------------------------------------
+
+ private async Task SubmitAndWaitAsync(
+ TransactionInput input,
+ string? signerExternalId,
+ string description,
+ CancellationToken ct
+ )
+ {
+ var submitted = await CreateTransactionAsync(input, signerExternalId, ct);
+ return await WaitForFinalizationAsync(submitted.Uuid!, description, ct);
+ }
+
+ private async Task CreateTransactionAsync(
+ TransactionInput input,
+ string? signerExternalId,
+ CancellationToken ct
+ )
+ {
+ var mutation = new MutationQueryBuilder().WithCreateTransaction(
+ new TransactionQueryBuilder().WithUuid().WithState(),
+ _network,
+ _chain,
+ transaction: input,
+ signerExternalId: signerExternalId
+ );
+
+ var resp = await _client.SendMutation(mutation);
+ EnsureSuccess(resp, "CreateTransaction");
+ var txn =
+ resp.Result.Data?.CreateTransaction
+ ?? throw new InvalidOperationException("CreateTransaction returned no transaction.");
+ if (string.IsNullOrEmpty(txn.Uuid))
+ throw new InvalidOperationException(
+ "CreateTransaction returned a transaction with no UUID."
+ );
+ return txn;
+ }
+
+ private async Task WaitForFinalizationAsync(
+ string uuid,
+ string description,
+ CancellationToken ct
+ )
+ {
+ if (_opts.TransactionInitialDelaySeconds > 0)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(_opts.TransactionInitialDelaySeconds), ct);
+ }
+
+ var started = DateTime.UtcNow;
+ TransactionStateEnum? lastLoggedState = null;
+ DateTime lastStuckWarning = DateTime.UtcNow;
+
+ while (true)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var query = new QueryQueryBuilder().WithGetTransaction(
+ new TransactionQueryBuilder().WithUuid().WithState().WithExtrinsicHash(),
+ _network,
+ _chain,
+ uuid: uuid
+ );
+
+ var resp = await _client.SendQuery(query);
+ EnsureSuccess(resp, "GetTransaction");
+ var txn =
+ resp.Result.Data?.GetTransaction
+ ?? throw new InvalidOperationException(
+ $"GetTransaction returned nothing for UUID {uuid}."
+ );
+
+ switch (txn.State)
+ {
+ case TransactionStateEnum.Finalized:
+ _log.LogInformation(
+ "Transaction {Uuid} ({Desc}) finalized after {Elapsed:F0}s.",
+ uuid,
+ description,
+ (DateTime.UtcNow - started).TotalSeconds
+ );
+ return txn;
+
+ case TransactionStateEnum.Failed:
+ case TransactionStateEnum.Abandoned:
+ case TransactionStateEnum.Timeout:
+ throw new InvalidOperationException(
+ $"Transaction {uuid} ({description}) ended in terminal state {txn.State}."
+ );
+
+ default:
+ // Log only on state transitions to avoid flooding logs while polling.
+ if (lastLoggedState != txn.State)
+ {
+ _log.LogInformation(
+ "Waiting for {Desc} (uuid={Uuid}, state={State})...",
+ description,
+ uuid,
+ txn.State
+ );
+ lastLoggedState = txn.State;
+ lastStuckWarning = DateTime.UtcNow;
+ }
+ else if (
+ txn.State == TransactionStateEnum.Pending
+ && (DateTime.UtcNow - lastStuckWarning).TotalSeconds >= 60
+ )
+ {
+ var elapsed = (DateTime.UtcNow - started).TotalSeconds;
+ _log.LogWarning(
+ "Transaction {Uuid} ({Desc}) has been Pending for {Elapsed:F0}s. "
+ + "Confirm that a wallet daemon is signing transactions for account {Daemon}.",
+ uuid,
+ description,
+ elapsed,
+ _opts.DaemonWalletAddress
+ );
+ lastStuckWarning = DateTime.UtcNow;
+ }
+
+ await Task.Delay(
+ TimeSpan.FromSeconds(_opts.TransactionPollIntervalSeconds),
+ ct
+ );
+ break;
+ }
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // Helpers
+ // ------------------------------------------------------------------
+
+ private BigInteger RequireCollectionId() =>
+ _state.CollectionId
+ ?? throw new InvalidOperationException(
+ "Collection ID not initialised; PrepareCollection has not run."
+ );
+
+ private static void EnsureSuccess(IPlatformResponse resp, string operation) =>
+ EnsureSuccessCore(
+ resp.IsSuccessStatusCode,
+ resp.StatusCode,
+ resp.Result?.Errors,
+ operation
+ );
+
+ private static void EnsureSuccess(IPlatformResponse resp, string operation) =>
+ EnsureSuccessCore(
+ resp.IsSuccessStatusCode,
+ resp.StatusCode,
+ resp.Result?.Errors,
+ operation
+ );
+
+ private static void EnsureSuccessCore(
+ bool isSuccess,
+ System.Net.HttpStatusCode status,
+ ICollection? errors,
+ string operation
+ )
+ {
+ if (!isSuccess)
+ {
+ throw new InvalidOperationException(
+ $"{operation} returned HTTP {(int)status} {status}."
+ );
+ }
+ if (errors is { Count: > 0 })
+ {
+ throw new InvalidOperationException(
+ $"{operation} returned GraphQL errors: {string.Join("; ", errors.Select(e => e.Message))}"
+ );
+ }
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ _client.Dispose();
+ return ValueTask.CompletedTask;
+ }
+}
+
+// Process-local mutable state. Persisted to state.json so we don't redo work
+// on every restart: bootstrapped collection ID, and the set of managed-wallet
+// externalIds we've already ENJ-dripped (so we never double-drip the same player).
+public sealed class ServerState
+{
+ private readonly string _path;
+ private BigInteger? _collectionId;
+
+ // Case-insensitive to match UserStore (OrdinalIgnoreCase). externalId is an
+ // email, and "Alice@x.com" / "alice@x.com" are the same user; tracking them
+ // separately here would let the same player be dripped twice.
+ private readonly HashSet _drippedExternalIds = new(StringComparer.OrdinalIgnoreCase);
+
+ // In-flight drips. Not persisted; lives only for the lifetime of the process
+ // and exists purely to serialise concurrent EnsureManagedWalletAsync calls
+ // for the same externalId so we never submit two TransferEnj transactions
+ // for a single player.
+ private readonly HashSet _drippingInFlight = new(StringComparer.OrdinalIgnoreCase);
+ private readonly object _lock = new();
+
+ public ServerState(IHostEnvironment env)
+ {
+ _path = Path.Combine(env.ContentRootPath, "state.json");
+ Load();
+ }
+
+ public BigInteger? CollectionId
+ {
+ get
+ {
+ lock (_lock)
+ {
+ return _collectionId;
+ }
+ }
+ }
+
+ public void SetCollectionId(BigInteger id)
+ {
+ lock (_lock)
+ {
+ _collectionId = id;
+ Persist();
+ }
+ }
+
+ public bool HasDripped(string externalId)
+ {
+ lock (_lock)
+ {
+ return _drippedExternalIds.Contains(externalId);
+ }
+ }
+
+ ///
+ /// Atomically reserves an externalId for drip. Returns true if the caller
+ /// should proceed with the on-chain transfer; false if the externalId has
+ /// already been dripped, or another caller is currently dripping it.
+ /// Pair every true return with either
+ /// on success or on failure.
+ ///
+ public bool TryBeginDrip(string externalId)
+ {
+ lock (_lock)
+ {
+ if (_drippedExternalIds.Contains(externalId))
+ return false;
+ return _drippingInFlight.Add(externalId);
+ }
+ }
+
+ public void CancelDrip(string externalId)
+ {
+ lock (_lock)
+ {
+ _drippingInFlight.Remove(externalId);
+ }
+ }
+
+ public void RecordDripped(string externalId)
+ {
+ lock (_lock)
+ {
+ _drippingInFlight.Remove(externalId);
+ if (_drippedExternalIds.Add(externalId))
+ Persist();
+ }
+ }
+
+ public void OverrideFromConfig(string? configured)
+ {
+ if (string.IsNullOrWhiteSpace(configured))
+ return;
+ if (!BigInteger.TryParse(configured, out var parsed))
+ return;
+ lock (_lock)
+ {
+ // Don't clobber on-disk state if it disagrees; on-disk wins because
+ // the operator has been running with that one.
+ _collectionId ??= parsed;
+ }
+ }
+
+ private void Load()
+ {
+ if (!File.Exists(_path))
+ return;
+ try
+ {
+ using var stream = File.OpenRead(_path);
+ var doc = JsonDocument.Parse(stream);
+ if (
+ doc.RootElement.TryGetProperty("collectionId", out var prop)
+ && prop.ValueKind == JsonValueKind.String
+ && BigInteger.TryParse(prop.GetString(), out var parsed)
+ )
+ {
+ _collectionId = parsed;
+ }
+ if (
+ doc.RootElement.TryGetProperty("drippedExternalIds", out var arr)
+ && arr.ValueKind == JsonValueKind.Array
+ )
+ {
+ foreach (var item in arr.EnumerateArray())
+ {
+ if (item.ValueKind == JsonValueKind.String)
+ {
+ var s = item.GetString();
+ if (!string.IsNullOrEmpty(s))
+ _drippedExternalIds.Add(s);
+ }
+ }
+ }
+ }
+ catch
+ {
+ // State file is best-effort; ignore corruption and let the bootstrap re-create.
+ }
+ }
+
+ private void Persist()
+ {
+ var payload = new
+ {
+ collectionId = _collectionId?.ToString(),
+ drippedExternalIds = _drippedExternalIds.OrderBy(s => s).ToArray(),
+ };
+ File.WriteAllText(
+ _path,
+ JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true })
+ );
+ }
+}
diff --git a/Services/Options.cs b/Services/Options.cs
new file mode 100644
index 0000000..3b7780f
--- /dev/null
+++ b/Services/Options.cs
@@ -0,0 +1,74 @@
+using Enjin.Platform.Sdk;
+
+namespace PlatformSampleGameServer.Services;
+
+// Strongly-typed configuration. Bound to the "Enjin" section of appsettings.json
+// (and overrides) via IOptions.
+public sealed class EnjinOptions
+{
+ public string ApiUrl { get; set; } = "";
+ public string ApiToken { get; set; } = "";
+
+ // Default to the Canary test network on Matrix relay. Override per environment.
+ public Network Network { get; set; } = Network.Canary;
+ public Chain Chain { get; set; } = Chain.Matrix;
+
+ // Recipient used when minting the initial supply for resource tokens (the
+ // daemon wallet that holds the master copy). Per-player mints use the
+ // player's managed-wallet address as recipient.
+ public string DaemonWalletAddress { get; set; } = "";
+
+ ///
+ /// SS58 prefix used to encode wallet public keys into addresses. Defaults to
+ /// 9030 (Enjin Matrixchain Canary). Use 1110 for Enjin Mainnet Matrixchain.
+ ///
+ public ushort Ss58Prefix { get; set; } = 9030;
+
+ ///
+ /// If true, the server transfers ENJ from the daemon
+ /// wallet to every newly created managed wallet exactly once. This is required
+ /// because the platform exposes no fuel-tank API on canary; managed wallets need
+ /// their own ENJ to cover transaction fees and storage reserves (e.g. for token
+ /// holding records minted into them).
+ ///
+ public bool DripEnjEnabled { get; set; } = true;
+
+ ///
+ /// Amount of ENJ to drip to each new managed wallet, expressed in WHOLE ENJ
+ /// (integer; the platform scales by 10^18 on-chain). Default 1.
+ /// Note: the TransferEnj mutation does not support fractional amounts.
+ ///
+ public string DripEnjAmount { get; set; } = "1";
+
+ public int TransactionPollIntervalSeconds { get; set; } = 10;
+ public int TransactionInitialDelaySeconds { get; set; } = 10;
+ public int ManagedWalletPollIntervalSeconds { get; set; } = 1;
+ public int ManagedWalletPollMaxAttempts { get; set; } = 10;
+
+ public List ResourceTokens { get; set; } = new();
+
+ public string CollectionName { get; set; } = "";
+ public string CollectionBannerImage { get; set; } = "";
+ public string CollectionMedia { get; set; } = "";
+}
+
+public sealed class ResourceTokenDefinition
+{
+ public int Id { get; set; }
+ public string Name { get; set; } = "";
+ public string Media { get; set; } = "";
+}
+
+public sealed class JwtOptions
+{
+ public string Issuer { get; set; } = "";
+ public string Audience { get; set; } = "";
+ public string Secret { get; set; } = "";
+ public int ExpiryHours { get; set; } = 24;
+}
+
+public sealed class ServerOptions
+{
+ public int Port { get; set; } = 3000;
+ public int RequestTimeoutSeconds { get; set; } = 3600;
+}
diff --git a/Services/SubstrateAddress.cs b/Services/SubstrateAddress.cs
new file mode 100644
index 0000000..417e656
--- /dev/null
+++ b/Services/SubstrateAddress.cs
@@ -0,0 +1,149 @@
+using System;
+using System.Linq;
+using Konscious.Security.Cryptography;
+
+namespace PlatformSampleGameServer.Services;
+
+///
+/// SS58 address encoding for Substrate-based chains.
+///
+/// SS58 is base58-check-style encoding wrapping (prefix-byte(s) ++ payload ++ checksum),
+/// where the checksum is the first 2 bytes of Blake2b-512("SS58PRE" ++ prefix ++ payload).
+///
+/// Used here to convert a managed wallet's hex public key (as returned by
+/// the Enjin platform's ManagedWallet.publicKey) into the canonical SS58 address
+/// that GetAccount(address:) expects.
+///
+/// SS58 prefixes of interest:
+/// - 9030: Enjin Matrixchain (Canary). Addresses start with "cx".
+/// - 1110: Enjin Mainnet Matrixchain. Addresses start with "ef".
+///
+public static class SubstrateAddress
+{
+ private static readonly byte[] Ss58Pre = System.Text.Encoding.ASCII.GetBytes("SS58PRE");
+ private const string Base58Alphabet =
+ "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
+
+ ///
+ /// Encode a 32-byte Substrate public key to an SS58 address string.
+ /// Accepts hex with or without a "0x" prefix.
+ ///
+ public static string Encode(string publicKeyHex, ushort ss58Prefix)
+ {
+ if (string.IsNullOrWhiteSpace(publicKeyHex))
+ throw new ArgumentException("Public key required.", nameof(publicKeyHex));
+
+ var hex = publicKeyHex.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
+ ? publicKeyHex[2..]
+ : publicKeyHex;
+ if (hex.Length != 64)
+ throw new ArgumentException(
+ $"Expected 32-byte public key (64 hex chars), got {hex.Length}.",
+ nameof(publicKeyHex)
+ );
+
+ var payload = Convert.FromHexString(hex);
+ return Encode(payload, ss58Prefix);
+ }
+
+ ///
+ /// Encode a 32-byte Substrate public key (raw bytes) to an SS58 address string.
+ ///
+ public static string Encode(byte[] publicKey, ushort ss58Prefix)
+ {
+ if (publicKey is null)
+ throw new ArgumentNullException(nameof(publicKey));
+ if (publicKey.Length != 32)
+ throw new ArgumentException(
+ $"Expected 32 bytes, got {publicKey.Length}.",
+ nameof(publicKey)
+ );
+
+ // Encode prefix. <64 fits in one byte; >=64 uses the two-byte form per
+ // https://docs.substrate.io/reference/address-formats/.
+ byte[] prefixBytes;
+ if (ss58Prefix < 64)
+ {
+ prefixBytes = new[] { (byte)ss58Prefix };
+ }
+ else if (ss58Prefix < 0x4000)
+ {
+ // Two-byte form: bits are split as 0b01aaaaaa | bbbbbbbb_bb
+ // = ((p & 0x3F) | 0x40) ++ ((p >> 8) | ((p & 0xFC00) >> 2))
+ byte lower = (byte)(((ss58Prefix & 0x00FC) >> 2) | 0x40);
+ byte upper = (byte)(((ss58Prefix >> 8) & 0xFF) | ((ss58Prefix & 0x0003) << 6));
+ prefixBytes = new[] { lower, upper };
+ }
+ else
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(ss58Prefix),
+ "SS58 prefixes >= 16384 are reserved."
+ );
+ }
+
+ // checksum = first 2 bytes of Blake2b-512("SS58PRE" || prefix || payload)
+ var hashInput = new byte[Ss58Pre.Length + prefixBytes.Length + publicKey.Length];
+ Buffer.BlockCopy(Ss58Pre, 0, hashInput, 0, Ss58Pre.Length);
+ Buffer.BlockCopy(prefixBytes, 0, hashInput, Ss58Pre.Length, prefixBytes.Length);
+ Buffer.BlockCopy(
+ publicKey,
+ 0,
+ hashInput,
+ Ss58Pre.Length + prefixBytes.Length,
+ publicKey.Length
+ );
+
+ using var blake = new HMACBlake2B(512);
+ // SS58 specifies an UNKEYED Blake2b-512 over "SS58PRE" || prefix || payload.
+ // Konscious's Blake2b is only exposed via the HMACBlake2B type, but when
+ // constructed without a key it does NOT perform the HMAC ipad/opad
+ // construction: it produces plain (unkeyed) Blake2b output identical to
+ // the standard test vectors (e.g. Blake2b-512("abc") matches RFC 7693).
+ // tools/Ss58SelfTest asserts this against the canonical Substrate
+ // Alice/Bob vectors so a library change that broke the equivalence would
+ // be caught immediately.
+ var hash = blake.ComputeHash(hashInput);
+
+ var full = new byte[prefixBytes.Length + publicKey.Length + 2];
+ Buffer.BlockCopy(prefixBytes, 0, full, 0, prefixBytes.Length);
+ Buffer.BlockCopy(publicKey, 0, full, prefixBytes.Length, publicKey.Length);
+ full[^2] = hash[0];
+ full[^1] = hash[1];
+
+ return Base58Encode(full);
+ }
+
+ private static string Base58Encode(byte[] data)
+ {
+ // Count leading zero bytes
+ int zeros = 0;
+ while (zeros < data.Length && data[zeros] == 0)
+ zeros++;
+
+ // Convert big-endian bytes to base58 by repeated division.
+ // Operate on a copy because we mutate during division.
+ var input = (byte[])data.Clone();
+ var encoded = new System.Collections.Generic.List(data.Length * 138 / 100 + 1);
+
+ int startAt = zeros;
+ while (startAt < input.Length)
+ {
+ int remainder = 0;
+ for (int i = startAt; i < input.Length; i++)
+ {
+ int num = (remainder << 8) | input[i];
+ input[i] = (byte)(num / 58);
+ remainder = num % 58;
+ }
+ encoded.Add(Base58Alphabet[remainder]);
+ if (input[startAt] == 0)
+ startAt++;
+ }
+
+ for (int i = 0; i < zeros; i++)
+ encoded.Add(Base58Alphabet[0]);
+ encoded.Reverse();
+ return new string(encoded.ToArray());
+ }
+}
diff --git a/Services/UserStore.cs b/Services/UserStore.cs
new file mode 100644
index 0000000..d854abe
--- /dev/null
+++ b/Services/UserStore.cs
@@ -0,0 +1,26 @@
+using System.Collections.Concurrent;
+using PlatformSampleGameServer.Models;
+
+namespace PlatformSampleGameServer.Services;
+
+// Process-local user registry. Matches the original Node.js sample's in-memory
+// model (src/models/user.js). Lost on process restart; this is intentional for
+// a sample. Replace with a real persistence layer for production use.
+public sealed class UserStore
+{
+ private readonly ConcurrentDictionary _users = new(
+ StringComparer.OrdinalIgnoreCase
+ );
+
+ public User? FindByEmail(string email) => _users.TryGetValue(email, out var user) ? user : null;
+
+ public User Create(string email, string passwordHash)
+ {
+ var user = new User(email, passwordHash);
+ if (!_users.TryAdd(email, user))
+ {
+ throw new InvalidOperationException($"User '{email}' already exists.");
+ }
+ return user;
+ }
+}
diff --git a/appsettings.Sample.json b/appsettings.Sample.json
new file mode 100644
index 0000000..ec60b47
--- /dev/null
+++ b/appsettings.Sample.json
@@ -0,0 +1,11 @@
+{
+ "// Notes": "Copy this file to appsettings.Local.json (gitignored) and fill in secrets.",
+ "Jwt": {
+ "Secret": "replace-with-a-long-random-string-at-least-32-chars"
+ },
+ "Enjin": {
+ "ApiUrl": "https://platform.canary.enjin.io/graphql",
+ "ApiToken": "your-enjin-platform-bearer-token-here",
+ "DaemonWalletAddress": "your-daemon-wallet-ss58-address"
+ }
+}
diff --git a/appsettings.json b/appsettings.json
new file mode 100644
index 0000000..b356097
--- /dev/null
+++ b/appsettings.json
@@ -0,0 +1,50 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "Server": {
+ "Port": 3000,
+ "RequestTimeoutSeconds": 3600
+ },
+ "Jwt": {
+ "Issuer": "platform-sample-game-server",
+ "Audience": "platform-sample-game-client",
+ "Secret": "",
+ "ExpiryHours": 24
+ },
+ "Enjin": {
+ "ApiUrl": "https://platform.canary.enjin.io/graphql",
+ "ApiToken": "",
+ "Network": "Canary",
+ "Chain": "Matrix",
+ "DaemonWalletAddress": "",
+ "TransactionPollIntervalSeconds": 10,
+ "TransactionInitialDelaySeconds": 10,
+ "ManagedWalletPollIntervalSeconds": 1,
+ "ManagedWalletPollMaxAttempts": 10,
+ "ResourceTokens": [
+ {
+ "Id": 1,
+ "Name": "Gold Coin",
+ "Media": "[{\"type\":\"image/png\",\"url\":\"https://cdn.enjin.io/mint/image/gold-coin.png\"}]"
+ },
+ {
+ "Id": 2,
+ "Name": "Gold Coin (Blue)",
+ "Media": "[{\"type\":\"image/png\",\"url\":\"https://cdn.enjin.io/mint/image/gold-coin-blue.png\"}]"
+ },
+ {
+ "Id": 3,
+ "Name": "Green Gem",
+ "Media": "[{\"type\":\"image/png\",\"url\":\"https://cdn.enjin.io/mint/image/green-gem.png\"}]"
+ }
+ ],
+ "CollectionName": "Enjin Sample Game",
+ "CollectionBannerImage": "https://cdn.enjin.io/mint/image/sample-game-collection-banner.png",
+ "CollectionMedia": "[{\"type\":\"image/png\",\"url\":\"https://cdn.enjin.io/mint/image/sample-game-collection-image.png\"}]"
+ }
+}
diff --git a/package-lock.json b/package-lock.json
deleted file mode 100644
index 79e32e7..0000000
--- a/package-lock.json
+++ /dev/null
@@ -1,1454 +0,0 @@
-{
- "name": "sample-game-server",
- "version": "1.0.0",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "sample-game-server",
- "version": "1.0.0",
- "license": "ISC",
- "dependencies": {
- "axios": "^1.10.0",
- "bcryptjs": "^3.0.2",
- "cors": "^2.8.5",
- "dotenv": "^17.2.0",
- "express": "^5.1.0",
- "jsonwebtoken": "^9.0.2"
- },
- "devDependencies": {
- "nodemon": "^3.1.10"
- }
- },
- "node_modules/accepts": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
- "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
- "license": "MIT",
- "dependencies": {
- "mime-types": "^3.0.0",
- "negotiator": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/anymatch": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
- "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "normalize-path": "^3.0.0",
- "picomatch": "^2.0.4"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/asynckit": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
- "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
- "license": "MIT"
- },
- "node_modules/axios": {
- "version": "1.10.0",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
- "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
- "license": "MIT",
- "dependencies": {
- "follow-redirects": "^1.15.6",
- "form-data": "^4.0.0",
- "proxy-from-env": "^1.1.0"
- }
- },
- "node_modules/balanced-match": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/bcryptjs": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
- "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==",
- "license": "BSD-3-Clause",
- "bin": {
- "bcrypt": "bin/bcrypt"
- }
- },
- "node_modules/binary-extensions": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
- "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/body-parser": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
- "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
- "license": "MIT",
- "dependencies": {
- "bytes": "^3.1.2",
- "content-type": "^1.0.5",
- "debug": "^4.4.0",
- "http-errors": "^2.0.0",
- "iconv-lite": "^0.6.3",
- "on-finished": "^2.4.1",
- "qs": "^6.14.0",
- "raw-body": "^3.0.0",
- "type-is": "^2.0.0"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
- }
- },
- "node_modules/braces": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
- "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fill-range": "^7.1.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/buffer-equal-constant-time": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
- "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
- "license": "BSD-3-Clause"
- },
- "node_modules/bytes": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
- "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/call-bind-apply-helpers": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
- "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0",
- "function-bind": "^1.1.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/call-bound": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
- "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
- "license": "MIT",
- "dependencies": {
- "call-bind-apply-helpers": "^1.0.2",
- "get-intrinsic": "^1.3.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/chokidar": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
- "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "anymatch": "~3.1.2",
- "braces": "~3.0.2",
- "glob-parent": "~5.1.2",
- "is-binary-path": "~2.1.0",
- "is-glob": "~4.0.1",
- "normalize-path": "~3.0.0",
- "readdirp": "~3.6.0"
- },
- "engines": {
- "node": ">= 8.10.0"
- },
- "funding": {
- "url": "https://paulmillr.com/funding/"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.2"
- }
- },
- "node_modules/combined-stream": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
- "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
- "license": "MIT",
- "dependencies": {
- "delayed-stream": "~1.0.0"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/concat-map": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
- "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/content-disposition": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
- "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
- "license": "MIT",
- "dependencies": {
- "safe-buffer": "5.2.1"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/content-type": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
- "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/cookie": {
- "version": "0.7.2",
- "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
- "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/cookie-signature": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
- "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
- "license": "MIT",
- "engines": {
- "node": ">=6.6.0"
- }
- },
- "node_modules/cors": {
- "version": "2.8.5",
- "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
- "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
- "license": "MIT",
- "dependencies": {
- "object-assign": "^4",
- "vary": "^1"
- },
- "engines": {
- "node": ">= 0.10"
- }
- },
- "node_modules/debug": {
- "version": "4.4.1",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
- "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/delayed-stream": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
- "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
- "license": "MIT",
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/depd": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
- "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/dotenv": {
- "version": "17.2.0",
- "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.0.tgz",
- "integrity": "sha512-Q4sgBT60gzd0BB0lSyYD3xM4YxrXA9y4uBDof1JNYGzOXrQdQ6yX+7XIAqoFOGQFOTK1D3Hts5OllpxMDZFONQ==",
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://dotenvx.com"
- }
- },
- "node_modules/dunder-proto": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
- "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
- "license": "MIT",
- "dependencies": {
- "call-bind-apply-helpers": "^1.0.1",
- "es-errors": "^1.3.0",
- "gopd": "^1.2.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/ecdsa-sig-formatter": {
- "version": "1.0.11",
- "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
- "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "safe-buffer": "^5.0.1"
- }
- },
- "node_modules/ee-first": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
- "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
- "license": "MIT"
- },
- "node_modules/encodeurl": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
- "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/es-define-property": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
- "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-errors": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
- "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-object-atoms": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
- "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-set-tostringtag": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
- "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0",
- "get-intrinsic": "^1.2.6",
- "has-tostringtag": "^1.0.2",
- "hasown": "^2.0.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/escape-html": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
- "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
- "license": "MIT"
- },
- "node_modules/etag": {
- "version": "1.8.1",
- "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
- "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/express": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
- "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
- "license": "MIT",
- "dependencies": {
- "accepts": "^2.0.0",
- "body-parser": "^2.2.0",
- "content-disposition": "^1.0.0",
- "content-type": "^1.0.5",
- "cookie": "^0.7.1",
- "cookie-signature": "^1.2.1",
- "debug": "^4.4.0",
- "encodeurl": "^2.0.0",
- "escape-html": "^1.0.3",
- "etag": "^1.8.1",
- "finalhandler": "^2.1.0",
- "fresh": "^2.0.0",
- "http-errors": "^2.0.0",
- "merge-descriptors": "^2.0.0",
- "mime-types": "^3.0.0",
- "on-finished": "^2.4.1",
- "once": "^1.4.0",
- "parseurl": "^1.3.3",
- "proxy-addr": "^2.0.7",
- "qs": "^6.14.0",
- "range-parser": "^1.2.1",
- "router": "^2.2.0",
- "send": "^1.1.0",
- "serve-static": "^2.2.0",
- "statuses": "^2.0.1",
- "type-is": "^2.0.1",
- "vary": "^1.1.2"
- },
- "engines": {
- "node": ">= 18"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/express"
- }
- },
- "node_modules/fill-range": {
- "version": "7.1.1",
- "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
- "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "to-regex-range": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/finalhandler": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
- "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
- "license": "MIT",
- "dependencies": {
- "debug": "^4.4.0",
- "encodeurl": "^2.0.0",
- "escape-html": "^1.0.3",
- "on-finished": "^2.4.1",
- "parseurl": "^1.3.3",
- "statuses": "^2.0.1"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/follow-redirects": {
- "version": "1.15.9",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
- "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
- "funding": [
- {
- "type": "individual",
- "url": "https://github.com/sponsors/RubenVerborgh"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=4.0"
- },
- "peerDependenciesMeta": {
- "debug": {
- "optional": true
- }
- }
- },
- "node_modules/form-data": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
- "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
- "license": "MIT",
- "dependencies": {
- "asynckit": "^0.4.0",
- "combined-stream": "^1.0.8",
- "es-set-tostringtag": "^2.1.0",
- "hasown": "^2.0.2",
- "mime-types": "^2.1.12"
- },
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/form-data/node_modules/mime-db": {
- "version": "1.52.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
- "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/form-data/node_modules/mime-types": {
- "version": "2.1.35",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
- "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
- "license": "MIT",
- "dependencies": {
- "mime-db": "1.52.0"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/forwarded": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
- "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/fresh": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
- "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
- "node_modules/function-bind": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
- "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/get-intrinsic": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
- "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
- "license": "MIT",
- "dependencies": {
- "call-bind-apply-helpers": "^1.0.2",
- "es-define-property": "^1.0.1",
- "es-errors": "^1.3.0",
- "es-object-atoms": "^1.1.1",
- "function-bind": "^1.1.2",
- "get-proto": "^1.0.1",
- "gopd": "^1.2.0",
- "has-symbols": "^1.1.0",
- "hasown": "^2.0.2",
- "math-intrinsics": "^1.1.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/get-proto": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
- "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
- "license": "MIT",
- "dependencies": {
- "dunder-proto": "^1.0.1",
- "es-object-atoms": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/glob-parent": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
- "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/gopd": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
- "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-flag": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
- "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/has-symbols": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
- "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-tostringtag": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
- "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
- "license": "MIT",
- "dependencies": {
- "has-symbols": "^1.0.3"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/hasown": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
- "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
- "license": "MIT",
- "dependencies": {
- "function-bind": "^1.1.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/http-errors": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
- "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
- "license": "MIT",
- "dependencies": {
- "depd": "2.0.0",
- "inherits": "2.0.4",
- "setprototypeof": "1.2.0",
- "statuses": "2.0.1",
- "toidentifier": "1.0.1"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/http-errors/node_modules/statuses": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
- "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/iconv-lite": {
- "version": "0.6.3",
- "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
- "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
- "license": "MIT",
- "dependencies": {
- "safer-buffer": ">= 2.1.2 < 3.0.0"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/ignore-by-default": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
- "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/inherits": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
- "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
- "license": "ISC"
- },
- "node_modules/ipaddr.js": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
- "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.10"
- }
- },
- "node_modules/is-binary-path": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
- "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "binary-extensions": "^2.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/is-extglob": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
- "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/is-glob": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
- "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-extglob": "^2.1.1"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/is-number": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
- "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.12.0"
- }
- },
- "node_modules/is-promise": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
- "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
- "license": "MIT"
- },
- "node_modules/jsonwebtoken": {
- "version": "9.0.2",
- "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
- "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
- "license": "MIT",
- "dependencies": {
- "jws": "^3.2.2",
- "lodash.includes": "^4.3.0",
- "lodash.isboolean": "^3.0.3",
- "lodash.isinteger": "^4.0.4",
- "lodash.isnumber": "^3.0.3",
- "lodash.isplainobject": "^4.0.6",
- "lodash.isstring": "^4.0.1",
- "lodash.once": "^4.0.0",
- "ms": "^2.1.1",
- "semver": "^7.5.4"
- },
- "engines": {
- "node": ">=12",
- "npm": ">=6"
- }
- },
- "node_modules/jwa": {
- "version": "1.4.2",
- "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
- "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
- "license": "MIT",
- "dependencies": {
- "buffer-equal-constant-time": "^1.0.1",
- "ecdsa-sig-formatter": "1.0.11",
- "safe-buffer": "^5.0.1"
- }
- },
- "node_modules/jws": {
- "version": "3.2.2",
- "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
- "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
- "license": "MIT",
- "dependencies": {
- "jwa": "^1.4.1",
- "safe-buffer": "^5.0.1"
- }
- },
- "node_modules/lodash.includes": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
- "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
- "license": "MIT"
- },
- "node_modules/lodash.isboolean": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
- "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
- "license": "MIT"
- },
- "node_modules/lodash.isinteger": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
- "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
- "license": "MIT"
- },
- "node_modules/lodash.isnumber": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
- "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
- "license": "MIT"
- },
- "node_modules/lodash.isplainobject": {
- "version": "4.0.6",
- "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
- "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
- "license": "MIT"
- },
- "node_modules/lodash.isstring": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
- "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
- "license": "MIT"
- },
- "node_modules/lodash.once": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
- "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
- "license": "MIT"
- },
- "node_modules/math-intrinsics": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
- "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/media-typer": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
- "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/merge-descriptors": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
- "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/mime-db": {
- "version": "1.54.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
- "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/mime-types": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
- "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
- "license": "MIT",
- "dependencies": {
- "mime-db": "^1.54.0"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^1.1.7"
- },
- "engines": {
- "node": "*"
- }
- },
- "node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "license": "MIT"
- },
- "node_modules/negotiator": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
- "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/nodemon": {
- "version": "3.1.10",
- "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
- "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "chokidar": "^3.5.2",
- "debug": "^4",
- "ignore-by-default": "^1.0.1",
- "minimatch": "^3.1.2",
- "pstree.remy": "^1.1.8",
- "semver": "^7.5.3",
- "simple-update-notifier": "^2.0.0",
- "supports-color": "^5.5.0",
- "touch": "^3.1.0",
- "undefsafe": "^2.0.5"
- },
- "bin": {
- "nodemon": "bin/nodemon.js"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/nodemon"
- }
- },
- "node_modules/normalize-path": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
- "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/object-assign": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
- "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/object-inspect": {
- "version": "1.13.4",
- "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
- "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/on-finished": {
- "version": "2.4.1",
- "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
- "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
- "license": "MIT",
- "dependencies": {
- "ee-first": "1.1.1"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/once": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
- "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
- "license": "ISC",
- "dependencies": {
- "wrappy": "1"
- }
- },
- "node_modules/parseurl": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
- "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/path-to-regexp": {
- "version": "8.2.0",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
- "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
- "license": "MIT",
- "engines": {
- "node": ">=16"
- }
- },
- "node_modules/picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/proxy-addr": {
- "version": "2.0.7",
- "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
- "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
- "license": "MIT",
- "dependencies": {
- "forwarded": "0.2.0",
- "ipaddr.js": "1.9.1"
- },
- "engines": {
- "node": ">= 0.10"
- }
- },
- "node_modules/proxy-from-env": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
- "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
- "license": "MIT"
- },
- "node_modules/pstree.remy": {
- "version": "1.1.8",
- "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
- "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/qs": {
- "version": "6.14.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
- "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "side-channel": "^1.1.0"
- },
- "engines": {
- "node": ">=0.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/range-parser": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
- "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/raw-body": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz",
- "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==",
- "license": "MIT",
- "dependencies": {
- "bytes": "3.1.2",
- "http-errors": "2.0.0",
- "iconv-lite": "0.6.3",
- "unpipe": "1.0.0"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/readdirp": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
- "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "picomatch": "^2.2.1"
- },
- "engines": {
- "node": ">=8.10.0"
- }
- },
- "node_modules/router": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
- "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
- "license": "MIT",
- "dependencies": {
- "debug": "^4.4.0",
- "depd": "^2.0.0",
- "is-promise": "^4.0.0",
- "parseurl": "^1.3.3",
- "path-to-regexp": "^8.0.0"
- },
- "engines": {
- "node": ">= 18"
- }
- },
- "node_modules/safe-buffer": {
- "version": "5.2.1",
- "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
- "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT"
- },
- "node_modules/safer-buffer": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
- "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
- "license": "MIT"
- },
- "node_modules/semver": {
- "version": "7.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
- "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/send": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
- "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
- "license": "MIT",
- "dependencies": {
- "debug": "^4.3.5",
- "encodeurl": "^2.0.0",
- "escape-html": "^1.0.3",
- "etag": "^1.8.1",
- "fresh": "^2.0.0",
- "http-errors": "^2.0.0",
- "mime-types": "^3.0.1",
- "ms": "^2.1.3",
- "on-finished": "^2.4.1",
- "range-parser": "^1.2.1",
- "statuses": "^2.0.1"
- },
- "engines": {
- "node": ">= 18"
- }
- },
- "node_modules/serve-static": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
- "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
- "license": "MIT",
- "dependencies": {
- "encodeurl": "^2.0.0",
- "escape-html": "^1.0.3",
- "parseurl": "^1.3.3",
- "send": "^1.2.0"
- },
- "engines": {
- "node": ">= 18"
- }
- },
- "node_modules/setprototypeof": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
- "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
- "license": "ISC"
- },
- "node_modules/side-channel": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
- "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0",
- "object-inspect": "^1.13.3",
- "side-channel-list": "^1.0.0",
- "side-channel-map": "^1.0.1",
- "side-channel-weakmap": "^1.0.2"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/side-channel-list": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
- "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0",
- "object-inspect": "^1.13.3"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/side-channel-map": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
- "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
- "license": "MIT",
- "dependencies": {
- "call-bound": "^1.0.2",
- "es-errors": "^1.3.0",
- "get-intrinsic": "^1.2.5",
- "object-inspect": "^1.13.3"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/side-channel-weakmap": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
- "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
- "license": "MIT",
- "dependencies": {
- "call-bound": "^1.0.2",
- "es-errors": "^1.3.0",
- "get-intrinsic": "^1.2.5",
- "object-inspect": "^1.13.3",
- "side-channel-map": "^1.0.1"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/simple-update-notifier": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
- "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "semver": "^7.5.3"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/statuses": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
- "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/supports-color": {
- "version": "5.5.0",
- "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
- "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-flag": "^3.0.0"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/to-regex-range": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
- "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-number": "^7.0.0"
- },
- "engines": {
- "node": ">=8.0"
- }
- },
- "node_modules/toidentifier": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
- "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
- "license": "MIT",
- "engines": {
- "node": ">=0.6"
- }
- },
- "node_modules/touch": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
- "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "nodetouch": "bin/nodetouch.js"
- }
- },
- "node_modules/type-is": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
- "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
- "license": "MIT",
- "dependencies": {
- "content-type": "^1.0.5",
- "media-typer": "^1.1.0",
- "mime-types": "^3.0.0"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/undefsafe": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
- "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/unpipe": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
- "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/vary": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
- "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/wrappy": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
- "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
- "license": "ISC"
- }
- }
-}
diff --git a/package.json b/package.json
deleted file mode 100644
index 7cf61ab..0000000
--- a/package.json
+++ /dev/null
@@ -1,24 +0,0 @@
-{
- "name": "sample-game-server",
- "version": "1.0.0",
- "main": "src/index.js",
- "scripts": {
- "start": "node src/index.js",
- "dev": "nodemon src/index.js"
- },
- "keywords": [],
- "author": "",
- "license": "ISC",
- "description": "Sample game server demonstrating Enjin implementation",
- "dependencies": {
- "axios": "^1.10.0",
- "bcryptjs": "^3.0.2",
- "cors": "^2.8.5",
- "dotenv": "^17.2.0",
- "express": "^5.1.0",
- "jsonwebtoken": "^9.0.2"
- },
- "devDependencies": {
- "nodemon": "^3.1.10"
- }
-}
diff --git a/scripts/precommit.sh b/scripts/precommit.sh
new file mode 100755
index 0000000..f4b5ebf
--- /dev/null
+++ b/scripts/precommit.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+# Run formatters, build, and tests before committing.
+# Usage: ./scripts/precommit.sh
+#
+# Works under bash and zsh, regardless of the caller's working directory.
+
+set -euo pipefail
+
+# Resolve this script's directory in a shell-agnostic way.
+# $BASH_SOURCE exists in bash; ${(%):-%x} is the zsh equivalent; $0 is the
+# universal fallback when the script is invoked as `sh script.sh`.
+if [ -n "${BASH_SOURCE:-}" ]; then
+ SCRIPT_PATH="${BASH_SOURCE[0]}"
+elif [ -n "${ZSH_VERSION:-}" ]; then
+ # shellcheck disable=SC2296
+ SCRIPT_PATH="${(%):-%x}"
+else
+ SCRIPT_PATH="$0"
+fi
+
+SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+PROJECT="$REPO_ROOT/PlatformSampleGameServer.csproj"
+SS58_TEST_PROJECT="$REPO_ROOT/tools/Ss58SelfTest/Ss58SelfTest.csproj"
+AMOUNT_TEST_PROJECT="$REPO_ROOT/tools/TransferAmountSelfTest/TransferAmountSelfTest.csproj"
+
+cd "$REPO_ROOT"
+
+echo "==> Restoring .NET tools"
+dotnet tool restore
+
+echo "==> Running CSharpier"
+dotnet csharpier format .
+
+echo "==> Running dotnet format (main project)"
+dotnet format "$PROJECT"
+
+echo "==> Running dotnet format (tools projects)"
+dotnet format "$SS58_TEST_PROJECT"
+dotnet format "$AMOUNT_TEST_PROJECT"
+
+echo "==> Building main project"
+dotnet build "$PROJECT" --configuration Release
+
+echo "==> Building tools projects"
+dotnet build "$SS58_TEST_PROJECT" --configuration Release
+dotnet build "$AMOUNT_TEST_PROJECT" --configuration Release
+
+echo "==> Running self-tests"
+dotnet run --project "$SS58_TEST_PROJECT" --configuration Release
+dotnet run --project "$AMOUNT_TEST_PROJECT" --configuration Release
+
+echo "==> All checks passed"
diff --git a/src/index.js b/src/index.js
deleted file mode 100644
index 7e6f042..0000000
--- a/src/index.js
+++ /dev/null
@@ -1,47 +0,0 @@
-const express = require('express');
-const cors = require('cors');
-const dotenv = require('dotenv');
-const { prepareCollection } = require('./services/enjinService');
-
-// Load environment variables
-dotenv.config();
-
-// Initialize express app
-const app = express();
-
-// Middleware
-app.use(cors());
-app.use(express.json());
-
-// Routes
-app.use('/api/auth', require('./routes/auth'));
-app.use('/api/wallet', require('./routes/wallet'));
-app.use('/api/token', require('./routes/token'));
-
-// Basic error handling
-app.use((err, req, res, next) => {
- console.error(err.stack);
- res.status(500).json({
- success: false,
- message: 'Internal Server Error'
- });
-});
-
-const PORT = process.env.PORT || 3000;
-
-// Start server after checking collection
-prepareCollection()
- .then(() => {
- console.log("----------------------------------------")
- console.log(`Collection and resource tokens are ready. Using collection ID: ${process.env.ENJIN_COLLECTION_ID}`);
- const server = app.listen(PORT, () => {
- console.log(`Server is running on port ${PORT}`);
- console.log("----------------------------------------")
- });
- // Set the timeout for all requests to 1 hour (3,600,000 ms)
- server.timeout = 3600000;
- })
- .catch(error => {
- console.error('Failed to start server:', error);
- process.exit(1);
- });
\ No newline at end of file
diff --git a/src/middlewares/jwtAuth.js b/src/middlewares/jwtAuth.js
deleted file mode 100644
index 8aa6932..0000000
--- a/src/middlewares/jwtAuth.js
+++ /dev/null
@@ -1,33 +0,0 @@
-const jwt = require('jsonwebtoken');
-const User = require('../models/user');
-
-const jwtAuth = (req, res, next) => {
- const authHeader = req.headers["authorization"];
-
- if (!authHeader) {
- return res.status(401).json({
- success: false,
- message: 'Authorization header is required'
- });
- }
-
- try {
- const token = authHeader.split(' ')[1]; // Format: "Bearer token"
- const decoded = jwt.verify(token, process.env.JWT_SECRET);
-
- const user = User.findByEmail(decoded.email);
- if (!user) {
- throw new Error('User not found');
- }
-
- req.user = user;
- next();
- } catch (error) {
- return res.status(401).json({
- success: false,
- message: 'Invalid token'
- });
- }
-};
-
-module.exports = jwtAuth;
\ No newline at end of file
diff --git a/src/models/user.js b/src/models/user.js
deleted file mode 100644
index 39134f0..0000000
--- a/src/models/user.js
+++ /dev/null
@@ -1,24 +0,0 @@
-const users = new Map();
-
-class User {
- constructor(email, password) {
- this.email = email;
- this.password = password;
- }
-
- static findByEmail(email) {
- return users.get(email);
- }
-
- static create(email, password) {
- if (users.has(email)) {
- throw new Error('User already exists');
- }
-
- const user = new User(email, password);
- users.set(email, user);
- return user;
- }
-}
-
-module.exports = User;
\ No newline at end of file
diff --git a/src/routes/auth.js b/src/routes/auth.js
deleted file mode 100644
index 09e9ced..0000000
--- a/src/routes/auth.js
+++ /dev/null
@@ -1,57 +0,0 @@
-const express = require('express');
-const router = express.Router();
-const AuthService = require('../services/authService');
-const { getManagedWallet, createManagedWallet } = require('../services/enjinService');
-
-// Health check endpoint
-router.get('/health-check', async (req, res) => {
- res.status(200).json({
- status: "OK"
- });
-});
-
-// Register endpoint
-router.post('/register', async (req, res) => {
- try {
- const { email, password } = req.body;
- const token = await AuthService.register(email, password);
- const createManagedWalletResponse = await createManagedWallet(email);
- let wallet = null;
- if (createManagedWalletResponse)
- wallet = createManagedWalletResponse.account.address;
-
- res.status(201).json({
- wallet: wallet,
- token: token
- });
- } catch (error) {
- res.status(400).json({
- success: false,
- message: error.message
- });
- }
-});
-
-// Login endpoint
-router.post('/login', async (req, res) => {
- try {
- const { email, password } = req.body;
- const token = await AuthService.login(email, password);
- const getManagedWalletResponse = await getManagedWallet(email);
- let wallet = null;
- if (getManagedWalletResponse)
- wallet = getManagedWalletResponse.account.address;
-
- res.status(200).json({
- wallet: wallet,
- token: token
- });
- } catch (error) {
- res.status(401).json({
- success: false,
- message: error.message
- });
- }
-});
-
-module.exports = router;
\ No newline at end of file
diff --git a/src/routes/token.js b/src/routes/token.js
deleted file mode 100644
index b95d0ef..0000000
--- a/src/routes/token.js
+++ /dev/null
@@ -1,70 +0,0 @@
-const express = require('express');
-const router = express.Router();
-const axios = require('axios');
-const jwtAuth = require('../middlewares/jwtAuth');
-const { mintTokenAndWaitForTransaction, meltTokenAndWaitForTransaction, transferTokenAndWaitForTransaction, getManagedWallet } = require('../services/enjinService');
-
-// Mint token endpoint
-router.post('/mint', jwtAuth, async (req, res) => {
- try {
- const userEmail = req.user.email;
- const getManagedWalletResponse = await getManagedWallet(userEmail)
- if (!getManagedWalletResponse)
- throw new Error(`Failed to get managed wallet for external id ${userEmail}`);
- const userWalletAddress = getManagedWalletResponse.account.address;
- await mintTokenAndWaitForTransaction(req.body.tokenId, req.body.amount, userWalletAddress);
-
- res.json({
- success: true
- });
- } catch (error) {
- res.status(500).json({
- success: false,
- message: error.message
- });
- }
-});
-
-// Melt token endpoint
-router.post('/melt', jwtAuth, async (req, res) => {
- try {
- const userEmail = req.user.email;
- const getManagedWalletResponse = await getManagedWallet(userEmail)
- if (!getManagedWalletResponse)
- throw new Error(`Failed to get managed wallet for external id ${userEmail}`);
- const userWalletAddress = getManagedWalletResponse.account.address;
- await meltTokenAndWaitForTransaction(req.body.tokenId, req.body.amount, userWalletAddress);
-
- res.json({
- success: true
- });
- } catch (error) {
- res.status(500).json({
- success: false,
- message: error.message
- });
- }
-});
-
-// Transfer token endpoint
-router.post('/transfer', jwtAuth, async (req, res) => {
- try {
- const userEmail = req.user.email;
- const getManagedWalletResponse = await getManagedWallet(userEmail)
- if (!getManagedWalletResponse)
- throw new Error(`Failed to get managed wallet for external id ${userEmail}`);
- const userWalletAddress = getManagedWalletResponse.account.address;
- await transferTokenAndWaitForTransaction(req.body.tokenId, req.body.amount, userWalletAddress, req.body.recipient);
-
- res.json({
- success: true
- });
- } catch (error) {
- res.status(500).json({
- success: false,
- message: error.message
- });
- }
-});
-
-module.exports = router;
\ No newline at end of file
diff --git a/src/routes/wallet.js b/src/routes/wallet.js
deleted file mode 100644
index d4783eb..0000000
--- a/src/routes/wallet.js
+++ /dev/null
@@ -1,66 +0,0 @@
-const express = require('express');
-const router = express.Router();
-const axios = require('axios');
-const jwtAuth = require('../middlewares/jwtAuth');
-const { getManagedWallet, createManagedWallet, getManagedWalletTokens } = require('../services/enjinService');
-
-// Get managed wallet endpoint
-router.post('/get', jwtAuth, async (req, res) => {
- try {
- const userEmail = req.user.email;
- const getManagedWalletResponse = await getManagedWallet(userEmail)
- if (!getManagedWalletResponse)
- throw new Error(`Failed to get managed wallet for external id ${userEmail}`);
- const wallet = getManagedWalletResponse.account.address;
- res.json({
- success: true,
- wallet: wallet
- });
- } catch (error) {
- res.status(500).json({
- success: false,
- message: error.message
- });
- }
-});
-
-// Create and return managed wallet endpoint
-router.post('/create', jwtAuth, async (req, res) => {
- try {
- const userEmail = req.user.email;
- const createManagedWalletResponse = await createManagedWallet(userEmail)
- if (!createManagedWalletResponse)
- throw new Error(`Failed to get managed wallet for external id ${userEmail}`);
- const wallet = createManagedWalletResponse.account.address;
- res.json({
- success: true,
- wallet: wallet
- });
- } catch (error) {
- res.status(500).json({
- success: false,
- message: error.message
- });
- }
-});
-
-// Get managed wallet endpoint
-router.get('/get-tokens', jwtAuth, async (req, res) => {
- try {
- const userEmail = req.user.email;
- const getManagedWalletTokensResponse = await getManagedWalletTokens(userEmail)
- if (!getManagedWalletTokensResponse)
- throw new Error(`Failed to get managed wallet tokens for external id ${userEmail}`);
- res.json({
- "account": getManagedWalletTokensResponse.account,
- "tokenAccounts": getManagedWalletTokensResponse.tokens
- });
- } catch (error) {
- res.status(500).json({
- success: false,
- message: error.message
- });
- }
-});
-
-module.exports = router;
\ No newline at end of file
diff --git a/src/services/authService.js b/src/services/authService.js
deleted file mode 100644
index 4d5fc54..0000000
--- a/src/services/authService.js
+++ /dev/null
@@ -1,64 +0,0 @@
-const bcrypt = require('bcryptjs');
-const jwt = require('jsonwebtoken');
-const User = require('../models/user');
-
-class AuthService {
- static async hashPassword(password) {
- const salt = await bcrypt.genSalt(10);
- return bcrypt.hash(password, salt);
- }
-
- static async comparePasswords(password, hashedPassword) {
- return bcrypt.compare(password, hashedPassword);
- }
-
- static generateToken(user) {
- return jwt.sign(
- { email: user.email },
- process.env.JWT_SECRET,
- { expiresIn: '24h' }
- );
- }
-
- static async register(email, password) {
- if (!email || !password) {
- throw new Error('Email and password are required');
- }
-
- const existingUser = User.findByEmail(email);
- if (existingUser) {
- // User already exists, try to login
- try {
- const token = await this.login(email, password);
- return token;
- } catch (loginError) {
- throw new Error('Registration failed: User already exists, but login failed.');
- }
- }
-
- const hashedPassword = await this.hashPassword(password);
- const user = User.create(email, hashedPassword);
- const token = this.generateToken(user);
-
- return token;
- }
-
- static async login(email, password) {
- const user = User.findByEmail(email);
-
- if (!user) {
- throw new Error('User not found');
- }
-
- const isValidPassword = await this.comparePasswords(password, user.password);
-
- if (!isValidPassword) {
- throw new Error('Invalid password');
- }
-
- const token = this.generateToken(user);
- return token;
- }
-}
-
-module.exports = AuthService;
\ No newline at end of file
diff --git a/src/services/enjinService.js b/src/services/enjinService.js
deleted file mode 100644
index 7a23682..0000000
--- a/src/services/enjinService.js
+++ /dev/null
@@ -1,581 +0,0 @@
-const axios = require('axios');
-const fs = require('fs');
-
-// Resource token definitions
-const RESOURCE_TOKENS = [
- { id: 1, name: "Gold Coin", media: "[{\"type\":\"image/png\",\"url\":\"https://cdn.enjin.io/mint/image/gold-coin.png\"}]"},
- { id: 2, name: "Gold Coin (Blue)", media: "[{\"type\":\"image/png\",\"url\":\"https://cdn.enjin.io/mint/image/gold-coin-blue.png\"}]" },
- { id: 3, name: "Green Gem", media: "[{\"type\":\"image/png\",\"url\":\"https://cdn.enjin.io/mint/image/green-gem.png\"}]" }
-];
-
-async function sleep(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
-}
-
-async function getTransactionStatus(requestId) {
- const response = await axios.post(process.env.ENJIN_API_URL, {
- query: `query GetTransaction {
- GetTransaction(id: ${requestId}) {
- state
- result
- events {
- edges {
- node {
- params {
- type
- value
- }
- }
- }
- }
- }
- }`
- }, {
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': process.env.ENJIN_API_KEY
- }
- });
-
- return response.data.data.GetTransaction;
-}
-
-async function extractCollectionId(transaction) {
- if (!transaction.events || !transaction.events.edges) {
- return null;
- }
-
- for (const edge of transaction.events.edges) {
- const params = edge.node.params;
- const collectionIdParam = params.find(p => p.type === 'collection_id');
- if (collectionIdParam) {
- return collectionIdParam.value;
- }
- }
-
- return null;
-}
-
-async function updateEnvFile(collectionId) {
- const envContent = fs.readFileSync('.env', 'utf8');
- const updatedContent = envContent.replace(
- /ENJIN_COLLECTION_ID=.*/,
- `ENJIN_COLLECTION_ID=${collectionId}`
- );
- fs.writeFileSync('.env', updatedContent);
- process.env.ENJIN_COLLECTION_ID = collectionId;
-}
-
-async function createCollection(){
- const response = await axios.post(process.env.ENJIN_API_URL, {
- query: `mutation CreateCollection(
- $forceCollapsingSupply: Boolean
- $name: String!
- $bannerImage: String!
- $media: String!
-) {
- CreateCollection(
- mintPolicy: { forceCollapsingSupply: $forceCollapsingSupply }
- attributes:[
- {
- key: \"name\",
- value: $name
- },
- {
- key: \"banner_image\",
- value: $bannerImage
- },
- {
- key: \"media\",
- value: $media
- }
- ]
- ) {
- id
- method
- state
- }
-}`,
- variables: {
- forceCollapsingSupply: false,
- name: "Enjin Sample Game",
- bannerImage: "https://cdn.enjin.io/mint/image/sample-game-collection-banner.png",
- media: "[{\"type\":\"image/png\",\"url\":\"https://cdn.enjin.io/mint/image/sample-game-collection-image.png\"}]"
-
- }
- }, {
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': process.env.ENJIN_API_KEY
- }
- });
-
- return response;
-}
-
-async function createToken(collectionId, tokenId, name, media){
- const response = await axios.post(process.env.ENJIN_API_URL, {
- query: `mutation CreateToken(
- $recipient: String!
- $collectionId: BigInt!
- $tokenId: BigInt
- $name: String!
- $media: String!
-){
- CreateToken(
- recipient: $recipient
- collectionId: $collectionId
- params:{
- tokenId: {integer: $tokenId}
- initialSupply: 1
- attributes: [
- {
- key: "name",
- value: $name
- },
- {
- key: "media",
- value: $media
- }
- ]
- }
- ){
- id
- method
- state
- }
-}`,
- variables: {
- recipient: process.env.DAEMON_WALLET_ADDRESS || "5EJDmqEoySnLk8xvPNPQGrb9qUrYLcbf38K4R6zKeNryvfD6",
- collectionId: collectionId,
- tokenId: tokenId,
- name: name,
- media: media
- }
- }, {
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': process.env.ENJIN_API_KEY
- }
- });
-
- return response;
-}
-
-async function getToken(collectionId, tokenId) {
- const response = await axios.post(process.env.ENJIN_API_URL, {
- query: `query GetToken{
- GetToken(
- collectionId: ${collectionId}
- tokenId: {integer: ${tokenId}}
- ){
- metadata
- }
-}`,
- }, {
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': process.env.ENJIN_API_KEY
- }
- });
-
- return response;
-}
-
-async function getManagedWallet(externalId){
- const response = await axios.post(process.env.ENJIN_API_URL, {
- query: `query GetWallet($externalId: String!){
- GetWallet(externalId: $externalId){
- account{
- publicKey
- address
- }
- }
-}`,
- variables: {
- externalId: externalId
- }
- }, {
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': process.env.ENJIN_API_KEY
- }
- });
-
- return response.data.data.GetWallet;
-}
-
-async function getManagedWalletTokens(externalId, after = "") {
- let allTokenAccounts = [];
- let hasNextPage = true;
- let endCursor = after;
- let account = null;
-
- while (hasNextPage) {
- const response = await axios.post(process.env.ENJIN_API_URL, {
- query: `query GetWalletTokens($externalId: String!){
- GetWallet(externalId: $externalId){
- account{
- publicKey
- address
- }
- tokenAccounts(after: "${endCursor}"){
- pageInfo{
- hasNextPage
- endCursor
- }
- edges{
- node{
- balance
- token{
- tokenId
- collection{
- collectionId
- }
- attributes{
- key
- value
- }
- }
- }
- }
- }
- }
-}`,
- variables: {
- externalId: externalId
- }
- }, {
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': process.env.ENJIN_API_KEY
- }
- });
-
- const walletData = response.data.data.GetWallet;
- if (!walletData || !walletData.tokenAccounts) {
- console.warn("No token accounts found in the response.");
- hasNextPage = false;
- break;
- }
-
- account = walletData.account
- allTokenAccounts = allTokenAccounts.concat(walletData.tokenAccounts.edges.map(edge => edge.node));
- hasNextPage = walletData.tokenAccounts.pageInfo.hasNextPage;
- endCursor = walletData.tokenAccounts.pageInfo.endCursor;
- }
-
- return {account: account, tokens: allTokenAccounts};
-}
-
-async function createManagedWallet(externalId){
- try {
- const existingWallet = await getManagedWallet(externalId);
- if (existingWallet) {
- console.log(`Managed wallet with external ID ${externalId} already exists.`);
- return existingWallet;
- }
- const response = await axios.post(process.env.ENJIN_API_URL, {
- query: `mutation CreateWallet($externalId: String!){
- CreateWallet(externalId: $externalId)
-}`,
- variables: {
- externalId: externalId
- }
- }, {
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': process.env.ENJIN_API_KEY
- }
- });
-
-
- const maxRetries = 10;
- const retryInterval = 1000;
-
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
- const wallet = await getManagedWallet(externalId);
- if (wallet && wallet.account && wallet.account.address) {
- return wallet; // Wallet found, return it.
- }
-
- // If wallet not found, wait before the next attempt.
- await sleep(retryInterval);
- }
-
- // If the loop completes without finding the wallet, handle the failure.
- console.error(`Failed to retrieve wallet for ${externalId} after ${maxRetries} attempts.`);
- return null;
-
- } catch (error) {
- console.error(`Failed to create managed wallet with external ID ${externalId}:`, error);
- throw error;
- }
-
-}
-
-async function mintToken(tokenId, amount, recipient){
- collectionId = process.env.ENJIN_COLLECTION_ID;
- const response = await axios.post(process.env.ENJIN_API_URL, {
- query: `mutation mintToken(
- $recipient: String!
- $collectionId: BigInt!
- $tokenId: BigInt
- $amount: BigInt!
-){
- MintToken(
- recipient: $recipient
- collectionId: $collectionId
- params: {
- tokenId: {integer: $tokenId}
- amount: $amount
- }
- ){
- id
- method
- state
- }
-}`,
- variables: {
- recipient: recipient,
- collectionId: collectionId,
- tokenId: tokenId,
- amount: amount
- }
- }, {
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': process.env.ENJIN_API_KEY
- }
- });
-
- return response;
-}
-
-async function mintTokenAndWaitForTransaction(tokenId, amount, recipient) {
- console.log('Minting Token, please wait...');
- try {
- const mintTokenResponse = await mintToken(tokenId, amount, recipient);
- await sleep(10000);
-
- const requestId = mintTokenResponse.data.data.MintToken.id;
- await waitForTransaction(requestId, "'Token #" + tokenId + "' minting");
- console.log(`'Token #${tokenId}' minted successfully.`);
- return;
- } catch (error) {
- console.error("Failed to mint 'token #" + tokenId + "': ", JSON.stringify(error));
- throw error;
- }
-}
-
-async function meltToken(tokenId, amount, signingAccount){
- collectionId = process.env.ENJIN_COLLECTION_ID;
- const response = await axios.post(process.env.ENJIN_API_URL, {
- query: `mutation burnToken(
- $collectionId: BigInt!
- $tokenId: BigInt
- $amount: BigInt!
- $signingAccount: String!
-){
- Burn(
- collectionId: $collectionId
- params: {
- tokenId: {integer: $tokenId}
- amount: $amount
- }
- signingAccount: $signingAccount
- ){
- id
- method
- state
- }
-}`,
- variables: {
- collectionId: collectionId,
- tokenId: tokenId,
- amount: amount,
- signingAccount: signingAccount
- }
- }, {
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': process.env.ENJIN_API_KEY
- }
- });
-
- return response;
-}
-
-async function meltTokenAndWaitForTransaction(tokenId, amount, signingAccount) {
- console.log('Melting Token, please wait...');
- try {
- const meltTokenResponse = await meltToken(tokenId, amount, signingAccount);
- await sleep(10000);
-
- const requestId = meltTokenResponse.data.data.Burn.id;
- await waitForTransaction(requestId, "'Token #" + tokenId + "' melting");
- console.log(`'Token #${tokenId}' melted successfully.`);
- return;
- } catch (error) {
- console.error("Failed to melt 'token #" + tokenId + "': ", JSON.stringify(error));
- throw error;
- }
-}
-
-async function transferToken(tokenId, amount, signingAccount, recipient){
- collectionId = process.env.ENJIN_COLLECTION_ID;
- const response = await axios.post(process.env.ENJIN_API_URL, {
- query: `mutation transferToken(
- $collectionId: BigInt!
- $tokenId: BigInt!
- $amount: BigInt!
- $recipient: String!
- $signingAccount: String!
-){
- SimpleTransferToken(
- collectionId: $collectionId
- recipient: $recipient
- params: {
- tokenId: {integer: $tokenId}
- amount: $amount
- }
- signingAccount: $signingAccount
- ){
- id
- method
- state
- }
-}`,
- variables: {
- recipient: recipient,
- collectionId: collectionId,
- tokenId: tokenId,
- amount: amount,
- signingAccount: signingAccount
- }
- }, {
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': process.env.ENJIN_API_KEY
- }
- });
-
- return response;
-}
-
-async function transferTokenAndWaitForTransaction(tokenId, amount, signingAccount, recipient) {
- console.log('Transferring Token, please wait...');
- try {
- const transferTokenResponse = await transferToken(tokenId, amount, signingAccount, recipient);
- await sleep(10000);
-
- const requestId = transferTokenResponse.data.data.SimpleTransferToken.id;
- await waitForTransaction(requestId, "'Token #" + tokenId + "' transfer");
- console.log(`'Token #${tokenId}' transferred successfully.`);
- return;
- } catch (error) {
- console.error("Failed to transfer 'token #" + tokenId + "': ", JSON.stringify(error));
- throw error;
- }
-}
-
-async function waitForTransaction(requestId, operationType = 'operation') {
- while (true) {
- const transaction = await getTransactionStatus(requestId);
-
- if (transaction.state === 'PENDING') {
- console.log(`Please confirm the ${operationType} request in the Enjin Platform. Request ID: ${requestId}`);
- await sleep(10000);
- continue;
- }
-
- if (transaction.state === 'FINALIZED' && transaction.result === 'EXTRINSIC_SUCCESS') {
- return transaction;
- }
-
- if (transaction.state === 'FAILED' || transaction.state === 'ABANDONED' || transaction.result === 'EXTRINSIC_FAILED') {
- throw new Error(`${operationType} failed`);
- }
-
- console.log(`Waiting for the ${operationType} to finalize...`);
- await sleep(10000);
- }
-}
-
-async function checkAndCreateCollection() {
- if (!process.env.ENJIN_COLLECTION_ID) {
- console.log('No collection ID found. Creating new collection, please wait...');
- try {
- const createCollectionResponse = await createCollection();
- await sleep(10000);
-
- const requestId = createCollectionResponse.data.data.CreateCollection.id;
- const transaction = await waitForTransaction(requestId, "'Enjin Sample Game' collection creation");
-
- const collectionId = await extractCollectionId(transaction);
- if (collectionId) {
- await updateEnvFile(collectionId);
- console.log(`Created new collection with ID: ${collectionId}.`);
- return collectionId;
- }
- throw new Error('Failed to extract collection ID');
- } catch (error) {
- console.error('Failed to create collection:', error);
- throw error;
- }
- }
- return process.env.ENJIN_COLLECTION_ID;
-}
-
-async function checkTokenExists(collectionId, tokenId) {
- try {
- await getToken(collectionId, tokenId);
- return true;
- } catch (error) {
- if (error.response && error.response.status === 400) {
- return false;
- }
- throw error;
- }
-}
-
-async function createResourceToken(collectionId, tokenId, name, media) {
- console.log(`Creating resource token '${name}', please wait...`);
- const createTokenResponse = await createToken(collectionId, tokenId, name, media);
- await sleep(10000);
-
- const requestId = createTokenResponse.data.data.CreateToken.id;
- await waitForTransaction(requestId, `'${name}' token creation`);
- console.log(`Resource token '${name}' created successfully.`);
-}
-
-async function prepareCollection() {
- // Ensure collection exists
- const collectionId = await checkAndCreateCollection();
-
- // Checks which resource tokens exist
- const tokenExistsChecks = await Promise.all(
- RESOURCE_TOKENS.map(token =>
- checkTokenExists(collectionId, token.id)
- .then(exists => ({ ...token, exists }))
- )
- );
-
- // Create missing tokens in parallel
- const tokensToCreate = tokenExistsChecks.filter(token => !token.exists);
- if (tokensToCreate.length > 0) {
- await Promise.all(
- tokensToCreate.map(token =>
- createResourceToken(collectionId, token.id, token.name, token.media)
- .catch(error => {
- console.error(`Failed to create resource token #${token.id}:`, error);
- throw error;
- })
- )
- );
- }
-}
-
-module.exports = {
- prepareCollection, createManagedWallet, getManagedWallet, getManagedWalletTokens, mintTokenAndWaitForTransaction, meltTokenAndWaitForTransaction, transferTokenAndWaitForTransaction
-};
\ No newline at end of file
diff --git a/tools/Ss58SelfTest/Program.cs b/tools/Ss58SelfTest/Program.cs
new file mode 100644
index 0000000..634a3d0
--- /dev/null
+++ b/tools/Ss58SelfTest/Program.cs
@@ -0,0 +1,70 @@
+using PlatformSampleGameServer.Services;
+
+// Regression test for Services/SubstrateAddress.cs.
+//
+// Asserts the SS58 encoder against canonical published vectors:
+//
+// - Alice (//Alice sr25519 dev key, public key
+// 0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d)
+// on the generic Substrate prefix (42), Polkadot mainnet (prefix 0),
+// and Kusama (prefix 2). These appear throughout the Substrate /
+// Polkadot documentation and in the subkey test fixtures.
+//
+// The three vectors collectively exercise both single-byte SS58 prefixes
+// (0, 2, 42) and the unkeyed Blake2b-512 checksum, which is the property
+// the production encoder depends on. (Enjin Matrixchain prefixes 9030 /
+// 1110 use the two-byte SS58 prefix form, which we exercise through the
+// running platform but do not assert here as we lack a published vector
+// for the //Alice dev key on those chains; if the checksum function or
+// base58 encoder regress, the single-byte vectors below will catch it.)
+//
+// Any mismatch prints the divergence and exits with a non-zero status so
+// CI / a manual `dotnet run --project tools/Ss58SelfTest` will fail loudly
+// if the encoder (or the underlying Blake2b implementation) regresses.
+
+var vectors = new (string Label, string PublicKeyHex, ushort Prefix, string ExpectedAddress)[]
+{
+ (
+ "Alice / Substrate generic (prefix 42)",
+ "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d",
+ 42,
+ "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"
+ ),
+ (
+ "Alice / Polkadot mainnet (prefix 0)",
+ "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d",
+ 0,
+ "15oF4uVJwmo4TdGW7VfQxNLavjCXviqxT9S1MgbjMNHr6Sp5"
+ ),
+ (
+ "Alice / Kusama (prefix 2)",
+ "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d",
+ 2,
+ "HNZata7iMYWmk5RvZRTiAsSDhV8366zq2YGb3tLH5Upf74F"
+ ),
+};
+
+int failures = 0;
+foreach (var v in vectors)
+{
+ var actual = SubstrateAddress.Encode(v.PublicKeyHex, v.Prefix);
+ var ok = string.Equals(actual, v.ExpectedAddress, StringComparison.Ordinal);
+ Console.WriteLine($"[{(ok ? "PASS" : "FAIL")}] {v.Label}");
+ if (!ok)
+ {
+ Console.WriteLine($" expected: {v.ExpectedAddress}");
+ Console.WriteLine($" actual: {actual}");
+ failures++;
+ }
+}
+
+if (failures > 0)
+{
+ Console.Error.WriteLine(
+ $"\n{failures} SS58 vector(s) failed. The encoder is producing incorrect addresses."
+ );
+ return 1;
+}
+
+Console.WriteLine($"\nAll {vectors.Length} SS58 vectors passed.");
+return 0;
diff --git a/tools/Ss58SelfTest/Ss58SelfTest.csproj b/tools/Ss58SelfTest/Ss58SelfTest.csproj
new file mode 100644
index 0000000..0c4241e
--- /dev/null
+++ b/tools/Ss58SelfTest/Ss58SelfTest.csproj
@@ -0,0 +1,12 @@
+
+
+ Exe
+ net9.0
+ enable
+ enable
+ Ss58SelfTest
+
+
+
+
+
diff --git a/tools/TransferAmountSelfTest/Program.cs b/tools/TransferAmountSelfTest/Program.cs
new file mode 100644
index 0000000..2bf0813
--- /dev/null
+++ b/tools/TransferAmountSelfTest/Program.cs
@@ -0,0 +1,71 @@
+using System.Numerics;
+using PlatformSampleGameServer.Endpoints;
+
+// Regression test for Endpoints/TokenEndpoints.TryParseAmount.
+//
+// Mint / melt / transfer amounts arrive on the wire as decimal strings because
+// token amounts are BigIntegers on-chain (Enjin Platform C# SDK v3). The parser
+// must:
+//
+// - accept positive integers of arbitrary size, preserving the exact value
+// (a value above long range is the property an int/long parser would lose);
+// - reject zero and negative amounts;
+// - reject non-integer / non-numeric input (decimals, words, empty, null).
+//
+// Any mismatch prints the divergence and exits non-zero so CI / a manual
+// `dotnet run --project tools/TransferAmountSelfTest` fails loudly if the
+// amount validation regresses.
+
+// 2^128, well beyond int and long range — the headline BigInteger case.
+var huge = BigInteger.Pow(2, 128);
+
+var vectors = new (
+ string Label,
+ string? Input,
+ bool ExpectOk,
+ BigInteger ExpectedAmount,
+ string? ExpectedError
+)[]
+{
+ ("small positive", "5", true, 5, null),
+ ("above int range", "3000000000", true, 3_000_000_000L, null),
+ ("above long range (2^128)", huge.ToString(), true, huge, null),
+ ("leading/trailing whitespace tolerated", " 42 ", true, 42, null),
+ ("zero rejected", "0", false, default, "Amount must be positive."),
+ ("negative rejected", "-1", false, default, "Amount must be positive."),
+ ("decimal rejected", "1.5", false, default, "Invalid amount '1.5'."),
+ ("non-numeric rejected", "abc", false, default, "Invalid amount 'abc'."),
+ ("empty rejected", "", false, default, "Invalid amount ''."),
+ ("null rejected", null, false, default, "Invalid amount ''."),
+};
+
+int failures = 0;
+foreach (var v in vectors)
+{
+ var ok = TokenEndpoints.TryParseAmount(v.Input, out var amount, out var error);
+
+ var passed =
+ ok == v.ExpectOk && (v.ExpectOk ? amount == v.ExpectedAmount : error == v.ExpectedError);
+
+ Console.WriteLine($"[{(passed ? "PASS" : "FAIL")}] {v.Label}");
+ if (!passed)
+ {
+ Console.WriteLine($" input: {v.Input ?? ""}");
+ Console.WriteLine(
+ $" expected: ok={v.ExpectOk} amount={v.ExpectedAmount} error={v.ExpectedError ?? ""}"
+ );
+ Console.WriteLine($" actual: ok={ok} amount={amount} error={error ?? ""}");
+ failures++;
+ }
+}
+
+if (failures > 0)
+{
+ Console.Error.WriteLine(
+ $"\n{failures} amount vector(s) failed. Transfer amount validation has regressed."
+ );
+ return 1;
+}
+
+Console.WriteLine($"\nAll {vectors.Length} transfer-amount vectors passed.");
+return 0;
diff --git a/tools/TransferAmountSelfTest/TransferAmountSelfTest.csproj b/tools/TransferAmountSelfTest/TransferAmountSelfTest.csproj
new file mode 100644
index 0000000..4f75d5e
--- /dev/null
+++ b/tools/TransferAmountSelfTest/TransferAmountSelfTest.csproj
@@ -0,0 +1,12 @@
+
+
+ Exe
+ net9.0
+ enable
+ enable
+ TransferAmountSelfTest
+
+
+
+
+