diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1fc11913..b5cf252c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,6 +3,7 @@ on: push: branches: - main + pull_request: # Serialize runs on a ref and cancel superseded ones, so an older run can't roll prod # back to its (older) image after a newer run has already deployed. @@ -10,25 +11,33 @@ concurrency: group: vapi-cicd-${{ github.ref }} cancel-in-progress: true +# Least privilege: no job writes to the repo (images push with Docker Hub +# credentials, deploys use SSH secrets). +permissions: + contents: read + jobs: - deps: + # Gate every PR and every deploy on the .NET test suite (crypto golden vectors, + # RPC failover, JS-semantics parity helpers). + test: runs-on: ubuntu-24.04 - strategy: - matrix: - node-version: [20.12.2] steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - name: npm install, lint and/or test - run: | - yarn - env: - CI: true + - uses: actions/checkout@v4 + with: + # PR-triggered job; don't leave the token in .git/config + persist-credentials: false + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Build + run: dotnet build dotnet/EcencyApi/EcencyApi.csproj -c Release + - name: Test + run: dotnet test dotnet/EcencyApi.Tests/EcencyApi.Tests.csproj -c Release + build: - needs: deps + # Build/push only on merges to main; PRs stop at the test gate. + if: github.event_name == 'push' + needs: test runs-on: ubuntu-24.04 outputs: digest: ${{ steps.docker_build.outputs.digest }} @@ -37,24 +46,44 @@ jobs: steps: - name: Check Out Repo - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Login to Docker Hub - uses: docker/login-action@v1 - if: ${{env.DOCKERHUB_USERNAME != 0}} + uses: docker/login-action@v3 + if: ${{ env.DOCKERHUB_USERNAME != 0 }} with: - username: ${{secrets.DOCKERHUB_USERNAME}} - password: ${{secrets.DOCKERHUB_TOKEN}} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - + # One-time, idempotent: before the first C# image overwrites :latest, + # preserve the last Node build under a durable tag. Rollback to the + # pre-rewrite service is then always `ecency/api:node-legacy`. + name: Preserve last Node image as rollback tag + if: ${{ env.DOCKERHUB_USERNAME != 0 }} + run: | + if docker manifest inspect ecency/api:node-legacy >/dev/null 2>&1; then + echo "node-legacy tag already exists; skipping" + else + docker buildx imagetools create --tag ecency/api:node-legacy ecency/api:latest + echo "tagged current :latest as ecency/api:node-legacy" + fi + - + # The deployed implementation is the C# service under dotnet/. Every build + # is also tagged with the commit SHA, so any previous version can be + # redeployed by tag (the deploy boxes prune local images, so rollback + # comes from the registry): + # docker service update --image ecency/api:sha- vision_vapi name: Build and push id: docker_build - uses: docker/build-push-action@v2 - if: ${{env.DOCKERHUB_USERNAME != 0}} + uses: docker/build-push-action@v5 + if: ${{ env.DOCKERHUB_USERNAME != 0 }} with: - context: ./ - file: ./Dockerfile + context: ./dotnet + file: ./dotnet/Dockerfile push: true - tags: ecency/api:latest + tags: | + ecency/api:latest + ecency/api:sha-${{ github.sha }} - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} @@ -112,4 +141,3 @@ jobs: fi echo "vision_vapi on $IMAGE (UpdateStatus=${state:-none})" docker system prune -f - diff --git a/dotnet/.dockerignore b/dotnet/.dockerignore new file mode 100644 index 00000000..151faf75 --- /dev/null +++ b/dotnet/.dockerignore @@ -0,0 +1,6 @@ +**/bin/ +**/obj/ +parity/ +tools/ +EcencyApi.Tests/ +README.md diff --git a/dotnet/.gitignore b/dotnet/.gitignore new file mode 100644 index 00000000..ac8d21bd --- /dev/null +++ b/dotnet/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ +*.user diff --git a/dotnet/Dockerfile b/dotnet/Dockerfile new file mode 100644 index 00000000..d1252b8a --- /dev/null +++ b/dotnet/Dockerfile @@ -0,0 +1,34 @@ +# Multi-stage build for the C# port of vision-api. +# Produces a self-contained ASP.NET Core service on the same 4000 port and +# /healthcheck.json contract as the Node image, so it is a drop-in swap in the +# vision_vapi stack. + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY EcencyApi/EcencyApi.csproj EcencyApi/ +RUN dotnet restore EcencyApi/EcencyApi.csproj + +COPY EcencyApi/ EcencyApi/ +RUN dotnet publish EcencyApi/EcencyApi.csproj -c Release -o /app --no-restore + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app + +# Static assets served by express.static in the Node version (public/). +COPY EcencyApi/public/ ./public/ +COPY --from=build /app ./ + +# Run as the non-root user the aspnet base image ships; port 4000 is unprivileged. +RUN chown -R app:app /app +USER app + +ENV API_PORT=4000 +ENV ASPNETCORE_URLS= +EXPOSE 4000 + +# Same health contract the Node healthCheck.js polled. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["dotnet", "EcencyApi.dll", "--healthcheck"] + +ENTRYPOINT ["dotnet", "EcencyApi.dll"] diff --git a/dotnet/EcencyApi.Tests/EcencyApi.Tests.csproj b/dotnet/EcencyApi.Tests/EcencyApi.Tests.csproj new file mode 100644 index 00000000..097b09b1 --- /dev/null +++ b/dotnet/EcencyApi.Tests/EcencyApi.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + diff --git a/dotnet/EcencyApi.Tests/HiveCryptoTests.cs b/dotnet/EcencyApi.Tests/HiveCryptoTests.cs new file mode 100644 index 00000000..1b8775c7 --- /dev/null +++ b/dotnet/EcencyApi.Tests/HiveCryptoTests.cs @@ -0,0 +1,159 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using EcencyApi.Infrastructure; +using Xunit; + +namespace EcencyApi.Tests; + +/// +/// Byte-for-byte verification of the crypto port against golden vectors +/// generated from the exact dhive/js-base64 versions the Node service uses +/// (dotnet/tools/gen-vectors.js). +/// +public class HiveCryptoTests +{ + private static readonly JsonObject Vectors = LoadVectors(); + + private static JsonObject LoadVectors() + { + var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "crypto-vectors.json"); + return (JsonObject)JsonNode.Parse(File.ReadAllText(path))!; + } + + [Fact] + public void FromLogin_MatchesDhive() + { + foreach (var v in Vectors["fromLogin"]!.AsArray()) + { + var username = v!["username"]!.GetValue(); + var password = v["password"]!.GetValue(); + var role = v["role"]!.GetValue(); + + var key = HiveCrypto.FromLogin(username, password, role); + + Assert.Equal(v["wif"]!.GetValue(), HiveCrypto.ToWif(key)); + Assert.Equal(v["publicKey"]!.GetValue(), + HiveCrypto.PublicKeyToString(key.CreatePubKey())); + } + } + + [Fact] + public void Sign_MatchesDhiveByteForByte() + { + foreach (var v in Vectors["sign"]!.AsArray()) + { + var key = HiveCrypto.FromLogin( + v!["username"]!.GetValue(), + v["password"]!.GetValue(), + v["role"]!.GetValue()); + + var digest = HiveCrypto.Sha256Utf8(v["message"]!.GetValue()); + Assert.Equal(v["digestHex"]!.GetValue(), Convert.ToHexStringLower(digest)); + + Assert.Equal(v["signature"]!.GetValue(), HiveCrypto.Sign(key, digest)); + } + } + + [Fact] + public void Recover_MatchesDhive() + { + foreach (var v in Vectors["recover"]!.AsArray()) + { + var digest = Convert.FromHexString(v!["digestHex"]!.GetValue()); + var recovered = HiveCrypto.RecoverPublicKey(v["signature"]!.GetValue(), digest); + + Assert.Equal(v["recoveredPublicKey"]!.GetValue(), recovered); + } + } + + [Fact] + public void B64uEncode_MatchesJsBase64() + { + foreach (var v in Vectors["b64u"]!.AsArray()) + { + Assert.Equal(v!["encoded"]!.GetValue(), + B64u.Encode(v["input"]!.GetValue())); + } + } + + [Fact] + public void HsTokenCreate_FullFlow_MatchesNode() + { + foreach (var v in Vectors["hsTokenCreate"]!.AsArray()) + { + var username = v!["username"]!.GetValue(); + var password = v["password"]!.GetValue(); + var app = v["app"]!.GetValue(); + var timestamp = v["timestamp"]!.GetValue(); + + // Reproduce hsTokenCreate exactly: build the message object with JS + // property order, hash the JSON.stringify form, sign, then append + // signatures and b64u-encode. + var messageObj = new JsonObject + { + ["signed_message"] = new JsonObject { ["type"] = "code", ["app"] = app }, + ["authors"] = new JsonArray(username), + ["timestamp"] = timestamp, + }; + + var hash = HiveCrypto.Sha256Utf8(JsJson.Stringify(messageObj)); + var key = HiveCrypto.FromLogin(username, password, "posting"); + var signature = HiveCrypto.Sign(key, hash); + messageObj["signatures"] = new JsonArray(signature); + + var signedJson = JsJson.Stringify(messageObj); + Assert.Equal(v["signedJson"]!.GetValue(), signedJson); + Assert.Equal(v["code"]!.GetValue(), B64u.Encode(signedJson)); + } + } + + [Fact] + public void ValidateCodeReserialization_MatchesNode() + { + foreach (var v in Vectors["validateCodeRaw"]!.AsArray()) + { + var token = (JsonObject)JsonNode.Parse(v!["tokenJson"]!.GetValue())!; + + // The exact re-serialization validateCode performs: + // JSON.stringify({signed_message, authors, timestamp}) with the + // parsed nodes (nested key order preserved from the token). + var raw = new JsonObject + { + ["signed_message"] = token["signed_message"]!.DeepClone(), + ["authors"] = token["authors"]!.DeepClone(), + ["timestamp"] = token["timestamp"]!.DeepClone(), + }; + + var rawMessage = JsJson.Stringify(raw); + Assert.Equal(v["rawMessage"]!.GetValue(), rawMessage); + Assert.Equal(v["digestHex"]!.GetValue(), + Convert.ToHexStringLower(HiveCrypto.Sha256Utf8(rawMessage))); + } + } + + [Fact] + public void NumberFormatting_MatchesV8() + { + foreach (var v in Vectors["numberFormat"]!.AsArray()) + { + var value = v!["value"]!.GetValue(); + var expected = v["text"]!.GetValue(); + // JSON.stringify path (fixture "value" is V8-serialized, so parsing it + // and re-serializing must reproduce "text" byte-for-byte) + Assert.Equal(expected, JsJson.Stringify(JsonValue.Create(value))); + } + } + + [Theory] + [InlineData("{\"a\":1,\"b\":\"x\"}")] + [InlineData("{\"b\":2,\"a\":1}")] // property order preserved, not sorted + [InlineData("[1,2.5,\"s\",true,null,{}]")] + [InlineData("{\"n\":1751900000.123}")] + [InlineData("{\"u\":\"caf\\u00e9 漢字\"}")] + public void JsJson_RoundTripsParsedJson(string json) + { + var node = JsonNode.Parse(json); + var expected = json.Replace("\\u00e9", "é"); // JSON.stringify emits raw unicode + Assert.Equal(expected, JsJson.Stringify(node)); + } +} diff --git a/dotnet/EcencyApi.Tests/HiveRpcFailoverTests.cs b/dotnet/EcencyApi.Tests/HiveRpcFailoverTests.cs new file mode 100644 index 00000000..326eb244 --- /dev/null +++ b/dotnet/EcencyApi.Tests/HiveRpcFailoverTests.cs @@ -0,0 +1,185 @@ +using System.Net; +using System.Text; +using System.Text.Json.Nodes; +using EcencyApi.Infrastructure; +using Xunit; + +namespace EcencyApi.Tests; + +/// +/// Exercises the dhive-style node failover in HiveRpcClient against local stub +/// HTTP servers: rate-limit / overload / timeout on one node must transparently +/// roll over to the next healthy node, and a healthy node becomes sticky. +/// +public class HiveRpcFailoverTests +{ + /// Minimal loopback HTTP server returning a scripted response per request. + private sealed class StubNode : IAsyncDisposable + { + private readonly HttpListener _listener = new(); + private readonly Func _handler; // returns HTTP status; 200 => valid RPC result + public string Url { get; } + public int Hits; + + public StubNode(Func handler) + { + _handler = handler; + var port = GetFreePort(); + Url = $"http://127.0.0.1:{port}/"; + _listener.Prefixes.Add(Url); + _listener.Start(); + _ = Loop(); + } + + private async Task Loop() + { + while (_listener.IsListening) + { + HttpListenerContext ctx; + try { ctx = await _listener.GetContextAsync(); } + catch { return; } + + Interlocked.Increment(ref Hits); + var status = _handler(); + byte[] body; + if (status == 200) + { + body = Encoding.UTF8.GetBytes( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":[{\"name\":\"served-by\",\"port\":\"" + Url + "\"}]}"); + } + else if (status == -1) + { + // Simulate a hang/timeout: delay past the client timeout, then close. + await Task.Delay(3000); + body = Encoding.UTF8.GetBytes("{}"); + status = 200; + } + else if (status == -2) + { + // Slow but successful: answers correctly after 1.5s (above the + // 1s unproven prior, below any test timeout). + await Task.Delay(1500); + body = Encoding.UTF8.GetBytes( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":[{\"name\":\"served-by\",\"port\":\"" + Url + "\"}]}"); + status = 200; + } + else + { + body = Encoding.UTF8.GetBytes("rate limited"); + } + + ctx.Response.StatusCode = status; + ctx.Response.ContentType = "application/json"; + ctx.Response.ContentLength64 = body.Length; + try + { + await ctx.Response.OutputStream.WriteAsync(body); + ctx.Response.Close(); + } + catch { /* client may have moved on */ } + } + } + + private static int GetFreePort() + { + var l = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); + l.Start(); + var port = ((IPEndPoint)l.LocalEndpoint).Port; + l.Stop(); + return port; + } + + public async ValueTask DisposeAsync() + { + _listener.Stop(); + _listener.Close(); + await Task.CompletedTask; + } + } + + [Fact] + public async Task RateLimitedNode_FailsOverToHealthyNode() + { + await using var bad = new StubNode(() => 429); // always rate-limited + await using var good = new StubNode(() => 200); // healthy + + var client = new HiveRpcClient(new[] { bad.Url, good.Url }, timeoutMs: 1500); + + var result = await client.Call("condenser_api", "get_accounts", new JsonArray()); + + Assert.NotNull(result); + Assert.Equal("served-by", result![0]!["name"]!.GetValue()); + // A 429 must advance immediately — exactly one hit on the bad node, no wasted retry. + Assert.Equal(1, bad.Hits); + Assert.True(good.Hits >= 1); + } + + [Fact] + public async Task HealthyNode_BecomesSticky() + { + await using var bad = new StubNode(() => 503); + await using var good = new StubNode(() => 200); + + var client = new HiveRpcClient(new[] { bad.Url, good.Url }, timeoutMs: 1500); + + await client.Call("condenser_api", "get_dynamic_global_properties", new JsonArray()); + var badAfterFirst = bad.Hits; + + // Second call should go straight to the now-sticky healthy node. + await client.Call("condenser_api", "get_dynamic_global_properties", new JsonArray()); + + Assert.Equal(badAfterFirst, bad.Hits); // bad node not touched again + Assert.True(good.Hits >= 2); + } + + [Fact] + public async Task TimeoutNode_FailsOverToHealthyNode() + { + await using var slow = new StubNode(() => -1); // hangs past the timeout + await using var good = new StubNode(() => 200); + + var client = new HiveRpcClient(new[] { slow.Url, good.Url }, timeoutMs: 800, failoverThreshold: 1); + + var result = await client.Call("condenser_api", "get_accounts", new JsonArray()); + + Assert.NotNull(result); + Assert.Equal("served-by", result![0]!["name"]!.GetValue()); + } + + [Fact] + public async Task ProvenSlowNode_IsDemotedByLatencyEwma() + { + // Adopted from @ecency/sdk's NodeHealthTracker: once a node's latency + // EWMA is trusted (3 samples) and exceeds the unproven prior (1s), an + // unexplored node is tried first. + await using var slow = new StubNode(() => -2); // responds 200 after 1.5s + await using var fast = new StubNode(() => 200); + + var client = new HiveRpcClient(new[] { slow.Url, fast.Url }, timeoutMs: 5000); + + // three successful-but-slow calls build a trusted ~1500ms EWMA + for (var i = 0; i < 3; i++) + { + await client.Call("condenser_api", "get_accounts", new JsonArray()); + } + Assert.Equal(3, slow.Hits); + Assert.Equal(0, fast.Hits); + + // fourth call: slow node scores ~1500 > 1000 prior -> fast node explored first + await client.Call("condenser_api", "get_accounts", new JsonArray()); + Assert.Equal(3, slow.Hits); + Assert.True(fast.Hits >= 1); + } + + [Fact] + public async Task AllNodesDown_Throws() + { + await using var bad1 = new StubNode(() => 500); + await using var bad2 = new StubNode(() => 502); + + var client = new HiveRpcClient(new[] { bad1.Url, bad2.Url }, timeoutMs: 1000, failoverThreshold: 1); + + await Assert.ThrowsAnyAsync(() => + client.Call("condenser_api", "get_accounts", new JsonArray())); + } +} diff --git a/dotnet/EcencyApi.Tests/JsValTests.cs b/dotnet/EcencyApi.Tests/JsValTests.cs new file mode 100644 index 00000000..3185e21e --- /dev/null +++ b/dotnet/EcencyApi.Tests/JsValTests.cs @@ -0,0 +1,109 @@ +using System.Text.Json.Nodes; +using EcencyApi.Handlers; +using EcencyApi.Infrastructure; +using Xunit; + +namespace EcencyApi.Tests; + +/// +/// The JS coercion primitives underpin the wallet numeric parity. These pin the +/// behaviors that differ from naive .NET parsing (Number("") == 0 but +/// parseFloat("") is NaN; Number("12abc") is NaN but parseFloat("12abc") is 12). +/// +public class JsValTests +{ + [Theory] + [InlineData("", double.NaN)] + [InlineData(" ", double.NaN)] + [InlineData("12", 12)] + [InlineData("12.5", 12.5)] + [InlineData("12abc", 12)] + [InlineData(" -3.14xyz", -3.14)] + [InlineData("abc", double.NaN)] + [InlineData(".5", 0.5)] + [InlineData("1e3", 1000)] + [InlineData("1.2e2foo", 120)] + [InlineData("+7", 7)] + [InlineData("Infinity", double.PositiveInfinity)] + public void ParseFloat_MatchesJs(string input, double expected) + { + var actual = JsVal.ParseFloat(input); + if (double.IsNaN(expected)) Assert.True(double.IsNaN(actual), $"expected NaN for '{input}', got {actual}"); + else Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("", 0)] // Number("") === 0 (the classic footgun) + [InlineData(" ", 0)] // Number(" ") === 0 + [InlineData("12", 12)] + [InlineData("12.5", 12.5)] + [InlineData("12abc", double.NaN)] // Number requires the WHOLE string + [InlineData(" 42 ", 42)] // surrounding whitespace trimmed + [InlineData("0x1F", 31)] // hex literal + [InlineData("abc", double.NaN)] + [InlineData("1e3", 1000)] + public void NumberCoerce_MatchesJs(string input, double expected) + { + var actual = JsVal.NumberCoerce(input); + if (double.IsNaN(expected)) Assert.True(double.IsNaN(actual), $"expected NaN for '{input}', got {actual}"); + else Assert.Equal(expected, actual); + } + + [Fact] + public void ToJsString_CoercesLikeJs() + { + Assert.Equal("null", JsVal.ToJsString(null)); + Assert.Equal("[object Object]", JsVal.ToJsString(new JsonObject { ["a"] = 1 })); + Assert.Equal("1,2,3", JsVal.ToJsString(new JsonArray(1, 2, 3))); + Assert.Equal("hi", JsVal.ToJsString(JsonValue.Create("hi"))); + Assert.Equal("true", JsVal.ToJsString(JsonValue.Create(true))); + Assert.Equal("42", JsVal.ToJsString(JsonValue.Create(42))); + // Array.prototype.toString: null/undefined elements become empty + Assert.Equal("1,,3", JsVal.ToJsString(new JsonArray(1, null, 3))); + } + + [Fact] + public void ParseMaybeNumber_MatchesTsHelper() + { + // number passthrough + Assert.Equal(3.5, WalletApi.ParseMaybeNumber(JsonValue.Create(3.5))); + // whole numeric string + Assert.Equal(42, WalletApi.ParseMaybeNumber(JsonValue.Create("42"))); + // embedded number via regex fallback + Assert.Equal(-7.25, WalletApi.ParseMaybeNumber(JsonValue.Create("balance: -7.25 HIVE"))); + // empty string -> null (trimmed empty short-circuits before Number()) + Assert.Null(WalletApi.ParseMaybeNumber(JsonValue.Create(""))); + // non-numeric -> null + Assert.Null(WalletApi.ParseMaybeNumber(JsonValue.Create("nope"))); + // null node -> null + Assert.Null(WalletApi.ParseMaybeNumber(null)); + } + + [Fact] + public void ConvertBaseUnitsToAmount_HandlesBigIntegerStrings() + { + // 1 ETH in wei + Assert.Equal(1.0, WalletApi.ConvertBaseUnitsToAmount("1000000000000000000", 18)); + // fractional trailing zeros stripped + Assert.Equal(1.5, WalletApi.ConvertBaseUnitsToAmount("1500000000", 9)); + // sub-unit value + Assert.Equal(0.000001, WalletApi.ConvertBaseUnitsToAmount("1000", 9)); + // negative + Assert.Equal(-2.0, WalletApi.ConvertBaseUnitsToAmount("-2000000", 6)); + // empty -> 0 + Assert.Equal(0, WalletApi.ConvertBaseUnitsToAmount("", 8)); + } + + [Fact] + public void ParseBoolean_MatchesTsHelper() + { + Assert.True(WalletApi.ParseBoolean(JsonValue.Create(true))); + Assert.True(WalletApi.ParseBoolean(JsonValue.Create(1))); + Assert.True(WalletApi.ParseBoolean(JsonValue.Create("yes"))); + Assert.False(WalletApi.ParseBoolean(JsonValue.Create("off"))); + Assert.False(WalletApi.ParseBoolean(JsonValue.Create(0))); + Assert.Null(WalletApi.ParseBoolean(JsonValue.Create("maybe"))); + Assert.Null(WalletApi.ParseBoolean(null)); + Assert.Null(WalletApi.ParseBoolean(JsonValue.Create(2))); // only 0/1 map + } +} diff --git a/dotnet/EcencyApi.Tests/fixtures/crypto-vectors.json b/dotnet/EcencyApi.Tests/fixtures/crypto-vectors.json new file mode 100644 index 00000000..d8d0e6f2 --- /dev/null +++ b/dotnet/EcencyApi.Tests/fixtures/crypto-vectors.json @@ -0,0 +1,1604 @@ +{ + "fromLogin": [ + { + "username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "role": "posting", + "wif": "5Jj3uAkUt2vaQU9kMwx1ZGCKU4mDof7FQf1LRDXKkAKssEJCoYP", + "publicKey": "STM6TBpMsQ4vFZxSTnfmsooJsYk8DtaTh5gyNdy9HRktH1ji7uCxq" + }, + { + "username": "bob.tester", + "password": "hunter2hunter2", + "role": "posting", + "wif": "5K2fXVtAEGTtnTzG7RA833zLhvVunJ21AyC8JsiFLpakuaZWGX4", + "publicKey": "STM87oQfkpCN9VMXJHU9Dw9sv6eXyGYr9rh1jmvcoDsyvyFFYtUEi" + }, + { + "username": "carol-x1", + "password": "correct horse battery staple", + "role": "active", + "wif": "5KHsQPXEFvGkLbbsL5WBLQH8Dn85K27LmGSKuEuXtATyvQnKr3J", + "publicKey": "STM83D7XeNRn7kDB9Neh6Tc442YmJmqUqE4EZzUUtMqNXmZyFfpg1" + }, + { + "username": "dave", + "password": "🔑 unicode pässwörd", + "role": "posting", + "wif": "5JbnRSX1bv2CDBHrVLNgowQ4uYoyutB3aXt4KgQYt7t6gMPfMaU", + "publicKey": "STM6PB54zab76Bdc3uKXKvSd7LTcM3RhHjsS3NxTeHuExHVTZoLcH" + }, + { + "username": "e", + "password": "x", + "role": "memo", + "wif": "5HtSsHFTMw98rZ18wqr49BMfDFVWGshViWqyaaHQUVXDB8LGbKf", + "publicKey": "STM5g3PLnWPjs763K7LVUxCFp3B54nxcc3soGGNnUEbTDpthanCzH" + } + ], + "sign": [ + { + "username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "role": "posting", + "message": "hello world", + "digestHex": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + "signature": "1f5087e3d69af1551d52a1eeccb42ecd5cee392b0f4a1091cd47e51d27dbcac1ae30287bb518d9b1371e54db311fbb38fcb5e655918c38e846f04f38900ac2add5" + }, + { + "username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "role": "posting", + "message": "hello world #0", + "digestHex": "d97b7931e531b14d2786feeff8f90e84be493978720361ee51ae1a650c4ee9f0", + "signature": "203fe2ba3928620892785d315d2584e99f42070275231ee62bed7a764c68b9e9cf1971d92e21def61b0bf4a804d7056030857e126db5aa1ca3ce1cef760be8a11b" + }, + { + "username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "role": "posting", + "message": "hello world !1", + "digestHex": "65a0d86bc100e450fb604a3f5b410be11751ebb31086c0f37bca5dd9ca71ab55", + "signature": "1f1d7f9c745eac65f049cf82b8b17f5d0262e9d42f72a086007e6766dc135022e0671d50964d092b847e2d141aa6d8a99722b62983246f47ed8afcfa663c8fe06a" + }, + { + "username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "role": "posting", + "message": "", + "digestHex": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "20452964c0d837426358e508992fc7bcfd03c774e9756331d55aba1c74f87d1525290f059c774abbb72901093ca4faebcd5675af795f84b9c772b756a43cc54d41" + }, + { + "username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "role": "posting", + "message": " #2", + "digestHex": "7f50aa8ce48d35bae64658f152f9176520f22e6b3a59c98ade2a3d78ec327967", + "signature": "206e5f85386cde75b1c1d043cccee925dd2d3f23abed93f8e3d095095fe56dff9f7cc367eacfca4e1ca841fa19a0c711ab09edd78d1510d9b2b67ef440d6f16780" + }, + { + "username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "role": "posting", + "message": " !3", + "digestHex": "9fdcd2492a8cb6f5a7e556e2044cf405b182cfc3fa38524b484dab1da32f28b2", + "signature": "2070ac3a1af4854ec86f278d0c9d0f7cb8e262c9f54f681fd084d15c2e8b7396ad5a673f73ae59a014606950a34624426c7087b794b4b347335ff0a0c954f338ba" + }, + { + "username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "role": "posting", + "message": "a", + "digestHex": "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + "signature": "205120c721da913ae4dfebd60bc3faea30b7d5b2d100973880d335fe68e18580674e12c49d25ab1cc3b1300fbc6c214cf57c488c2be3d79f54f5ee6066537a5a5b" + }, + { + "username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "role": "posting", + "message": "a #4", + "digestHex": "76e9ec70978b746ace92950668a10f331fe0c7230a759d695ebef339887c639e", + "signature": "1f72cd1c1b8bf2c3a3e0806834908f513d76e313b9a7b133cb8e465db7d5fac7ea591346faee10c3121219bcd63f2aa0cc339646582f28049455fd2d45fc6ea484" + }, + { + "username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "role": "posting", + "message": "a !5", + "digestHex": "fa9c27a53b5070181e28641c1917149c84a6c77005a5db6f375cf2cb055d2c7a", + "signature": "1f5c9e434d543c08c0aba3a6dc1bb67f0edaad7c8c7375158a7e78038af6b28f6c43d3bcbee6f470225e6f169852f667dabfa4770e3c56807d40e35f177d778403" + }, + { + "username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "role": "posting", + "message": "{\"signed_message\":{\"type\":\"code\",\"app\":\"ecency.app\"},\"authors\":[\"alice\"],\"timestamp\":1751900000}", + "digestHex": "5290d7673a06a74f856d8d142764739972d85430654b23fff2c5c301a2a718f5", + "signature": "1f30c08268a34497eb7d0d2df06486cc64b36cd81b2e03a8de158d001409d0d98e7d9557c27e67da50fc22edcfa1f56f4b6e250d6931678f4278a8365f06c4bbe6" + }, + { + "username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "role": "posting", + "message": "{\"signed_message\":{\"type\":\"code\",\"app\":\"ecency.app\"},\"authors\":[\"alice\"],\"timestamp\":1751900000} #6", + "digestHex": "92ede1edf7367e963da936c2cf921e33b49c07050b6efa6f400b5ba86458d1a8", + "signature": "20032a89b60d4a67878f62aa03ffdb66ffcee15e14f5fc2898a7f33353429b93426e628044a9f68717048291967c437ca9af48862c2e28da78f1b86e3805f1b2ff" + }, + { + "username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "role": "posting", + "message": "{\"signed_message\":{\"type\":\"code\",\"app\":\"ecency.app\"},\"authors\":[\"alice\"],\"timestamp\":1751900000} !7", + "digestHex": "6efb5772fd7b46b0bebd455ac3b3888c297d14262d349a8f8764649a9b977f2f", + "signature": "2019d04dc71c426032e815b700430684643feb8b3a92596ecdeb68ad1d4a72d55c360d632fb092abfd699af778c7ea521306cfb8bd251bc7e2bd65aa3c34055e2a" + }, + { + "username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "role": "posting", + "message": "The quick brown fox jumps over the lazy dog", + "digestHex": "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", + "signature": "203a80af111479bf4d196d30df89d76f68c78804794d9fa6aea357e2c68cc6a9a957f6e0bbaba3eee49325994f54f5ebd0ff188858f3cd6242fb789e93396a0d59" + }, + { + "username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "role": "posting", + "message": "The quick brown fox jumps over the lazy dog #8", + "digestHex": "bb9d21b343f425e3f037f11fc3bdfdeb943560322635388c53ddcbbc0c7091d3", + "signature": "2052f20534ea1ea9bb5b8cba3eff2dd94bbb2f98387fe4685f7d9e5f7f742210877ef60492a352ca77db373630ba67b784bed83c17ae6199400cd8ec5d28f6723e" + }, + { + "username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "role": "posting", + "message": "The quick brown fox jumps over the lazy dog !9", + "digestHex": "1e876114cd3bfc3e392d520b2c2813e55bf522edae36a27cb19ea4a778abf993", + "signature": "20486d3397f4b81a9e3fedac987c01349fcb5b5d471d7a8b9950a9e656d20ad9be48586edc8eb885f17d3de6a936606216c40373e76547b7ff9016e24bdafd0f04" + }, + { + "username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "role": "posting", + "message": "unicode: ñçü 漢字 🚀", + "digestHex": "417874e185288d80e951402e73559f85f3e8e589923e5ab868d7116b4b8859d5", + "signature": "1f7a0ff192ac4ce01c6ff80e398e4782337a4ca558140aabb2f94bccbbf150e3ce1eb7e53b58db44510ebad84e984f39e6412217fa41a506a9e32501391d0899f7" + }, + { + "username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "role": "posting", + "message": "unicode: ñçü 漢字 🚀 #10", + "digestHex": "af54d5b3e8b1813d75eae93d35bcf4393716b4c7764c55ee7dcdebc81fabaa76", + "signature": "204fe779cdede8fe59ba339c4bf67f81bd469c867ca027556ae5c37706236f7b6d27c9fb2105a1f58232002dd2df8a8711108262756685cade9b6ba30500acf6ba" + }, + { + "username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "role": "posting", + "message": "unicode: ñçü 漢字 🚀 !11", + "digestHex": "b99fb75cf8ee96bcc5ba6aade610d87fd6b5b97ac17de66dd8befbed2eec9415", + "signature": "2057afe43190c006505c32c0bd98e075f5ca89097f56dd97d10f917085aa8712fc4728d304a6bbadab7296f0a15675ef7716edece4987f533cb866f49c3ed6fbdc" + }, + { + "username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "role": "posting", + "message": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "digestHex": "44f8354494a5ba03ba1792a8d3e9c534c47a9181980fde7a3f44b06ef2ae7c7f", + "signature": "203ac1e04f50d6fa6695014c4848b513aca168b10c10055690e56bc217ea6f0c86692df480747d16af7e52ffde0fc0d41934e771ca8c704dad0c4096ab7255a99f" + }, + { + "username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "role": "posting", + "message": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx #12", + "digestHex": "41c8316357e697e20ebd4b6f4c4d4f259832db11952f2ccd2746baca22c67260", + "signature": "203208e1a77758f459184bda9c435b078611de668e1fa18022d801f27163c044f611f0ed89b71d96da27f93f64946098d4db45cc87c948e39c64faa5f4006d3d00" + }, + { + "username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "role": "posting", + "message": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx !13", + "digestHex": "06bf69eade94b7781a3974ed73585829ed1ec6bfc0b213c0257b03b1186e0db4", + "signature": "20516b5f93f83b2f035e0ce272b8701b319db4e53c7832901150645f5049e94bc9635a106120b9eba34a1e4886898b92cfd97ce82008767bd19f44d8dfc7f6cee9" + }, + { + "username": "bob.tester", + "password": "hunter2hunter2", + "role": "posting", + "message": "hello world", + "digestHex": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + "signature": "1f20593cb7f1fe9019f9e6345efc025fdbd8ef89749e7a99eb16bf26ec868670e51ab5a0a4b3ee8796d2613ce4103c3aa5554ee6b4a8260cbacbf58fac8a4c9e71" + }, + { + "username": "bob.tester", + "password": "hunter2hunter2", + "role": "posting", + "message": "hello world #14", + "digestHex": "6dd54b39bb764a27370d88f11d97792a7866dbf4eeb2ad3e90bd9510bc977ab7", + "signature": "1f7d9bddfd016e07e6183653e501390ee4b3b581b3d03aef410eff3bfcb7e29d2869cda1491b5475699833abc77051ca29c696f82e0125f4db1aa7150029cee2c0" + }, + { + "username": "bob.tester", + "password": "hunter2hunter2", + "role": "posting", + "message": "hello world !15", + "digestHex": "6c6183f526faf001fe1a67d413f34362f1ecff46d8e47c7f0a0eefb8ebc64bc5", + "signature": "1f69d85ff80db913bf5c91c2e3688ce925c96c8d89ef2702ea782efc2002b213c45b6d6375a2cdaa25bc764dd85c20f02ef95140a4a2f3ceec51a3f07b1db38746" + }, + { + "username": "bob.tester", + "password": "hunter2hunter2", + "role": "posting", + "message": "", + "digestHex": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "1f5bbc8a847b6a13adaa92f482b789f216d90a1fc97ec4c08c9c7cc9f8b0043b7c60d2ac45c36ba93173d71df3f7ec055db0c17ce37b58aee4db53c83e6f615c64" + }, + { + "username": "bob.tester", + "password": "hunter2hunter2", + "role": "posting", + "message": " #16", + "digestHex": "db41f5e263f1a2e50bbabdd225d6968de26d7b70b142eda674f6ec95db3d5203", + "signature": "206869f38bfdd7f5cb45283553875793e8b529c85e620e5d1d8abf870d83f9cccc36d65de82267fe9f01df2d7d5471c89b3b091b201ddae0d22b83fc89176bfc75" + }, + { + "username": "bob.tester", + "password": "hunter2hunter2", + "role": "posting", + "message": " !17", + "digestHex": "f5d64836ef858ddfad53b1d511c42040f21bac596e6e1bb06ac47ae7cfac5341", + "signature": "1f520c677ddc698b805d3294b2c632ae17ded76272f8b9523dd77ed6f1ca261be7199618ef5d3b9fc112685639cc953fe4c717e8bec27200b8dd88cb9a13cf360e" + }, + { + "username": "bob.tester", + "password": "hunter2hunter2", + "role": "posting", + "message": "a", + "digestHex": "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + "signature": "1f5722c476ab19fd282419d7e0b37a3c007a4506655b17a39d232d32463b6475313a34ceee8db51997fd310432ade7d1d3ccc35c404dfe153d0d1c7a7a672a0a32" + }, + { + "username": "bob.tester", + "password": "hunter2hunter2", + "role": "posting", + "message": "a #18", + "digestHex": "a2f98ef3ba88b31a6ec0ba0402068b6392f57d9c83569e92e02c9f399063c100", + "signature": "1f01453c1eecf99d5c8b7e41d16fdc3dbf10792f89451f2ad91ca7748494b6428f0c548c6dbac22f978c980747802046c4fd207b19a2a0ddf234b73b41b6194822" + }, + { + "username": "bob.tester", + "password": "hunter2hunter2", + "role": "posting", + "message": "a !19", + "digestHex": "974531237fe14d6ecbeac879ff55c64250ea687b7609a123e87019eee464659d", + "signature": "1f08af291d2eefdaeb717addb6d01d76f2fe32a01ab657ccaa0ba3bdf5916350ef7aa0982acaf0f0cbb64a7e85699e5e8d0fbeef6bfd4366dc3b3dced52c05f328" + }, + { + "username": "bob.tester", + "password": "hunter2hunter2", + "role": "posting", + "message": "{\"signed_message\":{\"type\":\"code\",\"app\":\"ecency.app\"},\"authors\":[\"alice\"],\"timestamp\":1751900000}", + "digestHex": "5290d7673a06a74f856d8d142764739972d85430654b23fff2c5c301a2a718f5", + "signature": "1f5d8020539058fb9be5e8f63310b665b9ae6c43c984d3f0f6dabe4420944edabc0c0e6a8d8d26d026edfff8e0e2c6029a17e4fe5d21d71d227212ad25d8f56570" + }, + { + "username": "bob.tester", + "password": "hunter2hunter2", + "role": "posting", + "message": "{\"signed_message\":{\"type\":\"code\",\"app\":\"ecency.app\"},\"authors\":[\"alice\"],\"timestamp\":1751900000} #20", + "digestHex": "514e48d230152eaf2565381c65a580fbc04d62e75f4a52ad2435c6eb29c7fcbb", + "signature": "1f2a7c6a0cf39515762755a3403d7fdf9d72aac998761acacdf38c429a525ae1991aed35491c5d6b16799150bca0def989162435246a2987d43682b2cf439ee30d" + }, + { + "username": "bob.tester", + "password": "hunter2hunter2", + "role": "posting", + "message": "{\"signed_message\":{\"type\":\"code\",\"app\":\"ecency.app\"},\"authors\":[\"alice\"],\"timestamp\":1751900000} !21", + "digestHex": "4a995ef75411f11a50975e3be478446121cd9ba0304ec0a313a0aa03c61097fd", + "signature": "1f53895db75f607c5cf946db542585956757256269605eb37d7f293bdbba313e057886b5648c7e4be81c0507e4f7098ad98c6fdf548bc8003aab989379b0b94bfd" + }, + { + "username": "bob.tester", + "password": "hunter2hunter2", + "role": "posting", + "message": "The quick brown fox jumps over the lazy dog", + "digestHex": "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", + "signature": "206db0e2c4d7bfd17a103ee38dfb7d825f17870597fe7105e564bd6ee2b211f9480dae1f17babaa9c7f8a89e493c2eaf6c792305f49a90ea83ea25261ef430897e" + }, + { + "username": "bob.tester", + "password": "hunter2hunter2", + "role": "posting", + "message": "The quick brown fox jumps over the lazy dog #22", + "digestHex": "45c224cab8b1a0f0484a70f5fbe2a90b9e3543ebf98c57a442c5c4530516aa43", + "signature": "205706134c4329c22c12c8b6ea2503164c9a280c5288c5a49bdac86869aa21f6f122ffcd92b1a2853acb3486f2c48332b0cbae21fb7ee5b98ae93453046574eb70" + }, + { + "username": "bob.tester", + "password": "hunter2hunter2", + "role": "posting", + "message": "The quick brown fox jumps over the lazy dog !23", + "digestHex": "10b7d0c13d8c8048e53831cf5cf2c1e4a334901bb11b7131016da4b2726cf344", + "signature": "204f714d412db4fc3ba6ec71043ba448031bf80dc11bed15646fd866b8ed4782361ec3ddf13aeef122b268a6a8241cda459a1acf7ed5bbe64308d974af938f8679" + }, + { + "username": "bob.tester", + "password": "hunter2hunter2", + "role": "posting", + "message": "unicode: ñçü 漢字 🚀", + "digestHex": "417874e185288d80e951402e73559f85f3e8e589923e5ab868d7116b4b8859d5", + "signature": "1f3cb1bd9a1a8bbdc08446cdf583188a10863e202e69b1956ec248b37cf3d46ef46dbdbbd0987523800af8973522218c477cd2deaf97387b4b0378fcf62fd20bac" + }, + { + "username": "bob.tester", + "password": "hunter2hunter2", + "role": "posting", + "message": "unicode: ñçü 漢字 🚀 #24", + "digestHex": "4582569c8a1460bcfce4502764a916ae476bc530654b27f32867ffd07a2b4ed4", + "signature": "204df36377f68b40b757e909bddc11eb5b24211e1228067f3e21a8c0b4ea2ba12d55ca083589fb0979e70594268f2c6a18b8911c3c7d56673e088415c31268788a" + }, + { + "username": "bob.tester", + "password": "hunter2hunter2", + "role": "posting", + "message": "unicode: ñçü 漢字 🚀 !25", + "digestHex": "414922d2b94823ff23f3d1401383d320cbccd33d7851b6aa1c3c4c95a6ce11aa", + "signature": "1f176564190ef192ba35529a980ffc387143ce40f7f3e8f0aa724241cf83a6fff124247cac3a4109ff0b6152a8039b23fb6a2538b166ff4437e0609498dedb942e" + }, + { + "username": "bob.tester", + "password": "hunter2hunter2", + "role": "posting", + "message": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "digestHex": "44f8354494a5ba03ba1792a8d3e9c534c47a9181980fde7a3f44b06ef2ae7c7f", + "signature": "2046322d317af9258c0c7bd79e128614ce78e5bb4ce4c1bd298fd960b42d6467d4070805677c1c1667c37a15c0271bafe7c819bbcfe0b9379da60d9d90096a486b" + }, + { + "username": "bob.tester", + "password": "hunter2hunter2", + "role": "posting", + "message": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx #26", + "digestHex": "f9cff37b6e0114d9b55f379e3f3b1dbdca77dc11a20395728f69ab981df4036f", + "signature": "2041cb0c6dfc15724954f92556745325d1e9a470857d00bd3a9115a7721954b5c332fd84b377857ffde3b78a42456773f42e006df3593c02db788e224547874810" + }, + { + "username": "bob.tester", + "password": "hunter2hunter2", + "role": "posting", + "message": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx !27", + "digestHex": "e8991505b7f414018feb528a74d7a992d88c6f6c23b35dd857ac332ee933eef5", + "signature": "1f6a13f2577dd08fa8b7428e9b4dbfe45ef4f6601d41060249a11ccfc31b79ed3e02f25d97851c2196d62af3917bb970d8b957e5bdf634f70342918236cc713ece" + }, + { + "username": "carol-x1", + "password": "correct horse battery staple", + "role": "active", + "message": "hello world", + "digestHex": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + "signature": "1f5306c75da2e4bcb74c54fce9812ca58451a36b7a68f35efaec161eccee83fd4f0c654902ab44f841bc5a0d1c2b15a25660edae54d1cc4bb6ddb981b2990df202" + }, + { + "username": "carol-x1", + "password": "correct horse battery staple", + "role": "active", + "message": "hello world #28", + "digestHex": "f83b66db34698acfed50a8f4372beb547ab839b77b151cfc5475ad8b16973eb9", + "signature": "2021d966b18e47033155add16d0f5fcc804f9d13e8c5ce5494b1ce3c12cde32726434767f9fb81c70d45947a5a15643484b64c3463bf0e1c5d879901429d60c1fe" + }, + { + "username": "carol-x1", + "password": "correct horse battery staple", + "role": "active", + "message": "hello world !29", + "digestHex": "00a9f67c46a9bbd7d201d9e0301d336cec1904e928b3944e76d455b42fd1865e", + "signature": "2051db6df5c9ee0d4a0bd0cf6cb74b920444c97de83c84dfb0a33ddb82ef160a5a4c4ec337f5b3c55fce769f2c5517a362860e5564678dc2f4b926ada47aba7f1d" + }, + { + "username": "carol-x1", + "password": "correct horse battery staple", + "role": "active", + "message": "", + "digestHex": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "2016f2fa1df03d246a6c0487ff00ade4c981d23891e924fe04fc70e5913fdbc4264b6dd2d942896b9cb28596deb1d8eda8b5162bad111cf4b38b3bd7e5d68e9500" + }, + { + "username": "carol-x1", + "password": "correct horse battery staple", + "role": "active", + "message": " #30", + "digestHex": "85306218c2468a84c25ec4c09af45f8117dd3da7d187085111dedfc742b42a13", + "signature": "202555c317417e94d41b04b109efc70cb3abf1d5ed78868e8d95eb9ac6cd844ee3312c3c6da2db2af1e8b3a34031af3fbd35fbde056e4d2f4e6a30bf82eb63cff5" + }, + { + "username": "carol-x1", + "password": "correct horse battery staple", + "role": "active", + "message": " !31", + "digestHex": "d1820f6a075d8a7156b7287fa188b3cb4857f6b1142e8912f44088476226f68a", + "signature": "1f32f802e5d79fc2ea04f33c27702e390378687925496bfef15a1dad6a73c4a5692fd4f2c7e522488309128ee4b48507f651cddcb7d87d40e4f1e998157f528747" + }, + { + "username": "carol-x1", + "password": "correct horse battery staple", + "role": "active", + "message": "a", + "digestHex": "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + "signature": "1f7d203f29ffa57354a8cf4542606a8b7de349f7ed37f893b9e3f8d4d7b62595692f3139f4c49da67a172297093c070ac771d09c254a26a82367423841ab83b746" + }, + { + "username": "carol-x1", + "password": "correct horse battery staple", + "role": "active", + "message": "a #32", + "digestHex": "174b8cbfb610764b18d4e3c93c0fd0def1e1d0335b73574dca60282988479d49", + "signature": "1f1da3092f5a4d726d5f9d31d927dd9f9a00e06dffea223957fda2efb79a05472071ca45e46e3ffbd0271a72589d67fb863b5e9adc14f2a2086600b3280f9d2901" + }, + { + "username": "carol-x1", + "password": "correct horse battery staple", + "role": "active", + "message": "a !33", + "digestHex": "18c7927be91ccbdbaa5246ec9537b0c1b57bbc549acf4c7d5d37579f26d29d64", + "signature": "201aa8e83e30ddcbd1f763e30b833757c39519a5808a0f194cf44e72aff17db6b7050167176e00291982831f75c55ccb4f98edc04220f626e00adb58dee442544b" + }, + { + "username": "carol-x1", + "password": "correct horse battery staple", + "role": "active", + "message": "{\"signed_message\":{\"type\":\"code\",\"app\":\"ecency.app\"},\"authors\":[\"alice\"],\"timestamp\":1751900000}", + "digestHex": "5290d7673a06a74f856d8d142764739972d85430654b23fff2c5c301a2a718f5", + "signature": "1f4c2315bec29094de6155e9cfe2bc4f874826a0d7a75ed312d1420f0a385615582723887f9bc718f523979c933f0fd6c85499bfac3e1a9b38eb489a5a51bd6698" + }, + { + "username": "carol-x1", + "password": "correct horse battery staple", + "role": "active", + "message": "{\"signed_message\":{\"type\":\"code\",\"app\":\"ecency.app\"},\"authors\":[\"alice\"],\"timestamp\":1751900000} #34", + "digestHex": "4bc618ec17a96e2faf4d15ef2d2fe7c3ca8e66c6abd78a11bb2fe30719854a85", + "signature": "2011c0e34694112cac953ab81ee390ba772731d5ba02999bfadb32d1ae3c6d765103a2e8465aed53d70ac5311b0a9c709e16cd6d5d2ba65e6cb007136ab948714f" + }, + { + "username": "carol-x1", + "password": "correct horse battery staple", + "role": "active", + "message": "{\"signed_message\":{\"type\":\"code\",\"app\":\"ecency.app\"},\"authors\":[\"alice\"],\"timestamp\":1751900000} !35", + "digestHex": "fe7c2782f240b38b52337b0fc6dc49b2a88efcdacc3c21d5ec5bc70ad48571e9", + "signature": "1f09e540971775a1265662936e1dd2cda28dca8eee06868790b08381f117bc476410e576a8820341d20d47e9cd595e336106d4bee34fd1cc5dcef23fd0736f8d4c" + }, + { + "username": "carol-x1", + "password": "correct horse battery staple", + "role": "active", + "message": "The quick brown fox jumps over the lazy dog", + "digestHex": "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", + "signature": "1f392b9f5c43d052f2e734ae3a342066af8293beeeb7b3c75cc0a22c41c4b54e6642b5f55c96a901ee476a7b48f623773d387508ca61e4aea75a0cbe3c478b5c81" + }, + { + "username": "carol-x1", + "password": "correct horse battery staple", + "role": "active", + "message": "The quick brown fox jumps over the lazy dog #36", + "digestHex": "c65b675f11086f93e4d7f46ceda0a2d523aa652d6c1674a95ba69d8f4b6eac80", + "signature": "2029bb41cda162dbb4ea562b597b5733f93be46ca82a4a35d4f1b18fd548a258f3656a3f1a537b5bed336c3a05a0c3e5620b989a0b364a2832191766f48c44b869" + }, + { + "username": "carol-x1", + "password": "correct horse battery staple", + "role": "active", + "message": "The quick brown fox jumps over the lazy dog !37", + "digestHex": "c46f0e9ee15c2a11416a774df378f4a700026ef1111d75c834b853c53cc404a3", + "signature": "1f0b711795214a81dfeccb331ebc4ece84305d78cdd23d52caf5127face8fad9b37e807ef98a5117c024f9e5deb069571595ff26c0f178cd595d41907b77be4012" + }, + { + "username": "carol-x1", + "password": "correct horse battery staple", + "role": "active", + "message": "unicode: ñçü 漢字 🚀", + "digestHex": "417874e185288d80e951402e73559f85f3e8e589923e5ab868d7116b4b8859d5", + "signature": "2021cb731980c86ae5517f6a951889d0d7e8cdcea9ce42e8d0a4ea32ba067f2ace6d0c370ce4c3ed0a54199ac3b18ba35ee8a7e3bcda79fa7225aad8c6de97d221" + }, + { + "username": "carol-x1", + "password": "correct horse battery staple", + "role": "active", + "message": "unicode: ñçü 漢字 🚀 #38", + "digestHex": "7b3ff2bb9b66c47ffdcebe6fb17cb8d975222dbc715a9391c1280f2d3f9e8c1a", + "signature": "201cb94e2643e075f8eacc5089d7e750befe226eab822b940441ab5a0cd660326a4eda0ae8d118b19747d358852dededd0ad190103ae7147b2189cdadb5119f5cd" + }, + { + "username": "carol-x1", + "password": "correct horse battery staple", + "role": "active", + "message": "unicode: ñçü 漢字 🚀 !39", + "digestHex": "826b64e58cc2ea8736e4c3721e41a35be327ca0169b2c61110a408c31132ae44", + "signature": "1f71709e55250ef00d2464e62a1127969bdb178986235c3165827092c99968fd29427b5e33fa985ac97014a49259d944cc4d3b5791f1260a0d6cbbef008f46a994" + }, + { + "username": "carol-x1", + "password": "correct horse battery staple", + "role": "active", + "message": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "digestHex": "44f8354494a5ba03ba1792a8d3e9c534c47a9181980fde7a3f44b06ef2ae7c7f", + "signature": "1f0e161c455cab2ccf3d8ce8eda586ccfffe4e6ec5c568ac069bf624a71945d598594667c64a22d8064684055b4d3a414f6662d515636905483bd15bcdb2b62ade" + }, + { + "username": "carol-x1", + "password": "correct horse battery staple", + "role": "active", + "message": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx #40", + "digestHex": "6cd8001fb780d3f2b767612a6387451dc90aa3a71e86c9c8ec5ba04f258e6a7d", + "signature": "204e7a9cfaa05525122c9f88815eedaaa8712f92abcf2e1ee692ce8a69cdddaa0b285eb4daa1fb29cf1dab7dcfebdef22cdac93370fb08fbd12de6f59a99488ca7" + }, + { + "username": "carol-x1", + "password": "correct horse battery staple", + "role": "active", + "message": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx !41", + "digestHex": "13a2857f386749f9e4fbf399d8d544d518b8f0ead15170508a4227c3987bec50", + "signature": "1f6645271241e0e29cb4944b78ae109d582a96af6b1d2c07ba41b3cefe2a69489261deca73e186c479a7a98cdc2baf93fd40198aab40a4c3843c0dfa3df20b4075" + }, + { + "username": "dave", + "password": "🔑 unicode pässwörd", + "role": "posting", + "message": "hello world", + "digestHex": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + "signature": "1f48f79140aaf2ecd4c4d87ed6908a7dbcb5a86dd9cb3107e2504daeb305abcb6a02614ab510d73c93fd3a09e9e7b664c459b56ec267b0e20ee070d177b7c495d9" + }, + { + "username": "dave", + "password": "🔑 unicode pässwörd", + "role": "posting", + "message": "hello world #42", + "digestHex": "5d98f13aaa7f996ec6dcb11f1d44663ca9023dd5aa6a4e14d7121fc6d0f5349a", + "signature": "200a8349f3d992a1a1e047a3fcf6b0930d116a6399c21279bf8955d43e22d4ebf62ec2ec6e401b36158518d88f8ba4cea2db75d57183d8ee0d1f56b8d662db2916" + }, + { + "username": "dave", + "password": "🔑 unicode pässwörd", + "role": "posting", + "message": "hello world !43", + "digestHex": "951f1aec600c4672462e460049996b86b7ca67f4c4b03c8dcb16bb509f9b9a35", + "signature": "1f29cd5b26c632cece6127d64af55365c0a0be9fc4791832609234c76a225e08915c5f8d61d81aea5d95c282ebc880cc4dc31a4ff7b19d51f71cc5ef56ed120304" + }, + { + "username": "dave", + "password": "🔑 unicode pässwörd", + "role": "posting", + "message": "", + "digestHex": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "1f2a0458ce0837dfc78d7653413d4e4e951ac6bc237111bb64fdff6ab41194a224425e423170531b4923e0dac285bb88a01478cb873cae4374124804b28794f169" + }, + { + "username": "dave", + "password": "🔑 unicode pässwörd", + "role": "posting", + "message": " #44", + "digestHex": "d45d36342c6788e00c3a1b929c240aaaa26bd1d9b6344e44edf467a120a7bb8c", + "signature": "203145c34d5bc5f1b7611f7d00e62005cb0de66175d02672ba3924a5dc612a5d870e09058e670c7d7496842a5a2474e9c2376c17a509ad2cf47dfc4270cfaec5ff" + }, + { + "username": "dave", + "password": "🔑 unicode pässwörd", + "role": "posting", + "message": " !45", + "digestHex": "01a058da791786f2fd4195a53202bd73ba451ebb27117aee90aeaa089118010f", + "signature": "1f4db64e3867cabaf1696ed4498a547e5f26ee0ccae3341f3a8e5fc95504de784e4eb832081b666f335bf421646f9cf37d791b612bc50861438d1155c50d08a325" + }, + { + "username": "dave", + "password": "🔑 unicode pässwörd", + "role": "posting", + "message": "a", + "digestHex": "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + "signature": "1f10c2a4f5ede530181680066c708481a23ed7bc8c6b5f0d6b28d5c72c811aa36d38267d84a28099b8c60f594ca88fb4b5aa7326f700bb7941d795827ee814be0f" + }, + { + "username": "dave", + "password": "🔑 unicode pässwörd", + "role": "posting", + "message": "a #46", + "digestHex": "3faf5c97943231cf6a9a1dc4bbc044d359a877d94a1b1733282956b8be97dcab", + "signature": "203f5b7e914991a687ea4b25f6180e90435949b818c2c13e65ecb983fbebdf63fc76ca3bdbcb43af211ce79356dc4ae4226b4964e2a2377ccc35dafccd61a6ba7e" + }, + { + "username": "dave", + "password": "🔑 unicode pässwörd", + "role": "posting", + "message": "a !47", + "digestHex": "a9ed276aaf65d9380c6a793c64d3e572685cece16f1a771a59af7984244b0f38", + "signature": "205e2ec8f28d3097ab0d922e3494e97127d74796d7a75fbfd552a5398569c17b4b0cf7566df934e94377daf46d88088b90bdfed0d6b34c7964925364ed35aaef73" + }, + { + "username": "dave", + "password": "🔑 unicode pässwörd", + "role": "posting", + "message": "{\"signed_message\":{\"type\":\"code\",\"app\":\"ecency.app\"},\"authors\":[\"alice\"],\"timestamp\":1751900000}", + "digestHex": "5290d7673a06a74f856d8d142764739972d85430654b23fff2c5c301a2a718f5", + "signature": "1f2fc69acce29f613f9b489f72f44d216a9c8f0fd63818a7291b15e9245a824857310768655216b18407dbdd9089e5a78fbc5dcb150ecbaff69ab1fd2390edab9a" + }, + { + "username": "dave", + "password": "🔑 unicode pässwörd", + "role": "posting", + "message": "{\"signed_message\":{\"type\":\"code\",\"app\":\"ecency.app\"},\"authors\":[\"alice\"],\"timestamp\":1751900000} #48", + "digestHex": "e575430eb5eba49ca2ab2c1734d5ce334ba5b36b6f636a5fb6cf54f21a9465bb", + "signature": "1f78493fa34aee5ea5dd7f0f7b41b6d2fbc789192d6af3ff0698c4d6e38aa42e4c00bde77c050618717d774f93f2fa55c536ad6e1baec7fe339ecb5530e07fa7cd" + }, + { + "username": "dave", + "password": "🔑 unicode pässwörd", + "role": "posting", + "message": "{\"signed_message\":{\"type\":\"code\",\"app\":\"ecency.app\"},\"authors\":[\"alice\"],\"timestamp\":1751900000} !49", + "digestHex": "7655fe1203d0b67aea73034b1c4245f576fa4c9bad09483647a213371790e67d", + "signature": "1f1cf4be3dc60e72ad3fef3e7d4fade1d62c7b577f0249d9fea126cb03d7ddddde4a401c27efa26e52c65c4239aa9c32c080a73a728deda0b1317864a4d54dae4a" + }, + { + "username": "dave", + "password": "🔑 unicode pässwörd", + "role": "posting", + "message": "The quick brown fox jumps over the lazy dog", + "digestHex": "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", + "signature": "1f78bafee7c0c3ccea25afa89bd6181526cb39c587cca41c101beffeb16a571f723793f4383fd6f31f40e5162a6cf562cc61305bfbd2c27a5a91fb528ab98931d7" + }, + { + "username": "dave", + "password": "🔑 unicode pässwörd", + "role": "posting", + "message": "The quick brown fox jumps over the lazy dog #50", + "digestHex": "655b726a2ef301dda26107863573a5e719a68da8bf1b24439eb857e4aeec7d52", + "signature": "1f06102c981afffe0f8112bc971970d0fb2f16b31099a9c040ff856f189f7cc1cd4fb448e0a700c16f00b0adf8ed4d0466e1c8c05ae7631d63cd52ed59316967fa" + }, + { + "username": "dave", + "password": "🔑 unicode pässwörd", + "role": "posting", + "message": "The quick brown fox jumps over the lazy dog !51", + "digestHex": "4bc7e2eeb7c3c5441898e7e5fc0fce70e003175fd7a3089a7771565c46e9e004", + "signature": "1f23bc8976839a01a19f14d80dd1e7554a4ebb7ae56535eeebcb6790eca7124fb71a883b318bb7d95d6b8cf8b3d4de92f94ddfcff3369464eb6f9d84bc124e12e7" + }, + { + "username": "dave", + "password": "🔑 unicode pässwörd", + "role": "posting", + "message": "unicode: ñçü 漢字 🚀", + "digestHex": "417874e185288d80e951402e73559f85f3e8e589923e5ab868d7116b4b8859d5", + "signature": "1f4ec788115dcd598db10c263a3bbcd5d042d4629e99a9d7e5a35da2e7312f637b40d60b6fa02c76b4a885808cf5f72a7d075549fb25e8f7b3e850b96c44fc8630" + }, + { + "username": "dave", + "password": "🔑 unicode pässwörd", + "role": "posting", + "message": "unicode: ñçü 漢字 🚀 #52", + "digestHex": "11e8ed75cc00f209328caefe7a70a13612600bd2d6c7aea8ea36f1d0b7fb635d", + "signature": "1f376136497294ffc660b699bfb3236a4f087692f75afedaee3cbabc0167b115ea56fd574811d19ceff67bb375344d83c6ff98873381153edd39ee997f7b438f30" + }, + { + "username": "dave", + "password": "🔑 unicode pässwörd", + "role": "posting", + "message": "unicode: ñçü 漢字 🚀 !53", + "digestHex": "ba29a9e86ac07b4dae7233325a1534aac1acf1f84b3fc81d2c6d1a1521143251", + "signature": "2018a4f6b424d850002a89d58e5fe000f7a60f915d8d106072e3fee27f616acd9022e30ec9677b34d99a857e471c102ec2ab6d1971091fe9a0e884e0ec2c18d99c" + }, + { + "username": "dave", + "password": "🔑 unicode pässwörd", + "role": "posting", + "message": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "digestHex": "44f8354494a5ba03ba1792a8d3e9c534c47a9181980fde7a3f44b06ef2ae7c7f", + "signature": "1f3c5e4dbcdcca854ca0656bd838d5ee8dd691ee672333219d46621beff30bd4a92bb7e28703deec9ed3cebf8ac1a18cb51f589168caaf26e09e8079ecda8b929c" + }, + { + "username": "dave", + "password": "🔑 unicode pässwörd", + "role": "posting", + "message": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx #54", + "digestHex": "8f4d9ed6da95eba5d9f4b16614e849092304925bdcdfea07fade0a950cfb0094", + "signature": "207cd81c76b63e7a802441d1da9947285ae87731aea4d41c7d7a065cf17f2064eb1542b6de1785f4e776ab8dcf7e565d93173290b9c9cc960b7713a720a92d43f4" + }, + { + "username": "dave", + "password": "🔑 unicode pässwörd", + "role": "posting", + "message": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx !55", + "digestHex": "bdbacebd72cf7462eb5513f241e7b8de488727dc750526f088f227c824df8db1", + "signature": "1f6eea39460f839ea212511b7fdcbdc52eb77a1b1968ac15a0548364d15a1640b73e97b63ccc9ecc9f96d5a686249a9c0af3b344593afb6c781d961727ad017623" + }, + { + "username": "e", + "password": "x", + "role": "memo", + "message": "hello world", + "digestHex": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + "signature": "1f110c4c2388a9b7d2be01c47b6a848015da32e13bfaf76b4f0c32ef7dd88b718d59b33339b137f1263f38bf8e43ea2d8769c9fa112347530f5921fd364af328ac" + }, + { + "username": "e", + "password": "x", + "role": "memo", + "message": "hello world #56", + "digestHex": "4fc3bd2a9ddf672e80f659687aaa1964d97339b16e6e2d72ef093631893766b0", + "signature": "1f5c485df12df880e83fb02878393c4a275d2b0dfefecc4d747729ce7b908cefea774f3aa988dfdf11d2336cfa80328f894da004f4b0e671f3d25295b449bfda43" + }, + { + "username": "e", + "password": "x", + "role": "memo", + "message": "hello world !57", + "digestHex": "3f1d00ba1a1c289d93be4d6aaac3387f99c0427360d25267b2b9d8b946b26985", + "signature": "2044aabc63cfbc7de854a7b056f2fbc52044670529656f5cb7bbd93ad300b32a6f03dfe8ddf8141ad49524b8d8b2686393a16fbc560401359b9bf0716b1e85e378" + }, + { + "username": "e", + "password": "x", + "role": "memo", + "message": "", + "digestHex": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "201d55918214f0400606ceba6093074aace028a2b76fb9c3b15f8d385fe1830e6d60d6ba98cfaa9c28d7c458bad3de5db34dcf0e3c8bad6b876c6afeda46700e68" + }, + { + "username": "e", + "password": "x", + "role": "memo", + "message": " #58", + "digestHex": "a0a9320f7ff21a2a661c31773b42757632ecff1da618d53de3cad4fe8011f5b5", + "signature": "1f13938b7e1848560ea73d90981392bd90dd87298ea94268244c6f3d2d29e9e55d3350fd9091f3bf38e09c0206e0b7c6308f106a9ccb498b3e63fd5df442b9301a" + }, + { + "username": "e", + "password": "x", + "role": "memo", + "message": " !59", + "digestHex": "f287049f60b7241f7d6eb0a26b2e98413911a4800584f493032a22f9a30dd3b2", + "signature": "2058445387b89b6527c3a08d2f40db89ecbd55a22e8fe6cbf842ef4fce7b2de11e31ef09095cf36bbe3a7365734c9fa875a7f5260b9c0566d0cbfd89d6c2e77a71" + }, + { + "username": "e", + "password": "x", + "role": "memo", + "message": "a", + "digestHex": "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + "signature": "204d0b23fe68cc576899c732e59be6b30c44dbcf0306382d530bc5219a65bc8ea02935075a3b580a23c1357e247e5ef4ef8efea50520488af40329075d4ef224ed" + }, + { + "username": "e", + "password": "x", + "role": "memo", + "message": "a #60", + "digestHex": "4a84a918b3ef6f6468af90c1091ee0025c7c1a208e390f69725b02c6eb50b592", + "signature": "205fc32b90b38498c4de13afdf3501f3b50071c023d3ad6e24524f906a727212a56d02a8fb7091d5fc02a5c07e42aad8256ee3f3d96ee9dbd7d630ea6fc3c144fe" + }, + { + "username": "e", + "password": "x", + "role": "memo", + "message": "a !61", + "digestHex": "1ff57f98976766bd8b5ca7aeef51a3ef30e89c8d8e48bfce20edf36f75e23d48", + "signature": "2054f78a8619cf9afe8491ef601fa0e2d076ad86894af0b500be5dcad5f5f83e2356f3c4d46fcfecc1a8e70719dde4e70ee90f6abab859f2bc26d284ca71db68dd" + }, + { + "username": "e", + "password": "x", + "role": "memo", + "message": "{\"signed_message\":{\"type\":\"code\",\"app\":\"ecency.app\"},\"authors\":[\"alice\"],\"timestamp\":1751900000}", + "digestHex": "5290d7673a06a74f856d8d142764739972d85430654b23fff2c5c301a2a718f5", + "signature": "1f74cc783cb04aee10d530ed8808b057a509423dc563995a723a62505e80ff8ed652105c7bc296072b3e6edd938caf0cf6b03be12bab2b57088240a998e5c3bcb2" + }, + { + "username": "e", + "password": "x", + "role": "memo", + "message": "{\"signed_message\":{\"type\":\"code\",\"app\":\"ecency.app\"},\"authors\":[\"alice\"],\"timestamp\":1751900000} #62", + "digestHex": "b45556b5d474baa3ee44fdaa9629a5930d71f5e106c9554b9080754f4efaa4e5", + "signature": "202f915ceface1313cfacc2c908bead87cd8339d85d1fc78739fb7b9db021aeba960b1a98e591fef37a6930967e85bc7bac0997d2a5e05758f7e1c72e4e08668ec" + }, + { + "username": "e", + "password": "x", + "role": "memo", + "message": "{\"signed_message\":{\"type\":\"code\",\"app\":\"ecency.app\"},\"authors\":[\"alice\"],\"timestamp\":1751900000} !63", + "digestHex": "7d6140160cf088b70f699ddfe1ddbb78af7710abf0afde1df4c5c0e885f78818", + "signature": "205580feda9eb5c8dc77ef3cde783702bfbd2159cc642f1f24a3514694e33f8fad533aadcb87dc422a21347e3886fd121bba139f773b97b8bd40786ae4567a25ff" + }, + { + "username": "e", + "password": "x", + "role": "memo", + "message": "The quick brown fox jumps over the lazy dog", + "digestHex": "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", + "signature": "203a28345fee7cc88d7a9aa448ce8c4b66f023840c672c5fc61a28b4534debb82b504f08a30854b38f678d1d4103d4bd044354906e749d7776f5c6eefe4b654471" + }, + { + "username": "e", + "password": "x", + "role": "memo", + "message": "The quick brown fox jumps over the lazy dog #64", + "digestHex": "3cfab68af280dd09dfb78b5b0cb15817df32de85a620ceae5c8a264384f4df69", + "signature": "1f158263b4dfb6fc79bc1b623d1c50c2c209298790dada660ce8908b48789b0db74d91e4c44aeadca0c2556c46dbe9e4de8de1dd9ae4f719c5d021a95b02cf4247" + }, + { + "username": "e", + "password": "x", + "role": "memo", + "message": "The quick brown fox jumps over the lazy dog !65", + "digestHex": "d79269713b24414541d95c9610e0a0c771ce81db1e340b0e6768b955c106c9f8", + "signature": "1f2a380d65ab52c3a8ddfd70e732bae1c7cfd38ba2de4256ced95a5437e4963d883e03f1990be5e55e90a9e8b488fd435ec06a27eb1c6e91e07a34cae0403f6dac" + }, + { + "username": "e", + "password": "x", + "role": "memo", + "message": "unicode: ñçü 漢字 🚀", + "digestHex": "417874e185288d80e951402e73559f85f3e8e589923e5ab868d7116b4b8859d5", + "signature": "2019427e91fc4fd4c2bf7109c61cb8598c23a8dddaa6e21cc5df9664af90f3c58045c26a5c97c2f102e2536417d4b37fce317d1856415ee1842c8c242964340c4d" + }, + { + "username": "e", + "password": "x", + "role": "memo", + "message": "unicode: ñçü 漢字 🚀 #66", + "digestHex": "a3bc05b94e11c76c18d08c2ab19780e15827b2e75ad184c701e8f8713a296750", + "signature": "1f51d7195536738f578098aea5952d2582039cac233b1805c24d7beff0a40d80eb5d6b598ed67299a4c1823f0254cb2affca33c414b07eac638ecd61b037487b91" + }, + { + "username": "e", + "password": "x", + "role": "memo", + "message": "unicode: ñçü 漢字 🚀 !67", + "digestHex": "164855e8f2cac8c68bf25a63218e1f6926524521b6114ec4ae9eb405e5bbae7a", + "signature": "1f4be059711a86e5b858b68558cfbea8d418029c52b66c14a546989175d06a1028549889fb6e5ccb1cfdfe37eeb49541eccaee85283198ba7312d0cb75c18b6f70" + }, + { + "username": "e", + "password": "x", + "role": "memo", + "message": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "digestHex": "44f8354494a5ba03ba1792a8d3e9c534c47a9181980fde7a3f44b06ef2ae7c7f", + "signature": "200c41024a055df64f75a3c892e288af657a8d242411834f6b856048e8616062056024bdd516f2a69f2fd75ea44e614c28f1c78f46bdfb60217ec4f06958a87440" + }, + { + "username": "e", + "password": "x", + "role": "memo", + "message": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx #68", + "digestHex": "de9ceacd4e16c11fbe5faf64c2ef7852ec55655a09475b11291ba42d724758ab", + "signature": "2036b180bf382f9a4df503de54bd1a34c762640939da644efd3f3f566dd0f3c68170a54c9911d0f5ba539250a2e311fdd39d81d951f2ffee7d545c4ee4cecd3c1f" + }, + { + "username": "e", + "password": "x", + "role": "memo", + "message": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx !69", + "digestHex": "c639e68407dd0c947466d3c08157c34472fee9d044b64800d6b7ea2262b4e7e7", + "signature": "204f21f2b2a2013772798f57d879a62e489ee57740d95d886131f4f40eb0855ae10217d174287a9e48a72d6dda96eccd6229b07c459fb059dd03127685b89e788d" + } + ], + "recover": [ + { + "signature": "1f5087e3d69af1551d52a1eeccb42ecd5cee392b0f4a1091cd47e51d27dbcac1ae30287bb518d9b1371e54db311fbb38fcb5e655918c38e846f04f38900ac2add5", + "digestHex": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + "recoveredPublicKey": "STM6TBpMsQ4vFZxSTnfmsooJsYk8DtaTh5gyNdy9HRktH1ji7uCxq" + }, + { + "signature": "203fe2ba3928620892785d315d2584e99f42070275231ee62bed7a764c68b9e9cf1971d92e21def61b0bf4a804d7056030857e126db5aa1ca3ce1cef760be8a11b", + "digestHex": "d97b7931e531b14d2786feeff8f90e84be493978720361ee51ae1a650c4ee9f0", + "recoveredPublicKey": "STM6TBpMsQ4vFZxSTnfmsooJsYk8DtaTh5gyNdy9HRktH1ji7uCxq" + }, + { + "signature": "1f1d7f9c745eac65f049cf82b8b17f5d0262e9d42f72a086007e6766dc135022e0671d50964d092b847e2d141aa6d8a99722b62983246f47ed8afcfa663c8fe06a", + "digestHex": "65a0d86bc100e450fb604a3f5b410be11751ebb31086c0f37bca5dd9ca71ab55", + "recoveredPublicKey": "STM6TBpMsQ4vFZxSTnfmsooJsYk8DtaTh5gyNdy9HRktH1ji7uCxq" + }, + { + "signature": "20452964c0d837426358e508992fc7bcfd03c774e9756331d55aba1c74f87d1525290f059c774abbb72901093ca4faebcd5675af795f84b9c772b756a43cc54d41", + "digestHex": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "recoveredPublicKey": "STM6TBpMsQ4vFZxSTnfmsooJsYk8DtaTh5gyNdy9HRktH1ji7uCxq" + }, + { + "signature": "206e5f85386cde75b1c1d043cccee925dd2d3f23abed93f8e3d095095fe56dff9f7cc367eacfca4e1ca841fa19a0c711ab09edd78d1510d9b2b67ef440d6f16780", + "digestHex": "7f50aa8ce48d35bae64658f152f9176520f22e6b3a59c98ade2a3d78ec327967", + "recoveredPublicKey": "STM6TBpMsQ4vFZxSTnfmsooJsYk8DtaTh5gyNdy9HRktH1ji7uCxq" + }, + { + "signature": "2070ac3a1af4854ec86f278d0c9d0f7cb8e262c9f54f681fd084d15c2e8b7396ad5a673f73ae59a014606950a34624426c7087b794b4b347335ff0a0c954f338ba", + "digestHex": "9fdcd2492a8cb6f5a7e556e2044cf405b182cfc3fa38524b484dab1da32f28b2", + "recoveredPublicKey": "STM6TBpMsQ4vFZxSTnfmsooJsYk8DtaTh5gyNdy9HRktH1ji7uCxq" + }, + { + "signature": "205120c721da913ae4dfebd60bc3faea30b7d5b2d100973880d335fe68e18580674e12c49d25ab1cc3b1300fbc6c214cf57c488c2be3d79f54f5ee6066537a5a5b", + "digestHex": "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + "recoveredPublicKey": "STM6TBpMsQ4vFZxSTnfmsooJsYk8DtaTh5gyNdy9HRktH1ji7uCxq" + }, + { + "signature": "1f72cd1c1b8bf2c3a3e0806834908f513d76e313b9a7b133cb8e465db7d5fac7ea591346faee10c3121219bcd63f2aa0cc339646582f28049455fd2d45fc6ea484", + "digestHex": "76e9ec70978b746ace92950668a10f331fe0c7230a759d695ebef339887c639e", + "recoveredPublicKey": "STM6TBpMsQ4vFZxSTnfmsooJsYk8DtaTh5gyNdy9HRktH1ji7uCxq" + }, + { + "signature": "1f5c9e434d543c08c0aba3a6dc1bb67f0edaad7c8c7375158a7e78038af6b28f6c43d3bcbee6f470225e6f169852f667dabfa4770e3c56807d40e35f177d778403", + "digestHex": "fa9c27a53b5070181e28641c1917149c84a6c77005a5db6f375cf2cb055d2c7a", + "recoveredPublicKey": "STM6TBpMsQ4vFZxSTnfmsooJsYk8DtaTh5gyNdy9HRktH1ji7uCxq" + }, + { + "signature": "1f30c08268a34497eb7d0d2df06486cc64b36cd81b2e03a8de158d001409d0d98e7d9557c27e67da50fc22edcfa1f56f4b6e250d6931678f4278a8365f06c4bbe6", + "digestHex": "5290d7673a06a74f856d8d142764739972d85430654b23fff2c5c301a2a718f5", + "recoveredPublicKey": "STM6TBpMsQ4vFZxSTnfmsooJsYk8DtaTh5gyNdy9HRktH1ji7uCxq" + }, + { + "signature": "20032a89b60d4a67878f62aa03ffdb66ffcee15e14f5fc2898a7f33353429b93426e628044a9f68717048291967c437ca9af48862c2e28da78f1b86e3805f1b2ff", + "digestHex": "92ede1edf7367e963da936c2cf921e33b49c07050b6efa6f400b5ba86458d1a8", + "recoveredPublicKey": "STM6TBpMsQ4vFZxSTnfmsooJsYk8DtaTh5gyNdy9HRktH1ji7uCxq" + }, + { + "signature": "2019d04dc71c426032e815b700430684643feb8b3a92596ecdeb68ad1d4a72d55c360d632fb092abfd699af778c7ea521306cfb8bd251bc7e2bd65aa3c34055e2a", + "digestHex": "6efb5772fd7b46b0bebd455ac3b3888c297d14262d349a8f8764649a9b977f2f", + "recoveredPublicKey": "STM6TBpMsQ4vFZxSTnfmsooJsYk8DtaTh5gyNdy9HRktH1ji7uCxq" + }, + { + "signature": "203a80af111479bf4d196d30df89d76f68c78804794d9fa6aea357e2c68cc6a9a957f6e0bbaba3eee49325994f54f5ebd0ff188858f3cd6242fb789e93396a0d59", + "digestHex": "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", + "recoveredPublicKey": "STM6TBpMsQ4vFZxSTnfmsooJsYk8DtaTh5gyNdy9HRktH1ji7uCxq" + }, + { + "signature": "2052f20534ea1ea9bb5b8cba3eff2dd94bbb2f98387fe4685f7d9e5f7f742210877ef60492a352ca77db373630ba67b784bed83c17ae6199400cd8ec5d28f6723e", + "digestHex": "bb9d21b343f425e3f037f11fc3bdfdeb943560322635388c53ddcbbc0c7091d3", + "recoveredPublicKey": "STM6TBpMsQ4vFZxSTnfmsooJsYk8DtaTh5gyNdy9HRktH1ji7uCxq" + }, + { + "signature": "20486d3397f4b81a9e3fedac987c01349fcb5b5d471d7a8b9950a9e656d20ad9be48586edc8eb885f17d3de6a936606216c40373e76547b7ff9016e24bdafd0f04", + "digestHex": "1e876114cd3bfc3e392d520b2c2813e55bf522edae36a27cb19ea4a778abf993", + "recoveredPublicKey": "STM6TBpMsQ4vFZxSTnfmsooJsYk8DtaTh5gyNdy9HRktH1ji7uCxq" + }, + { + "signature": "1f7a0ff192ac4ce01c6ff80e398e4782337a4ca558140aabb2f94bccbbf150e3ce1eb7e53b58db44510ebad84e984f39e6412217fa41a506a9e32501391d0899f7", + "digestHex": "417874e185288d80e951402e73559f85f3e8e589923e5ab868d7116b4b8859d5", + "recoveredPublicKey": "STM6TBpMsQ4vFZxSTnfmsooJsYk8DtaTh5gyNdy9HRktH1ji7uCxq" + }, + { + "signature": "204fe779cdede8fe59ba339c4bf67f81bd469c867ca027556ae5c37706236f7b6d27c9fb2105a1f58232002dd2df8a8711108262756685cade9b6ba30500acf6ba", + "digestHex": "af54d5b3e8b1813d75eae93d35bcf4393716b4c7764c55ee7dcdebc81fabaa76", + "recoveredPublicKey": "STM6TBpMsQ4vFZxSTnfmsooJsYk8DtaTh5gyNdy9HRktH1ji7uCxq" + }, + { + "signature": "2057afe43190c006505c32c0bd98e075f5ca89097f56dd97d10f917085aa8712fc4728d304a6bbadab7296f0a15675ef7716edece4987f533cb866f49c3ed6fbdc", + "digestHex": "b99fb75cf8ee96bcc5ba6aade610d87fd6b5b97ac17de66dd8befbed2eec9415", + "recoveredPublicKey": "STM6TBpMsQ4vFZxSTnfmsooJsYk8DtaTh5gyNdy9HRktH1ji7uCxq" + }, + { + "signature": "203ac1e04f50d6fa6695014c4848b513aca168b10c10055690e56bc217ea6f0c86692df480747d16af7e52ffde0fc0d41934e771ca8c704dad0c4096ab7255a99f", + "digestHex": "44f8354494a5ba03ba1792a8d3e9c534c47a9181980fde7a3f44b06ef2ae7c7f", + "recoveredPublicKey": "STM6TBpMsQ4vFZxSTnfmsooJsYk8DtaTh5gyNdy9HRktH1ji7uCxq" + }, + { + "signature": "203208e1a77758f459184bda9c435b078611de668e1fa18022d801f27163c044f611f0ed89b71d96da27f93f64946098d4db45cc87c948e39c64faa5f4006d3d00", + "digestHex": "41c8316357e697e20ebd4b6f4c4d4f259832db11952f2ccd2746baca22c67260", + "recoveredPublicKey": "STM6TBpMsQ4vFZxSTnfmsooJsYk8DtaTh5gyNdy9HRktH1ji7uCxq" + }, + { + "signature": "20516b5f93f83b2f035e0ce272b8701b319db4e53c7832901150645f5049e94bc9635a106120b9eba34a1e4886898b92cfd97ce82008767bd19f44d8dfc7f6cee9", + "digestHex": "06bf69eade94b7781a3974ed73585829ed1ec6bfc0b213c0257b03b1186e0db4", + "recoveredPublicKey": "STM6TBpMsQ4vFZxSTnfmsooJsYk8DtaTh5gyNdy9HRktH1ji7uCxq" + }, + { + "signature": "1f20593cb7f1fe9019f9e6345efc025fdbd8ef89749e7a99eb16bf26ec868670e51ab5a0a4b3ee8796d2613ce4103c3aa5554ee6b4a8260cbacbf58fac8a4c9e71", + "digestHex": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + "recoveredPublicKey": "STM87oQfkpCN9VMXJHU9Dw9sv6eXyGYr9rh1jmvcoDsyvyFFYtUEi" + }, + { + "signature": "1f7d9bddfd016e07e6183653e501390ee4b3b581b3d03aef410eff3bfcb7e29d2869cda1491b5475699833abc77051ca29c696f82e0125f4db1aa7150029cee2c0", + "digestHex": "6dd54b39bb764a27370d88f11d97792a7866dbf4eeb2ad3e90bd9510bc977ab7", + "recoveredPublicKey": "STM87oQfkpCN9VMXJHU9Dw9sv6eXyGYr9rh1jmvcoDsyvyFFYtUEi" + }, + { + "signature": "1f69d85ff80db913bf5c91c2e3688ce925c96c8d89ef2702ea782efc2002b213c45b6d6375a2cdaa25bc764dd85c20f02ef95140a4a2f3ceec51a3f07b1db38746", + "digestHex": "6c6183f526faf001fe1a67d413f34362f1ecff46d8e47c7f0a0eefb8ebc64bc5", + "recoveredPublicKey": "STM87oQfkpCN9VMXJHU9Dw9sv6eXyGYr9rh1jmvcoDsyvyFFYtUEi" + }, + { + "signature": "1f5bbc8a847b6a13adaa92f482b789f216d90a1fc97ec4c08c9c7cc9f8b0043b7c60d2ac45c36ba93173d71df3f7ec055db0c17ce37b58aee4db53c83e6f615c64", + "digestHex": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "recoveredPublicKey": "STM87oQfkpCN9VMXJHU9Dw9sv6eXyGYr9rh1jmvcoDsyvyFFYtUEi" + }, + { + "signature": "206869f38bfdd7f5cb45283553875793e8b529c85e620e5d1d8abf870d83f9cccc36d65de82267fe9f01df2d7d5471c89b3b091b201ddae0d22b83fc89176bfc75", + "digestHex": "db41f5e263f1a2e50bbabdd225d6968de26d7b70b142eda674f6ec95db3d5203", + "recoveredPublicKey": "STM87oQfkpCN9VMXJHU9Dw9sv6eXyGYr9rh1jmvcoDsyvyFFYtUEi" + }, + { + "signature": "1f520c677ddc698b805d3294b2c632ae17ded76272f8b9523dd77ed6f1ca261be7199618ef5d3b9fc112685639cc953fe4c717e8bec27200b8dd88cb9a13cf360e", + "digestHex": "f5d64836ef858ddfad53b1d511c42040f21bac596e6e1bb06ac47ae7cfac5341", + "recoveredPublicKey": "STM87oQfkpCN9VMXJHU9Dw9sv6eXyGYr9rh1jmvcoDsyvyFFYtUEi" + }, + { + "signature": "1f5722c476ab19fd282419d7e0b37a3c007a4506655b17a39d232d32463b6475313a34ceee8db51997fd310432ade7d1d3ccc35c404dfe153d0d1c7a7a672a0a32", + "digestHex": "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + "recoveredPublicKey": "STM87oQfkpCN9VMXJHU9Dw9sv6eXyGYr9rh1jmvcoDsyvyFFYtUEi" + }, + { + "signature": "1f01453c1eecf99d5c8b7e41d16fdc3dbf10792f89451f2ad91ca7748494b6428f0c548c6dbac22f978c980747802046c4fd207b19a2a0ddf234b73b41b6194822", + "digestHex": "a2f98ef3ba88b31a6ec0ba0402068b6392f57d9c83569e92e02c9f399063c100", + "recoveredPublicKey": "STM87oQfkpCN9VMXJHU9Dw9sv6eXyGYr9rh1jmvcoDsyvyFFYtUEi" + }, + { + "signature": "1f08af291d2eefdaeb717addb6d01d76f2fe32a01ab657ccaa0ba3bdf5916350ef7aa0982acaf0f0cbb64a7e85699e5e8d0fbeef6bfd4366dc3b3dced52c05f328", + "digestHex": "974531237fe14d6ecbeac879ff55c64250ea687b7609a123e87019eee464659d", + "recoveredPublicKey": "STM87oQfkpCN9VMXJHU9Dw9sv6eXyGYr9rh1jmvcoDsyvyFFYtUEi" + }, + { + "signature": "1f5d8020539058fb9be5e8f63310b665b9ae6c43c984d3f0f6dabe4420944edabc0c0e6a8d8d26d026edfff8e0e2c6029a17e4fe5d21d71d227212ad25d8f56570", + "digestHex": "5290d7673a06a74f856d8d142764739972d85430654b23fff2c5c301a2a718f5", + "recoveredPublicKey": "STM87oQfkpCN9VMXJHU9Dw9sv6eXyGYr9rh1jmvcoDsyvyFFYtUEi" + }, + { + "signature": "1f2a7c6a0cf39515762755a3403d7fdf9d72aac998761acacdf38c429a525ae1991aed35491c5d6b16799150bca0def989162435246a2987d43682b2cf439ee30d", + "digestHex": "514e48d230152eaf2565381c65a580fbc04d62e75f4a52ad2435c6eb29c7fcbb", + "recoveredPublicKey": "STM87oQfkpCN9VMXJHU9Dw9sv6eXyGYr9rh1jmvcoDsyvyFFYtUEi" + }, + { + "signature": "1f53895db75f607c5cf946db542585956757256269605eb37d7f293bdbba313e057886b5648c7e4be81c0507e4f7098ad98c6fdf548bc8003aab989379b0b94bfd", + "digestHex": "4a995ef75411f11a50975e3be478446121cd9ba0304ec0a313a0aa03c61097fd", + "recoveredPublicKey": "STM87oQfkpCN9VMXJHU9Dw9sv6eXyGYr9rh1jmvcoDsyvyFFYtUEi" + }, + { + "signature": "206db0e2c4d7bfd17a103ee38dfb7d825f17870597fe7105e564bd6ee2b211f9480dae1f17babaa9c7f8a89e493c2eaf6c792305f49a90ea83ea25261ef430897e", + "digestHex": "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", + "recoveredPublicKey": "STM87oQfkpCN9VMXJHU9Dw9sv6eXyGYr9rh1jmvcoDsyvyFFYtUEi" + }, + { + "signature": "205706134c4329c22c12c8b6ea2503164c9a280c5288c5a49bdac86869aa21f6f122ffcd92b1a2853acb3486f2c48332b0cbae21fb7ee5b98ae93453046574eb70", + "digestHex": "45c224cab8b1a0f0484a70f5fbe2a90b9e3543ebf98c57a442c5c4530516aa43", + "recoveredPublicKey": "STM87oQfkpCN9VMXJHU9Dw9sv6eXyGYr9rh1jmvcoDsyvyFFYtUEi" + }, + { + "signature": "204f714d412db4fc3ba6ec71043ba448031bf80dc11bed15646fd866b8ed4782361ec3ddf13aeef122b268a6a8241cda459a1acf7ed5bbe64308d974af938f8679", + "digestHex": "10b7d0c13d8c8048e53831cf5cf2c1e4a334901bb11b7131016da4b2726cf344", + "recoveredPublicKey": "STM87oQfkpCN9VMXJHU9Dw9sv6eXyGYr9rh1jmvcoDsyvyFFYtUEi" + }, + { + "signature": "1f3cb1bd9a1a8bbdc08446cdf583188a10863e202e69b1956ec248b37cf3d46ef46dbdbbd0987523800af8973522218c477cd2deaf97387b4b0378fcf62fd20bac", + "digestHex": "417874e185288d80e951402e73559f85f3e8e589923e5ab868d7116b4b8859d5", + "recoveredPublicKey": "STM87oQfkpCN9VMXJHU9Dw9sv6eXyGYr9rh1jmvcoDsyvyFFYtUEi" + }, + { + "signature": "204df36377f68b40b757e909bddc11eb5b24211e1228067f3e21a8c0b4ea2ba12d55ca083589fb0979e70594268f2c6a18b8911c3c7d56673e088415c31268788a", + "digestHex": "4582569c8a1460bcfce4502764a916ae476bc530654b27f32867ffd07a2b4ed4", + "recoveredPublicKey": "STM87oQfkpCN9VMXJHU9Dw9sv6eXyGYr9rh1jmvcoDsyvyFFYtUEi" + }, + { + "signature": "1f176564190ef192ba35529a980ffc387143ce40f7f3e8f0aa724241cf83a6fff124247cac3a4109ff0b6152a8039b23fb6a2538b166ff4437e0609498dedb942e", + "digestHex": "414922d2b94823ff23f3d1401383d320cbccd33d7851b6aa1c3c4c95a6ce11aa", + "recoveredPublicKey": "STM87oQfkpCN9VMXJHU9Dw9sv6eXyGYr9rh1jmvcoDsyvyFFYtUEi" + }, + { + "signature": "2046322d317af9258c0c7bd79e128614ce78e5bb4ce4c1bd298fd960b42d6467d4070805677c1c1667c37a15c0271bafe7c819bbcfe0b9379da60d9d90096a486b", + "digestHex": "44f8354494a5ba03ba1792a8d3e9c534c47a9181980fde7a3f44b06ef2ae7c7f", + "recoveredPublicKey": "STM87oQfkpCN9VMXJHU9Dw9sv6eXyGYr9rh1jmvcoDsyvyFFYtUEi" + }, + { + "signature": "2041cb0c6dfc15724954f92556745325d1e9a470857d00bd3a9115a7721954b5c332fd84b377857ffde3b78a42456773f42e006df3593c02db788e224547874810", + "digestHex": "f9cff37b6e0114d9b55f379e3f3b1dbdca77dc11a20395728f69ab981df4036f", + "recoveredPublicKey": "STM87oQfkpCN9VMXJHU9Dw9sv6eXyGYr9rh1jmvcoDsyvyFFYtUEi" + }, + { + "signature": "1f6a13f2577dd08fa8b7428e9b4dbfe45ef4f6601d41060249a11ccfc31b79ed3e02f25d97851c2196d62af3917bb970d8b957e5bdf634f70342918236cc713ece", + "digestHex": "e8991505b7f414018feb528a74d7a992d88c6f6c23b35dd857ac332ee933eef5", + "recoveredPublicKey": "STM87oQfkpCN9VMXJHU9Dw9sv6eXyGYr9rh1jmvcoDsyvyFFYtUEi" + }, + { + "signature": "1f5306c75da2e4bcb74c54fce9812ca58451a36b7a68f35efaec161eccee83fd4f0c654902ab44f841bc5a0d1c2b15a25660edae54d1cc4bb6ddb981b2990df202", + "digestHex": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + "recoveredPublicKey": "STM83D7XeNRn7kDB9Neh6Tc442YmJmqUqE4EZzUUtMqNXmZyFfpg1" + }, + { + "signature": "2021d966b18e47033155add16d0f5fcc804f9d13e8c5ce5494b1ce3c12cde32726434767f9fb81c70d45947a5a15643484b64c3463bf0e1c5d879901429d60c1fe", + "digestHex": "f83b66db34698acfed50a8f4372beb547ab839b77b151cfc5475ad8b16973eb9", + "recoveredPublicKey": "STM83D7XeNRn7kDB9Neh6Tc442YmJmqUqE4EZzUUtMqNXmZyFfpg1" + }, + { + "signature": "2051db6df5c9ee0d4a0bd0cf6cb74b920444c97de83c84dfb0a33ddb82ef160a5a4c4ec337f5b3c55fce769f2c5517a362860e5564678dc2f4b926ada47aba7f1d", + "digestHex": "00a9f67c46a9bbd7d201d9e0301d336cec1904e928b3944e76d455b42fd1865e", + "recoveredPublicKey": "STM83D7XeNRn7kDB9Neh6Tc442YmJmqUqE4EZzUUtMqNXmZyFfpg1" + }, + { + "signature": "2016f2fa1df03d246a6c0487ff00ade4c981d23891e924fe04fc70e5913fdbc4264b6dd2d942896b9cb28596deb1d8eda8b5162bad111cf4b38b3bd7e5d68e9500", + "digestHex": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "recoveredPublicKey": "STM83D7XeNRn7kDB9Neh6Tc442YmJmqUqE4EZzUUtMqNXmZyFfpg1" + }, + { + "signature": "202555c317417e94d41b04b109efc70cb3abf1d5ed78868e8d95eb9ac6cd844ee3312c3c6da2db2af1e8b3a34031af3fbd35fbde056e4d2f4e6a30bf82eb63cff5", + "digestHex": "85306218c2468a84c25ec4c09af45f8117dd3da7d187085111dedfc742b42a13", + "recoveredPublicKey": "STM83D7XeNRn7kDB9Neh6Tc442YmJmqUqE4EZzUUtMqNXmZyFfpg1" + }, + { + "signature": "1f32f802e5d79fc2ea04f33c27702e390378687925496bfef15a1dad6a73c4a5692fd4f2c7e522488309128ee4b48507f651cddcb7d87d40e4f1e998157f528747", + "digestHex": "d1820f6a075d8a7156b7287fa188b3cb4857f6b1142e8912f44088476226f68a", + "recoveredPublicKey": "STM83D7XeNRn7kDB9Neh6Tc442YmJmqUqE4EZzUUtMqNXmZyFfpg1" + }, + { + "signature": "1f7d203f29ffa57354a8cf4542606a8b7de349f7ed37f893b9e3f8d4d7b62595692f3139f4c49da67a172297093c070ac771d09c254a26a82367423841ab83b746", + "digestHex": "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + "recoveredPublicKey": "STM83D7XeNRn7kDB9Neh6Tc442YmJmqUqE4EZzUUtMqNXmZyFfpg1" + }, + { + "signature": "1f1da3092f5a4d726d5f9d31d927dd9f9a00e06dffea223957fda2efb79a05472071ca45e46e3ffbd0271a72589d67fb863b5e9adc14f2a2086600b3280f9d2901", + "digestHex": "174b8cbfb610764b18d4e3c93c0fd0def1e1d0335b73574dca60282988479d49", + "recoveredPublicKey": "STM83D7XeNRn7kDB9Neh6Tc442YmJmqUqE4EZzUUtMqNXmZyFfpg1" + }, + { + "signature": "201aa8e83e30ddcbd1f763e30b833757c39519a5808a0f194cf44e72aff17db6b7050167176e00291982831f75c55ccb4f98edc04220f626e00adb58dee442544b", + "digestHex": "18c7927be91ccbdbaa5246ec9537b0c1b57bbc549acf4c7d5d37579f26d29d64", + "recoveredPublicKey": "STM83D7XeNRn7kDB9Neh6Tc442YmJmqUqE4EZzUUtMqNXmZyFfpg1" + }, + { + "signature": "1f4c2315bec29094de6155e9cfe2bc4f874826a0d7a75ed312d1420f0a385615582723887f9bc718f523979c933f0fd6c85499bfac3e1a9b38eb489a5a51bd6698", + "digestHex": "5290d7673a06a74f856d8d142764739972d85430654b23fff2c5c301a2a718f5", + "recoveredPublicKey": "STM83D7XeNRn7kDB9Neh6Tc442YmJmqUqE4EZzUUtMqNXmZyFfpg1" + }, + { + "signature": "2011c0e34694112cac953ab81ee390ba772731d5ba02999bfadb32d1ae3c6d765103a2e8465aed53d70ac5311b0a9c709e16cd6d5d2ba65e6cb007136ab948714f", + "digestHex": "4bc618ec17a96e2faf4d15ef2d2fe7c3ca8e66c6abd78a11bb2fe30719854a85", + "recoveredPublicKey": "STM83D7XeNRn7kDB9Neh6Tc442YmJmqUqE4EZzUUtMqNXmZyFfpg1" + }, + { + "signature": "1f09e540971775a1265662936e1dd2cda28dca8eee06868790b08381f117bc476410e576a8820341d20d47e9cd595e336106d4bee34fd1cc5dcef23fd0736f8d4c", + "digestHex": "fe7c2782f240b38b52337b0fc6dc49b2a88efcdacc3c21d5ec5bc70ad48571e9", + "recoveredPublicKey": "STM83D7XeNRn7kDB9Neh6Tc442YmJmqUqE4EZzUUtMqNXmZyFfpg1" + }, + { + "signature": "1f392b9f5c43d052f2e734ae3a342066af8293beeeb7b3c75cc0a22c41c4b54e6642b5f55c96a901ee476a7b48f623773d387508ca61e4aea75a0cbe3c478b5c81", + "digestHex": "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", + "recoveredPublicKey": "STM83D7XeNRn7kDB9Neh6Tc442YmJmqUqE4EZzUUtMqNXmZyFfpg1" + }, + { + "signature": "2029bb41cda162dbb4ea562b597b5733f93be46ca82a4a35d4f1b18fd548a258f3656a3f1a537b5bed336c3a05a0c3e5620b989a0b364a2832191766f48c44b869", + "digestHex": "c65b675f11086f93e4d7f46ceda0a2d523aa652d6c1674a95ba69d8f4b6eac80", + "recoveredPublicKey": "STM83D7XeNRn7kDB9Neh6Tc442YmJmqUqE4EZzUUtMqNXmZyFfpg1" + }, + { + "signature": "1f0b711795214a81dfeccb331ebc4ece84305d78cdd23d52caf5127face8fad9b37e807ef98a5117c024f9e5deb069571595ff26c0f178cd595d41907b77be4012", + "digestHex": "c46f0e9ee15c2a11416a774df378f4a700026ef1111d75c834b853c53cc404a3", + "recoveredPublicKey": "STM83D7XeNRn7kDB9Neh6Tc442YmJmqUqE4EZzUUtMqNXmZyFfpg1" + }, + { + "signature": "2021cb731980c86ae5517f6a951889d0d7e8cdcea9ce42e8d0a4ea32ba067f2ace6d0c370ce4c3ed0a54199ac3b18ba35ee8a7e3bcda79fa7225aad8c6de97d221", + "digestHex": "417874e185288d80e951402e73559f85f3e8e589923e5ab868d7116b4b8859d5", + "recoveredPublicKey": "STM83D7XeNRn7kDB9Neh6Tc442YmJmqUqE4EZzUUtMqNXmZyFfpg1" + }, + { + "signature": "201cb94e2643e075f8eacc5089d7e750befe226eab822b940441ab5a0cd660326a4eda0ae8d118b19747d358852dededd0ad190103ae7147b2189cdadb5119f5cd", + "digestHex": "7b3ff2bb9b66c47ffdcebe6fb17cb8d975222dbc715a9391c1280f2d3f9e8c1a", + "recoveredPublicKey": "STM83D7XeNRn7kDB9Neh6Tc442YmJmqUqE4EZzUUtMqNXmZyFfpg1" + }, + { + "signature": "1f71709e55250ef00d2464e62a1127969bdb178986235c3165827092c99968fd29427b5e33fa985ac97014a49259d944cc4d3b5791f1260a0d6cbbef008f46a994", + "digestHex": "826b64e58cc2ea8736e4c3721e41a35be327ca0169b2c61110a408c31132ae44", + "recoveredPublicKey": "STM83D7XeNRn7kDB9Neh6Tc442YmJmqUqE4EZzUUtMqNXmZyFfpg1" + }, + { + "signature": "1f0e161c455cab2ccf3d8ce8eda586ccfffe4e6ec5c568ac069bf624a71945d598594667c64a22d8064684055b4d3a414f6662d515636905483bd15bcdb2b62ade", + "digestHex": "44f8354494a5ba03ba1792a8d3e9c534c47a9181980fde7a3f44b06ef2ae7c7f", + "recoveredPublicKey": "STM83D7XeNRn7kDB9Neh6Tc442YmJmqUqE4EZzUUtMqNXmZyFfpg1" + }, + { + "signature": "204e7a9cfaa05525122c9f88815eedaaa8712f92abcf2e1ee692ce8a69cdddaa0b285eb4daa1fb29cf1dab7dcfebdef22cdac93370fb08fbd12de6f59a99488ca7", + "digestHex": "6cd8001fb780d3f2b767612a6387451dc90aa3a71e86c9c8ec5ba04f258e6a7d", + "recoveredPublicKey": "STM83D7XeNRn7kDB9Neh6Tc442YmJmqUqE4EZzUUtMqNXmZyFfpg1" + }, + { + "signature": "1f6645271241e0e29cb4944b78ae109d582a96af6b1d2c07ba41b3cefe2a69489261deca73e186c479a7a98cdc2baf93fd40198aab40a4c3843c0dfa3df20b4075", + "digestHex": "13a2857f386749f9e4fbf399d8d544d518b8f0ead15170508a4227c3987bec50", + "recoveredPublicKey": "STM83D7XeNRn7kDB9Neh6Tc442YmJmqUqE4EZzUUtMqNXmZyFfpg1" + }, + { + "signature": "1f48f79140aaf2ecd4c4d87ed6908a7dbcb5a86dd9cb3107e2504daeb305abcb6a02614ab510d73c93fd3a09e9e7b664c459b56ec267b0e20ee070d177b7c495d9", + "digestHex": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + "recoveredPublicKey": "STM6PB54zab76Bdc3uKXKvSd7LTcM3RhHjsS3NxTeHuExHVTZoLcH" + }, + { + "signature": "200a8349f3d992a1a1e047a3fcf6b0930d116a6399c21279bf8955d43e22d4ebf62ec2ec6e401b36158518d88f8ba4cea2db75d57183d8ee0d1f56b8d662db2916", + "digestHex": "5d98f13aaa7f996ec6dcb11f1d44663ca9023dd5aa6a4e14d7121fc6d0f5349a", + "recoveredPublicKey": "STM6PB54zab76Bdc3uKXKvSd7LTcM3RhHjsS3NxTeHuExHVTZoLcH" + }, + { + "signature": "1f29cd5b26c632cece6127d64af55365c0a0be9fc4791832609234c76a225e08915c5f8d61d81aea5d95c282ebc880cc4dc31a4ff7b19d51f71cc5ef56ed120304", + "digestHex": "951f1aec600c4672462e460049996b86b7ca67f4c4b03c8dcb16bb509f9b9a35", + "recoveredPublicKey": "STM6PB54zab76Bdc3uKXKvSd7LTcM3RhHjsS3NxTeHuExHVTZoLcH" + }, + { + "signature": "1f2a0458ce0837dfc78d7653413d4e4e951ac6bc237111bb64fdff6ab41194a224425e423170531b4923e0dac285bb88a01478cb873cae4374124804b28794f169", + "digestHex": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "recoveredPublicKey": "STM6PB54zab76Bdc3uKXKvSd7LTcM3RhHjsS3NxTeHuExHVTZoLcH" + }, + { + "signature": "203145c34d5bc5f1b7611f7d00e62005cb0de66175d02672ba3924a5dc612a5d870e09058e670c7d7496842a5a2474e9c2376c17a509ad2cf47dfc4270cfaec5ff", + "digestHex": "d45d36342c6788e00c3a1b929c240aaaa26bd1d9b6344e44edf467a120a7bb8c", + "recoveredPublicKey": "STM6PB54zab76Bdc3uKXKvSd7LTcM3RhHjsS3NxTeHuExHVTZoLcH" + }, + { + "signature": "1f4db64e3867cabaf1696ed4498a547e5f26ee0ccae3341f3a8e5fc95504de784e4eb832081b666f335bf421646f9cf37d791b612bc50861438d1155c50d08a325", + "digestHex": "01a058da791786f2fd4195a53202bd73ba451ebb27117aee90aeaa089118010f", + "recoveredPublicKey": "STM6PB54zab76Bdc3uKXKvSd7LTcM3RhHjsS3NxTeHuExHVTZoLcH" + }, + { + "signature": "1f10c2a4f5ede530181680066c708481a23ed7bc8c6b5f0d6b28d5c72c811aa36d38267d84a28099b8c60f594ca88fb4b5aa7326f700bb7941d795827ee814be0f", + "digestHex": "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + "recoveredPublicKey": "STM6PB54zab76Bdc3uKXKvSd7LTcM3RhHjsS3NxTeHuExHVTZoLcH" + }, + { + "signature": "203f5b7e914991a687ea4b25f6180e90435949b818c2c13e65ecb983fbebdf63fc76ca3bdbcb43af211ce79356dc4ae4226b4964e2a2377ccc35dafccd61a6ba7e", + "digestHex": "3faf5c97943231cf6a9a1dc4bbc044d359a877d94a1b1733282956b8be97dcab", + "recoveredPublicKey": "STM6PB54zab76Bdc3uKXKvSd7LTcM3RhHjsS3NxTeHuExHVTZoLcH" + }, + { + "signature": "205e2ec8f28d3097ab0d922e3494e97127d74796d7a75fbfd552a5398569c17b4b0cf7566df934e94377daf46d88088b90bdfed0d6b34c7964925364ed35aaef73", + "digestHex": "a9ed276aaf65d9380c6a793c64d3e572685cece16f1a771a59af7984244b0f38", + "recoveredPublicKey": "STM6PB54zab76Bdc3uKXKvSd7LTcM3RhHjsS3NxTeHuExHVTZoLcH" + }, + { + "signature": "1f2fc69acce29f613f9b489f72f44d216a9c8f0fd63818a7291b15e9245a824857310768655216b18407dbdd9089e5a78fbc5dcb150ecbaff69ab1fd2390edab9a", + "digestHex": "5290d7673a06a74f856d8d142764739972d85430654b23fff2c5c301a2a718f5", + "recoveredPublicKey": "STM6PB54zab76Bdc3uKXKvSd7LTcM3RhHjsS3NxTeHuExHVTZoLcH" + }, + { + "signature": "1f78493fa34aee5ea5dd7f0f7b41b6d2fbc789192d6af3ff0698c4d6e38aa42e4c00bde77c050618717d774f93f2fa55c536ad6e1baec7fe339ecb5530e07fa7cd", + "digestHex": "e575430eb5eba49ca2ab2c1734d5ce334ba5b36b6f636a5fb6cf54f21a9465bb", + "recoveredPublicKey": "STM6PB54zab76Bdc3uKXKvSd7LTcM3RhHjsS3NxTeHuExHVTZoLcH" + }, + { + "signature": "1f1cf4be3dc60e72ad3fef3e7d4fade1d62c7b577f0249d9fea126cb03d7ddddde4a401c27efa26e52c65c4239aa9c32c080a73a728deda0b1317864a4d54dae4a", + "digestHex": "7655fe1203d0b67aea73034b1c4245f576fa4c9bad09483647a213371790e67d", + "recoveredPublicKey": "STM6PB54zab76Bdc3uKXKvSd7LTcM3RhHjsS3NxTeHuExHVTZoLcH" + }, + { + "signature": "1f78bafee7c0c3ccea25afa89bd6181526cb39c587cca41c101beffeb16a571f723793f4383fd6f31f40e5162a6cf562cc61305bfbd2c27a5a91fb528ab98931d7", + "digestHex": "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", + "recoveredPublicKey": "STM6PB54zab76Bdc3uKXKvSd7LTcM3RhHjsS3NxTeHuExHVTZoLcH" + }, + { + "signature": "1f06102c981afffe0f8112bc971970d0fb2f16b31099a9c040ff856f189f7cc1cd4fb448e0a700c16f00b0adf8ed4d0466e1c8c05ae7631d63cd52ed59316967fa", + "digestHex": "655b726a2ef301dda26107863573a5e719a68da8bf1b24439eb857e4aeec7d52", + "recoveredPublicKey": "STM6PB54zab76Bdc3uKXKvSd7LTcM3RhHjsS3NxTeHuExHVTZoLcH" + }, + { + "signature": "1f23bc8976839a01a19f14d80dd1e7554a4ebb7ae56535eeebcb6790eca7124fb71a883b318bb7d95d6b8cf8b3d4de92f94ddfcff3369464eb6f9d84bc124e12e7", + "digestHex": "4bc7e2eeb7c3c5441898e7e5fc0fce70e003175fd7a3089a7771565c46e9e004", + "recoveredPublicKey": "STM6PB54zab76Bdc3uKXKvSd7LTcM3RhHjsS3NxTeHuExHVTZoLcH" + }, + { + "signature": "1f4ec788115dcd598db10c263a3bbcd5d042d4629e99a9d7e5a35da2e7312f637b40d60b6fa02c76b4a885808cf5f72a7d075549fb25e8f7b3e850b96c44fc8630", + "digestHex": "417874e185288d80e951402e73559f85f3e8e589923e5ab868d7116b4b8859d5", + "recoveredPublicKey": "STM6PB54zab76Bdc3uKXKvSd7LTcM3RhHjsS3NxTeHuExHVTZoLcH" + }, + { + "signature": "1f376136497294ffc660b699bfb3236a4f087692f75afedaee3cbabc0167b115ea56fd574811d19ceff67bb375344d83c6ff98873381153edd39ee997f7b438f30", + "digestHex": "11e8ed75cc00f209328caefe7a70a13612600bd2d6c7aea8ea36f1d0b7fb635d", + "recoveredPublicKey": "STM6PB54zab76Bdc3uKXKvSd7LTcM3RhHjsS3NxTeHuExHVTZoLcH" + }, + { + "signature": "2018a4f6b424d850002a89d58e5fe000f7a60f915d8d106072e3fee27f616acd9022e30ec9677b34d99a857e471c102ec2ab6d1971091fe9a0e884e0ec2c18d99c", + "digestHex": "ba29a9e86ac07b4dae7233325a1534aac1acf1f84b3fc81d2c6d1a1521143251", + "recoveredPublicKey": "STM6PB54zab76Bdc3uKXKvSd7LTcM3RhHjsS3NxTeHuExHVTZoLcH" + }, + { + "signature": "1f3c5e4dbcdcca854ca0656bd838d5ee8dd691ee672333219d46621beff30bd4a92bb7e28703deec9ed3cebf8ac1a18cb51f589168caaf26e09e8079ecda8b929c", + "digestHex": "44f8354494a5ba03ba1792a8d3e9c534c47a9181980fde7a3f44b06ef2ae7c7f", + "recoveredPublicKey": "STM6PB54zab76Bdc3uKXKvSd7LTcM3RhHjsS3NxTeHuExHVTZoLcH" + }, + { + "signature": "207cd81c76b63e7a802441d1da9947285ae87731aea4d41c7d7a065cf17f2064eb1542b6de1785f4e776ab8dcf7e565d93173290b9c9cc960b7713a720a92d43f4", + "digestHex": "8f4d9ed6da95eba5d9f4b16614e849092304925bdcdfea07fade0a950cfb0094", + "recoveredPublicKey": "STM6PB54zab76Bdc3uKXKvSd7LTcM3RhHjsS3NxTeHuExHVTZoLcH" + }, + { + "signature": "1f6eea39460f839ea212511b7fdcbdc52eb77a1b1968ac15a0548364d15a1640b73e97b63ccc9ecc9f96d5a686249a9c0af3b344593afb6c781d961727ad017623", + "digestHex": "bdbacebd72cf7462eb5513f241e7b8de488727dc750526f088f227c824df8db1", + "recoveredPublicKey": "STM6PB54zab76Bdc3uKXKvSd7LTcM3RhHjsS3NxTeHuExHVTZoLcH" + }, + { + "signature": "1f110c4c2388a9b7d2be01c47b6a848015da32e13bfaf76b4f0c32ef7dd88b718d59b33339b137f1263f38bf8e43ea2d8769c9fa112347530f5921fd364af328ac", + "digestHex": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + "recoveredPublicKey": "STM5g3PLnWPjs763K7LVUxCFp3B54nxcc3soGGNnUEbTDpthanCzH" + }, + { + "signature": "1f5c485df12df880e83fb02878393c4a275d2b0dfefecc4d747729ce7b908cefea774f3aa988dfdf11d2336cfa80328f894da004f4b0e671f3d25295b449bfda43", + "digestHex": "4fc3bd2a9ddf672e80f659687aaa1964d97339b16e6e2d72ef093631893766b0", + "recoveredPublicKey": "STM5g3PLnWPjs763K7LVUxCFp3B54nxcc3soGGNnUEbTDpthanCzH" + }, + { + "signature": "2044aabc63cfbc7de854a7b056f2fbc52044670529656f5cb7bbd93ad300b32a6f03dfe8ddf8141ad49524b8d8b2686393a16fbc560401359b9bf0716b1e85e378", + "digestHex": "3f1d00ba1a1c289d93be4d6aaac3387f99c0427360d25267b2b9d8b946b26985", + "recoveredPublicKey": "STM5g3PLnWPjs763K7LVUxCFp3B54nxcc3soGGNnUEbTDpthanCzH" + }, + { + "signature": "201d55918214f0400606ceba6093074aace028a2b76fb9c3b15f8d385fe1830e6d60d6ba98cfaa9c28d7c458bad3de5db34dcf0e3c8bad6b876c6afeda46700e68", + "digestHex": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "recoveredPublicKey": "STM5g3PLnWPjs763K7LVUxCFp3B54nxcc3soGGNnUEbTDpthanCzH" + }, + { + "signature": "1f13938b7e1848560ea73d90981392bd90dd87298ea94268244c6f3d2d29e9e55d3350fd9091f3bf38e09c0206e0b7c6308f106a9ccb498b3e63fd5df442b9301a", + "digestHex": "a0a9320f7ff21a2a661c31773b42757632ecff1da618d53de3cad4fe8011f5b5", + "recoveredPublicKey": "STM5g3PLnWPjs763K7LVUxCFp3B54nxcc3soGGNnUEbTDpthanCzH" + }, + { + "signature": "2058445387b89b6527c3a08d2f40db89ecbd55a22e8fe6cbf842ef4fce7b2de11e31ef09095cf36bbe3a7365734c9fa875a7f5260b9c0566d0cbfd89d6c2e77a71", + "digestHex": "f287049f60b7241f7d6eb0a26b2e98413911a4800584f493032a22f9a30dd3b2", + "recoveredPublicKey": "STM5g3PLnWPjs763K7LVUxCFp3B54nxcc3soGGNnUEbTDpthanCzH" + }, + { + "signature": "204d0b23fe68cc576899c732e59be6b30c44dbcf0306382d530bc5219a65bc8ea02935075a3b580a23c1357e247e5ef4ef8efea50520488af40329075d4ef224ed", + "digestHex": "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + "recoveredPublicKey": "STM5g3PLnWPjs763K7LVUxCFp3B54nxcc3soGGNnUEbTDpthanCzH" + }, + { + "signature": "205fc32b90b38498c4de13afdf3501f3b50071c023d3ad6e24524f906a727212a56d02a8fb7091d5fc02a5c07e42aad8256ee3f3d96ee9dbd7d630ea6fc3c144fe", + "digestHex": "4a84a918b3ef6f6468af90c1091ee0025c7c1a208e390f69725b02c6eb50b592", + "recoveredPublicKey": "STM5g3PLnWPjs763K7LVUxCFp3B54nxcc3soGGNnUEbTDpthanCzH" + }, + { + "signature": "2054f78a8619cf9afe8491ef601fa0e2d076ad86894af0b500be5dcad5f5f83e2356f3c4d46fcfecc1a8e70719dde4e70ee90f6abab859f2bc26d284ca71db68dd", + "digestHex": "1ff57f98976766bd8b5ca7aeef51a3ef30e89c8d8e48bfce20edf36f75e23d48", + "recoveredPublicKey": "STM5g3PLnWPjs763K7LVUxCFp3B54nxcc3soGGNnUEbTDpthanCzH" + }, + { + "signature": "1f74cc783cb04aee10d530ed8808b057a509423dc563995a723a62505e80ff8ed652105c7bc296072b3e6edd938caf0cf6b03be12bab2b57088240a998e5c3bcb2", + "digestHex": "5290d7673a06a74f856d8d142764739972d85430654b23fff2c5c301a2a718f5", + "recoveredPublicKey": "STM5g3PLnWPjs763K7LVUxCFp3B54nxcc3soGGNnUEbTDpthanCzH" + }, + { + "signature": "202f915ceface1313cfacc2c908bead87cd8339d85d1fc78739fb7b9db021aeba960b1a98e591fef37a6930967e85bc7bac0997d2a5e05758f7e1c72e4e08668ec", + "digestHex": "b45556b5d474baa3ee44fdaa9629a5930d71f5e106c9554b9080754f4efaa4e5", + "recoveredPublicKey": "STM5g3PLnWPjs763K7LVUxCFp3B54nxcc3soGGNnUEbTDpthanCzH" + }, + { + "signature": "205580feda9eb5c8dc77ef3cde783702bfbd2159cc642f1f24a3514694e33f8fad533aadcb87dc422a21347e3886fd121bba139f773b97b8bd40786ae4567a25ff", + "digestHex": "7d6140160cf088b70f699ddfe1ddbb78af7710abf0afde1df4c5c0e885f78818", + "recoveredPublicKey": "STM5g3PLnWPjs763K7LVUxCFp3B54nxcc3soGGNnUEbTDpthanCzH" + }, + { + "signature": "203a28345fee7cc88d7a9aa448ce8c4b66f023840c672c5fc61a28b4534debb82b504f08a30854b38f678d1d4103d4bd044354906e749d7776f5c6eefe4b654471", + "digestHex": "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", + "recoveredPublicKey": "STM5g3PLnWPjs763K7LVUxCFp3B54nxcc3soGGNnUEbTDpthanCzH" + }, + { + "signature": "1f158263b4dfb6fc79bc1b623d1c50c2c209298790dada660ce8908b48789b0db74d91e4c44aeadca0c2556c46dbe9e4de8de1dd9ae4f719c5d021a95b02cf4247", + "digestHex": "3cfab68af280dd09dfb78b5b0cb15817df32de85a620ceae5c8a264384f4df69", + "recoveredPublicKey": "STM5g3PLnWPjs763K7LVUxCFp3B54nxcc3soGGNnUEbTDpthanCzH" + }, + { + "signature": "1f2a380d65ab52c3a8ddfd70e732bae1c7cfd38ba2de4256ced95a5437e4963d883e03f1990be5e55e90a9e8b488fd435ec06a27eb1c6e91e07a34cae0403f6dac", + "digestHex": "d79269713b24414541d95c9610e0a0c771ce81db1e340b0e6768b955c106c9f8", + "recoveredPublicKey": "STM5g3PLnWPjs763K7LVUxCFp3B54nxcc3soGGNnUEbTDpthanCzH" + }, + { + "signature": "2019427e91fc4fd4c2bf7109c61cb8598c23a8dddaa6e21cc5df9664af90f3c58045c26a5c97c2f102e2536417d4b37fce317d1856415ee1842c8c242964340c4d", + "digestHex": "417874e185288d80e951402e73559f85f3e8e589923e5ab868d7116b4b8859d5", + "recoveredPublicKey": "STM5g3PLnWPjs763K7LVUxCFp3B54nxcc3soGGNnUEbTDpthanCzH" + }, + { + "signature": "1f51d7195536738f578098aea5952d2582039cac233b1805c24d7beff0a40d80eb5d6b598ed67299a4c1823f0254cb2affca33c414b07eac638ecd61b037487b91", + "digestHex": "a3bc05b94e11c76c18d08c2ab19780e15827b2e75ad184c701e8f8713a296750", + "recoveredPublicKey": "STM5g3PLnWPjs763K7LVUxCFp3B54nxcc3soGGNnUEbTDpthanCzH" + }, + { + "signature": "1f4be059711a86e5b858b68558cfbea8d418029c52b66c14a546989175d06a1028549889fb6e5ccb1cfdfe37eeb49541eccaee85283198ba7312d0cb75c18b6f70", + "digestHex": "164855e8f2cac8c68bf25a63218e1f6926524521b6114ec4ae9eb405e5bbae7a", + "recoveredPublicKey": "STM5g3PLnWPjs763K7LVUxCFp3B54nxcc3soGGNnUEbTDpthanCzH" + }, + { + "signature": "200c41024a055df64f75a3c892e288af657a8d242411834f6b856048e8616062056024bdd516f2a69f2fd75ea44e614c28f1c78f46bdfb60217ec4f06958a87440", + "digestHex": "44f8354494a5ba03ba1792a8d3e9c534c47a9181980fde7a3f44b06ef2ae7c7f", + "recoveredPublicKey": "STM5g3PLnWPjs763K7LVUxCFp3B54nxcc3soGGNnUEbTDpthanCzH" + }, + { + "signature": "2036b180bf382f9a4df503de54bd1a34c762640939da644efd3f3f566dd0f3c68170a54c9911d0f5ba539250a2e311fdd39d81d951f2ffee7d545c4ee4cecd3c1f", + "digestHex": "de9ceacd4e16c11fbe5faf64c2ef7852ec55655a09475b11291ba42d724758ab", + "recoveredPublicKey": "STM5g3PLnWPjs763K7LVUxCFp3B54nxcc3soGGNnUEbTDpthanCzH" + }, + { + "signature": "204f21f2b2a2013772798f57d879a62e489ee57740d95d886131f4f40eb0855ae10217d174287a9e48a72d6dda96eccd6229b07c459fb059dd03127685b89e788d", + "digestHex": "c639e68407dd0c947466d3c08157c34472fee9d044b64800d6b7ea2262b4e7e7", + "recoveredPublicKey": "STM5g3PLnWPjs763K7LVUxCFp3B54nxcc3soGGNnUEbTDpthanCzH" + } + ], + "b64u": [ + { + "input": "hello", + "encoded": "aGVsbG8." + }, + { + "input": "", + "encoded": "" + }, + { + "input": "a", + "encoded": "YQ.." + }, + { + "input": "ab", + "encoded": "YWI." + }, + { + "input": "abc", + "encoded": "YWJj" + }, + { + "input": "{\"json\":true}", + "encoded": "eyJqc29uIjp0cnVlfQ.." + }, + { + "input": "unicode ñ 漢字 🚀", + "encoded": "dW5pY29kZSDDsSDmvKLlrZcg8J-agA.." + }, + { + "input": "??>>~~``", + "encoded": "Pz8-Pn5-YGA." + }, + { + "input": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "encoded": "eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4" + } + ], + "hsTokenCreate": [ + { + "username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "app": "ecency.app", + "timestamp": 1751900000, + "signedJson": "{\"signed_message\":{\"type\":\"code\",\"app\":\"ecency.app\"},\"authors\":[\"alice\"],\"timestamp\":1751900000,\"signatures\":[\"1f30c08268a34497eb7d0d2df06486cc64b36cd81b2e03a8de158d001409d0d98e7d9557c27e67da50fc22edcfa1f56f4b6e250d6931678f4278a8365f06c4bbe6\"]}", + "code": "eyJzaWduZWRfbWVzc2FnZSI6eyJ0eXBlIjoiY29kZSIsImFwcCI6ImVjZW5jeS5hcHAifSwiYXV0aG9ycyI6WyJhbGljZSJdLCJ0aW1lc3RhbXAiOjE3NTE5MDAwMDAsInNpZ25hdHVyZXMiOlsiMWYzMGMwODI2OGEzNDQ5N2ViN2QwZDJkZjA2NDg2Y2M2NGIzNmNkODFiMmUwM2E4ZGUxNThkMDAxNDA5ZDBkOThlN2Q5NTU3YzI3ZTY3ZGE1MGZjMjJlZGNmYTFmNTZmNGI2ZTI1MGQ2OTMxNjc4ZjQyNzhhODM2NWYwNmM0YmJlNiJdfQ.." + }, + { + "username": "bob.tester", + "password": "hunter2hunter2", + "app": "ecency.app", + "timestamp": 1700000001, + "signedJson": "{\"signed_message\":{\"type\":\"code\",\"app\":\"ecency.app\"},\"authors\":[\"bob.tester\"],\"timestamp\":1700000001,\"signatures\":[\"1f3e4b9deb3eb0781dc78b8fcbe617911eccc898323ccc58d6aac82085f6dda63140f8fefcd829861766a190a1b32ac829fabd8e6c8b35e109c896597012274d32\"]}", + "code": "eyJzaWduZWRfbWVzc2FnZSI6eyJ0eXBlIjoiY29kZSIsImFwcCI6ImVjZW5jeS5hcHAifSwiYXV0aG9ycyI6WyJib2IudGVzdGVyIl0sInRpbWVzdGFtcCI6MTcwMDAwMDAwMSwic2lnbmF0dXJlcyI6WyIxZjNlNGI5ZGViM2ViMDc4MWRjNzhiOGZjYmU2MTc5MTFlY2NjODk4MzIzY2NjNThkNmFhYzgyMDg1ZjZkZGE2MzE0MGY4ZmVmY2Q4Mjk4NjE3NjZhMTkwYTFiMzJhYzgyOWZhYmQ4ZTZjOGIzNWUxMDljODk2NTk3MDEyMjc0ZDMyIl19" + }, + { + "username": "dave", + "password": "🔑 unicode pässwörd", + "app": "some.other.app", + "timestamp": 1751912345, + "signedJson": "{\"signed_message\":{\"type\":\"code\",\"app\":\"some.other.app\"},\"authors\":[\"dave\"],\"timestamp\":1751912345,\"signatures\":[\"20444c2f18c89cb31c5ab51269613ba21219a71ec209d3448d5c4a42168471b0e5673afd209b11ac6726e96426cbf3c26ac0feec494773be8d55907aba49706cf3\"]}", + "code": "eyJzaWduZWRfbWVzc2FnZSI6eyJ0eXBlIjoiY29kZSIsImFwcCI6InNvbWUub3RoZXIuYXBwIn0sImF1dGhvcnMiOlsiZGF2ZSJdLCJ0aW1lc3RhbXAiOjE3NTE5MTIzNDUsInNpZ25hdHVyZXMiOlsiMjA0NDRjMmYxOGM4OWNiMzFjNWFiNTEyNjk2MTNiYTIxMjE5YTcxZWMyMDlkMzQ0OGQ1YzRhNDIxNjg0NzFiMGU1NjczYWZkMjA5YjExYWM2NzI2ZTk2NDI2Y2JmM2MyNmFjMGZlZWM0OTQ3NzNiZThkNTU5MDdhYmE0OTcwNmNmMyJdfQ.." + } + ], + "validateCodeRaw": [ + { + "tokenJson": "{\"signed_message\":{\"type\":\"code\",\"app\":\"ecency.app\"},\"authors\":[\"alice\"],\"timestamp\":1751900000,\"signatures\":[\"ab\"]}", + "rawMessage": "{\"signed_message\":{\"type\":\"code\",\"app\":\"ecency.app\"},\"authors\":[\"alice\"],\"timestamp\":1751900000}", + "digestHex": "5290d7673a06a74f856d8d142764739972d85430654b23fff2c5c301a2a718f5" + }, + { + "tokenJson": "{\"signed_message\":{\"app\":\"ecency.app\",\"type\":\"code\"},\"authors\":[\"bob.tester\"],\"timestamp\":1751900000.123,\"signatures\":[\"cd\"]}", + "rawMessage": "{\"signed_message\":{\"app\":\"ecency.app\",\"type\":\"code\"},\"authors\":[\"bob.tester\"],\"timestamp\":1751900000.123}", + "digestHex": "2f21a9d85298d934988f4ce2767c895d74dc087e88b1ed7b7191ef9eed7a3a36" + }, + { + "tokenJson": "{\"timestamp\":1700000000.5,\"signatures\":[\"ef\"],\"signed_message\":{\"type\":\"login\",\"app\":\"éçency 漢字\"},\"authors\":[\"carol-x1\"]}", + "rawMessage": "{\"signed_message\":{\"type\":\"login\",\"app\":\"éçency 漢字\"},\"authors\":[\"carol-x1\"],\"timestamp\":1700000000.5}", + "digestHex": "9db55ab9b1ce6ba382895edd40e8550a3fa7ce4e2c9a5c5c76082db7a4664a73" + }, + { + "tokenJson": "{\"signed_message\":{\"type\":\"code\",\"app\":\"x\",\"extra\":{\"z\":1,\"a\":[1.5,2e3,0.001]}},\"authors\":[\"dave\",\"eve\"],\"timestamp\":1e10,\"signatures\":[\"01\"]}", + "rawMessage": "{\"signed_message\":{\"type\":\"code\",\"app\":\"x\",\"extra\":{\"z\":1,\"a\":[1.5,2000,0.001]}},\"authors\":[\"dave\",\"eve\"],\"timestamp\":10000000000}", + "digestHex": "2dce017b549431ac1706c97580275fa41e490df5d1053425e8e24a81ee267d17" + } + ], + "numberFormat": [ + { + "value": 0, + "text": "0" + }, + { + "value": 0, + "text": "0" + }, + { + "value": 1, + "text": "1" + }, + { + "value": -1, + "text": "-1" + }, + { + "value": 42, + "text": "42" + }, + { + "value": 1.5, + "text": "1.5" + }, + { + "value": -2.5, + "text": "-2.5" + }, + { + "value": 0.1, + "text": "0.1" + }, + { + "value": 1000000, + "text": "1000000" + }, + { + "value": 123456789, + "text": "123456789" + }, + { + "value": 100000000000000000000, + "text": "100000000000000000000" + }, + { + "value": 1e+21, + "text": "1e+21" + }, + { + "value": 5e+21, + "text": "5e+21" + }, + { + "value": -1e+21, + "text": "-1e+21" + }, + { + "value": 0.000001, + "text": "0.000001" + }, + { + "value": 0.000001, + "text": "0.000001" + }, + { + "value": 1e-7, + "text": "1e-7" + }, + { + "value": -1.5e-7, + "text": "-1.5e-7" + }, + { + "value": 0.0000015, + "text": "0.0000015" + }, + { + "value": 123456789012345680000, + "text": "123456789012345680000" + }, + { + "value": 6.02e+23, + "text": "6.02e+23" + }, + { + "value": 1e-300, + "text": "1e-300" + }, + { + "value": 1.7976931348623157e+308, + "text": "1.7976931348623157e+308" + }, + { + "value": 5e-324, + "text": "5e-324" + }, + { + "value": 1751900000.123, + "text": "1751900000.123" + }, + { + "value": 9007199254740992, + "text": "9007199254740992" + }, + { + "value": 9007199254740994, + "text": "9007199254740994" + } + ] +} \ No newline at end of file diff --git a/dotnet/EcencyApi/Config.cs b/dotnet/EcencyApi/Config.cs new file mode 100644 index 00000000..0a7c55bf --- /dev/null +++ b/dotnet/EcencyApi/Config.cs @@ -0,0 +1,34 @@ +namespace EcencyApi; + +/// +/// Mirror of src/config.ts — same env vars, same defaults, so the two +/// implementations are drop-in interchangeable behind the same compose file. +/// +public static class Config +{ + public static string PrivateApiAddr { get; } = + Env("PRIVATE_API_ADDR") ?? "https://domain.com/api"; + + public static string PrivateApiAuth { get; } = + Env("PRIVATE_API_AUTH") ?? "privateapiauth"; + + public static string HsClientSecret { get; } = + Env("HIVESIGNER_SECRET") ?? "hivesignerclientsecret"; + + public static string SearchApiAddr { get; } = + Env("SEARCH_API_ADDR") ?? "https://api.search.com"; + + public static string SearchApiToken { get; } = + Env("SEARCH_API_SECRET") ?? "searchApiSecret"; + + // No default: when unset the Stripe routes fail closed (503) rather than + // forward an empty secret (matches config.ts). + public static string? StripeInternalSecret { get; } = Env("STRIPE_INTERNAL_SECRET"); + + public static string? TurnstileSecret { get; } = Env("TURNSTILE_SECRET"); + + public static string CaptchaMode { get; } = + (Env("CAPTCHA_MODE") ?? "hard").Trim().ToLowerInvariant(); + + private static string? Env(string name) => Environment.GetEnvironmentVariable(name); +} diff --git a/dotnet/EcencyApi/EcencyApi.csproj b/dotnet/EcencyApi/EcencyApi.csproj new file mode 100644 index 00000000..b22327a5 --- /dev/null +++ b/dotnet/EcencyApi/EcencyApi.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + true + true + + + + + + + + + + + + + + + + diff --git a/dotnet/EcencyApi/Handlers/Announcements.cs b/dotnet/EcencyApi/Handlers/Announcements.cs new file mode 100644 index 00000000..10417172 --- /dev/null +++ b/dotnet/EcencyApi/Handlers/Announcements.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Nodes; +using EcencyApi.Infrastructure; + +namespace EcencyApi.Handlers; + +/// +/// Port of src/server/handlers/announcements.ts. +/// +/// id - incremental +/// title - title of announcement +/// description - description of announcement +/// button_text - text of actionable button +/// button_link - link that actionable button opens +/// path - which path it should show, supports regex on location +/// auth - should there be authorized/logged in user to show announcement +/// ops - hive uri format operation for mobile app signing +/// proposal_ids - hive proposal ids for an inline support prompt; web builds the +/// update_proposal_votes operation from these, mobile uses ops +/// +public static class Announcements +{ + /// Fresh copy of the announcements array (wire shape identical to the TS export). + public static JsonArray Json() => new() + { + new JsonObject + { + ["id"] = 110, + ["title"] = "Support Ecency! ❤️", + ["description"] = "New proposal to support Ecency and its future development. Every vote and support counts!", + ["button_text"] = "Support now", + ["button_link"] = "/proposals/379", + ["path"] = "/@.+/(blog|posts|wallet|points|engine|permissions|spk)", + ["auth"] = true, + ["proposal_ids"] = new JsonArray { 379 }, + ["ops"] = "hive://sign/op/WyJ1cGRhdGVfcHJvcG9zYWxfdm90ZXMiLHsidm90ZXIiOiAiX19zaWduZXIiLCJwcm9wb3NhbF9pZHMiOiBbMzc5XSwiYXBwcm92ZSI6dHJ1ZSwiZXh0ZW5zaW9ucyI6IFtdfV0.", + }, + }; +} + +public static partial class PrivateApi +{ + // GET ^/private-api/announcements$ + public static async Task GetAnnouncement(HttpContext ctx) + { + await ctx.SendJson(200, Announcements.Json()); + } +} diff --git a/dotnet/EcencyApi/Handlers/AuthApi.cs b/dotnet/EcencyApi/Handlers/AuthApi.cs new file mode 100644 index 00000000..2976bf2d --- /dev/null +++ b/dotnet/EcencyApi/Handlers/AuthApi.cs @@ -0,0 +1,121 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +using EcencyApi.Infrastructure; + +namespace EcencyApi.Handlers; + +/// +/// Port of src/server/handlers/auth-api.ts, plus the getTokenUrl/decodeToken +/// helpers it uses from src/common/helper/hive-signer.ts. +/// +public static class AuthApi +{ + /// hive-signer.ts getTokenUrl — values interpolated raw, not URL-encoded. + private static string GetTokenUrl(string code, string secret) => + $"https://hivesigner.com/api/oauth2/token?code={code}&client_secret={secret}"; + + /// + /// hive-signer.ts decodeToken: normalize base64url -> base64, decode with + /// Node Buffer leniency, JSON.parse. Returns the parsed node (JSON null + /// parses to a null reference, matching the JS falsy check) or null on + /// any decode/parse failure. + /// + private static JsonNode? DecodeToken(string code) + { + var bytes = B64u.DecodeLenient(code); + if (bytes == null) + { + return null; + } + + try + { + return JsonNode.Parse(Encoding.UTF8.GetString(bytes)); + } + catch (JsonException) + { + return null; + } + } + + /// + /// `${field}` template-literal / string-concatenation coercion for body + /// fields (absent -> "undefined", null -> "null", etc), as used for + /// username/password in hsTokenCreate. + /// + private static string JsTemplate(JsonObject body, string key) => + body.TryGetPropertyValue(key, out var v) ? JsToString(v) : "undefined"; + + private static string JsToString(JsonNode? node) + { + switch (node) + { + case null: + return "null"; + case JsonObject: + return "[object Object]"; + case JsonArray arr: + // Array.prototype.toString: elements joined with ",", null -> "" + return string.Join(",", arr.Select(e => e == null ? "" : JsToString(e))); + case JsonValue v: + if (v.TryGetValue(out var s)) return s; + if (v.TryGetValue(out var b)) return b ? "true" : "false"; + return JsJson.Stringify(v); + default: + return "undefined"; + } + } + + public static async Task HsTokenRefresh(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var code = body.Str("code"); + + // if (!decodeToken(code)) — the JS falsy check also rejects codes that + // decode to falsy JSON (null/0/""/false). + if (code == null || !JsJson.IsTruthy(DecodeToken(code))) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + + await Upstream.Pipe( + Upstream.BaseApiRequest(GetTokenUrl(code, Config.HsClientSecret), HttpMethod.Get), ctx); + } + + public static async Task HsTokenCreate(HttpContext ctx) + { + var body = await ctx.ReadBody(); + + var username = JsTemplate(body, "username"); + var password = JsTemplate(body, "password"); + + // parseInt((new Date().getTime() / 1000) + '', 10) -> whole seconds + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000; + + // {"type":"code",app} — JSON.stringify drops the "app" key when the + // body has no app (undefined); present-with-null is kept as null. + var signedMessage = new JsonObject { ["type"] = "code" }; + if (body.TryGetPropertyValue("app", out var app)) + { + signedMessage["app"] = app?.DeepClone(); + } + + var messageObj = new JsonObject + { + ["signed_message"] = signedMessage, + ["authors"] = new JsonArray(JsonValue.Create(username)), + ["timestamp"] = timestamp, + }; + + var hash = HiveCrypto.Sha256Utf8(JsJson.Stringify(messageObj)); + var privateKey = HiveCrypto.FromLogin(username, password, "posting"); + var signature = HiveCrypto.Sign(privateKey, hash); + messageObj["signatures"] = new JsonArray(JsonValue.Create(signature)); + + // res.send(string) — plain 200 text response + await ctx.SendText(200, B64u.Encode(JsJson.Stringify(messageObj))); + } +} diff --git a/dotnet/EcencyApi/Handlers/ChainProviders.cs b/dotnet/EcencyApi/Handlers/ChainProviders.cs new file mode 100644 index 00000000..1960c107 --- /dev/null +++ b/dotnet/EcencyApi/Handlers/ChainProviders.cs @@ -0,0 +1,156 @@ +namespace EcencyApi.Handlers; + +// Port of src/server/chain-providers.ts. +// +// Static pools of public endpoints per supported chain, ordered by preference. +// Each pool can be overridden without a code change via a comma-separated env +// var (ETH_RPC_URLS, BNB_RPC_URLS, SOL_RPC_URLS, BTC_ESPLORA_URLS). + +/// Structural stand-in for the TS generic constraint `P extends { id: string }`. +public interface IChainProvider +{ + string Id { get; } +} + +/// +/// JSON-RPC provider. Extra request headers (e.g. an Authorization bearer for a +/// keyed endpoint) are kept out of the URL so credentials never land in access +/// logs or error traces. +/// +public sealed record RpcProvider(string Id, string Url, IReadOnlyDictionary? Headers = null) : IChainProvider; + +/// +/// Esplora (Bitcoin) provider. BearerAuth: authenticate with the Blockstream +/// enterprise OAuth token (BLOCKSTREAM_CLIENT_ID/SECRET). +/// +public sealed record EsploraProvider(string Id, string Url, bool BearerAuth = false) : IChainProvider; + +public static class ChainProviders +{ + private static string HostnameId(string url) + { + try + { + return new Uri(url, UriKind.Absolute).Host; + } + catch (UriFormatException) + { + return url; + } + } + + private static IReadOnlyList? PoolFromEnv(string envName) + { + var raw = Environment.GetEnvironmentVariable(envName); + + if (string.IsNullOrEmpty(raw)) + { + return null; + } + + var providers = raw + .Split(',') + .Select(value => value.Trim()) + .Where(value => value.Length > 0) + .Select(url => new RpcProvider(HostnameId(url), url)) + .ToList(); + + return providers.Count > 0 ? providers : null; + } + + public static readonly IReadOnlyList EthRpcPool = PoolFromEnv("ETH_RPC_URLS") ?? new List + { + new("publicnode", "https://ethereum-rpc.publicnode.com"), + new("drpc", "https://eth.drpc.org"), + new("mevblocker", "https://rpc.mevblocker.io"), + }; + + public static readonly IReadOnlyList BnbRpcPool = PoolFromEnv("BNB_RPC_URLS") ?? new List + { + new("publicnode", "https://bsc-rpc.publicnode.com"), + new("bnbchain", "https://bsc-dataseed.bnbchain.org"), + new("defibit", "https://bsc-dataseed1.defibit.io"), + }; + + private static readonly string HeliusKey = + (Environment.GetEnvironmentVariable("HELIUS_API_KEY") ?? "").Trim(); + + private static string HeliusRpcUrl(string apiKey) => + $"https://mainnet.helius-rpc.com/?api-key={EncodeUriComponent(apiKey)}"; + + public static readonly IReadOnlyList SolRpcPool = PoolFromEnv("SOL_RPC_URLS") ?? BuildDefaultSolPool(); + + private static IReadOnlyList BuildDefaultSolPool() + { + var pool = new List + { + new("publicnode", "https://solana-rpc.publicnode.com"), + new("solana-foundation", "https://api.mainnet.solana.com"), + }; + + // Helius RPC authenticates with the api-key query parameter. + if (HeliusKey.Length > 0) + { + pool.Add(new RpcProvider("helius", HeliusRpcUrl(HeliusKey))); + } + + return pool; + } + + private static readonly bool BlockstreamCredsConfigured = + (Environment.GetEnvironmentVariable("BLOCKSTREAM_CLIENT_ID") ?? "").Trim().Length > 0 + && (Environment.GetEnvironmentVariable("BLOCKSTREAM_CLIENT_SECRET") ?? "").Trim().Length > 0; + + // enterprise.blockstream.info requires the Blockstream OAuth bearer token; infer + // it so an env override can include the enterprise endpoint without silently + // running unauthenticated. + private static readonly HashSet BearerAuthHosts = new() { "enterprise.blockstream.info" }; + + private static EsploraProvider EsploraProviderFromUrl(string url) => + new(HostnameId(url), url, BearerAuthHosts.Contains(HostnameId(url))); + + public static readonly IReadOnlyList BtcEsploraPool = BuildBtcEsploraPool(); + + private static IReadOnlyList BuildBtcEsploraPool() + { + var rawEnv = Environment.GetEnvironmentVariable("BTC_ESPLORA_URLS"); + if (!string.IsNullOrEmpty(rawEnv)) + { + var fromEnv = rawEnv + .Split(',') + .Select(value => value.Trim()) + .Where(value => value.Length > 0) + .Select(EsploraProviderFromUrl) + .ToList(); + if (fromEnv.Count > 0) + { + return fromEnv; + } + } + + var pool = new List { new("mempool", "https://mempool.space/api") }; + + if (BlockstreamCredsConfigured) + { + pool.Add(new EsploraProvider("blockstream", "https://enterprise.blockstream.info/api", true)); + } + + pool.Add(new EsploraProvider("blockstream-public", "https://blockstream.info/api")); + + return pool; + } + + /// + /// JS encodeURIComponent parity. Uri.EscapeDataString escapes the RFC 3986 + /// sub-delims ! * ' ( ) that encodeURIComponent leaves raw, so restore them. + /// (EscapeDataString output only contains "%21" etc. as escapes of those very + /// characters, so the replacements are unambiguous.) + /// + internal static string EncodeUriComponent(string value) => + Uri.EscapeDataString(value) + .Replace("%21", "!") + .Replace("%2A", "*") + .Replace("%27", "'") + .Replace("%28", "(") + .Replace("%29", ")"); +} diff --git a/dotnet/EcencyApi/Handlers/Constants.cs b/dotnet/EcencyApi/Handlers/Constants.cs new file mode 100644 index 00000000..d7cfab09 --- /dev/null +++ b/dotnet/EcencyApi/Handlers/Constants.cs @@ -0,0 +1,102 @@ +using System.Text.Json.Nodes; + +namespace EcencyApi.Handlers; + +/// Port of src/server/handlers/constants.ts. +public static class Constants +{ + public static readonly string[] Bots = + { + "amazingdrinks", + "asean.hive", + "bbhbot", + "bdcommunity", + "beerlover", + "boomerang", + "booster", + "buildawhale", + "curation-cartel", + "curangel", + "c-squared", + "dhedge", + "discovery-it", + "dookbot", + "dsc-r2cornell", + "gifbot", + "hive-177745", + "hivebits", + "hivebuzz", + "hive-br.voter", + "hivecurators", + "hivegifbot", + "hivepakistan", + "hive-lu", + "hiq.smartbot", + "hk-gifts", + "hug.bot", + "indiaunited", + "indeedly", + "kennybrown", + "ladytoken", + "lolzbot", + "luvshares", + "meme.bot", + "neoxian-city", + "pgm-curator", + "pinmapple", + "pixresteemer", + "pizzabot", + "poshthreads", + "poshtoken", + "promobot", + "sloth.buzz", + "sneaky-ninja", + "splinterboost", + "steem-bet", + "steemitboard", + "steemstem", + "steem-ua", + "steemium", + "steem-plus", + "terraboost", + "tipu", + "thepimpdistrict", + "upmewhale", + "upvotebank", + "utopian-io", + "visualbot", + "warofclans", + "weed.dispenser", + "worldmappin", + "wine.bot", + "youarealive", + }; + + public static JsonArray BotsJson() + { + var arr = new JsonArray(); + foreach (var b in Bots) + { + arr.Add(b); + } + return arr; + } + + public const int ActiveProposalId = 336; + + public static readonly IReadOnlyDictionary AssetIconUrls = + new Dictionary + { + ["POINTS"] = "https://images.ecency.com/DQmRhnpQt7zzfS67uDGv6TPLGmKFhWfNFrDLECP4M3bRo4f/ecency_logo_2x.png", + ["HIVE"] = "https://images.ecency.com/DQmb7w4JNXXPKyzYVk615xGSfMTKLArQ7R9kWapuiY8dNHW/hive_icon.png", + ["HBD"] = "https://images.ecency.com/DQmbXwGcbDvATn7x7XpZVPuYhB8YYTozDDvNtJanVpbdE8f/hbd_icon.png", + ["SPK"] = "https://images.ecency.com/DQmZKETm7FX3HPmbohY2EEXDy3CebxZg8ESqU2ixVC77aA2/image.png", + ["SPK_PLACEHOLDER"] = "https://images.ecency.com/DQmbUoMBYahwwaZ795LV1T19Nm6nWQ5EpD9NCV9jqEnY92b/image_1_.png", + ["ENGINE_PLACEHOLDER"] = "https://images.ecency.com/DQmUWpEYA5f2U9NmCEheSdzXwqQrBTL16jG6vWBJGYjUtZB/image_2_.png", + ["CHAIN_PLACEHOLDER"] = "https://images.ecency.com/DQmNhVLfUxifjFQidjj9BN8kbTBcp36LSoqR1zYauUMnJEC/chain_placeholder.png", + ["BNB"] = "https://images.ecency.com/DQmezqZDiHN1NKYsLBWYwvBQ1mNhypkoe5w4JPwjeC3E4xH/bnb.png", + ["BTC"] = "https://images.ecency.com/DQmPKC8rkqjCdMLMtwayQ81Mj62pjVBj81ZZoZBVdiJSeeH/btc.png", + ["SOL"] = "https://images.ecency.com/DQmZJJmjz38nQGwNDG7Fuo6dHbS6P29SN7FcNy1RcSbWaj1/solana.png", + ["ETH"] = "https://images.ecency.com/DQmebRVg81CP1kMeSEsMm8JHDMCu5Vkac5ndUHH4ppGSFU6/eth.png", + }; +} diff --git a/dotnet/EcencyApi/Handlers/HiveExplorer.cs b/dotnet/EcencyApi/Handlers/HiveExplorer.cs new file mode 100644 index 00000000..094d6b61 --- /dev/null +++ b/dotnet/EcencyApi/Handlers/HiveExplorer.cs @@ -0,0 +1,355 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using EcencyApi.Infrastructure; + +namespace EcencyApi.Handlers; + +/// +/// Port of src/server/handlers/hive-explorer.ts — a helper module (no wired +/// routes) used by the wallet handlers. +/// +/// Fetch chain data directly from Hive RPC nodes (HiveClients.Default handles +/// failover across the list) instead of routing through an external REST +/// gateway, which is a single point of failure for the portfolio endpoints. +/// +public static class HiveExplorer +{ + public sealed record GlobalProps(double HivePerMVests, double HbdApr, double HpApr); + + public static async Task FetchGlobalProps() + { + try + { + var globalDynamic = await HiveClients.Default.GetDynamicGlobalProperties(); + + if (!JsJson.IsTruthy(globalDynamic)) + { + throw new Exception("Invalid global props data"); + } + + var totalFunds = GetVestAmount(globalDynamic!["total_vesting_fund_hive"]); + var totalShares = GetVestAmount(globalDynamic["total_vesting_shares"]); + var virtualSupply = GetVestAmount(globalDynamic["virtual_supply"]); + + var hivePerMVests = (totalFunds / totalShares) * 1e6; + + var hbdInterestRateRaw = NumericFieldOrZero(globalDynamic, "hbd_interest_rate"); + var hbdApr = double.IsFinite(hbdInterestRateRaw) ? hbdInterestRateRaw / 100 : 0d; + + var vestingRewardPercentRaw = NumericFieldOrZero(globalDynamic, "vesting_reward_percent"); + var vestingRewardPercent = vestingRewardPercentRaw / 10000; + + var headBlockNumber = NumericFieldOrZero(globalDynamic, "head_block_number"); + + // The blockchain exposes the current inflation rate (in basis points) as part + // of the global props. Prefer that exact value when present so the APR stays + // perfectly aligned with what on-chain tooling reports. + var currentInflationRateRaw = NumericFieldOrZero(globalDynamic, "current_inflation_rate"); + + const double inflationBase = 9.5; + const double inflationDecreasePerStep = 0.01; + const double blocksPerStep = 250000; + const double inflationFloor = 0.95; + // Historically Hive did not start lowering the inflation rate until several + // million blocks into the chain. Mirror that behaviour so the fallback math + // matches the node implementation when `current_inflation_rate` is missing. + const double inflationStartBlock = 7000000; + var inflationReductionSteps = Math.Max(headBlockNumber - inflationStartBlock, 0d) / blocksPerStep; + var derivedInflationRate = Math.Max( + inflationFloor, + inflationBase - Math.Floor(inflationReductionSteps) * inflationDecreasePerStep); + + var inflationRatePercent = currentInflationRateRaw > 0 + ? currentInflationRateRaw / 100 + : derivedInflationRate; + + var hpAprCandidate = + totalFunds > 0 && virtualSupply > 0 && vestingRewardPercent > 0 + ? (virtualSupply * (inflationRatePercent / 100) * vestingRewardPercent * 100) / totalFunds + : 0d; + var hpApr = double.IsFinite(hpAprCandidate) ? hpAprCandidate : 0d; + + return new GlobalProps(hivePerMVests, hbdApr, hpApr); + } + catch + { + throw new Exception("Failed to get globalProps"); + } + } + + /// getAccount — fetch raw account data without post processings. + public static async Task GetAccount(string? username) + { + try + { + var data = await HiveClients.Default.GetAccounts(new[] { username }); + + if (data is { Count: > 0 }) + { + return data[0]!; + } + + // In JS a non-array result TypeErrors on data.length instead; either way the + // catch below rewrites the error to the fixed message. + throw new Exception($"Account not found, {(data is null ? "null" : JsJson.Stringify(data))}"); + } + catch + { + throw new Exception("Failed to get account data"); + } + } + + // util.ts parseToken — shared with the wallet handlers. + // condenser_api (what dhive calls) returns string assets like "123.456 HIVE". + // database_api / NAI responses encode the same value as { amount, precision }. + // Accept both forms so a balance never silently parses to 0 if the upstream + // asset representation changes. + public static double ParseToken(JsonNode? val) + { + if (val is JsonObject obj) + { + // Number(val.amount) — absent key is undefined -> NaN + var amount = obj.TryGetPropertyValue("amount", out var amountNode) + ? JsNumber(amountNode) + : double.NaN; + var precisionRaw = obj.TryGetPropertyValue("precision", out var precisionNode) + ? JsNumber(precisionNode) + : double.NaN; + // Number(val.precision) || 0 + var precision = double.IsNaN(precisionRaw) || precisionRaw == 0d ? 0d : precisionRaw; + return double.IsFinite(amount) ? amount / Math.Pow(10, precision) : 0d; + } + + if (val is JsonArray) + { + // JS: typeof [] === 'object' -> object branch; missing .amount -> NaN -> 0 + return 0d; + } + + if (val is not JsonValue jv || !jv.TryGetValue(out var s)) + { + return 0d; + } + + // checks if first part of string is float + if (!TokenRegex.IsMatch(s)) + { + return 0d; + } + + return ParseFloatJs(s.Split(' ')[0]); + } + + // JS: /^\-?[0-9]+(e[0-9]+)?(\.[0-9]+)? .*$/ — '.' excludes JS line terminators + // (\n \r U+2028 U+2029) and '$' (non-multiline) anchors to the very end of input. + private static readonly Regex TokenRegex = + new("^-?[0-9]+(e[0-9]+)?(\\.[0-9]+)? [^\\n\\r\\u2028\\u2029]*\\z", RegexOptions.Compiled); + + // parseFloat: longest StrDecimalLiteral prefix ("1e3.5" parses as "1e3" -> 1000). + private static readonly Regex ParseFloatPrefix = + new("^[+-]?([0-9]+(\\.[0-9]*)?|\\.[0-9]+)([eE][+-]?[0-9]+)?", RegexOptions.Compiled); + + private static double ParseFloatJs(string s) + { + var m = ParseFloatPrefix.Match(s); + if (!m.Success) + { + return double.NaN; + } + return double.Parse(m.Value, NumberStyles.Float, CultureInfo.InvariantCulture); + } + + // hive-explorer.ts _getVestAmount: string -> parseToken; otherwise + // value.amount / Math.pow(10, value.precision). null/undefined throw (the JS + // TypeError), which the outer catch rewrites to "Failed to get globalProps". + private static double GetVestAmount(JsonNode? value) + { + if (value is JsonValue jv && jv.TryGetValue(out _)) + { + return ParseToken(value); + } + + if (value is JsonObject obj) + { + var amount = obj.TryGetPropertyValue("amount", out var amountNode) + ? JsNumber(amountNode) + : double.NaN; + var precision = obj.TryGetPropertyValue("precision", out var precisionNode) + ? JsNumber(precisionNode) + : double.NaN; + return amount / Math.Pow(10, precision); + } + + if (value is null) + { + throw new Exception("Cannot read properties of null (reading 'amount')"); + } + + // numbers/booleans/arrays: .amount / .precision are undefined -> NaN / NaN + return double.NaN; + } + + // typeof v === 'number' ? v : Number(v) || 0 + private static double NumericFieldOrZero(JsonNode globalDynamic, string key) + { + var node = globalDynamic[key]; + if (node is JsonValue jv && jv.GetValueKind() == JsonValueKind.Number) + { + return JsNumber(node); + } + // absent (Number(undefined) -> NaN) and JSON null (Number(null) -> 0) both + // collapse to the || 0 fallback + var n = node is null ? double.NaN : JsNumber(node); + return double.IsNaN(n) || n == 0d ? 0d : n; + } + + // Number(x) coercion for a JSON value. Callers map absent keys to NaN + // themselves, since Number(undefined) is NaN while Number(null) is 0. + private static double JsNumber(JsonNode? node) + { + switch (node) + { + case null: + return 0d; // Number(null) + case JsonObject: + return double.NaN; // Number({}) -> NaN + case JsonArray arr: + // Number([]) -> 0; Number([x]) coerces via [x].toString() + if (arr.Count == 0) + { + return 0d; + } + if (arr.Count == 1) + { + var el = arr[0]; + if (el is null) + { + return 0d; // [null] -> "" -> 0 + } + if (el is JsonArray nested) + { + return JsNumber(nested); + } + if (el is JsonValue ev) + { + if (ev.TryGetValue(out _)) + { + return double.NaN; // "true"/"false" don't parse + } + if (ev.TryGetValue(out var es)) + { + return JsNumberString(es); + } + return JsNumber(el); // number round-trips through toString + } + return double.NaN; // [{}] -> "[object Object]" + } + return double.NaN; // "a,b" never parses as a number + case JsonValue value: + if (value.TryGetValue(out var b)) + { + return b ? 1d : 0d; + } + if (value.TryGetValue(out var str)) + { + return JsNumberString(str); + } + if (value.TryGetValue(out var d)) + { + return d; + } + if (value.TryGetValue(out var l)) + { + return l; + } + if (value.TryGetValue(out var i)) + { + return i; + } + if (value.TryGetValue(out var dec)) + { + return (double)dec; + } + return double.NaN; + default: + return double.NaN; + } + } + + // Number("...") string coercion (ES StringNumericLiteral). + private static double JsNumberString(string s) + { + var t = TrimJsWhiteSpace(s); + if (t.Length == 0) + { + return 0d; // Number("") / whitespace-only -> 0 + } + + // hex / binary / octal integer literals (no sign allowed) + if (t.Length > 2 && t[0] == '0' && t[1] is 'x' or 'X' or 'b' or 'B' or 'o' or 'O') + { + var radix = t[1] is 'x' or 'X' ? 16 : t[1] is 'b' or 'B' ? 2 : 8; + var acc = 0d; + for (var i = 2; i < t.Length; i++) + { + var digit = DigitValue(t[i]); + if (digit < 0 || digit >= radix) + { + return double.NaN; + } + acc = acc * radix + digit; + } + return acc; + } + + if (t == "Infinity" || t == "+Infinity") + { + return double.PositiveInfinity; + } + if (t == "-Infinity") + { + return double.NegativeInfinity; + } + + return double.TryParse( + t, + NumberStyles.Float & ~(NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite), + CultureInfo.InvariantCulture, + out var v) + ? v + : double.NaN; + } + + private static int DigitValue(char c) => c switch + { + >= '0' and <= '9' => c - '0', + >= 'a' and <= 'f' => c - 'a' + 10, + >= 'A' and <= 'F' => c - 'A' + 10, + _ => -1, + }; + + private static string TrimJsWhiteSpace(string s) + { + var start = 0; + var end = s.Length; + while (start < end && IsJsWhiteSpace(s[start])) + { + start++; + } + while (end > start && IsJsWhiteSpace(s[end - 1])) + { + end--; + } + return s.Substring(start, end - start); + } + + // WhiteSpace + LineTerminator per ECMA-262 (differs from char.IsWhiteSpace: + // U+0085 is not JS whitespace while U+FEFF is). + private static bool IsJsWhiteSpace(char c) => + c is '\t' or '\n' or '\v' or '\f' or '\r' or ' ' + or '\u00A0' or '\u1680' or '\u2028' or '\u2029' + or '\u202F' or '\u205F' or '\u3000' or '\uFEFF' + || (c >= '\u2000' && c <= '\u200A'); +} diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Accounts.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Accounts.cs new file mode 100644 index 00000000..7a853a18 --- /dev/null +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Accounts.cs @@ -0,0 +1,234 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using EcencyApi.Infrastructure; + +namespace EcencyApi.Handlers; + +/// +/// Port of src/server/handlers/private-api.ts lines 901-1003: streak-freeze +/// endpoints, signup client-IP/Turnstile helpers, and the account-create routes. +/// +public static partial class PrivateApi +{ + // Streak Freeze buys/spends Points, so the username is taken from the authenticated + // token (validateCode), never from the request body. + public static async Task StreakFreeze(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await RequireAuthedUsername(ctx, body); + if (username == null) + { + return; + } + await Upstream.Pipe( + ApiClient.ApiRequest($"users/{username}/streak-freeze", HttpMethod.Get), ctx); + } + + public static async Task StreakFreezeBuy(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await RequireAuthedUsername(ctx, body); + if (username == null) + { + return; + } + + // Require the idempotency key: a spend must never reach ePoints without it (a + // missing key drops from the payload and removes double-charge protection on retry). + var idempotencyKey = body.Str("idempotency_key"); + if (idempotencyKey == null || idempotencyKey.Trim().Length == 0) + { + await ctx.SendText(400, "idempotency_key is required"); + return; + } + + var payload = new JsonObject { ["idempotency_key"] = idempotencyKey }; + await Upstream.Pipe( + ApiClient.ApiRequest($"users/{username}/streak-freeze", HttpMethod.Post, null, payload), ctx); + } + + /// + /// Resolve the originating client IP for the account-create endpoints from the + /// proxy-set X-Real-IP header. X-Forwarded-For is not used here because it can + /// carry client-supplied values; there is no X-Forwarded-For fallback. Returns '' + /// when the header is absent. + /// + public static string SignupClientIp(HttpContext ctx) + { + var realIp = ctx.Request.Headers["x-real-ip"]; + var first = realIp.Count > 0 ? realIp[0] : null; + return string.IsNullOrEmpty(first) ? "" : first; + } + + /// + /// Server-side Cloudflare Turnstile verification -- the single captcha verifier for the + /// account-create and paid-account-create routes. Returns true ONLY on a confirmed-human + /// token; fails CLOSED on a missing secret/token, provider error, or rejection (a network + /// blip must never open the gate). The secret is server-side only (config.turnstileSecret). + /// + private static async Task VerifyTurnstile(JsonNode? token, string ip) + { + var secret = Config.TurnstileSecret; + var tokenStr = token is JsonValue tv && tv.TryGetValue(out string? ts) ? ts : null; + if (string.IsNullOrEmpty(secret) || tokenStr == null || tokenStr.Trim().Length == 0) + { + return false; + } + + try + { + var form = new List> + { + new("secret", secret), + new("response", tokenStr.Trim()), + }; + if (ip.Length != 0) + { + form.Add(new("remoteip", ip)); + } + + var data = await PostTurnstileForm(form); + return data is JsonObject obj + && obj.TryGetPropertyValue("success", out var success) + && success is JsonValue sv + && sv.TryGetValue(out bool b) + && b; + } + catch (Exception err) + { + Console.WriteLine($"verifyTurnstile: siteverify error {err.Message}"); + return false; + } + } + + /// + /// Documented exception to the shared-infra rule: the TS uses a bare axios.post + /// with a form-encoded body (baseApiRequest always sends JSON), so this posts the + /// form via Upstream.Http directly with the same 8000ms timeout and axios's + /// default 2xx-only / parse-lenient response semantics. Returns the parsed JSON + /// body, or null when the body wasn't a JSON document (axios would keep the raw + /// string, whose .success is undefined). Non-2xx statuses and timeouts throw, + /// matching axios's rejection into the caller's fail-closed catch. + /// + private static async Task PostTurnstileForm(List> form) + { + using var req = new HttpRequestMessage( + HttpMethod.Post, "https://challenges.cloudflare.com/turnstile/v0/siteverify") + { + Content = new FormUrlEncodedContent(form), + }; + + using var cts = new CancellationTokenSource(8000); + HttpResponseMessage resp; + try + { + resp = await Upstream.Http.SendAsync(req, HttpCompletionOption.ResponseContentRead, cts.Token); + } + catch (OperationCanceledException e) when (cts.IsCancellationRequested) + { + throw new Exception("timeout of 8000ms exceeded", e); + } + + using (resp) + { + var text = await resp.Content.ReadAsStringAsync(); + if (!resp.IsSuccessStatusCode) + { + throw new Exception($"Request failed with status code {(int)resp.StatusCode}"); + } + + if (text.Length == 0) + { + return null; + } + + try + { + return JsonNode.Parse(text); + } + catch (JsonException) + { + return null; + } + } + } + + /// + /// Turnstile gate for account creation. Returns true when the request must be REJECTED. + /// Enforced for every request by default (config.captchaMode "hard"); an operator can set + /// "off" as an emergency break-glass, since verification otherwise fails closed. + /// + private static async Task AccountCaptchaRejected(JsonNode? token, string ip) + { + if (Config.CaptchaMode == "off") + { + return false; + } + return !(await VerifyTurnstile(token, ip)); + } + + public static async Task CreateAccount(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var ip = SignupClientIp(ctx); + + // Single server-side Turnstile gate, enforced by default. vapi is the sole verifier, so the + // (single-use) token is consumed here and never forwarded downstream. + if (await AccountCaptchaRejected(body.Field("captcha_token"), ip)) + { + await ctx.SendJson(406, new JsonObject + { + ["code"] = 113, + ["message"] = "Please complete the verification and try again.", + }); + return; + } + + var headers = new List> { new("X-Real-IP-V", ip) }; + + // { username, email, referral }: keys present in the request body are forwarded + // (null included); absent keys are omitted like JSON.stringify drops undefined. + var payload = new JsonObject(); + if (body.ContainsKey("username")) + { + payload["username"] = body["username"]?.DeepClone(); + } + if (body.ContainsKey("email")) + { + payload["email"] = body["email"]?.DeepClone(); + } + if (body.ContainsKey("referral")) + { + payload["referral"] = body["referral"]?.DeepClone(); + } + + // On-chain account creation/broadcast can take longer than the default. + await Upstream.Pipe( + ApiClient.ApiRequest("signup/account-create", HttpMethod.Post, headers, payload, null, 30000), ctx); + } + + public static async Task CreateAccountFriend(HttpContext ctx) + { + var body = await ctx.ReadBody(); + + var headers = new List> { new("X-Real-IP-V", SignupClientIp(ctx)) }; + + var payload = new JsonObject(); + if (body.ContainsKey("username")) + { + payload["username"] = body["username"]?.DeepClone(); + } + if (body.ContainsKey("email")) + { + payload["email"] = body["email"]?.DeepClone(); + } + if (body.ContainsKey("friend")) + { + payload["friend"] = body["friend"]?.DeepClone(); + } + + // On-chain account creation/broadcast can take longer than the default. + await Upstream.Pipe( + ApiClient.ApiRequest("signup/account-create-friend", HttpMethod.Post, headers, payload, null, 30000), ctx); + } +} diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Chain.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Chain.cs new file mode 100644 index 00000000..b2695a5a --- /dev/null +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Chain.cs @@ -0,0 +1,1167 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Numerics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using EcencyApi.Infrastructure; + +namespace EcencyApi.Handlers; + +/// +/// Port of src/server/handlers/private-api.ts lines 186-711 (chain balance + +/// JSON-RPC proxy) plus the esplora/blockstream section of src/server/helper.ts +/// (ChainBalanceResponse, blockstream token cache, fetchEsploraBalance, +/// parseRateLimitHeader, toBigInt). +/// +public sealed class ChainBalanceResponse +{ + public required string Chain; + public string? Balance; + public required string Unit; + public JsonNode? Raw; + public string? NodeId; + public required string Provider; + public double? RateLimitRemaining; + + /// + /// Serializes with the TS object-literal key order; nodeId/rateLimitRemaining + /// are omitted when null the way JSON.stringify omits undefined keys. + /// Raw is deep-cloned because a cached/deduped response can be serialized + /// more than once and a JsonNode may only have a single parent. + /// + public JsonObject ToJson() + { + var obj = new JsonObject + { + ["chain"] = Chain, + ["balance"] = Balance, + ["unit"] = Unit, + ["raw"] = Raw?.DeepClone(), + }; + if (NodeId != null) + { + obj["nodeId"] = NodeId; + } + obj["provider"] = Provider; + if (RateLimitRemaining != null) + { + obj["rateLimitRemaining"] = JsonValue.Create(RateLimitRemaining.Value); + } + return obj; + } +} + +public static partial class PrivateApi +{ + private const int ProviderTimeoutMs = 10_000; + + // Short-lived per-address balance cache (via the shared cache) plus an + // in-flight dedup map so concurrent requests for one address make one upstream call. + private const double BalanceCacheTtlSeconds = 15; + + private const string BlockstreamTokenCacheKey = "blockstream:access-token"; + private const string BlockstreamTokenUrl = + "https://login.blockstream.com/realms/blockstream-public/protocol/openid-connect/token"; + + private const string EsploraUserAgent = "EcencyBalanceBot/1.0 (+https://ecency.com)"; + + private static readonly Regex ChainParamRegex = new("^[a-z0-9-]+$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + // Address validators double as a hard allowlist: every accepted address is a + // safe URL path segment (no "/", ".", "%"), which blocks path-traversal into + // other provider endpoints via the balance route. + private static readonly Regex EvmAddressRegex = new("^0x[a-fA-F0-9]{40}$"); + private static readonly Regex BtcAddressRegex = new("^(bc1[a-z0-9]{6,87}|[13][a-km-zA-HJ-NP-Z1-9]{25,62})$"); + private static readonly Regex SolAddressRegex = new("^[1-9A-HJ-NP-Za-km-z]{32,44}$"); + + private static readonly Regex HexStringRegex = new("^[a-fA-F0-9]+$"); + private static readonly Regex IntegerStringRegex = new("^-?[0-9]+$"); + + private static readonly ConcurrentDictionary> InFlightBalance = new(); + + private static string BalanceCacheKey(string chain, string address) => $"chain-balance:{chain}:{address}"; + + // A request the client got wrong (bad address, unsupported chain). It is + // rejected up front with its HTTP status and never reaches a provider, so it + // must not be reported as a 502 upstream failure. + private sealed class TerminalProviderException : Exception + { + public int Status { get; } + + public TerminalProviderException(string message, int status = 400) : base(message) + { + Status = status; + } + } + + // Stand-in for an axios HTTP-status rejection (default validateStatus): + // carries the upstream response so handlers can pass status/body through + // exactly like the `axios.isAxiosError(err) && err.response` branches. + private sealed class ChainHttpError : Exception + { + public UpstreamResponse Response { get; } + + public int Status => Response.Status; + + public ChainHttpError(UpstreamResponse response) + : base($"Request failed with status code {response.Status}") + { + Response = response; + } + } + + // TS attaches `jsonRpcBody` to a plain Error: a JSON-RPC error envelope that + // should fail over to the next provider but still reach the client verbatim + // if every provider returns one. + private sealed class ChainRpcEnvelopeError : Exception + { + public JsonNode? Body { get; } + + public ChainRpcEnvelopeError(string message, JsonNode? body) : base(message) + { + Body = body; + } + } + + // Try each provider in the pool in order until one succeeds; fail over on any + // provider-side error to maximize balance availability. + private static async Task TryProviders( + string chain, + IReadOnlyList providers, + string operation, + Func> fn) where TProvider : IChainProvider + { + Exception lastError = new Exception($"No {operation} providers configured for {chain}"); + + foreach (var provider in providers) + { + try + { + return await fn(provider); + } + catch (TerminalProviderException) + { + throw; + } + catch (Exception err) + { + lastError = err; + var detail = DescribeProviderError(err); + Console.WriteLine($"{chain} {operation} failed on {provider.Id} ({detail}), trying next provider"); + } + } + + throw lastError; + } + + // axios-error detail string: `status=${err.response?.status || "none"} code=${err.code || "none"}` + private static string DescribeProviderError(Exception err) => err switch + { + ChainHttpError httpErr => + $"status={httpErr.Status} code={(httpErr.Status >= 500 ? "ERR_BAD_RESPONSE" : "ERR_BAD_REQUEST")}", + UpstreamTimeoutException => "status=none code=ECONNABORTED", + HttpRequestException transportErr => $"status=none code={SocketErrorCode(transportErr)}", + _ => err.Message, + }; + + private static string SocketErrorCode(HttpRequestException err) => + (err.InnerException as System.Net.Sockets.SocketException)?.SocketErrorCode switch + { + System.Net.Sockets.SocketError.ConnectionRefused => "ECONNREFUSED", + System.Net.Sockets.SocketError.HostNotFound => "ENOTFOUND", + System.Net.Sockets.SocketError.TimedOut => "ETIMEDOUT", + System.Net.Sockets.SocketError.ConnectionReset => "ECONNRESET", + _ => "none", + }; + + // axios timeout errors carry "timeout of {n}ms exceeded"; every other error + // surfaces its own message (err instanceof Error ? err.message : ...). + private static string ChainErrorMessage(Exception err) => + err is UpstreamTimeoutException ? $"timeout of {ProviderTimeoutMs}ms exceeded" : err.Message; + + private static void LogUpstreamError(string context, Exception err) + { + if (err is ChainHttpError or UpstreamTimeoutException or HttpRequestException) + { + var status = err is ChainHttpError httpErr ? httpErr.Status.ToString(CultureInfo.InvariantCulture) : "none"; + var code = err switch + { + ChainHttpError h => h.Status >= 500 ? "ERR_BAD_RESPONSE" : "ERR_BAD_REQUEST", + UpstreamTimeoutException => "ECONNABORTED", + HttpRequestException transportErr => SocketErrorCode(transportErr), + _ => "none", + }; + Console.Error.WriteLine($"{context} {{ status: {status}, code: '{code}', message: '{ChainErrorMessage(err)}' }}"); + return; + } + + Console.Error.WriteLine($"{context} {err}"); + } + + // { ...JSON_RPC_HEADERS, ...provider.headers } + private static Dictionary RpcHeaders(RpcProvider provider) + { + var headers = new Dictionary { ["Content-Type"] = "application/json" }; + if (provider.Headers != null) + { + foreach (var (name, value) in provider.Headers) + { + headers[name] = value; + } + } + return headers; + } + + // data.error?.message || fallback (with JS truthiness + String() coercion) + private static string RpcErrorMessage(JsonNode? errorNode, string fallback) + { + var messageNode = errorNode is JsonObject obj ? obj["message"] : null; + return JsJson.IsTruthy(messageNode) ? ChainJsString(messageNode) : fallback; + } + + // Shared JSON-RPC POST used by every EVM balance and proxy call. + private static async Task JsonRpcPost( + RpcProvider provider, + string method, + JsonArray parameters, + string id, + string errorLabel) + { + var payload = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["method"] = method, + ["params"] = parameters, + }; + + var resp = await Upstream.BaseApiRequest(provider.Url, HttpMethod.Post, + RpcHeaders(provider), payload, timeoutMs: ProviderTimeoutMs); + + // axios default validateStatus: non-2xx rejects + if (resp.Status < 200 || resp.Status >= 300) + { + throw new ChainHttpError(resp); + } + + JsonNode? data = resp.BodyIsJson ? resp.Json : JsonValue.Create(resp.RawText); + + var errorNode = data is JsonObject dataObj ? dataObj["error"] : null; + if (JsJson.IsTruthy(errorNode)) + { + throw new Exception(RpcErrorMessage(errorNode, errorLabel)); + } + + return data; + } + + private static string ParseHexBalance(JsonNode? value) + { + if (value is not JsonValue jsonValue || !jsonValue.TryGetValue(out var text)) + { + throw new Exception("Invalid hexadecimal balance response"); + } + + var normalized = text.StartsWith("0x", StringComparison.Ordinal) || text.StartsWith("0X", StringComparison.Ordinal) + ? text.Substring(2) + : text; + + if (normalized == "") + { + return "0"; + } + + if (!HexStringRegex.IsMatch(normalized)) + { + throw new Exception("Invalid hexadecimal balance response"); + } + + // The leading "0" keeps BigInteger's hex parser from reading a high first + // nibble as a sign bit (BigInt("0x...") is always unsigned). + return BigInteger.Parse("0" + normalized, NumberStyles.HexNumber, CultureInfo.InvariantCulture).ToString(); + } + + private static int FindJsonObjectEnd(string text, int start) + { + var depth = 0; + var inString = false; + var escaped = false; + + for (var index = start; index < text.Length; index += 1) + { + var ch = text[index]; + + if (inString) + { + if (escaped) + { + escaped = false; + } + else if (ch == '\\') + { + escaped = true; + } + else if (ch == '"') + { + inString = false; + } + continue; + } + + if (ch == '"') + { + inString = true; + } + else if (ch == '{') + { + depth += 1; + } + else if (ch == '}') + { + depth -= 1; + if (depth == 0) + { + return index; + } + } + } + + return -1; + } + + private static string? ExtractTopLevelNumericProperty(string text, string property) + { + var depth = 0; + var inString = false; + var escaped = false; + var keyLiteral = "\"" + property + "\""; + + for (var index = 0; index < text.Length; index += 1) + { + var ch = text[index]; + + if (inString) + { + if (escaped) + { + escaped = false; + } + else if (ch == '\\') + { + escaped = true; + } + else if (ch == '"') + { + inString = false; + } + continue; + } + + if (ch == '"') + { + if (depth == 0 && index + keyLiteral.Length <= text.Length + && string.CompareOrdinal(text, index, keyLiteral, 0, keyLiteral.Length) == 0) + { + var cursor = index + keyLiteral.Length; + while (cursor < text.Length && char.IsWhiteSpace(text[cursor])) cursor += 1; + if (cursor >= text.Length || text[cursor] != ':') + { + inString = true; + continue; + } + cursor += 1; + while (cursor < text.Length && char.IsWhiteSpace(text[cursor])) cursor += 1; + + // /^\d+/ — leading run of ASCII digits; returns unconditionally + // once the top-level key was located, exactly like the TS code. + var end = cursor; + while (end < text.Length && text[end] >= '0' && text[end] <= '9') end += 1; + return end > cursor ? text.Substring(cursor, end - cursor) : null; + } + + inString = true; + } + else if (ch == '{' || ch == '[') + { + depth += 1; + } + else if (ch == '}' || ch == ']') + { + depth -= 1; + } + } + + return null; + } + + private static string ExtractSolanaLamports(string rawText, JsonNode? parsed) + { + // typeof parsed?.result?.value !== "number" + var resultNode = parsed is JsonObject parsedObj ? parsedObj["result"] : null; + var valueNode = resultNode is JsonObject resultObj ? resultObj["value"] : null; + + if (valueNode is not JsonValue jsonValue + || !jsonValue.TryGetValue(out var element) + || element.ValueKind != JsonValueKind.Number) + { + throw new Exception("Invalid Solana balance response"); + } + + var resultMatch = Regex.Match(rawText, "\"result\"\\s*:"); + if (resultMatch.Success) + { + var resultStart = resultMatch.Index + resultMatch.Length; + var objectStart = rawText.IndexOf('{', resultStart); + if (objectStart != -1) + { + var objectEnd = FindJsonObjectEnd(rawText, objectStart); + if (objectEnd != -1) + { + var resultObject = rawText.Substring(objectStart + 1, objectEnd - objectStart - 1); + var rawLamports = ExtractTopLevelNumericProperty(resultObject, "value"); + if (!string.IsNullOrEmpty(rawLamports)) + { + return rawLamports; + } + } + } + } + + return ChainFormatNumber(Math.Truncate(element.GetDouble())); + } + + private static async Task FetchEvmBalance(string chain, RpcProvider provider, string address) + { + var data = await JsonRpcPost(provider, "eth_getBalance", new JsonArray(address, "latest"), + "balance", "EVM balance request failed"); + var balance = ParseHexBalance(data is JsonObject obj ? obj["result"] : null); + + return new ChainBalanceResponse + { + Chain = chain, + Balance = balance, + Unit = "wei", + Raw = data, + NodeId = provider.Id, + Provider = provider.Id, + }; + } + + private static async Task FetchSolanaBalance(RpcProvider provider, string address) + { + // TS keeps the raw response text (transformResponse: raw => raw): lamports + // is a u64 and JSON.parse would cap a balance above 2^53 as a lossy double, + // so it reads the integer from the text. Upstream already parsed the body, + // but System.Text.Json keeps raw numeric literals, so ToJsonString() is a + // faithful reconstruction of the raw body for the extraction below. + var payload = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = "balance", + ["method"] = "getBalance", + ["params"] = new JsonArray(address, new JsonObject { ["commitment"] = "finalized" }), + }; + + var resp = await Upstream.BaseApiRequest(provider.Url, HttpMethod.Post, + RpcHeaders(provider), payload, timeoutMs: ProviderTimeoutMs); + + // axios default validateStatus: non-2xx rejects + if (resp.Status < 200 || resp.Status >= 300) + { + throw new ChainHttpError(resp); + } + + if (!resp.BodyIsJson) + { + // JSON.parse(rawText) would throw + throw new Exception("Invalid Solana balance response"); + } + + var parsed = resp.Json; + var rawText = parsed?.ToJsonString() ?? "null"; + + var errorNode = parsed is JsonObject parsedObj ? parsedObj["error"] : null; + if (JsJson.IsTruthy(errorNode)) + { + throw new Exception(RpcErrorMessage(errorNode, "Solana balance request failed")); + } + + var balance = ExtractSolanaLamports(rawText, parsed); + + return new ChainBalanceResponse + { + Chain = "sol", + Balance = balance, + Unit = "lamports", + Raw = parsed, + NodeId = provider.Id, + Provider = provider.Id, + }; + } + + // ------------------------------------------------------------------ + // helper.ts: blockstream token + esplora balance + // ------------------------------------------------------------------ + + private static BigInteger ToBigInt(JsonNode? value) + { + // toBigInt: bigint pass-through (impossible from JSON), finite number -> + // truncate, integer-string -> parse, everything else -> 0. + if (value is JsonValue jsonValue && jsonValue.TryGetValue(out var element)) + { + if (element.ValueKind == JsonValueKind.Number) + { + var d = element.GetDouble(); + return double.IsFinite(d) ? new BigInteger(Math.Truncate(d)) : BigInteger.Zero; + } + + if (element.ValueKind == JsonValueKind.String) + { + var text = element.GetString()!.Trim(); + if (IntegerStringRegex.IsMatch(text)) + { + try + { + return BigInteger.Parse(text, CultureInfo.InvariantCulture); + } + catch (FormatException) + { + return BigInteger.Zero; + } + } + } + } + + return BigInteger.Zero; + } + + private static double? ParseRateLimitHeader(string? headerValue) + { + // The TS version also recurses into arrays of header values; axios on Node + // only ever yields arrays for set-cookie — duplicates of these headers are + // joined with ", " by Node's http module, and HttpResponseHeaders2 joins + // identically, so this string path is the complete observable behavior + // (a joined "5, 10" parses to NaN -> undefined in both stacks). + if (headerValue == null) + { + return null; + } + + var text = headerValue.Trim(); + if (text.Length == 0) + { + return null; + } + + var num = ChainJsNumber(text); + return double.IsFinite(num) ? num : null; + } + + private static async Task GetBlockstreamAccessToken() + { + var cached = MemCache.Get(BlockstreamTokenCacheKey); + if (!string.IsNullOrEmpty(cached)) + { + return cached; + } + + var clientId = Environment.GetEnvironmentVariable("BLOCKSTREAM_CLIENT_ID")?.Trim(); + var clientSecret = Environment.GetEnvironmentVariable("BLOCKSTREAM_CLIENT_SECRET")?.Trim(); + + if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientSecret)) + { + throw new Exception("Blockstream credentials are not configured"); + } + + var bodyText = string.Join("&", new[] + { + ("client_id", clientId), + ("client_secret", clientSecret), + ("grant_type", "client_credentials"), + ("scope", "openid"), + }.Select(pair => FormUrlEncodeComponent(pair.Item1) + "=" + FormUrlEncodeComponent(pair.Item2))); + + var resp = await PostFormUrlEncoded(BlockstreamTokenUrl, bodyText); + + // axios default validateStatus: non-2xx rejects + if (resp.Status < 200 || resp.Status >= 300) + { + throw new ChainHttpError(resp); + } + + // const { access_token, expires_in } = resp.data ?? {}; + var data = resp.BodyIsJson ? resp.Json : JsonValue.Create(resp.RawText); + var obj = data as JsonObject; + + var accessToken = obj?["access_token"] is JsonValue tokenValue && tokenValue.TryGetValue(out var tokenText) + ? tokenText + : null; + + if (string.IsNullOrEmpty(accessToken)) + { + throw new Exception("Blockstream token response missing access_token"); + } + + double? expiresNumeric = null; + if (obj?["expires_in"] is JsonValue expiresValue && expiresValue.TryGetValue(out var expiresEl)) + { + if (expiresEl.ValueKind == JsonValueKind.Number) + { + expiresNumeric = expiresEl.GetDouble(); + } + else if (expiresEl.ValueKind == JsonValueKind.String) + { + expiresNumeric = ChainJsNumber(expiresEl.GetString()!); + } + } + + var ttl = 240d; // default 4 minutes + if (expiresNumeric is double expires && double.IsFinite(expires)) + { + var adjusted = Math.Max(30, Math.Floor(expires - 30)); + if (double.IsFinite(adjusted) && adjusted > 0) + { + ttl = adjusted; + } + } + + MemCache.Set(BlockstreamTokenCacheKey, accessToken, ttl); + return accessToken; + } + + // Documented exception to "always use Upstream.BaseApiRequest": the token + // endpoint takes a URLSearchParams (application/x-www-form-urlencoded) body, + // which BaseApiRequest cannot produce (it always JSON-encodes payloads). + // Mirrors the axios call: 10000ms timeout, urlencoded body, JSON response + // parsing with raw-text fallback. + private static async Task PostFormUrlEncoded(string url, string body) + { + using var req = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = new StringContent(body), + }; + req.Content.Headers.ContentType = + System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded"); + + using var cts = new CancellationTokenSource(10000); + HttpResponseMessage resp; + try + { + resp = await Upstream.Http.SendAsync(req, HttpCompletionOption.ResponseContentRead, cts.Token); + } + catch (OperationCanceledException e) when (cts.IsCancellationRequested) + { + throw new UpstreamTimeoutException(url, e); + } + + using (resp) + { + var status = (int)resp.StatusCode; + var headers = new HttpResponseHeaders2(resp); + var text = await resp.Content.ReadAsStringAsync(); + + if (text.Length == 0) + { + return new UpstreamResponse { Status = status, RawText = "", Headers = headers }; + } + + try + { + var node = JsonNode.Parse(text); + return new UpstreamResponse { Status = status, Json = node, Headers = headers }; + } + catch (JsonException) + { + return new UpstreamResponse { Status = status, RawText = text, Headers = headers }; + } + } + } + + // application/x-www-form-urlencoded serializer with URLSearchParams parity: + // space -> "+", unreserved set is [A-Za-z0-9*-._], everything else %XX (UTF-8). + private static string FormUrlEncodeComponent(string value) + { + var sb = new StringBuilder(); + foreach (var b in Encoding.UTF8.GetBytes(value)) + { + if (b == (byte)' ') + { + sb.Append('+'); + } + else if (b == (byte)'*' || b == (byte)'-' || b == (byte)'.' || b == (byte)'_' + || (b >= (byte)'0' && b <= (byte)'9') + || (b >= (byte)'A' && b <= (byte)'Z') + || (b >= (byte)'a' && b <= (byte)'z')) + { + sb.Append((char)b); + } + else + { + sb.Append('%').Append(b.ToString("X2", CultureInfo.InvariantCulture)); + } + } + return sb.ToString(); + } + + private static async Task> BuildEsploraHeaders(EsploraProvider provider) + { + var headers = new Dictionary + { + ["User-Agent"] = EsploraUserAgent, + }; + + if (provider.BearerAuth) + { + headers["Authorization"] = $"Bearer {await GetBlockstreamAccessToken()}"; + } + + return headers; + } + + private static async Task FetchEsploraBalance(EsploraProvider provider, string address) + { + async Task Attempt(bool allowAuthRetry) + { + var resp = await Upstream.BaseApiRequest( + $"{provider.Url}/address/{ChainProviders.EncodeUriComponent(address)}", + HttpMethod.Get, + await BuildEsploraHeaders(provider), + timeoutMs: 10000); + + // axios validateStatus: (status) => status >= 200 && status < 500 — 5xx rejects + if (resp.Status < 200 || resp.Status >= 500) + { + throw new ChainHttpError(resp); + } + + if (resp.Status == 401 && provider.BearerAuth && allowAuthRetry) + { + MemCache.Del(BlockstreamTokenCacheKey); + return await Attempt(false); + } + + if (resp.Status != 200) + { + throw new Exception($"Esplora API error from {provider.Id} ({resp.Status})"); + } + + // const data = resp.data ?? {}; + JsonNode data = (resp.BodyIsJson ? resp.Json : JsonValue.Create(resp.RawText)) ?? new JsonObject(); + + var chainStats = (data as JsonObject)?["chain_stats"] as JsonObject; + var mempoolStats = (data as JsonObject)?["mempool_stats"] as JsonObject; + + var confirmedFunded = ToBigInt(chainStats?["funded_txo_sum"]); + var confirmedSpent = ToBigInt(chainStats?["spent_txo_sum"]); + var mempoolFunded = ToBigInt(mempoolStats?["funded_txo_sum"]); + var mempoolSpent = ToBigInt(mempoolStats?["spent_txo_sum"]); + + var confirmedBalance = confirmedFunded - confirmedSpent; + var mempoolBalance = mempoolFunded - mempoolSpent; + var total = confirmedBalance + mempoolBalance; + + var rateLimitRemaining = + ParseRateLimitHeader(resp.Headers.Get("x-ratelimit-remaining")) + ?? ParseRateLimitHeader(resp.Headers.Get("ratelimit-remaining")); + + return new ChainBalanceResponse + { + Chain = "btc", + Balance = total.ToString(), + Unit = "satoshi", + Raw = data, + NodeId = provider.Id, + Provider = provider.Id, + RateLimitRemaining = rateLimitRemaining, + }; + } + + return await Attempt(true); + } + + // ------------------------------------------------------------------ + // chain handler registry + fetchChainBalance + route handlers + // ------------------------------------------------------------------ + + private sealed record ChainHandlerDef( + Func? ValidateAddress, + Func> FetchBalance); + + private static readonly IReadOnlyDictionary ChainHandlers = + new Dictionary + { + ["btc"] = new( + address => BtcAddressRegex.IsMatch(address), + address => TryProviders("btc", ChainProviders.BtcEsploraPool, "balance", + provider => FetchEsploraBalance(provider, address))), + ["eth"] = new( + address => EvmAddressRegex.IsMatch(address), + address => TryProviders("eth", ChainProviders.EthRpcPool, "balance", + provider => FetchEvmBalance("eth", provider, address))), + ["bnb"] = new( + address => EvmAddressRegex.IsMatch(address), + address => TryProviders("bnb", ChainProviders.BnbRpcPool, "balance", + provider => FetchEvmBalance("bnb", provider, address))), + ["sol"] = new( + address => SolAddressRegex.IsMatch(address), + address => TryProviders("sol", ChainProviders.SolRpcPool, "balance", + provider => FetchSolanaBalance(provider, address))), + }; + + private static readonly IReadOnlyDictionary> RpcPoolByChain = + new Dictionary> + { + ["sol"] = ChainProviders.SolRpcPool, + ["eth"] = ChainProviders.EthRpcPool, + ["bnb"] = ChainProviders.BnbRpcPool, + }; + + /// + /// Fetches balance for a given chain and address directly (without HTTP overhead). + /// This is the core logic extracted from the balance endpoint for reuse. + /// + public static async Task FetchChainBalance(string chain, string address) + { + if (string.IsNullOrEmpty(chain) || string.IsNullOrEmpty(address)) + { + throw new Exception("Missing chain or address"); + } + + if (!ChainParamRegex.IsMatch(chain)) + { + throw new Exception("Invalid chain parameter"); + } + + var normalizedChain = chain.ToLowerInvariant(); + + if (!ChainHandlers.TryGetValue(normalizedChain, out var handler)) + { + throw new TerminalProviderException("Unsupported chain"); + } + + if (handler.ValidateAddress != null && !handler.ValidateAddress(address)) + { + throw new TerminalProviderException("Invalid address format"); + } + + var cacheKey = BalanceCacheKey(normalizedChain, address); + + var cached = MemCache.Get(cacheKey); + if (cached != null) + { + return cached; + } + + if (InFlightBalance.TryGetValue(cacheKey, out var existing)) + { + return await existing; + } + + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var winner = InFlightBalance.GetOrAdd(cacheKey, pending.Task); + if (!ReferenceEquals(winner, pending.Task)) + { + return await winner; + } + + try + { + var response = await handler.FetchBalance(address); + MemCache.Set(cacheKey, response, BalanceCacheTtlSeconds); + pending.TrySetResult(response); + return response; + } + catch (Exception e) + { + pending.TrySetException(e); + _ = pending.Task.Exception; // observe: a deduped waiter may not exist + throw; + } + finally + { + InFlightBalance.TryRemove(new KeyValuePair>(cacheKey, pending.Task)); + } + } + + public static async Task Balance(HttpContext ctx) + { + var chain = ctx.Request.RouteValues["chain"]?.ToString(); + var address = ctx.Request.RouteValues["address"]?.ToString(); + + if (string.IsNullOrEmpty(chain) || string.IsNullOrEmpty(address)) + { + await ctx.SendText(400, "Missing chain or address"); + return; + } + + // Older clients retry with ?provider=... (e.g. chainz); the parameter is + // accepted and ignored — provider selection is handled by the pool. + + try + { + var balanceResponse = await FetchChainBalance(chain, address); + + ctx.Response.Headers["x-provider"] = balanceResponse.Provider; + if (balanceResponse.RateLimitRemaining != null) + { + ctx.Response.Headers["x-provider-ratelimit-remaining"] = + ChainFormatNumber(balanceResponse.RateLimitRemaining.Value); + } + + await ctx.SendJson(200, balanceResponse.ToJson()); + } + catch (Exception err) + { + if (err is TerminalProviderException terminal) + { + await ctx.SendJson(terminal.Status, new JsonObject { ["error"] = terminal.Message }); + return; + } + + LogUpstreamError("balance(): error while fetching chain balance", err); + + if (err is ChainHttpError httpErr) + { + // axios always materializes response.data (an empty body is ""), so + // the TS `data === undefined -> res.sendStatus(status)` branch is + // unreachable; objects/arrays pass through, everything else is + // { error: String(data) }. + var data = httpErr.Response.BodyIsJson + ? httpErr.Response.Json + : JsonValue.Create(httpErr.Response.RawText); + if (data is JsonObject or JsonArray) + { + await ctx.SendJson(httpErr.Status, data); + } + else + { + await ctx.SendJson(httpErr.Status, new JsonObject { ["error"] = ChainJsString(data) }); + } + return; + } + + // Final catch-all + await ctx.SendJson(502, new JsonObject { ["error"] = ChainErrorMessage(err) }); + } + } + + /// + /// Allowed JSON-RPC methods per chain for the RPC proxy. + /// Only read-only / transaction-building methods are allowed; signing and + /// broadcasting happen client-side (MetaMask submits the transaction directly). + /// + private static readonly IReadOnlyDictionary> AllowedRpcMethods = + new Dictionary> + { + ["sol"] = new() + { + "getLatestBlockhash", + "getBalance", + "getBlockHeight", + "getFeeForMessage", + "getMinimumBalanceForRentExemption", + "getRecentPrioritizationFees", + }, + ["eth"] = new() + { + "eth_chainId", + "eth_gasPrice", + "eth_estimateGas", + "eth_getBalance", + "eth_blockNumber", + "eth_getTransactionCount", + }, + ["bnb"] = new() + { + "eth_chainId", + "eth_gasPrice", + "eth_estimateGas", + "eth_getBalance", + "eth_blockNumber", + "eth_getTransactionCount", + }, + }; + + /// + /// Generic JSON-RPC proxy for supported chains. + /// Forwards allowed read-only RPC calls to the configured public RPC providers. + /// + /// POST /private-api/rpc/:chain + /// Body: standard JSON-RPC payload { jsonrpc, id, method, params } + /// + public static async Task ChainRpc(HttpContext ctx) + { + var chain = ctx.Request.RouteValues["chain"]?.ToString(); + + if (string.IsNullOrEmpty(chain) || !ChainParamRegex.IsMatch(chain)) + { + await ctx.SendJson(400, new JsonObject { ["error"] = "Invalid chain parameter" }); + return; + } + + var normalizedChain = chain.ToLowerInvariant(); + + if (!AllowedRpcMethods.TryGetValue(normalizedChain, out var allowedMethods)) + { + await ctx.SendJson(400, new JsonObject { ["error"] = $"RPC proxy not available for chain: {chain}" }); + return; + } + + var body = await ctx.ReadBody(); + var methodNode = body.Field("method"); + if (!JsJson.IsTruthy(methodNode)) + { + await ctx.SendJson(400, new JsonObject { ["error"] = "Invalid JSON-RPC payload" }); + return; + } + + var methodName = body.Str("method"); + if (methodName == null || !allowedMethods.Contains(methodName)) + { + await ctx.SendJson(403, new JsonObject { ["error"] = $"Method not allowed: {ChainJsString(methodNode)}" }); + return; + } + + if (!RpcPoolByChain.TryGetValue(normalizedChain, out var pool)) + { + await ctx.SendJson(400, new JsonObject { ["error"] = "Unsupported chain" }); + return; + } + + // express.json parse + axios JSON.stringify round-trip: reproduces V8 + // number normalization (e.g. integers beyond 2^53 become lossy doubles) + // so the forwarded payload is byte-equal to what the Node service sends. + var forwardBody = JsonNode.Parse(JsJson.Stringify(body)); + + try + { + var result = await TryProviders(normalizedChain, pool, "rpc", async provider => + { + var resp = await Upstream.BaseApiRequest(provider.Url, HttpMethod.Post, + RpcHeaders(provider), forwardBody, timeoutMs: ProviderTimeoutMs); + + // axios default validateStatus: non-2xx rejects + if (resp.Status < 200 || resp.Status >= 300) + { + throw new ChainHttpError(resp); + } + + JsonNode? data = resp.BodyIsJson ? resp.Json : JsonValue.Create(resp.RawText); + + // A JSON-RPC error body arrives with HTTP 200; treat it as a provider + // failure so tryProviders advances to the next pool member (e.g. a + // rate-limited node returning {error:{code:-32005}}), but keep the + // envelope so a genuine application error can still reach the client + // if every provider returns one. + var errorNode = data is JsonObject dataObj ? dataObj["error"] : null; + if (JsJson.IsTruthy(errorNode)) + { + throw new ChainRpcEnvelopeError(RpcErrorMessage(errorNode, "RPC error"), data); + } + + return (ProviderId: provider.Id, Data: data); + }); + + ctx.Response.Headers["x-provider"] = result.ProviderId; + await ctx.SendJson(200, result.Data); + } + catch (Exception err) + { + // Every provider returned a JSON-RPC error — pass the last envelope through verbatim. + if (err is ChainRpcEnvelopeError envelope) + { + await ctx.SendJson(200, envelope.Body); + return; + } + + LogUpstreamError($"chainRpc({normalizedChain}): error", err); + + if (err is ChainHttpError httpErr) + { + // res.status(status).json(data !== undefined ? data : {...}) — axios + // always materializes data (empty body -> ""), so pass it through. + var data = httpErr.Response.BodyIsJson + ? httpErr.Response.Json + : JsonValue.Create(httpErr.Response.RawText); + await ctx.SendJson(httpErr.Status, data); + return; + } + + await ctx.SendJson(502, new JsonObject { ["error"] = ChainErrorMessage(err) }); + } + } + + // ------------------------------------------------------------------ + // small JS-semantics helpers + // ------------------------------------------------------------------ + + // String(number) / JSON number formatting parity (JsJson formats doubles the + // way V8 does: shortest round-trip, no ".0" on integral values). + private static string ChainFormatNumber(double value) => JsJson.Stringify(JsonValue.Create(value)); + + // JS String() coercion for values that land in template literals / Error messages. + private static string ChainJsString(JsonNode? node) => node switch + { + null => "null", + JsonObject => "[object Object]", + JsonArray arr => string.Join(",", arr.Select(ChainJsString)), + JsonValue v when v.TryGetValue(out var s) => s, + _ => JsJson.Stringify(node), + }; + + // Number(string) semantics: "" -> 0 (callers pre-check), hex/octal/binary + // prefixes, decimal/exponent forms, anything else -> NaN. + private static double ChainJsNumber(string text) + { + var t = text.Trim(); + if (t.Length == 0) + { + return 0; + } + + if (t.Length > 2 && t[0] == '0') + { + var radix = t[1] switch + { + 'x' or 'X' => 16, + 'o' or 'O' => 8, + 'b' or 'B' => 2, + _ => 0, + }; + if (radix != 0) + { + return ParseRadixInteger(t.Substring(2), radix); + } + } + + return double.TryParse(t, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) ? d : double.NaN; + } + + private static double ParseRadixInteger(string digits, int radix) + { + if (digits.Length == 0) + { + return double.NaN; + } + + var acc = BigInteger.Zero; + foreach (var c in digits) + { + var v = c switch + { + >= '0' and <= '9' => c - '0', + >= 'a' and <= 'f' => c - 'a' + 10, + >= 'A' and <= 'F' => c - 'A' + 10, + _ => -1, + }; + if (v < 0 || v >= radix) + { + return double.NaN; + } + acc = acc * radix + v; + } + return (double)acc; + } +} diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs new file mode 100644 index 00000000..a0b1c33f --- /dev/null +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs @@ -0,0 +1,256 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using EcencyApi.Infrastructure; + +namespace EcencyApi.Handlers; + +/// +/// Port of src/server/handlers/private-api.ts lines 1-185 plus 890-900: +/// validateCode (HiveSigner code validation), requireAuthedUsername, and the +/// simple pipe handlers (receivedVesting, receivedRC, rewardedCommunities, +/// proMembers). +/// +public static partial class PrivateApi +{ + // Module-level hivesigner account cache (plain fields; refresh races are + // benign, matching the Node module variables). + private static JsonNode? _hivesignerAccountCache; + private static long _hivesignerCacheTime; + private const long HiveSignerCacheTtlMs = 24 * 60 * 60 * 1000; // 24 hours + + // String.prototype.trim character set (ECMAScript WhiteSpace + LineTerminator, + // including U+FEFF which .NET's Trim() does not strip). + private static readonly char[] JsWhitespace = + { + '\t', '\n', '\v', '\f', '\r', ' ', '\u00A0', '\u1680', + '\u2000', '\u2001', '\u2002', '\u2003', '\u2004', '\u2005', '\u2006', + '\u2007', '\u2008', '\u2009', '\u200A', '\u2028', '\u2029', '\u202F', + '\u205F', '\u3000', '\uFEFF', + }; + + /// + /// validateCode(req): resolves the validated username from the signed + /// HiveSigner-style code in the request body, or null (TS returns false). + /// + public static async Task ValidateCode(JsonObject body) + { + var hasCode = body.TryGetPropertyValue("code", out var codeNode); + string? code = null; + if (codeNode is JsonValue codeValue && codeValue.TryGetValue(out var codeStr)) + { + code = codeStr; + } + + if (code == null) + { + // typeof code !== "string" — warn only when the key was present with + // a non-null value (undefined and null stay silent). + if (hasCode && codeNode != null) + { + Console.WriteLine("validateCode(): received non-string code payload"); + } + + return null; + } + + var trimmedCode = code.Trim(JsWhitespace); + + if (trimmedCode.Length == 0) + { + return null; + } + + // Normalize base64url → standard base64 (client encodes with b64uEnc which replaces +→-, /→_, =→.) + var normalizedCode = trimmedCode.Replace('-', '+').Replace('_', '/').Replace('.', '='); + + try + { + var buffer = B64u.DecodeLenient(normalizedCode); + + if (buffer == null || buffer.Length == 0) + { + return null; + } + + var decoded = JsonNode.Parse(Encoding.UTF8.GetString(buffer)); + + // const {...} = decoded — destructuring null throws in JS, landing + // in the catch below ("Token validation error"). + if (decoded is null) + { + throw new InvalidOperationException("Cannot destructure properties of null"); + } + + var decodedObj = decoded as JsonObject; + + JsonNode? signedMessage = null; + var hasSignedMessage = + decodedObj != null && decodedObj.TryGetPropertyValue("signed_message", out signedMessage); + var authors = decodedObj?["authors"] as JsonArray; + var timestampNode = decodedObj?["timestamp"]; + var signatures = decodedObj?["signatures"] as JsonArray; + + // typeof signed_message !== "object" — JSON null and arrays pass + // (typeof null === "object"), missing/strings/numbers/bools fail. + var signedMessageTypeofObject = + hasSignedMessage && signedMessage is null or JsonObject or JsonArray; + + string? author = null; + if (authors is { Count: > 0 } && authors[0] is JsonValue authorValue + && authorValue.TryGetValue(out var authorStr)) + { + author = authorStr; + } + + var timestampIsNumber = + timestampNode is JsonValue timestampValue + && timestampValue.GetValueKind() == JsonValueKind.Number; + + string? signature = null; + if (signatures is { Count: > 0 } && signatures[0] is JsonValue signatureValue + && signatureValue.TryGetValue(out var signatureStr)) + { + signature = signatureStr; + } + + if (!signedMessageTypeofObject || author is null || !timestampIsNumber || signature is null) + { + Console.WriteLine($"Invalid token structure {JsJson.Stringify(decoded)}"); + return null; + } + + var currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + // rawMessage = JSON.stringify({ signed_message, authors, timestamp }) + // — property order matters because the digest is hashed from it; + // signed_message keeps its original key order from the token. + var messageObj = new JsonObject + { + ["signed_message"] = signedMessage?.DeepClone(), + ["authors"] = authors!.DeepClone(), + ["timestamp"] = timestampNode!.DeepClone(), + }; + var rawMessage = JsJson.Stringify(messageObj); + var digest = HiveCrypto.Sha256Utf8(rawMessage); + var recoveredPubKey = HiveCrypto.RecoverPublicKey(signature, digest); + + var accounts = await HiveClients.Default.GetAccounts(new[] { author }) + ?? throw new InvalidOperationException("getAccounts result is not iterable"); + var account = accounts.Count > 0 ? accounts[0] : null; + if (account is null) + { + Console.Error.WriteLine("Fetching account error"); + return null; + } + + // account.posting.key_auths.map(([key]) => key) — unexpected shapes + // throw and land in the catch below, like the TS TypeError would. + var postingPubKeys = MapKeyAuths(account["posting"]!["key_auths"]); + if (recoveredPubKey != null && postingPubKeys.Contains(recoveredPubKey)) + { + return author; + } + + // Use cached hivesigner account, refresh every 24h + if (_hivesignerAccountCache is null + || currentTime - _hivesignerCacheTime > HiveSignerCacheTtlMs) + { + try + { + var hsAccounts = await HiveClients.Default.GetAccounts(new[] { "hivesigner" }) + ?? throw new InvalidOperationException("getAccounts result is not iterable"); + // TS caches whatever destructures out — undefined included — + // and stamps the time either way. + _hivesignerAccountCache = hsAccounts.Count > 0 ? hsAccounts[0] : null; + _hivesignerCacheTime = currentTime; + } + catch (Exception err) + { + Console.Error.WriteLine($"Failed to fetch hivesigner account {err}"); + return null; + } + } + + // hivesignerAccountCache?.posting?.key_auths?.map(([key]) => key) || [] + var hsCache = _hivesignerAccountCache; + var hsKeyAuths = ((hsCache as JsonObject)?["posting"] as JsonObject)?["key_auths"]; + var hsPostingKeys = hsKeyAuths is null ? new List() : MapKeyAuths(hsKeyAuths); + if (recoveredPubKey != null && hsPostingKeys.Contains(recoveredPubKey)) + { + return author; + } + + Console.WriteLine( + $"Posting key mismatch {recoveredPubKey} [{string.Join(", ", postingPubKeys)}] [{string.Join(", ", hsPostingKeys)}]"); + return null; + } + catch (Exception err) + { + Console.Error.WriteLine($"Token validation error {err}"); + return null; + } + } + + /// key_auths.map(([key]) => key) — throws on non-array input like .map on a non-array. + private static List MapKeyAuths(JsonNode? keyAuths) + { + if (keyAuths is not JsonArray arr) + { + throw new InvalidOperationException("key_auths is not an array"); + } + + var keys = new List(); + foreach (var item in arr) + { + if (item is null) + { + // destructuring [key] of null throws in JS + throw new InvalidOperationException("key_auths entry is not iterable"); + } + + var first = item[0]; // throws for non-indexable nodes, like JS destructuring a non-iterable + keys.Add(first is JsonValue v && v.TryGetValue(out var key) ? key : null); + } + + return keys; + } + + // Resolve the authenticated username from the signed token, or send 401 and return + // null. Used by the spend endpoints, which must never trust a body-supplied user. + public static async Task RequireAuthedUsername(HttpContext ctx, JsonObject body) + { + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return null; + } + return username; + } + + public static async Task ReceivedVesting(HttpContext ctx) + { + var username = ctx.Request.RouteValues["username"]?.ToString(); + await Upstream.Pipe( + ApiClient.ApiRequest($"delegatee_vesting_shares/{username}", HttpMethod.Get), ctx); + } + + public static async Task ReceivedRC(HttpContext ctx) + { + var username = ctx.Request.RouteValues["username"]?.ToString(); + await Upstream.Pipe(ApiClient.ApiRequest($"delegatee_rc/{username}", HttpMethod.Get), ctx); + } + + public static async Task RewardedCommunities(HttpContext ctx) + { + await Upstream.Pipe(ApiClient.ApiRequest("rewarded-communities", HttpMethod.Get), ctx); + } + + // Public list of Pro usernames for the Pro badge roster. No auth: this is a + // public, cached list served straight from the backend. + public static async Task ProMembers(HttpContext ctx) + { + await Upstream.Pipe(ApiClient.ApiRequest("pro-members", HttpMethod.Get), ctx); + } +} diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Feeds.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Feeds.cs new file mode 100644 index 00000000..56228986 --- /dev/null +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Feeds.cs @@ -0,0 +1,370 @@ +using System.Text; +using System.Text.Json.Nodes; +using EcencyApi.Infrastructure; +using Microsoft.Extensions.Primitives; + +namespace EcencyApi.Handlers; + +/// +/// Port of src/server/handlers/private-api.ts — leaderboard, referrals, +/// curation, promoted entries, comment history, waves endpoints, points and +/// quests (the read-only private-API pass-through group). +/// +public static partial class PrivateApi +{ + public static async Task Leaderboard(HttpContext ctx) + { + // :duration(day|week|month) — the regex constraint lives in the route table. + var duration = ctx.Request.RouteValues["duration"]?.ToString(); + await Upstream.Pipe(ApiClient.ApiRequest($"leaderboard?duration={duration}", HttpMethod.Get), ctx); + } + + public static async Task Referrals(HttpContext ctx) + { + var username = ctx.Request.RouteValues["username"]?.ToString(); + var maxId = ctx.Request.Query["max_id"]; + + var u = $"referrals/{username}?size=20"; + // JS `if (max_id)`: absent or single empty string is falsy; an array + // (duplicate params) is truthy and interpolates comma-joined. + if (maxId.Count > 1 || (maxId.Count == 1 && !string.IsNullOrEmpty(maxId[0]))) + { + u += $"&max_id={maxId.ToString()}"; + } + + await Upstream.Pipe(ApiClient.ApiRequest(u, HttpMethod.Get), ctx); + } + + public static async Task ReferralsStats(HttpContext ctx) + { + var username = ctx.Request.RouteValues["username"]?.ToString(); + var u = $"referrals/{username}/stats"; + await Upstream.Pipe(ApiClient.ApiRequest(u, HttpMethod.Get), ctx); + } + + public static async Task Curation(HttpContext ctx) + { + var duration = ctx.Request.RouteValues["duration"]?.ToString(); + await Upstream.Pipe(ApiClient.ApiRequest($"curation?duration={duration}", HttpMethod.Get), ctx); + } + + public static async Task PromotedEntries(HttpContext ctx) + { + // const { limit = '200', short_content = '0' } = req.query; + // Destructuring defaults only apply when the key is ABSENT — a present + // empty string stays "" and parseInt("") is NaN, exactly like Node. + var limitSv = ctx.Request.Query["limit"]; + var shortSv = ctx.Request.Query["short_content"]; + var limitStr = limitSv.Count == 0 ? "200" : limitSv.ToString(); + var shortStr = shortSv.Count == 0 ? "0" : shortSv.ToString(); + + var limitNum = FeedsParseInt(limitStr); + var shortNum = FeedsParseInt(shortStr); + + // GetPromotedEntries takes ints; JS forwards NaN / out-of-int-range + // doubles verbatim to the upstream. Map NaN -> 0 and clamp (parity gap + // only for degenerate inputs; see port notes). + var limit = double.IsNaN(limitNum) ? 0 : (int)Math.Clamp(limitNum, int.MinValue, int.MaxValue); + var shortContent = double.IsNaN(shortNum) ? 0 : (int)Math.Clamp(shortNum, int.MinValue, int.MaxValue); + + var posts = await ApiClient.GetPromotedEntries(limit, shortContent); + await ctx.SendJson(200, posts); + } + + public static async Task CommentHistory(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var author = FeedsBodyInterp(body, "author"); + var permlink = FeedsBodyInterp(body, "permlink"); + + var u = $"comment-history/{author}/{permlink}"; + // onlyMeta === '1' — strict equality: only the string "1" qualifies. + if (body.Str("onlyMeta") == "1") + { + u += "?only_meta=1"; + } + + await Upstream.Pipe(ApiClient.ApiRequest(u, HttpMethod.Get), ctx); + } + + public static async Task WavesTags(HttpContext ctx) + { + var container = FeedsQueryInterp(ctx.Request.Query["container"]); + var tag = FeedsQueryInterp(ctx.Request.Query["tag"]); + var u = $"waves/tags?container={container}&tag={tag}"; + await Upstream.Pipe(ApiClient.ApiRequest(u, HttpMethod.Get), ctx); + } + + public static async Task WavesAccount(HttpContext ctx) + { + var container = FeedsQueryInterp(ctx.Request.Query["container"]); + var username = FeedsQueryInterp(ctx.Request.Query["username"]); + var u = $"waves/account?container={container}&username={username}"; + await Upstream.Pipe(ApiClient.ApiRequest(u, HttpMethod.Get), ctx); + } + + public static async Task WavesFollowing(HttpContext ctx) + { + var container = FeedsQueryInterp(ctx.Request.Query["container"]); + var username = FeedsQueryInterp(ctx.Request.Query["username"]); + var u = $"waves/following?container={container}&username={username}"; + await Upstream.Pipe(ApiClient.ApiRequest(u, HttpMethod.Get), ctx); + } + + public static async Task WavesTrendingTags(HttpContext ctx) + { + var q = ctx.Request.Query; + var parts = new List(); + + // container is optional: omit it for combined trending tags across all + // containers (do not forward a literal "undefined"). + FeedsSetSingle(parts, "container", q["container"]); + FeedsSetSingle(parts, "hours", q["hours"]); + FeedsSetSingle(parts, "days", q["days"]); + + var u = $"waves/trending/tags?{string.Join("&", parts)}"; + await Upstream.Pipe(ApiClient.ApiRequest(u, HttpMethod.Get), ctx); + } + + public static async Task WavesTrendingAuthors(HttpContext ctx) + { + var container = FeedsQueryInterp(ctx.Request.Query["container"]); + var u = $"waves/trending/authors?container={container}"; + await Upstream.Pipe(ApiClient.ApiRequest(u, HttpMethod.Get), ctx); + } + + public static async Task WavesFeed(HttpContext ctx) + { + var q = ctx.Request.Query; + var parts = new List(); + + // These are single-value; ignore array input (duplicate params) so a + // String([...]) never forwards a comma-joined value to the backend. + FeedsSetSingle(parts, "limit", q["limit"]); + FeedsSetSingle(parts, "cursor", q["cursor"]); + FeedsSetSingle(parts, "tag", q["tag"]); + FeedsSetSingle(parts, "following", q["following"]); + FeedsSetSingle(parts, "author", q["author"]); + FeedsSetSingle(parts, "observer", q["observer"]); + + // container is optional and repeatable (omit for the full combined feed). + FeedsAppendRepeatable(parts, "container", q["container"]); + + var u = $"waves/feed?{string.Join("&", parts)}"; + await Upstream.Pipe(ApiClient.ApiRequest(u, HttpMethod.Get), ctx); + } + + public static async Task WavesShorts(HttpContext ctx) + { + var q = ctx.Request.Query; + var parts = new List(); + + // Single-value params; ignore array (duplicate) input as wavesFeed does. + FeedsSetSingle(parts, "limit", q["limit"]); + FeedsSetSingle(parts, "cursor", q["cursor"]); + FeedsSetSingle(parts, "tag", q["tag"]); + FeedsSetSingle(parts, "author", q["author"]); + FeedsSetSingle(parts, "observer", q["observer"]); + + // container is optional and repeatable (omit for the full combined shorts feed). + FeedsAppendRepeatable(parts, "container", q["container"]); + + var u = $"waves/shorts?{string.Join("&", parts)}"; + await Upstream.Pipe(ApiClient.ApiRequest(u, HttpMethod.Get), ctx); + } + + public static async Task Points(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = FeedsBodyInterp(body, "username"); + await Upstream.Pipe(ApiClient.ApiRequest($"users/{username}", HttpMethod.Get), ctx); + } + + public static async Task PointList(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = FeedsBodyInterp(body, "username"); + + var u = $"users/{username}/points?size=50"; + if (JsJson.IsTruthy(body.Field("type"))) + { + u += $"&type={FeedsBodyInterp(body, "type")}"; + } + + await Upstream.Pipe(ApiClient.ApiRequest(u, HttpMethod.Get), ctx); + } + + public static async Task Quests(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = FeedsBodyInterp(body, "username"); + await Upstream.Pipe(ApiClient.ApiRequest($"users/{username}/quests", HttpMethod.Get), ctx); + } + + // --------------------------------------------------------------------- + // Helpers replicating Express/JS query and template-literal semantics. + // --------------------------------------------------------------------- + + /// + /// Template-literal interpolation of a req.query value: absent key + /// interpolates as the literal "undefined"; duplicate params (an array in + /// qs) interpolate comma-joined, which StringValues.ToString() matches. + /// + private static string FeedsQueryInterp(StringValues sv) => + sv.Count == 0 ? "undefined" : sv.ToString(); + + /// + /// URLSearchParams `if (v && !Array.isArray(v)) params.set(key, String(v))`: + /// forward only a single, truthy (non-empty) value. + /// + private static void FeedsSetSingle(List parts, string key, StringValues sv) + { + if (sv.Count == 1 && !string.IsNullOrEmpty(sv[0])) + { + parts.Add($"{FeedsFormUrlEncode(key)}={FeedsFormUrlEncode(sv[0]!)}"); + } + } + + /// + /// Repeatable param: an array appends every element (no truthiness filter, + /// matching the TS forEach), a single truthy value appends once, absent or + /// single-empty appends nothing. + /// + private static void FeedsAppendRepeatable(List parts, string key, StringValues sv) + { + if (sv.Count > 1) + { + foreach (var v in sv) + { + parts.Add($"{FeedsFormUrlEncode(key)}={FeedsFormUrlEncode(v ?? "")}"); + } + } + else if (sv.Count == 1 && !string.IsNullOrEmpty(sv[0])) + { + parts.Add($"{FeedsFormUrlEncode(key)}={FeedsFormUrlEncode(sv[0]!)}"); + } + } + + /// + /// WHATWG application/x-www-form-urlencoded serializer, byte-exact with + /// URLSearchParams.toString(): keep [A-Za-z0-9*-._], space -> '+', + /// everything else percent-encoded (uppercase hex) over UTF-8 bytes. + /// + private static string FeedsFormUrlEncode(string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + var sb = new StringBuilder(bytes.Length); + foreach (var b in bytes) + { + if (b == 0x20) + { + sb.Append('+'); + } + else if ((b >= '0' && b <= '9') || (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') + || b == '*' || b == '-' || b == '.' || b == '_') + { + sb.Append((char)b); + } + else + { + sb.Append('%').Append(b.ToString("X2")); + } + } + return sb.ToString(); + } + + /// + /// Template-literal interpolation of a destructured req.body field: + /// absent -> "undefined", null -> "null", JS String() conversion otherwise. + /// + private static string FeedsBodyInterp(JsonObject body, string key) => + body.TryGetPropertyValue(key, out var node) ? FeedsJsToString(node) : "undefined"; + + private static string FeedsJsToString(JsonNode? node) + { + switch (node) + { + case null: + return "null"; + case JsonObject: + return "[object Object]"; + case JsonArray arr: + // Array.prototype.toString: elements joined with ",", + // null/undefined elements become "". + return string.Join(",", arr.Select(e => e switch + { + null => "", + JsonObject => "[object Object]", + _ => FeedsJsToString(e), + })); + case JsonValue v when v.TryGetValue(out var s): + return s; + case JsonValue v when v.TryGetValue(out var b): + return b ? "true" : "false"; + default: + // JSON numbers: JSON.stringify(n) equals String(n) for finite numbers. + return JsJson.Stringify(node); + } + } + + /// + /// JS parseInt(str) semantics (radix unspecified): skip leading whitespace, + /// optional sign, optional 0x/0X hex prefix, take leading digits; NaN when + /// no digits were consumed. Returns double so NaN is representable. + /// + private static double FeedsParseInt(string s) + { + var i = 0; + while (i < s.Length && char.IsWhiteSpace(s[i])) + { + i++; + } + + var sign = 1.0; + if (i < s.Length && (s[i] == '+' || s[i] == '-')) + { + if (s[i] == '-') + { + sign = -1.0; + } + i++; + } + + var radix = 10; + if (i + 1 < s.Length && s[i] == '0' && (s[i + 1] == 'x' || s[i + 1] == 'X')) + { + radix = 16; + i += 2; + } + + var value = 0.0; + var any = false; + while (i < s.Length) + { + var c = s[i]; + int d; + if (c >= '0' && c <= '9') + { + d = c - '0'; + } + else if (radix == 16 && c >= 'a' && c <= 'f') + { + d = c - 'a' + 10; + } + else if (radix == 16 && c >= 'A' && c <= 'F') + { + d = c - 'A' + 10; + } + else + { + break; + } + + any = true; + value = value * radix + d; + i++; + } + + return any ? sign * value : double.NaN; + } +} diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs new file mode 100644 index 00000000..09e2344f --- /dev/null +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs @@ -0,0 +1,551 @@ +using System.Text.Json.Nodes; +using EcencyApi.Infrastructure; + +namespace EcencyApi.Handlers; + +/// +/// Port of src/server/handlers/private-api.ts lines 1704-2033: activity tracking, +/// newsletter, market data, reports, reblogs/tips, chats/channels, wallets, +/// games, purchase orders, active proposal and AI endpoints. +/// +public static partial class PrivateApi +{ + // --- local helpers (Misc-prefixed to avoid collisions across partial-class chunks) --- + + /// JS template-literal semantics for a JsonNode value: strings raw, + /// numbers via Number::toString, true/false, JSON null -> "null", + /// arrays join with "," (null elements -> ""), objects -> "[object Object]". + private static string MiscJsString(JsonNode? node) + { + if (node == null) + { + return "null"; + } + if (node is JsonArray arr) + { + return string.Join(",", arr.Select(item => item == null ? "" : MiscJsString(item))); + } + if (node is JsonValue v) + { + if (v.TryGetValue(out var s)) + { + return s; + } + if (v.TryGetValue(out var b)) + { + return b ? "true" : "false"; + } + // numbers: String(n) === JSON.stringify(n) + return JsJson.Stringify(node); + } + return "[object Object]"; + } + + /// `${req.body.key}` — absent key (undefined) interpolates as "undefined". + private static string MiscBodyInterp(JsonObject body, string key) => + body.TryGetPropertyValue(key, out var v) ? MiscJsString(v) : "undefined"; + + /// `${req.params.name}` — missing route param (undefined) -> "undefined". + private static string MiscRouteParam(HttpContext ctx, string name) => + ctx.Request.RouteValues[name]?.ToString() ?? "undefined"; + + /// Destructure-and-rebuild payload semantics: a key is added only when it + /// was present in the request body (absent == undefined == omitted by JSON.stringify; + /// present-with-null stays null). + private static void MiscCopyIfPresent(JsonObject data, JsonObject body, params string[] keys) + { + foreach (var key in keys) + { + if (body.TryGetPropertyValue(key, out var v)) + { + data[key] = v?.DeepClone(); + } + } + } + + // --- handlers --- + + public static async Task Activities(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + + var ty = body.Field("ty"); + // ty === 10 (strict: JSON number equal to 10) + var tyIsTen = ty is JsonValue tyVal && tyVal.TryGetValue(out var tyNum) && tyNum == 10; + + if (tyIsTen) + { + // req.headers['x-real-ip'] || req.connection.remoteAddress || req.headers['x-forwarded-for'] || '' + var vip = ctx.Request.Headers["x-real-ip"].ToString(); + if (vip.Length == 0) + { + vip = ctx.Connection.RemoteIpAddress?.ToString() ?? ""; + } + if (vip.Length == 0) + { + vip = ctx.Request.Headers["x-forwarded-for"].ToString(); + } + var identifier = vip; + + string? rec = null; + try + { + rec = MemCache.Get(identifier); + } + catch (Exception e) + { + Console.Error.WriteLine(e); + Console.Error.WriteLine("Cache get failed."); + } + + if (!string.IsNullOrEmpty(rec)) + { + var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + if (double.TryParse(rec, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var recMs) + && nowMs - recMs < 900000) + { + // Node sends 201 here WITHOUT returning; the cache write and the + // upstream usr-activity call below still run (pipe then skips the + // second response) — replicated as-is. + await ctx.SendJson(201, new JsonObject()); + } + try + { + MemCache.Set(identifier, + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(), 901); + } + catch (Exception e) + { + Console.Error.WriteLine(e); + Console.Error.WriteLine("Cache set failed."); + } + } + else + { + try + { + MemCache.Set(identifier, + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(), 901); + } + catch (Exception e) + { + Console.Error.WriteLine(e); + Console.Error.WriteLine("Cache set failed."); + } + } + } + + var pipeJson = new JsonObject + { + ["us"] = username, + }; + // { us, ty } object literal: ty key survives JSON.stringify only when present + if (body.TryGetPropertyValue("ty", out var tyRaw)) + { + pipeJson["ty"] = tyRaw?.DeepClone(); + } + var bl = body.Field("bl"); + if (JsJson.IsTruthy(bl)) + { + pipeJson["bl"] = bl!.DeepClone(); + } + var tx = body.Field("tx"); + if (JsJson.IsTruthy(tx)) + { + pipeJson["tx"] = tx!.DeepClone(); + } + + await Upstream.Pipe(ApiClient.ApiRequest("usr-activity", HttpMethod.Post, null, pipeJson), ctx); + } + + public static async Task SubscribeNewsletter(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var data = new JsonObject(); + MiscCopyIfPresent(data, body, "email"); + await Upstream.Pipe(ApiClient.ApiRequest("newsletter/subscribe", HttpMethod.Post, null, data), ctx); + } + + public static async Task MarketData(HttpContext ctx) + { + var fiat = MiscRouteParam(ctx, "fiat"); + var token = MiscRouteParam(ctx, "token"); + // `?fixed=${req.query.fixed}` — absent query value interpolates as "undefined" + var fixedValues = ctx.Request.Query["fixed"]; + var fixedStr = fixedValues.Count == 0 ? "undefined" : fixedValues.ToString(); + await Upstream.Pipe( + ApiClient.ApiRequest($"market-data/currency-rate/{fiat}/{token}?fixed={fixedStr}", HttpMethod.Get), + ctx); + } + + public static async Task MarketDataLatest(HttpContext ctx) + { + var currency = ctx.Request.Query["currency"].ToString(); + var queryString = currency.Length > 0 ? $"?currency={currency}" : ""; + await Upstream.Pipe(ApiClient.ApiRequest($"market-data/latest{queryString}", HttpMethod.Get), ctx); + } + + public static async Task Report(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var type = body.Field("type"); + var author = body.Field("author"); + var permlink = body.Field("permlink"); + var reporter = body.Field("reporter"); + var notes = body.Field("notes"); + + if (!JsJson.IsTruthy(type) || !JsJson.IsTruthy(author)) + { + await ctx.SendText(400, "Missing required fields: type, author"); + return; + } + + // type !== 'post' && type !== 'account' (strict string equality) + var typeStr = body.Str("type"); + if (typeStr != "post" && typeStr != "account") + { + await ctx.SendText(400, "Invalid report type. Must be 'post' or 'account'"); + return; + } + + if (typeStr == "post" && !JsJson.IsTruthy(permlink)) + { + await ctx.SendText(400, "Missing required field: permlink for post reports"); + return; + } + + var data = new JsonObject + { + ["type"] = type!.DeepClone(), + ["author"] = author!.DeepClone(), + }; + if (typeStr == "post") + { + data["permlink"] = permlink!.DeepClone(); + } + data["reporter"] = JsJson.IsTruthy(reporter) ? reporter!.DeepClone() : (JsonNode)"anonymous"; + if (JsJson.IsTruthy(notes)) + { + data["notes"] = notes!.DeepClone(); + } + + await Upstream.Pipe(ApiClient.ApiRequest("report", HttpMethod.Post, null, data), ctx); + } + + /// + /// Account-deletion acknowledgment stub. Hive accounts cannot be deleted + /// on-chain; this endpoint satisfies the app-store account-deletion + /// requirement by acknowledging the request. (The Node route table pointed + /// this at the report handler, whose validation rejected the mobile payload + /// with 400 — this wires the stub the code intended.) + /// + public static async Task RequestDelete(HttpContext ctx) + { + await ctx.SendJson(200, new JsonObject + { + ["status"] = 200, + ["body"] = new JsonObject { ["status"] = "ok" }, + }); + } + + public static async Task Tips(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var author = MiscBodyInterp(body, "author"); + var permlink = MiscBodyInterp(body, "permlink"); + await Upstream.Pipe(ApiClient.ApiRequest($"post-tips/{author}/{permlink}", HttpMethod.Get), ctx); + } + + public static async Task GameGet(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var gameType = MiscBodyInterp(body, "game_type"); + await Upstream.Pipe(ApiClient.ApiRequest($"game/{username}?type={gameType}", HttpMethod.Get), ctx); + } + + public static async Task GamePost(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + + var gameType = MiscBodyInterp(body, "game_type"); + var data = new JsonObject(); + MiscCopyIfPresent(data, body, "key"); + await Upstream.Pipe( + ApiClient.ApiRequest($"game/{username}?type={gameType}", HttpMethod.Post, null, data), ctx); + } + + public static async Task PurchaseOrder(HttpContext ctx) + { + var body = await ctx.ReadBody(); + // user !== 'ecency' (strict) — anything but the exact string requires auth + if (body.Str("user") != "ecency") + { + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + } + + var data = new JsonObject(); + MiscCopyIfPresent(data, body, "platform", "product", "receipt", "user", "meta"); + // External payment/receipt validation can be slow. + await Upstream.Pipe( + ApiClient.ApiRequest("purchase-order", HttpMethod.Post, null, data, null, 30000), ctx); + } + + public static async Task Chats(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + await Upstream.Pipe(ApiClient.ApiRequest($"chats/{username}", HttpMethod.Get), ctx); + } + + public static async Task ChatsAdd(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var data = new JsonObject + { + ["username"] = username, + }; + MiscCopyIfPresent(data, body, "key", "pubkey", "iv", "meta"); + await Upstream.Pipe(ApiClient.ApiRequest("chats", HttpMethod.Post, null, data), ctx); + } + + public static async Task ChatsUpdate(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var id = MiscBodyInterp(body, "id"); + var data = new JsonObject(); + MiscCopyIfPresent(data, body, "key", "pubkey", "iv", "meta"); + await Upstream.Pipe(ApiClient.ApiRequest($"chats/{username}/{id}", HttpMethod.Put, null, data), ctx); + } + + public static async Task ChatsPub(HttpContext ctx) + { + var username = MiscRouteParam(ctx, "username"); + await Upstream.Pipe(ApiClient.ApiRequest($"chats/pub/{username}", HttpMethod.Get), ctx); + } + + public static async Task ChannelAdd(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var creator = await ValidateCode(body); + if (creator == null || creator != "ecency") + { + await ctx.SendText(401, "Unauthorized"); + return; + } + + var data = new JsonObject + { + ["creator"] = creator, + }; + MiscCopyIfPresent(data, body, "username", "channel_id", "meta"); + await Upstream.Pipe(ApiClient.ApiRequest("channel", HttpMethod.Post, null, data), ctx); + } + + public static async Task ChannelGet(HttpContext ctx) + { + var username = MiscRouteParam(ctx, "username"); + await Upstream.Pipe(ApiClient.ApiRequest($"channel/{username}", HttpMethod.Get), ctx); + } + + public static async Task ChannelsGet(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var data = new JsonObject(); + MiscCopyIfPresent(data, body, "users"); + await Upstream.Pipe(ApiClient.ApiRequest("channels", HttpMethod.Post, null, data), ctx); + } + + public static async Task ChatsGet(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var data = new JsonObject(); + MiscCopyIfPresent(data, body, "users"); + await Upstream.Pipe(ApiClient.ApiRequest("chats/pubs", HttpMethod.Post, null, data), ctx); + } + + public static async Task BotsGet(HttpContext ctx) + { + await ctx.SendJson(200, Constants.BotsJson()); + } + + public static async Task Wallets(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + await Upstream.Pipe(ApiClient.ApiRequest($"wallets/{username}", HttpMethod.Get), ctx); + } + + public static async Task WalletsAdd(HttpContext ctx) + { + // No auth in the Node handler — forwarded as-is. + var body = await ctx.ReadBody(); + var data = new JsonObject(); + MiscCopyIfPresent(data, body, "username", "token", "address", "meta", "status"); + await Upstream.Pipe(ApiClient.ApiRequest("wallet", HttpMethod.Post, null, data), ctx); + } + + public static async Task WalletsUpdate(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var id = MiscBodyInterp(body, "id"); + var data = new JsonObject + { + ["username"] = username, + }; + MiscCopyIfPresent(data, body, "token", "address", "meta"); + await Upstream.Pipe(ApiClient.ApiRequest($"wallets/{username}/{id}", HttpMethod.Put, null, data), ctx); + } + + public static async Task WalletsDelete(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var id = MiscBodyInterp(body, "id"); + await Upstream.Pipe(ApiClient.ApiRequest($"wallets/{username}/{id}", HttpMethod.Delete), ctx); + } + + public static async Task WalletsExist(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var address = MiscBodyInterp(body, "address"); + var token = MiscBodyInterp(body, "token"); + await Upstream.Pipe( + ApiClient.ApiRequest($"signup/exist-wallet-accounts?address={address}&token={token}", HttpMethod.Get), + ctx); + } + + public static async Task WalletsChkUser(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = MiscBodyInterp(body, "username"); + await Upstream.Pipe( + ApiClient.ApiRequest($"signup/exist-wallet-user?username={username}", HttpMethod.Get), ctx); + } + + public static async Task ProposalActive(HttpContext ctx) + { + // res.send(ACTIVE_PROPOSAL_META) — the constant { id: 336 } object. + await ctx.SendJson(200, new JsonObject + { + ["id"] = Constants.ActiveProposalId, + }); + } + + public static async Task AiGeneratePrice(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + await Upstream.Pipe(ApiClient.ApiRequest("ai-image-price", HttpMethod.Get), ctx); + } + + public static async Task AiGenerateImage(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var data = new JsonObject + { + ["us"] = username, + }; + MiscCopyIfPresent(data, body, "prompt", "aspect_ratio", "power"); + // AI image generation legitimately takes 10-60s+; keep it long. + await Upstream.Pipe( + ApiClient.ApiRequest("ai-image-generate", HttpMethod.Post, null, data, null, 120000), ctx); + } + + public static async Task AiAssistPrice(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + await Upstream.Pipe(ApiClient.ApiRequest($"ai-assist-price?us={username}", HttpMethod.Get), ctx); + } + + public static async Task AiAssist(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var data = new JsonObject + { + ["us"] = username, + }; + MiscCopyIfPresent(data, body, "action", "text"); + // AI assist generation can take a long time; keep it long. + await Upstream.Pipe(ApiClient.ApiRequest("ai-assist", HttpMethod.Post, null, data, null, 120000), ctx); + } +} diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Payments.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Payments.cs new file mode 100644 index 00000000..68ecef40 --- /dev/null +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Payments.cs @@ -0,0 +1,491 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using EcencyApi.Infrastructure; + +namespace EcencyApi.Handlers; + +/// +/// Port of src/server/handlers/private-api.ts lines 1463-1703: points/promote/ +/// boost/RC-delegation price+status endpoints and the Stripe payment routes. +/// +public static partial class PrivateApi +{ + public static async Task PointsClaim(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var data = new JsonObject { ["us"] = username }; + await Upstream.Pipe(ApiClient.ApiRequest("claim", HttpMethod.Put, null, data), ctx); + } + + public static async Task PointsCalc(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var amount = PaymentsTemplateValue(body, "amount"); + await Upstream.Pipe( + ApiClient.ApiRequest($"estm-calc?username={username}&amount={amount}", HttpMethod.Get), ctx); + } + + public static async Task PromotePrice(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + await Upstream.Pipe(ApiClient.ApiRequest("promote-price", HttpMethod.Get), ctx); + } + + public static async Task PromotedPost(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var author = PaymentsTemplateValue(body, "author"); + var permlink = PaymentsTemplateValue(body, "permlink"); + await Upstream.Pipe( + ApiClient.ApiRequest($"promoted-posts/{author}/{permlink}", HttpMethod.Get), ctx); + } + + public static async Task BoostPlusPrice(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + await Upstream.Pipe(ApiClient.ApiRequest("boost-plus-price", HttpMethod.Get), ctx); + } + + public static async Task BoostedPlusAccount(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var account = PaymentsTemplateValue(body, "account"); + await Upstream.Pipe( + ApiClient.ApiRequest($"boosted-plus-accounts/{account}", HttpMethod.Get), ctx); + } + + public static async Task RcDelegationPrice(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + await Upstream.Pipe(ApiClient.ApiRequest("rc-delegation-price", HttpMethod.Get), ctx); + } + + public static async Task RcDelegationActive(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + // Self-only: always check the authenticated user's own active top-up. + await Upstream.Pipe( + ApiClient.ApiRequest($"rc-delegation-active/{username}", HttpMethod.Get), ctx); + } + + public static async Task StripeCreateIntent(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + // Fail closed at the route (not a whole-service startup crash): never forward a money + // request with an empty/absent shared secret. + var secret = Config.StripeInternalSecret; + if (string.IsNullOrEmpty(secret)) + { + await ctx.SendText(503, "payments not configured"); + return; + } + // `user` is ALWAYS the authenticated caller (never client-supplied). sku/nonce/meta + // are validated server-side by ePoints (amount + points come from its catalog, the + // nonce is required for idempotency, the sku is points-only). The shared secret + // authenticates this hop to ePoints; ePoints never trusts a client-sent price. + // hosting_target is optional: on the hosting rail it activates a DIFFERENT tenant than + // the buyer; ePoints validates it and ignores it on every non-hosting rail. + // gift_recipient / gift_message (Points gift rail): the buyer pays and the Points are + // credited to gift_recipient instead of the buyer; ePoints validates + verifies the + // recipient exists before charging and ignores these on non-Points skus. + + // hosting_target is client-supplied and crosses into the trusted internal request. + // Reject a malformed value at this boundary before forwarding (typeof guard first: + // a non-string must never be coerced by the regex test and forwarded). + var hostingTargetPresent = body.TryGetPropertyValue("hosting_target", out var hostingTargetNode); + var hostingTarget = hostingTargetNode is JsonValue htv && htv.TryGetValue(out var hts) + ? hts + : null; + if (hostingTargetPresent && (hostingTarget == null || !PaymentsHiveNameRegex.IsMatch(hostingTarget))) + { + await ctx.SendText(400, "invalid hosting target"); + return; + } + // Same boundary guard for the gift recipient (typeof before regex); Hive names are + // lowercase, so normalize typed input (trim + lowercase) before validating so a + // recipient like "Alice" is accepted as "alice", and forward the canonical form. + var giftRecipientPresent = body.TryGetPropertyValue("gift_recipient", out var giftRecipientNode); + var giftRecipient = giftRecipientNode is JsonValue grv && grv.TryGetValue(out var grs) + ? grs.Trim().ToLowerInvariant() + : null; + if (giftRecipientPresent && (giftRecipient == null || !PaymentsHiveNameRegex.IsMatch(giftRecipient))) + { + await ctx.SendText(400, "invalid gift recipient"); + return; + } + var giftMessagePresent = body.TryGetPropertyValue("gift_message", out var giftMessageNode); + var giftMessage = giftMessageNode is JsonValue gmv && gmv.TryGetValue(out var gms) + ? gms + : null; + if (giftMessagePresent && (giftMessage == null || giftMessage.Length > 200)) + { + await ctx.SendText(400, "invalid gift message"); + return; + } + + var headers = new[] { new KeyValuePair("X-Internal-Secret", secret) }; + // JSON.stringify omits undefined: each optional key is forwarded only when it was + // present in the request body (present-with-null is kept as null). + var payload = new JsonObject { ["user"] = username }; + if (body.TryGetPropertyValue("sku", out var sku)) + { + payload["sku"] = sku?.DeepClone(); + } + if (body.TryGetPropertyValue("nonce", out var nonce)) + { + payload["nonce"] = nonce?.DeepClone(); + } + if (body.TryGetPropertyValue("meta", out var meta)) + { + payload["meta"] = meta?.DeepClone(); + } + if (hostingTargetPresent) + { + payload["hosting_target"] = hostingTarget; + } + if (giftRecipientPresent) + { + payload["gift_recipient"] = giftRecipient; + } + if (giftMessagePresent) + { + payload["gift_message"] = giftMessage; + } + await Upstream.Pipe( + ApiClient.ApiRequest("stripe/create-intent", HttpMethod.Post, headers, payload, null, 20000), ctx); + } + + public static async Task StripeOrderStatus(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var secret = Config.StripeInternalSecret; + if (string.IsNullOrEmpty(secret)) + { + await ctx.SendText(503, "payments not configured"); + return; + } + var paymentIntent = body.Str("payment_intent"); + if (paymentIntent == null || paymentIntent.Trim().Length == 0) + { + await ctx.SendText(400, "payment_intent required"); + return; + } + // Owner-scoped: ePoints filters by ?user, so a caller can only read its own order. + var headers = new[] { new KeyValuePair("X-Internal-Secret", secret) }; + await Upstream.Pipe( + ApiClient.ApiRequest( + $"stripe/order/{PaymentsEncodeUriComponent(paymentIntent.Trim())}?user={PaymentsEncodeUriComponent(username)}", + HttpMethod.Get, headers, null, null, 20000), + ctx); + } + + public static async Task StripeCreateAccountIntent(HttpContext ctx) + { + // ANONYMOUS paid-account purchase: NO validateCode (the buyer has no Hive account yet). + // The captcha is the human gate (ALWAYS enforced for this paid flow, independent of + // CAPTCHA_MODE). The backend re-validates the username/email/availability and computes + // the amount server-side before charging; this hop only authenticates with the shared + // secret and forwards the request. + var body = await ctx.ReadBody(); + var secret = Config.StripeInternalSecret; + if (string.IsNullOrEmpty(secret)) + { + await ctx.SendText(503, "payments not configured"); + return; + } + var ip = PaymentsSignupClientIp(ctx); + // This route mints ONLY the accounts rail; never let it create a points order (which + // the points route binds to an authenticated caller). + var sku = body.Str("sku"); + if (sku == null || !sku.EndsWith("accounts", StringComparison.Ordinal)) + { + await ctx.SendText(400, "invalid product"); + return; + } + // Validate cheap inputs BEFORE the single-use Turnstile check, so a fixable input error + // (e.g. a missing username) never burns the user's one-time captcha token. + var metaObj = body.Field("meta") as JsonObject; + var metaUsername = metaObj?.Str("username"); + var username = metaUsername != null ? metaUsername.Trim().ToLowerInvariant() : ""; + if (username.Length == 0) + { + await ctx.SendText(400, "username required"); + return; + } + if (!await PaymentsVerifyTurnstile(body.Field("captcha_token"), ip)) + { + await ctx.SendJson(406, new JsonObject + { + ["code"] = 113, + ["message"] = "Please complete the verification and try again.", + }); + return; + } + // `user` carries the new account name as the order identity; the backend re-derives + + // re-validates it. Forward a NORMALIZED meta.username so meta and `user` agree. Forward + // the client IP for backend-side rate-limiting / audit. + var headers = new[] + { + new KeyValuePair("X-Internal-Secret", secret), + new KeyValuePair("X-Real-IP-V", ip), + }; + // meta is necessarily an object here (meta.username was a non-empty string), so the + // TS `meta ? { ...meta, username } : meta` branch always spreads. + var metaClone = (JsonObject)metaObj!.DeepClone(); + metaClone["username"] = username; + var payload = new JsonObject { ["user"] = username, ["sku"] = sku }; + if (body.TryGetPropertyValue("nonce", out var nonce)) + { + payload["nonce"] = nonce?.DeepClone(); + } + payload["meta"] = metaClone; + await Upstream.Pipe( + ApiClient.ApiRequest("stripe/create-intent", HttpMethod.Post, headers, payload, null, 20000), ctx); + } + + public static async Task StripeAccountStatus(HttpContext ctx) + { + // ANONYMOUS status poll: no Hive account yet, so no validateCode owner-binding. Scope by + // the buyer-supplied username + payment_intent; the backend filters by ?user, so the + // order resolves only when BOTH match what it created. Status is low-sensitivity. + var body = await ctx.ReadBody(); + var secret = Config.StripeInternalSecret; + if (string.IsNullOrEmpty(secret)) + { + await ctx.SendText(503, "payments not configured"); + return; + } + var paymentIntent = body.Str("payment_intent"); + var pi = paymentIntent != null ? paymentIntent.Trim() : ""; + var usernameRaw = body.Str("username"); + var user = usernameRaw != null ? usernameRaw.Trim().ToLowerInvariant() : ""; + if (pi.Length == 0 || user.Length == 0) + { + await ctx.SendText(400, "payment_intent and username required"); + return; + } + var headers = new[] { new KeyValuePair("X-Internal-Secret", secret) }; + await Upstream.Pipe( + ApiClient.ApiRequest( + $"stripe/order/{PaymentsEncodeUriComponent(pi)}?user={PaymentsEncodeUriComponent(user)}", + HttpMethod.Get, headers, null, null, 20000), + ctx); + } + + public static async Task BoostOptions(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + await Upstream.Pipe(ApiClient.ApiRequest("boost-options", HttpMethod.Get), ctx); + } + + public static async Task BoostedPost(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var author = PaymentsTemplateValue(body, "author"); + var permlink = PaymentsTemplateValue(body, "permlink"); + await Upstream.Pipe( + ApiClient.ApiRequest($"boosted-posts/{author}/{permlink}", HttpMethod.Get), ctx); + } + + // --- helpers local to this chunk (prefixed to avoid partial-class collisions) --- + + // /^(?=.{3,16}$)[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/ — \z instead of $ because .NET's $ + // also matches before a trailing newline, which JS's does not. + private static readonly Regex PaymentsHiveNameRegex = + new(@"^(?=.{3,16}\z)[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*\z", RegexOptions.Compiled); + + /// + /// signupClientIp(): originating client IP from the proxy-set X-Real-IP header. + /// X-Forwarded-For is deliberately not used (client-suppliable). '' when absent. + /// + private static string PaymentsSignupClientIp(HttpContext ctx) + { + var realIp = ctx.Request.Headers["X-Real-IP"]; + var value = realIp.Count switch + { + 0 => (string?)null, + 1 => realIp[0], + // Node joins duplicate non-special headers with ", " + _ => string.Join(", ", realIp.ToArray()), + }; + return string.IsNullOrEmpty(value) ? "" : value; + } + + /// + /// verifyTurnstile(): server-side Cloudflare Turnstile verification. Returns true ONLY on + /// a confirmed-human token; fails CLOSED on a missing secret/token, provider error, or + /// rejection (a network blip must never open the gate). + /// + private static async Task PaymentsVerifyTurnstile(JsonNode? token, string ip) + { + var secret = Config.TurnstileSecret; + var tokenStr = token is JsonValue tv && tv.TryGetValue(out var ts) ? ts : null; + if (string.IsNullOrEmpty(secret) || tokenStr == null || tokenStr.Trim().Length == 0) + { + return false; + } + try + { + var form = new List> + { + new("secret", secret), + new("response", tokenStr.Trim()), + }; + if (ip.Length > 0) + { + form.Add(new("remoteip", ip)); + } + using var req = new HttpRequestMessage( + HttpMethod.Post, "https://challenges.cloudflare.com/turnstile/v0/siteverify") + { + Content = new FormUrlEncodedContent(form), + }; + using var cts = new CancellationTokenSource(8000); + using var resp = await Upstream.Http.SendAsync( + req, HttpCompletionOption.ResponseContentRead, cts.Token); + if (!resp.IsSuccessStatusCode) + { + // axios' default validateStatus rejects non-2xx -> caught -> false + Console.WriteLine( + $"verifyTurnstile: siteverify error Request failed with status code {(int)resp.StatusCode}"); + return false; + } + var text = await resp.Content.ReadAsStringAsync(cts.Token); + JsonNode? parsed; + try + { + parsed = JsonNode.Parse(text); + } + catch (JsonException) + { + // axios keeps a non-JSON body as a raw string; .success is then undefined + return false; + } + // r?.data?.success === true (strict: boolean true only) + return parsed is JsonObject po + && po.TryGetPropertyValue("success", out var success) + && success is JsonValue sv + && sv.TryGetValue(out var ok) + && ok; + } + catch (Exception err) + { + Console.WriteLine($"verifyTurnstile: siteverify error {err.Message}"); + return false; + } + } + + /// encodeURIComponent(): Uri.EscapeDataString also escapes !'()* — undo those. + private static string PaymentsEncodeUriComponent(string s) => + Uri.EscapeDataString(s) + .Replace("%21", "!") + .Replace("%27", "'") + .Replace("%28", "(") + .Replace("%29", ")") + .Replace("%2A", "*"); + + /// JS template-literal coercion for a body field: absent -> "undefined". + private static string PaymentsTemplateValue(JsonObject body, string key) => + body.TryGetPropertyValue(key, out var v) ? PaymentsJsToString(v) : "undefined"; + + /// String(x) coercion for a JSON value (null -> "null", array -> joined, ...). + private static string PaymentsJsToString(JsonNode? node) + { + switch (node) + { + case null: + return "null"; + case JsonArray arr: + // Array.prototype.toString: comma-joined elements; null slots -> "" + return string.Join(",", arr.Select(e => e == null ? "" : PaymentsJsToString(e))); + case JsonObject: + return "[object Object]"; + case JsonValue value: + if (value.TryGetValue(out var s)) + { + return s; + } + if (value.TryGetValue(out var b)) + { + return b ? "true" : "false"; + } + // numbers: String(n) == JSON.stringify(n) for every finite double + return JsJson.Stringify(node); + default: + return JsJson.Stringify(node); + } + } +} diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.UserData1.cs b/dotnet/EcencyApi/Handlers/PrivateApi.UserData1.cs new file mode 100644 index 00000000..0898ced0 --- /dev/null +++ b/dotnet/EcencyApi/Handlers/PrivateApi.UserData1.cs @@ -0,0 +1,507 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using EcencyApi.Infrastructure; + +namespace EcencyApi.Handlers; + +/// +/// Port of src/server/handlers/private-api.ts lines 1004-1257: notifications, +/// devices, images, drafts, bookmarks and support-settings passthroughs. +/// +public static partial class PrivateApi +{ + // POST ^/private-api/notifications$ + public static async Task Notifications(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + var user = body.Field("user"); + + if (string.IsNullOrEmpty(username)) + { + if (!JsJson.IsTruthy(user)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + username = UserData1Helpers.Template(user); + } + // if user defined but not same as user's code + if (JsJson.IsTruthy(user)) + { + username = UserData1Helpers.Template(user); + } + + var filter = body.Field("filter"); + var since = body.Field("since"); + var limit = body.Field("limit"); + + var u = $"activities/{username}"; + + if (JsJson.IsTruthy(filter)) + { + u = $"{UserData1Helpers.Template(filter)}/{username}"; + } + + if (JsJson.IsTruthy(since)) + { + u += $"?since={UserData1Helpers.Template(since)}"; + } + + if (JsJson.IsTruthy(since) && JsJson.IsTruthy(limit)) + { + u += $"&limit={UserData1Helpers.Template(limit)}"; + } + + if (!JsJson.IsTruthy(since) && JsJson.IsTruthy(limit)) + { + u += $"?limit={UserData1Helpers.Template(limit)}"; + } + + await Upstream.Pipe(ApiClient.ApiRequest(u, HttpMethod.Get), ctx); + } + + // GET ^/private-api/pub-notifications/:username + public static async Task PublicUnreadNotifications(HttpContext ctx) + { + var username = ctx.Request.RouteValues["username"]?.ToString(); + + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(400, "Missing username"); + return; + } + + await Upstream.Pipe(ApiClient.ApiRequest($"activities/{username}/unread-count", HttpMethod.Get), ctx); + } + + /* Login required endpoints */ + + // POST ^/private-api/notifications/unread$ + public static async Task UnreadNotifications(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + + await Upstream.Pipe(ApiClient.ApiRequest($"activities/{username}/unread-count", HttpMethod.Get), ctx); + } + + // POST ^/private-api/notifications/mark$ + public static async Task MarkNotifications(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + + var id = body.Field("id"); + var data = new JsonObject(); + + if (JsJson.IsTruthy(id)) + { + data["id"] = id!.DeepClone(); + } + + await Upstream.Pipe(ApiClient.ApiRequest($"activities/{username}", HttpMethod.Put, null, data), ctx); + } + + // POST ^/private-api/register-device$ + public static async Task RegisterDevice(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var authedUsername = await ValidateCode(body); + if (string.IsNullOrEmpty(authedUsername)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + + // Payload takes the fields from the request body, not the authed user. + var data = new JsonObject(); + UserData1Helpers.CopyIfPresent(data, body, "username"); + UserData1Helpers.CopyIfPresent(data, body, "token"); + UserData1Helpers.CopyIfPresent(data, body, "system"); + UserData1Helpers.CopyIfPresent(data, body, "allows_notify"); + UserData1Helpers.CopyIfPresent(data, body, "notify_types"); + await Upstream.Pipe(ApiClient.ApiRequest("rgstrmbldvc/", HttpMethod.Post, null, data), ctx); + } + + // POST ^/private-api/detail-device$ + public static async Task DetailDevice(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var authedUsername = await ValidateCode(body); + if (string.IsNullOrEmpty(authedUsername)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + + var username = UserData1Helpers.TemplateOf(body, "username"); + var token = UserData1Helpers.TemplateOf(body, "token"); + await Upstream.Pipe(ApiClient.ApiRequest($"mbldvcdtl/{username}/{token}", HttpMethod.Get), ctx); + } + + // POST ^/private-api/images$ + public static async Task Images(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + + await Upstream.Pipe( + ApiClient.ApiRequest($"images/{username}", HttpMethod.Get, null, null, UserData1Helpers.ExpressQuery(ctx)), + ctx); + } + + // POST ^/private-api/images-delete$ + public static async Task ImagesDelete(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + + var id = UserData1Helpers.TemplateOf(body, "id"); + await Upstream.Pipe(ApiClient.ApiRequest($"images/{username}/{id}", HttpMethod.Delete), ctx); + } + + // POST ^/private-api/images-add$ + public static async Task ImagesAdd(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + + var data = new JsonObject { ["username"] = username }; + if (body.TryGetPropertyValue("url", out var url)) + { + data["image_url"] = url?.DeepClone(); + } + await Upstream.Pipe(ApiClient.ApiRequest("image", HttpMethod.Post, null, data), ctx); + } + + // POST ^/private-api/drafts$ + public static async Task Drafts(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + + await Upstream.Pipe( + ApiClient.ApiRequest($"drafts/{username}", HttpMethod.Get, null, null, UserData1Helpers.ExpressQuery(ctx)), + ctx); + } + + // POST ^/private-api/drafts-add$ + public static async Task DraftsAdd(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + + var data = new JsonObject { ["username"] = username }; + UserData1Helpers.CopyIfPresent(data, body, "title"); + UserData1Helpers.CopyIfPresent(data, body, "body"); + UserData1Helpers.CopyIfPresent(data, body, "tags"); + UserData1Helpers.CopyIfPresent(data, body, "meta"); + await Upstream.Pipe(ApiClient.ApiRequest("draft", HttpMethod.Post, null, data), ctx); + } + + // POST ^/private-api/drafts-update$ + public static async Task DraftsUpdate(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + + var id = UserData1Helpers.TemplateOf(body, "id"); + var data = new JsonObject { ["username"] = username }; + UserData1Helpers.CopyIfPresent(data, body, "title"); + UserData1Helpers.CopyIfPresent(data, body, "body"); + UserData1Helpers.CopyIfPresent(data, body, "tags"); + UserData1Helpers.CopyIfPresent(data, body, "meta"); + await Upstream.Pipe(ApiClient.ApiRequest($"drafts/{username}/{id}", HttpMethod.Put, null, data), ctx); + } + + // POST ^/private-api/drafts-delete$ + public static async Task DraftsDelete(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + + var id = UserData1Helpers.TemplateOf(body, "id"); + await Upstream.Pipe(ApiClient.ApiRequest($"drafts/{username}/{id}", HttpMethod.Delete), ctx); + } + + // POST ^/private-api/bookmarks$ + public static async Task Bookmarks(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + + await Upstream.Pipe( + ApiClient.ApiRequest($"bookmarks/{username}", HttpMethod.Get, null, null, UserData1Helpers.ExpressQuery(ctx)), + ctx); + } + + // POST ^/private-api/bookmarks-add$ + public static async Task BookmarksAdd(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + + var data = new JsonObject { ["username"] = username }; + UserData1Helpers.CopyIfPresent(data, body, "author"); + UserData1Helpers.CopyIfPresent(data, body, "permlink"); + data["chain"] = "steem"; + await Upstream.Pipe(ApiClient.ApiRequest("bookmark", HttpMethod.Post, null, data), ctx); + } + + // POST ^/private-api/bookmarks-delete$ + public static async Task BookmarksDelete(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + + var id = UserData1Helpers.TemplateOf(body, "id"); + await Upstream.Pipe(ApiClient.ApiRequest($"bookmarks/{username}/{id}", HttpMethod.Delete), ctx); + } + + /// + /// Parse and validate the support-settings payload. Both fields must be integers + /// within 0..100 (booleans, strings and floats are rejected). Returns the parsed + /// pair, or null when the payload is invalid. Exported for unit tests in the TS + /// source; kept public here for the same reason (not wired to any route itself). + /// Values stay doubles because JS Number.isInteger accepts any integral number. + /// + public static (double BeneficiaryPercent, double CurationPercent)? ParseSupportSettingsPayload(JsonNode? body) + { + JsonNode? beneficiaryPercent = null; + JsonNode? curationPercent = null; + if (body is JsonObject obj) + { + obj.TryGetPropertyValue("beneficiary_percent", out beneficiaryPercent); + obj.TryGetPropertyValue("curation_percent", out curationPercent); + } + + if (!UserData1Helpers.IsPercent(beneficiaryPercent, out var beneficiary) || + !UserData1Helpers.IsPercent(curationPercent, out var curation)) + { + return null; + } + return (beneficiary, curation); + } + + // Support settings are per-user opt-ins, so the username is taken from the + // authenticated token (validateCode), never from the request body. + // POST ^/private-api/support-settings$ + public static async Task SupportSettings(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await RequireAuthedUsername(ctx, body); + if (username == null) + { + return; + } + await Upstream.Pipe(ApiClient.ApiRequest($"support-settings/{username}", HttpMethod.Get), ctx); + } + + // POST ^/private-api/support-settings-update$ + public static async Task SupportSettingsUpdate(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await RequireAuthedUsername(ctx, body); + if (username == null) + { + return; + } + + var parsed = ParseSupportSettingsPayload(body); + if (parsed == null) + { + await ctx.SendText(400, + "beneficiary_percent and curation_percent must be integers between 0 and 100"); + return; + } + + var (beneficiaryPercent, curationPercent) = parsed.Value; + var payload = new JsonObject + { + ["username"] = username, + ["beneficiary_percent"] = beneficiaryPercent, + ["curation_percent"] = curationPercent, + }; + await Upstream.Pipe( + ApiClient.ApiRequest($"support-settings/{username}", HttpMethod.Put, null, payload), + ctx); + } +} + +/// File-local helpers (JS template-literal/undefined semantics). +file static class UserData1Helpers +{ + /// JS template-literal interpolation (`${v}`) for a JSON value. + public static string Template(JsonNode? node) + { + switch (node) + { + case null: + return "null"; + case JsonObject: + return "[object Object]"; + case JsonArray arr: + // Array.prototype.toString: elements joined by ",", holes/null -> "" + return string.Join(",", arr.Select(item => item == null ? "" : Template(item))); + default: + if (node is JsonValue jv && jv.TryGetValue(out var s)) + { + return s; + } + // numbers and booleans stringify identically to String(v) + return JsJson.Stringify(node); + } + } + + /// Template interpolation of a body field; absent key == undefined -> "undefined". + public static string TemplateOf(JsonObject body, string key) => + body.TryGetPropertyValue(key, out var v) ? Template(v) : "undefined"; + + /// + /// Copy a destructured body field into a payload only when the key is present + /// (absent == undefined, which JSON.stringify omits; present null is kept). + /// + public static void CopyIfPresent(JsonObject target, JsonObject body, string key) + { + if (body.TryGetPropertyValue(key, out var v)) + { + target[key] = v?.DeepClone(); + } + } + + /// + /// req.query -> axios params: single values as key=value, repeated keys the + /// way axios serializes qs arrays (key[]=v per element). + /// + public static IEnumerable> ExpressQuery(HttpContext ctx) + { + var list = new List>(); + foreach (var kv in ctx.Request.Query) + { + if (kv.Value.Count > 1) + { + foreach (var v in kv.Value) + { + list.Add(new KeyValuePair($"{kv.Key}[]", v)); + } + } + else + { + list.Add(new KeyValuePair(kv.Key, kv.Value.ToString())); + } + } + return list; + } + + /// typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 100. + public static bool IsPercent(JsonNode? v, out double value) + { + value = 0; + if (v is not JsonValue jv) + { + return false; + } + + double d; + if (jv.TryGetValue(out var el)) + { + if (el.ValueKind != JsonValueKind.Number) + { + return false; + } + d = el.GetDouble(); + } + else if (jv.TryGetValue(out var i)) + { + d = i; + } + else if (jv.TryGetValue(out var l)) + { + d = l; + } + else if (jv.TryGetValue(out var dd)) + { + d = dd; + } + else if (jv.TryGetValue(out var m)) + { + d = (double)m; + } + else + { + return false; + } + + if (double.IsNaN(d) || double.IsInfinity(d) || Math.Floor(d) != d) + { + return false; // Number.isInteger rejects non-integral values + } + if (d < 0 || d > 100) + { + return false; + } + value = d; + return true; + } +} diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.UserData2.cs b/dotnet/EcencyApi/Handlers/PrivateApi.UserData2.cs new file mode 100644 index 00000000..ccde5fed --- /dev/null +++ b/dotnet/EcencyApi/Handlers/PrivateApi.UserData2.cs @@ -0,0 +1,365 @@ +using System.Text.Json.Nodes; +using EcencyApi.Infrastructure; + +namespace EcencyApi.Handlers; + +/// +/// Port of src/server/handlers/private-api.ts user-data endpoints: +/// schedules, favorites, fragments, decks and recoveries. All are +/// validateCode-gated passthroughs to the private API. +/// +public static partial class PrivateApi +{ + public static async Task Schedules(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + await Upstream.Pipe( + ApiClient.ApiRequest($"schedules/{username}", HttpMethod.Get, null, null, UserData2Js.Query(ctx)), + ctx); + } + + public static async Task SchedulesAdd(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + + // { username, permlink, title, body, meta, options, schedule, reblog: reblog ? 1 : 0 } + // — destructured keys are only serialized when present in the request + // body (JSON.stringify omits undefined); reblog is always 0/1. + var data = new JsonObject { ["username"] = username }; + UserData2Js.CopyIfPresent(body, data, "permlink"); + UserData2Js.CopyIfPresent(body, data, "title"); + UserData2Js.CopyIfPresent(body, data, "body"); + UserData2Js.CopyIfPresent(body, data, "meta"); + UserData2Js.CopyIfPresent(body, data, "options"); + UserData2Js.CopyIfPresent(body, data, "schedule"); + data["reblog"] = JsJson.IsTruthy(body.Field("reblog")) ? 1 : 0; + + await Upstream.Pipe(ApiClient.ApiRequest("schedules", HttpMethod.Post, null, data), ctx); + } + + public static async Task SchedulesDelete(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var id = UserData2Js.TemplateField(body, "id"); + await Upstream.Pipe(ApiClient.ApiRequest($"schedules/{username}/{id}", HttpMethod.Delete), ctx); + } + + public static async Task SchedulesMove(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var id = UserData2Js.TemplateField(body, "id"); + await Upstream.Pipe(ApiClient.ApiRequest($"schedules/{username}/{id}", HttpMethod.Put), ctx); + } + + public static async Task Favorites(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + await Upstream.Pipe( + ApiClient.ApiRequest($"favorites/{username}", HttpMethod.Get, null, null, UserData2Js.Query(ctx)), + ctx); + } + + public static async Task FavoritesCheck(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var account = UserData2Js.TemplateField(body, "account"); + await Upstream.Pipe(ApiClient.ApiRequest($"isfavorite/{username}/{account}", HttpMethod.Get), ctx); + } + + public static async Task FavoritesAdd(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var data = new JsonObject { ["username"] = username }; + UserData2Js.CopyIfPresent(body, data, "account"); + await Upstream.Pipe(ApiClient.ApiRequest("favorite", HttpMethod.Post, null, data), ctx); + } + + public static async Task FavoritesDelete(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var account = UserData2Js.TemplateField(body, "account"); + await Upstream.Pipe(ApiClient.ApiRequest($"favoriteUser/{username}/{account}", HttpMethod.Delete), ctx); + } + + public static async Task Fragments(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + await Upstream.Pipe( + ApiClient.ApiRequest($"fragments/{username}", HttpMethod.Get, null, null, UserData2Js.Query(ctx)), + ctx); + } + + public static async Task FragmentsAdd(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var data = new JsonObject { ["username"] = username }; + UserData2Js.CopyIfPresent(body, data, "title"); + UserData2Js.CopyIfPresent(body, data, "body"); + await Upstream.Pipe(ApiClient.ApiRequest("fragment", HttpMethod.Post, null, data), ctx); + } + + public static async Task FragmentsUpdate(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var id = UserData2Js.TemplateField(body, "id"); + var data = new JsonObject(); + UserData2Js.CopyIfPresent(body, data, "title"); + UserData2Js.CopyIfPresent(body, data, "body"); + await Upstream.Pipe(ApiClient.ApiRequest($"fragments/{username}/{id}", HttpMethod.Put, null, data), ctx); + } + + public static async Task FragmentsDelete(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var id = UserData2Js.TemplateField(body, "id"); + await Upstream.Pipe(ApiClient.ApiRequest($"fragments/{username}/{id}", HttpMethod.Delete), ctx); + } + + public static async Task Decks(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + await Upstream.Pipe(ApiClient.ApiRequest($"decks/{username}", HttpMethod.Get), ctx); + } + + public static async Task DecksAdd(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var data = new JsonObject { ["username"] = username }; + UserData2Js.CopyIfPresent(body, data, "title"); + UserData2Js.CopyIfPresent(body, data, "settings"); + await Upstream.Pipe(ApiClient.ApiRequest("deck", HttpMethod.Post, null, data), ctx); + } + + public static async Task DecksUpdate(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var id = UserData2Js.TemplateField(body, "id"); + var data = new JsonObject { ["username"] = username }; + UserData2Js.CopyIfPresent(body, data, "title"); + UserData2Js.CopyIfPresent(body, data, "settings"); + await Upstream.Pipe(ApiClient.ApiRequest($"decks/{username}/{id}", HttpMethod.Put, null, data), ctx); + } + + public static async Task DecksDelete(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var id = UserData2Js.TemplateField(body, "id"); + await Upstream.Pipe(ApiClient.ApiRequest($"decks/{username}/{id}", HttpMethod.Delete), ctx); + } + + public static async Task Recoveries(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + await Upstream.Pipe(ApiClient.ApiRequest($"recoveries/{username}", HttpMethod.Get), ctx); + } + + public static async Task RecoveriesAdd(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var data = new JsonObject { ["username"] = username }; + UserData2Js.CopyIfPresent(body, data, "email"); + UserData2Js.CopyIfPresent(body, data, "public_keys"); + await Upstream.Pipe(ApiClient.ApiRequest("recovery", HttpMethod.Post, null, data), ctx); + } + + public static async Task RecoveriesUpdate(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var id = UserData2Js.TemplateField(body, "id"); + var data = new JsonObject { ["username"] = username }; + UserData2Js.CopyIfPresent(body, data, "email"); + UserData2Js.CopyIfPresent(body, data, "public_keys"); + await Upstream.Pipe(ApiClient.ApiRequest($"recoveries/{username}/{id}", HttpMethod.Put, null, data), ctx); + } + + public static async Task RecoveriesDelete(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var id = UserData2Js.TemplateField(body, "id"); + await Upstream.Pipe(ApiClient.ApiRequest($"recoveries/{username}/{id}", HttpMethod.Delete), ctx); + } +} + +/// +/// File-local helpers replicating the JS semantics these handlers rely on +/// (template-literal string coercion of destructured body fields, undefined-key +/// omission in payloads, req.query forwarding). +/// +file static class UserData2Js +{ + /// + /// `${field}` for a destructured req.body field: absent key -> "undefined", + /// explicit null -> "null", otherwise JS String() coercion. + /// + public static string TemplateField(JsonObject body, string key) + { + return body.TryGetPropertyValue(key, out var v) ? JsString(v) : "undefined"; + } + + private static string JsString(JsonNode? node) + { + switch (node) + { + case null: + return "null"; + case JsonObject: + return "[object Object]"; + case JsonArray arr: + // Array.prototype.toString: join(","), null/undefined elements -> "" + return string.Join(",", arr.Select(el => el == null ? "" : JsString(el))); + case JsonValue v: + if (v.TryGetValue(out var s)) return s; + if (v.TryGetValue(out var b)) return b ? "true" : "false"; + // numbers: String(n) == JSON.stringify(n) for finite doubles + return JsJson.Stringify(node); + default: + return node.ToJsonString(); + } + } + + /// + /// Add a payload key only when it exists in the request body (absent == + /// undefined == omitted by JSON.stringify; explicit null is kept). + /// + public static void CopyIfPresent(JsonObject src, JsonObject dst, string key) + { + if (src.TryGetPropertyValue(key, out var v)) + { + dst[key] = v?.DeepClone(); + } + } + + /// req.query forwarded as axios params. + public static List> Query(HttpContext ctx) + { + var list = new List>(); + foreach (var kv in ctx.Request.Query) + { + foreach (var value in kv.Value) + { + list.Add(new KeyValuePair(kv.Key, value)); + } + } + return list; + } +} diff --git a/dotnet/EcencyApi/Handlers/Routes.cs b/dotnet/EcencyApi/Handlers/Routes.cs new file mode 100644 index 00000000..347a6193 --- /dev/null +++ b/dotnet/EcencyApi/Handlers/Routes.cs @@ -0,0 +1,270 @@ +using System.Text; +using System.Text.Json.Nodes; +using EcencyApi.Infrastructure; + +namespace EcencyApi.Handlers; + +/// +/// 1:1 port of the route table in src/server/index.tsx. Every route maps to the +/// same path and method as the Express original; handlers are static methods on +/// the SearchApi / AuthApi / WalletApi / PrivateApi partial classes. +/// +/// Routing parity verified against the live Node image: +/// - declared routes are EXACT matches even without a trailing '$' (path-to-regexp +/// 0.1.7 fully anchors them); +/// - :param matches a single path segment; +/// - unmatched GET/HEAD -> 200 + the template page; +/// - unmatched other methods -> 404 + Express finalhandler "Cannot METHOD /path". +/// +public static class Routes +{ + public static void Map(WebApplication app) + { + // ---- Search Api ---- + app.MapPost("/search-api/search", SearchApi.Search); + app.MapPost("/search-api/search-follower", SearchApi.SearchFollower); + app.MapPost("/search-api/search-following", SearchApi.SearchFollowing); + app.MapPost("/search-api/search-account", SearchApi.SearchAccount); + app.MapPost("/search-api/search-tag", SearchApi.SearchTag); + app.MapPost("/search-api/search-path", SearchApi.SearchPath); + app.MapPost("/search-api/similar", SearchApi.SearchSimilar); + + // ---- Auth Api ---- + app.MapPost("/auth-api/hs-token-refresh", AuthApi.HsTokenRefresh); + app.MapPost("/auth-api/hs-token-create", AuthApi.HsTokenCreate); + + // ---- Wallet Api ---- + app.MapPost("/wallet-api/portfolio", WalletApi.Portfolio); + app.MapPost("/wallet-api/portfolio-v2", WalletApi.PortfolioV2); + app.MapPost("/private-api/engine-api", WalletApi.Eapi); + app.MapGet("/private-api/engine-reward-api/{username}", WalletApi.Erewardapi); + app.MapGet("/private-api/engine-chart-api", WalletApi.Echartapi); + app.MapGet("/private-api/engine-account-history", WalletApi.EngineAccountHistory); + + // ---- Private Api (GET) ---- + app.MapGet("/private-api/public/bots", PrivateApi.BotsGet); + app.MapGet("/private-api/received-vesting/{username}", PrivateApi.ReceivedVesting); + app.MapGet("/private-api/received-rc/{username}", PrivateApi.ReceivedRC); + app.MapGet("/private-api/rewarded-communities", PrivateApi.RewardedCommunities); + app.MapGet("/private-api/pro-members", PrivateApi.ProMembers); + app.MapGet("/private-api/balance/{chain}/{address}", PrivateApi.Balance); + app.MapPost("/private-api/rpc/{chain}", PrivateApi.ChainRpc); + app.MapGet("/private-api/leaderboard/{duration:regex(^(day|week|month)$)}", PrivateApi.Leaderboard); + app.MapGet("/private-api/curation/{duration:regex(^(day|week|month)$)}", PrivateApi.Curation); + app.MapGet("/private-api/promoted-entries", PrivateApi.PromotedEntries); + // market-data/latest (1 segment) and market-data/{fiat}/{token} (2 segments) + // never collide by arity; register both like the Node table. + app.MapGet("/private-api/market-data/{fiat}/{token}", PrivateApi.MarketData); + app.MapGet("/private-api/market-data/latest", PrivateApi.MarketDataLatest); + app.MapGet("/private-api/referrals/{username}", PrivateApi.Referrals); + app.MapGet("/private-api/referrals/{username}/stats", PrivateApi.ReferralsStats); + app.MapGet("/private-api/announcements", PrivateApi.GetAnnouncement); + app.MapGet("/private-api/spotlights", PrivateApi.GetSpotlight); + app.MapGet("/private-api/chats-pub/{username}", PrivateApi.ChatsPub); + app.MapGet("/private-api/channel/{username}", PrivateApi.ChannelGet); + app.MapGet("/private-api/proposal/active", PrivateApi.ProposalActive); + app.MapGet("/private-api/pub-notifications/{username}", PrivateApi.PublicUnreadNotifications); + app.MapGet("/private-api/waves/feed", PrivateApi.WavesFeed); + app.MapGet("/private-api/waves/shorts", PrivateApi.WavesShorts); + app.MapGet("/private-api/waves/tags", PrivateApi.WavesTags); + app.MapGet("/private-api/waves/account", PrivateApi.WavesAccount); + app.MapGet("/private-api/waves/following", PrivateApi.WavesFollowing); + app.MapGet("/private-api/waves/trending/tags", PrivateApi.WavesTrendingTags); + app.MapGet("/private-api/waves/trending/authors", PrivateApi.WavesTrendingAuthors); + + // ---- Private Api (POST) ---- + app.MapPost("/private-api/comment-history", PrivateApi.CommentHistory); + app.MapPost("/private-api/points", PrivateApi.Points); + app.MapPost("/private-api/point-list", PrivateApi.PointList); + app.MapPost("/private-api/quests", PrivateApi.Quests); + app.MapPost("/private-api/streak-freeze", PrivateApi.StreakFreeze); + app.MapPost("/private-api/streak-freeze/buy", PrivateApi.StreakFreezeBuy); + app.MapPost("/private-api/account-create", PrivateApi.CreateAccount); + app.MapPost("/private-api/account-create-friend", PrivateApi.CreateAccountFriend); + app.MapPost("/private-api/subscribe", PrivateApi.SubscribeNewsletter); + app.MapPost("/private-api/notifications", PrivateApi.Notifications); + app.MapPost("/private-api/report", PrivateApi.Report); + // request-delete is the app-store account-deletion acknowledgment stub + // (accounts cannot be deleted on-chain). The Node table misrouted it to + // the report handler, which 400'd the mobile payload. + app.MapPost("/private-api/request-delete", PrivateApi.RequestDelete); + // post-reblogs / post-reblog-count were removed: dead endpoints (no + // client callers, no traffic) that also read route params their POST + // routes never had, so they always queried "undefined/undefined". + app.MapPost("/private-api/post-tips", PrivateApi.Tips); + app.MapPost("/private-api/chats-get", PrivateApi.ChatsGet); + app.MapPost("/private-api/channels-get", PrivateApi.ChannelsGet); + app.MapPost("/private-api/wallets-add", PrivateApi.WalletsAdd); + app.MapPost("/private-api/wallets-chkuser", PrivateApi.WalletsChkUser); + app.MapPost("/private-api/wallets", PrivateApi.Wallets); + app.MapPost("/private-api/wallets-update", PrivateApi.WalletsUpdate); + app.MapPost("/private-api/wallets-delete", PrivateApi.WalletsDelete); + app.MapPost("/private-api/wallets-exist", PrivateApi.WalletsExist); + app.MapPost("/private-api/notifications/unread", PrivateApi.UnreadNotifications); + app.MapPost("/private-api/notifications/mark", PrivateApi.MarkNotifications); + app.MapPost("/private-api/register-device", PrivateApi.RegisterDevice); + app.MapPost("/private-api/detail-device", PrivateApi.DetailDevice); + app.MapPost("/private-api/images", PrivateApi.Images); + app.MapPost("/private-api/images-delete", PrivateApi.ImagesDelete); + app.MapPost("/private-api/images-add", PrivateApi.ImagesAdd); + app.MapPost("/private-api/drafts", PrivateApi.Drafts); + app.MapPost("/private-api/drafts-add", PrivateApi.DraftsAdd); + app.MapPost("/private-api/drafts-update", PrivateApi.DraftsUpdate); + app.MapPost("/private-api/drafts-delete", PrivateApi.DraftsDelete); + app.MapPost("/private-api/bookmarks", PrivateApi.Bookmarks); + app.MapPost("/private-api/bookmarks-add", PrivateApi.BookmarksAdd); + app.MapPost("/private-api/bookmarks-delete", PrivateApi.BookmarksDelete); + app.MapPost("/private-api/support-settings", PrivateApi.SupportSettings); + app.MapPost("/private-api/support-settings-update", PrivateApi.SupportSettingsUpdate); + app.MapPost("/private-api/schedules", PrivateApi.Schedules); + app.MapPost("/private-api/schedules-add", PrivateApi.SchedulesAdd); + app.MapPost("/private-api/schedules-delete", PrivateApi.SchedulesDelete); + app.MapPost("/private-api/schedules-move", PrivateApi.SchedulesMove); + app.MapPost("/private-api/favorites", PrivateApi.Favorites); + app.MapPost("/private-api/favorites-check", PrivateApi.FavoritesCheck); + app.MapPost("/private-api/favorites-add", PrivateApi.FavoritesAdd); + app.MapPost("/private-api/favorites-delete", PrivateApi.FavoritesDelete); + app.MapPost("/private-api/fragments", PrivateApi.Fragments); + app.MapPost("/private-api/fragments-add", PrivateApi.FragmentsAdd); + app.MapPost("/private-api/fragments-update", PrivateApi.FragmentsUpdate); + app.MapPost("/private-api/fragments-delete", PrivateApi.FragmentsDelete); + app.MapPost("/private-api/decks", PrivateApi.Decks); + app.MapPost("/private-api/decks-add", PrivateApi.DecksAdd); + app.MapPost("/private-api/decks-update", PrivateApi.DecksUpdate); + app.MapPost("/private-api/decks-delete", PrivateApi.DecksDelete); + app.MapPost("/private-api/recoveries", PrivateApi.Recoveries); + app.MapPost("/private-api/recoveries-add", PrivateApi.RecoveriesAdd); + app.MapPost("/private-api/recoveries-update", PrivateApi.RecoveriesUpdate); + app.MapPost("/private-api/recoveries-delete", PrivateApi.RecoveriesDelete); + app.MapPost("/private-api/points-claim", PrivateApi.PointsClaim); + app.MapPost("/private-api/points-calc", PrivateApi.PointsCalc); + app.MapPost("/private-api/promote-price", PrivateApi.PromotePrice); + app.MapPost("/private-api/promoted-post", PrivateApi.PromotedPost); + app.MapPost("/private-api/boost-plus-price", PrivateApi.BoostPlusPrice); + app.MapPost("/private-api/boosted-plus-account", PrivateApi.BoostedPlusAccount); + app.MapPost("/private-api/rc-delegation-price", PrivateApi.RcDelegationPrice); + app.MapPost("/private-api/rc-delegation-active", PrivateApi.RcDelegationActive); + app.MapPost("/private-api/stripe-create-intent", PrivateApi.StripeCreateIntent); + app.MapPost("/private-api/stripe-order-status", PrivateApi.StripeOrderStatus); + app.MapPost("/private-api/stripe-account-intent", PrivateApi.StripeCreateAccountIntent); + app.MapPost("/private-api/stripe-account-status", PrivateApi.StripeAccountStatus); + app.MapPost("/private-api/boost-options", PrivateApi.BoostOptions); + app.MapPost("/private-api/boosted-post", PrivateApi.BoostedPost); + app.MapPost("/private-api/ai-generate-price", PrivateApi.AiGeneratePrice); + app.MapPost("/private-api/ai-generate-image", PrivateApi.AiGenerateImage); + app.MapPost("/private-api/ai-assist-price", PrivateApi.AiAssistPrice); + app.MapPost("/private-api/ai-assist", PrivateApi.AiAssist); + app.MapPost("/private-api/usr-activity", PrivateApi.Activities); + app.MapPost("/private-api/get-game", PrivateApi.GameGet); + app.MapPost("/private-api/post-game", PrivateApi.GamePost); + app.MapPost("/private-api/purchase-order", PrivateApi.PurchaseOrder); + app.MapPost("/private-api/chats", PrivateApi.Chats); + app.MapPost("/private-api/chats-add", PrivateApi.ChatsAdd); + app.MapPost("/private-api/chats-update", PrivateApi.ChatsUpdate); + app.MapPost("/private-api/channel-add", PrivateApi.ChannelAdd); + + // ---- Health check for docker swarm ---- + app.MapGet("/healthcheck.json", async ctx => + { + await ctx.SendJson(200, new JsonObject + { + ["status"] = 200, + ["body"] = new JsonObject { ["status"] = "ok" }, + }); + }); + + // ---- Fallback ---- + // GET/HEAD -> the template page (Express .get("*", fallbackHandler)). + // Everything else -> Express's default finalhandler 404. + app.MapFallback(async ctx => + { + var method = ctx.Request.Method; + if (HttpMethods.IsGet(method) || HttpMethods.IsHead(method)) + { + ctx.Response.StatusCode = 200; + ctx.Response.ContentType = "text/html; charset=utf-8"; + if (!HttpMethods.IsHead(method)) + { + await ctx.Response.WriteAsync(TemplateHtml); + } + return; + } + + var message = $"Cannot {method} {EscapeHtml(EncodeUrl(ctx.Request.Path.Value ?? "/"))}"; + var html = + "\n\n\n\n" + + "Error\n\n\n
" + message + "
\n\n\n"; + ctx.Response.StatusCode = 404; + ctx.Response.ContentType = "text/html; charset=utf-8"; + await ctx.Response.WriteAsync(html); + }); + } + + /// + /// Exact render of template.tsx (renderToString(<App/>) -> "Hello there! This is + /// Vision API."). Byte-for-byte with the Node output (496 bytes); parity-verified. + /// + private const string TemplateHtml = + "\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " Ecency Api\n" + + " \n" + + " \n" + + "
Hello there! This is Vision API.
\n" + + " \n" + + " "; + + /// + /// Port of the `encodeurl` npm package (used by Express finalhandler): encode + /// characters outside its allow-list, but leave already-percent-encoded + /// sequences intact. ctx.Request.Path is URL-decoded, so this re-encodes it. + /// + private static string EncodeUrl(string path) + { + var sb = new StringBuilder(path.Length); + foreach (var ch in path) + { + // encodeurl's ENCODE_CHARS_REGEXP leaves these unescaped: + // A-Za-z0-9 and - _ . ! ~ * ' ( ) ; / ? : @ & = + $ , # [ ] % + if (ch is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z') or (>= '0' and <= '9') + or '-' or '_' or '.' or '!' or '~' or '*' or '\'' or '(' or ')' + or ';' or '/' or '?' or ':' or '@' or '&' or '=' or '+' or '$' + or ',' or '#' or '[' or ']' or '%') + { + sb.Append(ch); + } + else + { + foreach (var b in Encoding.UTF8.GetBytes(ch.ToString())) + { + sb.Append('%').Append(((int)b).ToString("X2")); + } + } + } + return sb.ToString(); + } + + /// Port of the `escape-html` npm package (Express finalhandler). + private static string EscapeHtml(string s) + { + var sb = new StringBuilder(s.Length); + foreach (var c in s) + { + switch (c) + { + case '"': sb.Append("""); break; + case '&': sb.Append("&"); break; + case '\'': sb.Append("'"); break; + case '<': sb.Append("<"); break; + case '>': sb.Append(">"); break; + default: sb.Append(c); break; + } + } + return sb.ToString(); + } +} diff --git a/dotnet/EcencyApi/Handlers/SearchApi.cs b/dotnet/EcencyApi/Handlers/SearchApi.cs new file mode 100644 index 00000000..056d9c7a --- /dev/null +++ b/dotnet/EcencyApi/Handlers/SearchApi.cs @@ -0,0 +1,174 @@ +using System.Text.Json.Nodes; + +using EcencyApi.Infrastructure; + +namespace EcencyApi.Handlers; + +/// +/// Port of src/server/handlers/search-api.ts — thin proxies to the search API, +/// forwarding selected body fields with the search token as Authorization. +/// +public static class SearchApi +{ + private static KeyValuePair[] AuthHeaders() => + new[] { KeyValuePair.Create("Authorization", Config.SearchApiToken) }; + + /// + /// TS object literal from destructured body fields ({q, sort, ...}): + /// JSON.stringify drops undefined (absent) keys but keeps null — copy the + /// key only when it was present in the request body. + /// + private static void CopyIfPresent(JsonObject body, JsonObject payload, string key) + { + if (body.TryGetPropertyValue(key, out var v)) + { + payload[key] = v?.DeepClone(); + } + } + + /// "if (field) payload.field = field" — add only when JS-truthy. + private static void CopyIfTruthy(JsonObject body, JsonObject payload, string key) + { + if (body.TryGetPropertyValue(key, out var v) && JsJson.IsTruthy(v)) + { + payload[key] = v!.DeepClone(); + } + } + + /// + /// `${field}` template-literal coercion for body fields interpolated into + /// upstream URLs: absent -> "undefined", null -> "null", etc. + /// + private static string JsTemplate(JsonObject body, string key) => + body.TryGetPropertyValue(key, out var v) ? JsToString(v) : "undefined"; + + private static string JsToString(JsonNode? node) + { + switch (node) + { + case null: + return "null"; + case JsonObject: + return "[object Object]"; + case JsonArray arr: + // Array.prototype.toString: elements joined with ",", null -> "" + return string.Join(",", arr.Select(e => e == null ? "" : JsToString(e))); + case JsonValue v: + if (v.TryGetValue(out var s)) return s; + if (v.TryGetValue(out var b)) return b ? "true" : "false"; + return JsJson.Stringify(v); + default: + return "undefined"; + } + } + + public static async Task Search(HttpContext ctx) + { + var body = await ctx.ReadBody(); + + var url = $"{Config.SearchApiAddr}/search"; + + var payload = new JsonObject(); + CopyIfPresent(body, payload, "q"); + CopyIfPresent(body, payload, "sort"); + CopyIfPresent(body, payload, "hide_low"); + + CopyIfTruthy(body, payload, "since"); + CopyIfTruthy(body, payload, "scroll_id"); + CopyIfTruthy(body, payload, "votes"); + CopyIfTruthy(body, payload, "include_nsfw"); + + // Search can be moderately slow; allow more than the default. + await Upstream.Pipe( + Upstream.BaseApiRequest(url, HttpMethod.Post, AuthHeaders(), payload, timeoutMs: 15000), ctx); + } + + public static async Task SearchFollower(HttpContext ctx) + { + var body = await ctx.ReadBody(); + + var url = $"{Config.SearchApiAddr}/search-follower/{JsTemplate(body, "following")}"; + + var payload = new JsonObject(); + CopyIfPresent(body, payload, "q"); + + await Upstream.Pipe( + Upstream.BaseApiRequest(url, HttpMethod.Post, AuthHeaders(), payload, timeoutMs: 15000), ctx); + } + + public static async Task SearchFollowing(HttpContext ctx) + { + var body = await ctx.ReadBody(); + + var url = $"{Config.SearchApiAddr}/search-following/{JsTemplate(body, "follower")}"; + + var payload = new JsonObject(); + CopyIfPresent(body, payload, "q"); + + await Upstream.Pipe( + Upstream.BaseApiRequest(url, HttpMethod.Post, AuthHeaders(), payload, timeoutMs: 15000), ctx); + } + + public static async Task SearchAccount(HttpContext ctx) + { + var body = await ctx.ReadBody(); + + var url = $"{Config.SearchApiAddr}/search-account"; + + var payload = new JsonObject(); + CopyIfPresent(body, payload, "q"); + CopyIfPresent(body, payload, "limit"); + CopyIfPresent(body, payload, "random"); + + await Upstream.Pipe( + Upstream.BaseApiRequest(url, HttpMethod.Post, AuthHeaders(), payload, timeoutMs: 15000), ctx); + } + + public static async Task SearchTag(HttpContext ctx) + { + var body = await ctx.ReadBody(); + + var url = $"{Config.SearchApiAddr}/search-tag"; + + var payload = new JsonObject(); + CopyIfPresent(body, payload, "q"); + CopyIfPresent(body, payload, "limit"); + CopyIfPresent(body, payload, "random"); + + await Upstream.Pipe( + Upstream.BaseApiRequest(url, HttpMethod.Post, AuthHeaders(), payload, timeoutMs: 15000), ctx); + } + + public static async Task SearchPath(HttpContext ctx) + { + var body = await ctx.ReadBody(); + + var url = $"{Config.SearchApiAddr}/search-path"; + + var payload = new JsonObject(); + CopyIfPresent(body, payload, "q"); + + await Upstream.Pipe( + Upstream.BaseApiRequest(url, HttpMethod.Post, AuthHeaders(), payload, timeoutMs: 15000), ctx); + } + + public static async Task SearchSimilar(HttpContext ctx) + { + var body = await ctx.ReadBody(); + + var url = $"{Config.SearchApiAddr}/similar"; + + var payload = new JsonObject(); + CopyIfPresent(body, payload, "author"); + CopyIfPresent(body, payload, "permlink"); + + CopyIfTruthy(body, payload, "title"); + CopyIfTruthy(body, payload, "body"); + CopyIfTruthy(body, payload, "tags"); + CopyIfTruthy(body, payload, "since"); + CopyIfTruthy(body, payload, "include_nsfw"); + + await Upstream.Pipe( + Upstream.BaseApiRequest(url, HttpMethod.Post, AuthHeaders(), payload, timeoutMs: 15000), ctx); + } +} diff --git a/dotnet/EcencyApi/Handlers/Spotlights.cs b/dotnet/EcencyApi/Handlers/Spotlights.cs new file mode 100644 index 00000000..be159ca8 --- /dev/null +++ b/dotnet/EcencyApi/Handlers/Spotlights.cs @@ -0,0 +1,266 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using EcencyApi.Infrastructure; + +namespace EcencyApi.Handlers; + +/// +/// Port of src/server/handlers/spotlights.ts. +/// +/// Feature Spotlight content - single source of truth for the "did you know?" cards. +/// Edit this array (set start/end or bump weight) to rotate the spotlight. The server +/// filters by the start/end window only; guestsOnly/path/dismiss are applied client-side +/// (web + mobile). Wire shape must stay in sync with @ecency/sdk (types/spotlight.ts) - +/// clients parse exactly this shape. +/// +/// id - stable slug, e.g. "waves-2026-w23"; used as the dismiss key, never reuse +/// feature - which feature this promotes (waves|polls|points|communities|...), for gating +/// title - card title +/// description - card body +/// icon - optional icon name or i.ecency.com image url +/// button_text - actionable button text +/// button_link - internal route ("/waves") or external url the button opens +/// path - which path(s) it should show on, supports regex on location; omit = everywhere +/// guestsOnly - show only to signed-out visitors; omit = logged-in users only +/// platforms - which platform(s) may show it: "web" (website) and/or "mobile" (mobile app). omit = both +/// start - ISO date (UTC); do not show before this. Bare "YYYY-MM-DD" = that day at 00:00Z +/// end - ISO date (UTC); inclusive - bare "YYYY-MM-DD" shows through the END of that day. Omit = never expires +/// weight - higher wins when multiple are active +/// locales - optional per-language overrides for title/description/button_text +/// +public static class Spotlights +{ + /// Fresh copy of the spotlights array (wire shape identical to the TS export). + public static JsonArray Json() => new() + { + // Guest funnel: shown only to signed-out visitors (guestsOnly). These never expire and + // descend in weight, so a guest sees the top hook first and the next one each time they + // dismiss. Logged-in users never match a guestsOnly card, so there is no overlap with the + // educational cards below. + new JsonObject + { + ["id"] = "guest-signup", + ["feature"] = "signup", + ["title"] = "New to Ecency?", + ["description"] = "Create your free account and start earning rewards for everything you post.", + ["button_text"] = "Create account", + ["button_link"] = "/signup", + ["guestsOnly"] = true, + ["start"] = "2026-06-05", + ["weight"] = 10, + }, + new JsonObject + { + ["id"] = "guest-earn", + ["feature"] = "signup", + ["title"] = "Get paid to post", + ["description"] = "On Ecency the rewards go to creators, not the platform. Your posts, your earnings.", + ["button_text"] = "Join free", + ["button_link"] = "/signup", + ["guestsOnly"] = true, + ["start"] = "2026-06-05", + ["weight"] = 9, + }, + new JsonObject + { + ["id"] = "guest-own", + ["feature"] = "signup", + ["title"] = "Own your content", + ["description"] = "No ads, no gatekeepers. Your account lives on the blockchain, and it is yours.", + ["button_text"] = "Get started", + ["button_link"] = "/signup", + ["guestsOnly"] = true, + ["start"] = "2026-06-05", + ["weight"] = 8, + }, + + // ---- Jun 5 - 18 ---- + new JsonObject + { + ["id"] = "web-waves-2026-06", + ["feature"] = "waves", + ["title"] = "Have you tried Waves?", + ["description"] = "Share short posts and jump into the conversation on Hive in seconds.", + ["button_text"] = "Open Waves", + ["button_link"] = "/waves", + ["platforms"] = new JsonArray { "web" }, + ["start"] = "2026-06-05", + ["end"] = "2026-06-18", + ["weight"] = 10, + }, + new JsonObject + { + ["id"] = "mob-trending-2026-06", + ["feature"] = "discover", + ["title"] = "See what's trending", + ["description"] = "Tap into the most popular posts on Hive right now.", + ["button_text"] = "View trending", + ["button_link"] = "/trending", + ["platforms"] = new JsonArray { "mobile" }, + ["start"] = "2026-06-05", + ["end"] = "2026-06-18", + ["weight"] = 10, + }, + + // ---- Jun 19 - Jul 2 ---- + new JsonObject + { + ["id"] = "communities-2026-06", + ["feature"] = "communities", + ["title"] = "Find your community", + ["description"] = "Join topic-based communities and meet people who share your interests.", + ["button_text"] = "Browse communities", + ["button_link"] = "/communities", + ["start"] = "2026-06-19", + ["end"] = "2026-07-02", + ["weight"] = 10, + }, + + // ---- Jul 3 - 16 ---- + new JsonObject + { + ["id"] = "web-decks-2026-07", + ["feature"] = "decks", + ["title"] = "Power up with Decks", + ["description"] = "A customizable multi-column dashboard. Track tags, communities, and notifications side by side.", + ["button_text"] = "Open Decks", + ["button_link"] = "/decks", + ["platforms"] = new JsonArray { "web" }, + ["start"] = "2026-07-03", + ["end"] = "2026-07-16", + ["weight"] = 10, + }, + new JsonObject + { + ["id"] = "mob-hot-2026-07", + ["feature"] = "discover", + ["title"] = "Catch what's hot", + ["description"] = "See the posts heating up across Hive.", + ["button_text"] = "View hot", + ["button_link"] = "/hot", + ["platforms"] = new JsonArray { "mobile" }, + ["start"] = "2026-07-03", + ["end"] = "2026-07-16", + ["weight"] = 10, + }, + + // ---- Jul 17 - 30 ---- + new JsonObject + { + ["id"] = "wallet-2026-07", + ["feature"] = "wallet", + ["title"] = "Your wallet, at a glance", + ["description"] = "Check your HIVE, HBD, Hive Power and Points, then claim your rewards.", + ["button_text"] = "Open wallet", + ["button_link"] = "/wallet", + ["start"] = "2026-07-17", + ["end"] = "2026-07-30", + ["weight"] = 10, + }, + + // ---- Jul 31 - Aug 13 ---- + new JsonObject + { + ["id"] = "search-2026-08", + ["feature"] = "search", + ["title"] = "Find people and topics to follow", + ["description"] = "Search posts, tags, and creators across Hive, and follow the ones you love.", + ["button_text"] = "Start searching", + ["button_link"] = "/search", + ["start"] = "2026-07-31", + ["end"] = "2026-08-13", + ["weight"] = 10, + }, + + // ---- Aug 14 - 27 ---- + new JsonObject + { + ["id"] = "web-points-2026-08", + ["feature"] = "points", + ["title"] = "Earn Ecency Points", + ["description"] = "Get rewarded for posting and engaging, then spend Points to boost your reach.", + ["button_text"] = "Explore perks", + ["button_link"] = "/perks", + ["platforms"] = new JsonArray { "web" }, + ["start"] = "2026-08-14", + ["end"] = "2026-08-27", + ["weight"] = 10, + }, + new JsonObject + { + ["id"] = "mob-bookmarks-2026-08", + ["feature"] = "bookmarks", + ["title"] = "Save it for later", + ["description"] = "Bookmark posts you want to come back to, they are always one tap away.", + ["button_text"] = "Open bookmarks", + ["button_link"] = "/bookmarks", + ["platforms"] = new JsonArray { "mobile" }, + ["start"] = "2026-08-14", + ["end"] = "2026-08-27", + ["weight"] = 10, + }, + }; + + private static readonly Regex BareDate = new(@"^\d{4}-\d{2}-\d{2}$", RegexOptions.Compiled); + + /// isBareDate from private-api.ts getSpotlight: /^\d{4}-\d{2}-\d{2}$/. + internal static bool IsBareDate(string d) => BareDate.IsMatch(d); + + /// + /// JS `new Date(s).getTime()` equivalent: milliseconds since epoch, NaN when unparsable. + /// Bare "YYYY-MM-DD" parses as UTC midnight, like the JS Date ISO short form. + /// + internal static double ParseDateMs(string d) + { + if (IsBareDate(d)) + { + return DateTime.TryParseExact(d, "yyyy-MM-dd", CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var day) + ? (day - DateTime.UnixEpoch).TotalMilliseconds + : double.NaN; + } + + return DateTimeOffset.TryParse(d, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dto) + ? dto.ToUnixTimeMilliseconds() + : double.NaN; + } +} + +public static partial class PrivateApi +{ + // GET ^/private-api/spotlights$ + public static async Task GetSpotlight(HttpContext ctx) + { + var now = (double)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + // Bare ISO dates ("YYYY-MM-DD") parse as UTC midnight. `start` is inclusive from the + // beginning of its day; `end` is inclusive through the END of its day, so an item with + // end "2026-06-30" still shows throughout June 30 (UTC). A malformed date fails open + // (the item stays visible) rather than silently hiding a live spotlight. + const double dayMs = 86_400_000; + + var active = new JsonArray(); + foreach (var node in Spotlights.Json()) + { + var s = (JsonObject)node!; + var start = s["start"]?.GetValue(); + var end = s["end"]?.GetValue(); + + var startMs = !string.IsNullOrEmpty(start) ? Spotlights.ParseDateMs(start) : double.NaN; + var endMs = !string.IsNullOrEmpty(end) ? Spotlights.ParseDateMs(end) : double.NaN; + var endBoundary = !string.IsNullOrEmpty(end) && Spotlights.IsBareDate(end) + ? endMs + dayMs - 1 + : endMs; + + var startsOk = string.IsNullOrEmpty(start) || double.IsNaN(startMs) || startMs <= now; + var endsOk = string.IsNullOrEmpty(end) || double.IsNaN(endMs) || endBoundary >= now; + + if (startsOk && endsOk) + { + active.Add(s.DeepClone()); + } + } + + await ctx.SendJson(200, active); + } +} diff --git a/dotnet/EcencyApi/Handlers/WalletApi.Engine.cs b/dotnet/EcencyApi/Handlers/WalletApi.Engine.cs new file mode 100644 index 00000000..58bc46f4 --- /dev/null +++ b/dotnet/EcencyApi/Handlers/WalletApi.Engine.cs @@ -0,0 +1,439 @@ +using System.Text.Json.Nodes; +using EcencyApi.Infrastructure; +using EcencyApi.Models; + +namespace EcencyApi.Handlers; + +/// +/// Port of wallet-api.ts lines 1748-2165: Hive-Engine node pool + failover, the +/// engine passthrough routes, the portfolio data fetchers, and the portfolio / +/// portfolioV2 orchestration (fail-fast leg timeouts, partial-degrade on error). +/// +public static partial class WalletApi +{ + private static readonly string[] EngineNodes = + { + "https://herpc.dtools.dev", + "https://api.hive-engine.com/rpc", + "https://ha.herpc.dtools.dev", + "https://herpc.kanibot.com", + "https://herpc.actifit.io", + }; + + // min and max included + private static int RandomIntFromInterval(int min, int max) => + (int)Math.Floor(Random.Shared.NextDouble() * (max - min + 1) + min); + + private static string PickRandomEngineUrl() => + $"{EngineNodes[RandomIntFromInterval(0, EngineNodes.Length - 1)]}/contracts"; + + // Module-level mutable base URL, reselected on engine failure (dhive-style + // rotation across the pool). Volatile: read/written from concurrent requests. + private static string _baseEngineUrl = PickRandomEngineUrl(); + private static string BaseEngineUrl + { + get => Volatile.Read(ref _baseEngineUrl); + set => Volatile.Write(ref _baseEngineUrl, value); + } + + private const string BaseSpkUrl = "https://spk.good-karma.xyz"; + private const string EngineRewardsUrl = "https://scot-api.hive-engine.com/"; + private const string EngineChartUrl = "https://info-api.tribaldex.com/market/ohlcv"; + private const string EngineAccountHistoryUrl = "https://history.hive-engine.com/accountHistory"; + + private static readonly KeyValuePair[] EngineHeaders = + { + new("Content-type", "application/json"), + new("User-Agent", "Ecency"), + }; + + // ---- routes ---------------------------------------------------------- + + public static async Task Eapi(HttpContext ctx) + { + var data = await ctx.ReadBody(); + await Upstream.Pipe(EngineContractsRequest(data, BaseEngineUrl), ctx); + } + + public static async Task Erewardapi(HttpContext ctx) + { + var username = ctx.Request.RouteValues["username"]?.ToString() ?? ""; + var query = QueryOf(ctx); + await Upstream.Pipe(EngineRewardsRequest(username, query), ctx); + } + + public static async Task Echartapi(HttpContext ctx) + { + var query = QueryOf(ctx); + await Upstream.Pipe( + Upstream.BaseApiRequest(EngineChartUrl, HttpMethod.Get, EngineHeaders, null, query, 30000), ctx); + } + + public static async Task EngineAccountHistory(HttpContext ctx) + { + var query = QueryOf(ctx); + await Upstream.Pipe( + Upstream.BaseApiRequest(EngineAccountHistoryUrl, HttpMethod.Get, EngineHeaders, null, query, 30000), ctx); + } + + private static List> QueryOf(HttpContext ctx) + { + // Express req.query -> axios params. Single-valued forwarding; duplicate + // query keys (rare for these endpoints) take the first value. + var list = new List>(); + foreach (var kv in ctx.Request.Query) + { + list.Add(new KeyValuePair(kv.Key, kv.Value.Count > 0 ? kv.Value[0] : "")); + } + return list; + } + + // ---- raw engine calls ------------------------------------------------ + + private static Task EngineContractsRequest(JsonNode? data, string url) => + Upstream.BaseApiRequest(url, HttpMethod.Post, EngineHeaders, data, null, 8000); + + private static Task EngineRewardsRequest(string username, IEnumerable> query) + { + var url = $"{EngineRewardsUrl}/@{username}"; + return Upstream.BaseApiRequest(url, HttpMethod.Get, EngineHeaders, null, query, 30000); + } + + private static JsonObject EngineFindPayload(string contract, string table, JsonObject query) => new() + { + ["jsonrpc"] = HiveEngine.JsonRpc2, + ["method"] = HiveEngine.MethodFind, + ["params"] = new JsonObject + { + ["contract"] = contract, + ["table"] = table, + ["query"] = query, + }, + ["id"] = HiveEngine.IdOne, + }; + + private static JsonNode? ResultOf(UpstreamResponse r) => r.Json?["result"]; + + public static async Task FetchEngineBalances(string account) + { + var data = EngineFindPayload(HiveEngine.ContractTokens, HiveEngine.TableBalances, + new JsonObject { ["account"] = account }); + try + { + var response = await EngineContractsRequest(data.DeepClone(), BaseEngineUrl); + var result = ResultOf(response); + if (result is not JsonArray a) throw new Exception("Failed to get engine balances"); + return a; + } + catch + { + BaseEngineUrl = PickRandomEngineUrl(); + var response = await EngineContractsRequest(data.DeepClone(), BaseEngineUrl); + var result = ResultOf(response); + if (result is not JsonArray a) throw new Exception("Failed to get engine balances"); + return a; + } + } + + public static async Task FetchEngineTokens(List tokens) + { + var query = new JsonObject { ["symbol"] = new JsonObject { ["$in"] = ToJsonArray(tokens) } }; + var data = EngineFindPayload(HiveEngine.ContractTokens, HiveEngine.TableTokens, query); + try + { + var response = await EngineContractsRequest(data.DeepClone(), BaseEngineUrl); + if (ResultOf(response) is not JsonArray a) throw new Exception("Failed to get engine tokens data"); + return a; + } + catch + { + BaseEngineUrl = PickRandomEngineUrl(); + var response = await EngineContractsRequest(data.DeepClone(), BaseEngineUrl); + if (ResultOf(response) is not JsonArray a) throw new Exception("Failed to get engine tokens data"); + return a; + } + } + + public static async Task FetchEngineMetrics(List tokens) + { + var query = new JsonObject { ["symbol"] = new JsonObject { ["$in"] = ToJsonArray(tokens) } }; + var data = EngineFindPayload(HiveEngine.ContractMarket, HiveEngine.TableMetrics, query); + try + { + var response = await EngineContractsRequest(data.DeepClone(), BaseEngineUrl); + if (ResultOf(response) is not JsonArray a) throw new Exception("Failed to get engine metrics data"); + return a; + } + catch + { + BaseEngineUrl = PickRandomEngineUrl(); + var response = await EngineContractsRequest(data.DeepClone(), BaseEngineUrl); + if (ResultOf(response) is not JsonArray a) throw new Exception("Failed to get engine metrics data"); + return a; + } + } + + public static async Task FetchEngineRewards(string username) + { + try + { + var response = await EngineRewardsRequest(username, + new[] { new KeyValuePair("hive", "1") }); + + if (response.Json is not JsonObject obj) + throw new Exception("No rewards data returned"); + + var rawValues = obj.Select(kv => kv.Value).ToList(); + if (rawValues.Count == 0) throw new Exception("No rewards data returned"); + + var filtered = new JsonArray(); + foreach (var raw in rawValues) + { + var converted = HiveEngine.ConvertRewardsStatus(raw); + var pendingToken = JsVal.AsNumber(converted["pendingToken"]); + if (pendingToken is > 0) filtered.Add(converted); + } + return filtered; + } + catch (Exception err) + { + Console.WriteLine($"failed to get unclaimed engine rewards {err.Message}"); + return new JsonArray(); + } + } + + private static async Task FetchEngineTokensWithBalance(string username) + { + try + { + var balances = await FetchEngineBalances(username); + + var symbols = balances.Select(b => JsVal.AsString(JsVal.Prop(b, "symbol")) ?? JsVal.ToJsString(JsVal.Prop(b, "symbol"))) + .Where(s => s != null).Select(s => s!).ToList(); + + var tokensTask = FetchEngineTokens(symbols); + var metricsTask = FetchEngineMetrics(symbols); + var unclaimedTask = FetchEngineRewards(username); + await Task.WhenAll(tokensTask, metricsTask, unclaimedTask); + var tokens = tokensTask.Result; + var metrics = metricsTask.Result; + var unclaimed = unclaimedTask.Result; + + var output = new JsonArray(); + foreach (var balance in balances) + { + var sym = JsVal.Prop(balance, "symbol"); + var symStr = JsVal.ToJsString(sym); + var token = tokens.FirstOrDefault(t => JsVal.ToJsString(JsVal.Prop(t, "symbol")) == symStr); + var metric = metrics.FirstOrDefault(t => JsVal.ToJsString(JsVal.Prop(t, "symbol")) == symStr); + var pending = unclaimed.FirstOrDefault(t => JsVal.ToJsString(JsVal.Prop(t, "symbol")) == symStr); + var converted = HiveEngine.ConvertEngineToken( + balance?.DeepClone(), token?.DeepClone(), metric?.DeepClone(), pending?.DeepClone()); + output.Add(converted); + } + return output; + } + catch (Exception err) + { + Console.WriteLine($"Engine data fetch failed {err.Message}"); + return new JsonArray(); + } + } + + private static async Task FetchSpkData(string username) + { + try + { + var url = $"{BaseSpkUrl}/@{username}"; + var response = await Upstream.BaseApiRequest(url, HttpMethod.Get, null, null, null, 30000); + var data = DataOf(response); + if (!JsJson.IsTruthy(data)) throw new Exception("Invalid spk data"); + return data; + } + catch (Exception err) + { + Console.WriteLine($"Spk data fetch failed {err.Message}"); + return null; + } + } + + private static async Task ApiRequestData(string endpoint) + { + var resp = await ApiClient.ApiRequest(endpoint, HttpMethod.Get); + var data = DataOf(resp); + if (!JsJson.IsTruthy(data)) throw new Exception("failed to get data"); + return data; + } + + // axios response.data: parsed JSON when present, else the raw string body. + private static JsonNode? DataOf(UpstreamResponse r) => + r.Json ?? (r.RawText != null ? JsonValue.Create(r.RawText) : null); + + private static JsonArray ToJsonArray(IEnumerable items) + { + var arr = new JsonArray(); + foreach (var i in items) arr.Add(i); + return arr; + } + + // ---- portfolio (v1) -------------------------------------------------- + + public static async Task Portfolio(HttpContext ctx) + { + try + { + var body = await ctx.ReadBody(); + // JS `const {username} = req.body`: getAccount receives the raw value + // (undefined/null -> RPC [null] -> empty), while `users/${username}` + // uses String() coercion (undefined -> "undefined", null -> "null"). + var usernameNode = body.Field("username"); + var usernameForAccount = JsVal.AsString(usernameNode); // null when missing/non-string + var usernameForPath = body.ContainsKey("username") ? JsVal.ToJsString(usernameNode) : "undefined"; + + var globalPropsTask = HiveExplorer.FetchGlobalProps(); + var accountTask = HiveExplorer.GetAccount(usernameForAccount); + var marketTask = ApiRequestData("market-data/latest"); + var pointsTask = ApiRequestData($"users/{usernameForPath}"); + var engineTask = FetchEngineTokensWithBalance(usernameForPath); + var spkTask = FetchSpkData(usernameForPath); + + // Promise.all rejects on first failure; mirror by awaiting each and + // surfacing the first exception's message as a 500. + await Task.WhenAll(globalPropsTask, accountTask, marketTask, pointsTask, engineTask, spkTask) + .ContinueWith(_ => { }); // swallow aggregate; we inspect individually below + + foreach (var t in new Task[] { globalPropsTask, accountTask, marketTask, pointsTask, engineTask, spkTask }) + { + if (t.IsFaulted) throw t.Exception!.InnerExceptions[0]; + } + + var responses = new (string Key, JsonNode? Value)[] + { + ("globalProps", GlobalPropsJson(globalPropsTask.Result)), + ("marketData", marketTask.Result), + ("accountData", accountTask.Result), + ("pointsData", pointsTask.Result), + ("engineData", engineTask.Result), + ("spkData", spkTask.Result), + }; + + var respObj = new JsonObject(); + foreach (var (key, value) in responses) + { + if (JsJson.IsTruthy(value)) respObj[key] = value!.DeepClone(); + } + + await ctx.SendJson(200, respObj); + } + catch (Exception err) + { + Console.WriteLine($"failed to compile portfolio {err.Message}"); + await ctx.SendText(500, err.Message); + } + } + + private static JsonObject GlobalPropsJson(HiveExplorer.GlobalProps g) => new() + { + ["hivePerMVests"] = g.HivePerMVests, + ["hbdApr"] = g.HbdApr, + ["hpApr"] = g.HpApr, + }; + + // ---- portfolioV2 ----------------------------------------------------- + + private const int FastLegTimeout = 3000; + private const int SlowLegTimeout = 4500; + + private static async Task WithTimeout(Task task, int ms, T fallback) + { + var timeout = Task.Delay(ms); + var completed = await Task.WhenAny(task, timeout); + if (completed != task) return fallback; + try { return await task; } + catch { return fallback; } + } + + public static async Task PortfolioV2(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var usernameNode = body.Field("username"); + var username = JsVal.AsString(usernameNode); + + if (string.IsNullOrEmpty(username)) + { + await ctx.SendText(400, "Missing username"); + return; + } + + var normalizedCurrency = NormalizeCurrency(body.Field("currency")); + var onlyEnabledFlag = ParseBoolean(body.Field("onlyEnabled")); + + try + { + var globalPropsPromise = HiveExplorer.FetchGlobalProps(); + var accountPromise = HiveExplorer.GetAccount(username); + var marketEndpoint = normalizedCurrency == "usd" + ? "market-data/latest" + : $"market-data/latest?currency={normalizedCurrency}"; + var marketPromise = ApiRequestData(marketEndpoint); + var pointsPromise = ApiRequestData($"users/{username}"); + var enginePromise = FetchEngineTokensWithBalance(username); + + // Phase 1 — fast Hive/internal legs. + var globalPropsT = WithTimeout(WrapNullable(globalPropsPromise), FastLegTimeout, (HiveExplorer.GlobalProps?)null); + var accountT = WithTimeout(WrapNullable(accountPromise), FastLegTimeout, (JsonNode?)null); + var marketT = WithTimeout(marketPromise, FastLegTimeout, (JsonNode?)null); + var pointsT = WithTimeout(pointsPromise, FastLegTimeout, (JsonNode?)null); + await Task.WhenAll(globalPropsT, accountT, marketT, pointsT); + var globalProps = globalPropsT.Result; + var accountData = accountT.Result; + var marketData = marketT.Result; + var pointsData = pointsT.Result; + + var hivePrice = GetTokenPrice(marketData, "hive", normalizedCurrency); + + var wallets = new List(); + wallets.AddRange(BuildPointsLayer(pointsData, marketData, normalizedCurrency)); + wallets.AddRange(BuildHiveLayer(accountData, globalProps, marketData, normalizedCurrency)); + + var engineOnlyEnabled = onlyEnabledFlag == true; + var engineAllowed = onlyEnabledFlag == true ? ExtractEnabledEngineTokenSymbols(accountData) : null; + bool? chainOnlyEnabled = onlyEnabledFlag; // undefined -> null -> buildChainLayer with no options + + // Phase 2 — slow external layers in parallel, each fail-fast. + var engineT = WithTimeout(enginePromise, SlowLegTimeout, new JsonArray()); + var chainT = WithTimeout( + BuildChainLayer(accountData, marketData, normalizedCurrency, chainOnlyEnabled), + SlowLegTimeout, new List()); + await Task.WhenAll(engineT, chainT); + var engineData = engineT.Result; + var chainWallets = chainT.Result; + + wallets.AddRange(BuildEngineLayer(engineData, marketData, normalizedCurrency, hivePrice, engineOnlyEnabled, engineAllowed)); + wallets.AddRange(chainWallets); + + var filteredWallets = new JsonArray(); + foreach (var item in wallets) + { + var bal = JsVal.AsNumber(item["balance"]); + if (bal.HasValue) filteredWallets.Add(item.DeepClone()); + } + + await ctx.SendJson(200, new JsonObject + { + ["username"] = username, + ["currency"] = normalizedCurrency.ToUpperInvariant(), + ["wallets"] = filteredWallets, + }); + } + catch (Exception err) + { + Console.WriteLine($"failed to compile portfolio v2 {err.Message}"); + var message = !string.IsNullOrEmpty(err.Message) ? err.Message : "Failed to compile portfolio"; + await ctx.SendText(500, message); + } + } + + private static async Task WrapNullable(Task task) where T : class => await task; + private static async Task WrapNullable(Task task) => await task; +} diff --git a/dotnet/EcencyApi/Handlers/WalletApi.Layers.cs b/dotnet/EcencyApi/Handlers/WalletApi.Layers.cs new file mode 100644 index 00000000..28ecbf12 --- /dev/null +++ b/dotnet/EcencyApi/Handlers/WalletApi.Layers.cs @@ -0,0 +1,737 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using EcencyApi.Infrastructure; + +namespace EcencyApi.Handlers; + +/// +/// Port of wallet-api.ts lines 800-1747: portfolio-item construction and the +/// per-layer builders (points, hive, engine, spk, chain). Output JSON preserves +/// the exact property insertion order of the TS objects (JSON.stringify drops +/// undefined-valued keys, so optional keys are added only when present). +/// +public static partial class WalletApi +{ + internal sealed class ItemOptions + { + public string? Address; + public string? Error; + public double? PendingRewards; + public double? Savings; + public double? Staked; + public double? Apr; + } + + internal static JsonObject MakePortfolioItem( + string name, string symbol, string layer, double balance, double fiatRate, + string currency, int precision, ItemOptions? options = null, + string? iconUrl = null, JsonArray? actions = null, JsonArray? extraData = null) + { + options ??= new ItemOptions(); + + var normalizedBalance = double.IsFinite(balance) ? balance : 0; + var normalizedRate = double.IsFinite(fiatRate) ? fiatRate : 0; + var hasSavings = options.Savings.HasValue && double.IsFinite(options.Savings.Value); + var savingsValue = hasSavings ? options.Savings!.Value : 0; + var hasStaked = options.Staked.HasValue && double.IsFinite(options.Staked.Value); + var stakedValue = hasStaked ? options.Staked!.Value : 0; + var totalBalance = normalizedBalance + (hasSavings ? savingsValue : 0) + (hasStaked ? stakedValue : 0); + var hasPendingRewards = options.PendingRewards.HasValue && double.IsFinite(options.PendingRewards.Value); + var normalizedPendingRewards = hasPendingRewards ? options.PendingRewards!.Value : 0; + var hasApr = options.Apr.HasValue && double.IsFinite(options.Apr.Value); + + var item = new JsonObject + { + ["name"] = name, + ["symbol"] = symbol, + ["layer"] = layer, + ["balance"] = totalBalance, + ["fiatRate"] = normalizedRate, + ["currency"] = currency.ToUpperInvariant(), + ["precision"] = precision, + }; + // iconUrl / actions / extraData: keys present in the TS literal but + // JSON.stringify omits them when the value is undefined. + if (iconUrl != null) item["iconUrl"] = iconUrl; + if (actions != null) item["actions"] = actions.DeepClone(); + if (extraData != null) item["extraData"] = extraData.DeepClone(); + if (!string.IsNullOrEmpty(options.Error)) item["error"] = options.Error; + if (!string.IsNullOrEmpty(options.Address)) item["address"] = options.Address; + if (hasPendingRewards) + { + item["pendingRewards"] = normalizedPendingRewards; + item["pendingRewardsFiat"] = normalizedPendingRewards * normalizedRate; + } + if (hasApr) item["apr"] = options.Apr!.Value; + + item["liquid"] = normalizedBalance; + item["liquidFiat"] = normalizedBalance * normalizedRate; + + if (hasSavings) + { + item["savings"] = savingsValue; + item["savingsFiat"] = savingsValue * normalizedRate; + } + if (hasStaked) + { + item["staked"] = stakedValue; + item["stakedFiat"] = stakedValue * normalizedRate; + } + + return item; + } + + internal static double VestsToHivePower(double vests, double hivePerMVests) + { + if (!double.IsFinite(vests) || !double.IsFinite(hivePerMVests)) return 0; + return (vests / 1e6) * hivePerMVests; + } + + // parseToken(node || "fallback") + private static double PToken(JsonNode? node, string fallback) => + HiveExplorer.ParseToken(JsJson.IsTruthy(node) ? node : JsonValue.Create(fallback)); + + // JS Number(node): string -> NumberCoerce, number -> value, else NaN/0/1 + private static double NumberOf(JsonNode? node) + { + if (node == null) return double.NaN; // Number(undefined) — callers pass missing as null + var s = JsVal.AsString(node); + if (s != null) return JsVal.NumberCoerce(s); + var n = JsVal.AsNumber(node); + if (n.HasValue) return n.Value; + if (JsVal.IsNumber(node)) return double.NaN; // non-finite + var b = JsVal.AsBool(node); + if (b.HasValue) return b.Value ? 1 : 0; + return double.NaN; + } + + // ---- posting_json_metadata parsing ----------------------------------- + + private sealed class ParsedMeta + { + public JsonNode? Profile; + public JsonNode? Root; + public JsonNode? WalletsRoot; + public JsonArray Tokens = new(); + } + + private static ParsedMeta? ParsePostingJsonMetadata(JsonNode? accountData) + { + if (accountData == null) return null; + var metadataString = JsVal.AsString(JsVal.Prop(accountData, "posting_json_metadata")); + if (string.IsNullOrEmpty(metadataString)) return null; + + JsonNode? parsed; + try { parsed = JsonNode.Parse(metadataString!); } + catch { return null; } + + if (parsed == null || !JsVal.IsObjectOrArray(parsed)) return null; + + var profile = JsVal.Prop(parsed, "profile") is JsonObject p ? (JsonNode?)p : null; + + var walletsRootCandidate = FirstDefined( + JsVal.Prop(profile, "wallets"), + JsVal.Prop(profile, "wallet"), + JsVal.Prop(parsed, "wallets")); + + JsonArray tokensRoot; + if (JsVal.Prop(profile, "tokens") is JsonArray pt) tokensRoot = pt; + else if (JsVal.Prop(parsed, "tokens") is JsonArray rt) tokensRoot = rt; + else tokensRoot = new JsonArray(); + + return new ParsedMeta + { + Profile = profile, + Root = parsed, + WalletsRoot = walletsRootCandidate, + Tokens = tokensRoot, + }; + } + + internal static HashSet ExtractEnabledEngineTokenSymbols(JsonNode? accountData) + { + var enabled = new HashSet(); + var metadata = ParsePostingJsonMetadata(accountData); + if (metadata == null) return enabled; + + void ConsiderToken(JsonNode? token, JsonNode? metaSource = null) + { + if (token == null || !JsVal.IsObjectOrArray(token)) return; + + var typeCandidate = FirstDefined(JsVal.Prop(token, "type"), JsVal.Prop(token, "layer")); + var type = JsVal.IsString(typeCandidate) ? JsVal.AsString(typeCandidate)!.Trim().ToLowerInvariant() : ""; + if (type != "engine") return; + + var meta = metaSource is JsonObject ? metaSource + : JsVal.Prop(token, "meta") is JsonObject tm ? tm : null; + + var showCandidate = FirstDefined( + JsVal.IsBool(JsVal.Prop(meta, "show")) ? JsVal.Prop(meta, "show") : null, + JsVal.IsBool(JsVal.Prop(token, "show")) ? JsVal.Prop(token, "show") : null, + JsVal.IsBool(JsVal.Prop(token, "enabled")) ? JsVal.Prop(token, "enabled") : null); + + if (JsVal.AsBool(showCandidate) != true) return; + + var symbolCandidate = FirstDefined( + JsVal.Prop(token, "symbol"), + JsVal.Prop(token, "token"), + meta != null ? (JsVal.Prop(meta, "symbol") ?? JsVal.Prop(meta, "token")) : null, + JsVal.Prop(token, "name")); + + if (symbolCandidate == null) return; + var normalized = JsVal.ToJsString(symbolCandidate).Trim(); + if (normalized.Length == 0) return; + enabled.Add(normalized.ToUpperInvariant()); + } + + foreach (var token in metadata.Tokens) ConsiderToken(token); + + if (metadata.WalletsRoot is JsonObject raw) + { + var items = JsVal.Prop(raw, "items") as JsonArray + ?? JsVal.Prop(raw, "wallets") as JsonArray + ?? JsVal.Prop(raw, "list") as JsonArray + ?? new JsonArray(); + foreach (var item in items) + { + var meta = JsVal.Prop(item, "meta") is JsonObject m ? (JsonNode?)m : null; + ConsiderToken(item, meta); + } + } + + return enabled; + } + + internal sealed class ExternalWallet + { + public required string Address; + public required string Chain; + public required string Symbol; + public required string Name; + public double? Decimals; + } + + internal static List ExtractExternalWallets(JsonNode? accountData, bool onlyEnabled = false) + { + var results = new Dictionary(); + var order = new List(); + var metadata = ParsePostingJsonMetadata(accountData); + if (metadata == null) return new List(); + + var profile = metadata.Profile is JsonObject ? metadata.Profile : metadata.Root; + var walletsRoot = metadata.WalletsRoot; + var tokensRoot = metadata.Tokens; + + void AddResult(ExternalWallet w) + { + var key = $"{w.Chain}::{w.Address}".ToLowerInvariant(); + if (!results.ContainsKey(key)) { results[key] = w; order.Add(key); } + } + + if (walletsRoot is JsonObject walletObject) + { + var enabledFlag = JsVal.AsBool(JsVal.Prop(walletObject, "enabled")) + ?? JsVal.AsBool(JsVal.Prop(profile, "wallets_enabled")) + ?? true; + + if (enabledFlag) + { + var items = JsVal.Prop(walletObject, "items") as JsonArray + ?? JsVal.Prop(walletObject, "wallets") as JsonArray + ?? JsVal.Prop(walletObject, "list") as JsonArray + ?? new JsonArray(); + + foreach (var rawItem in items) + { + if (rawItem == null || !JsVal.IsObjectOrArray(rawItem)) continue; + var meta = JsVal.Prop(rawItem, "meta") is JsonObject rm ? (JsonNode?)rm : null; + + if (onlyEnabled) + { + var showCandidate = FirstDefined( + JsVal.IsBool(JsVal.Prop(rawItem, "show")) ? JsVal.Prop(rawItem, "show") : null, + JsVal.IsBool(JsVal.Prop(rawItem, "enabled")) ? JsVal.Prop(rawItem, "enabled") : null, + JsVal.IsBool(JsVal.Prop(meta, "show")) ? JsVal.Prop(meta, "show") : null); + if (JsVal.AsBool(showCandidate) != true) continue; + } + + var addressCandidate = FirstDefined( + JsVal.Prop(rawItem, "address"), JsVal.Prop(rawItem, "addr"), + JsVal.Prop(rawItem, "wallet"), JsVal.Prop(rawItem, "value"), + meta != null ? (JsVal.Prop(meta, "address") ?? JsVal.Prop(meta, "addr")) : null); + var chainCandidate = FirstDefined( + JsVal.Prop(rawItem, "chain"), JsVal.Prop(rawItem, "network"), JsVal.Prop(rawItem, "type"), + JsVal.Prop(rawItem, "token"), JsVal.Prop(rawItem, "symbol"), JsVal.Prop(rawItem, "name")); + var symbolCandidate = FirstDefined( + JsVal.Prop(rawItem, "symbol"), JsVal.Prop(rawItem, "token"), JsVal.Prop(rawItem, "name")); + var resolved = ResolveChainConfig(chainCandidate, symbolCandidate); + + if (addressCandidate == null || resolved == null) continue; + + var (chain, config) = resolved.Value; + var decimalsSource = FirstDefined( + JsVal.Prop(rawItem, "decimals"), JsVal.Prop(rawItem, "precision"), + JsVal.Prop(rawItem, "scale"), JsVal.Prop(rawItem, "decimal"), + meta != null ? (JsVal.Prop(meta, "decimals") ?? JsVal.Prop(meta, "precision") + ?? JsVal.Prop(meta, "scale") ?? JsVal.Prop(meta, "decimal")) : null); + var symbolSource = FirstDefined( + JsVal.Prop(rawItem, "symbol"), JsVal.Prop(rawItem, "token"), + JsonValue.Create(config.Symbol), JsonValue.Create(chain)); + var nameSource = FirstDefined( + JsVal.Prop(rawItem, "name"), JsonValue.Create(config.Name), symbolSource); + var symbol = JsVal.ToJsString(symbolSource ?? JsonValue.Create(config.Symbol)).ToUpperInvariant(); + var name = JsVal.ToJsString(nameSource ?? JsonValue.Create(symbol)); + var decimals = ParseMaybeNumber(decimalsSource); + + AddResult(new ExternalWallet + { + Address = JsVal.ToJsString(addressCandidate), + Chain = chain, Symbol = symbol, Name = name, Decimals = decimals, + }); + } + } + } + + if (tokensRoot.Count > 0) + { + foreach (var token in tokensRoot) + { + if (token == null || !JsVal.IsObjectOrArray(token)) continue; + + var typeCandidate = FirstDefined(JsVal.Prop(token, "type"), JsVal.Prop(token, "layer")); + var type = JsVal.IsString(typeCandidate) ? JsVal.AsString(typeCandidate)!.Trim().ToLowerInvariant() : ""; + if (type != "chain") continue; + + var meta = JsVal.Prop(token, "meta") is JsonObject tm ? (JsonNode?)tm : null; + + if (onlyEnabled) + { + var showCandidate = JsVal.IsBool(JsVal.Prop(meta, "show")) ? JsVal.AsBool(JsVal.Prop(meta, "show")) : null; + var fallbackShow = JsVal.IsBool(JsVal.Prop(token, "show")) ? JsVal.AsBool(JsVal.Prop(token, "show")) : null; + if (showCandidate != true && fallbackShow != true) continue; + } + + var addressCandidate = FirstDefined( + meta != null ? (JsVal.Prop(meta, "address") ?? JsVal.Prop(meta, "addr") + ?? JsVal.Prop(meta, "wallet") ?? JsVal.Prop(meta, "value")) : null, + JsVal.Prop(token, "address"), JsVal.Prop(token, "wallet"), JsVal.Prop(token, "value")); + + if (addressCandidate == null) continue; + + var chainCandidate = FirstDefined( + JsVal.Prop(token, "chain"), JsVal.Prop(token, "network"), + JsVal.Prop(token, "symbol"), JsVal.Prop(token, "name"), JsVal.Prop(token, "type")); + var symbolCandidate = FirstDefined( + JsVal.Prop(token, "symbol"), meta != null ? JsVal.Prop(meta, "symbol") : null, chainCandidate); + var resolved = ResolveChainConfig(chainCandidate, symbolCandidate); + if (resolved == null) continue; + + var (chain, config) = resolved.Value; + var symbolSource = FirstDefined( + JsVal.Prop(token, "symbol"), JsonValue.Create(config.Symbol), chainCandidate, JsonValue.Create(chain)); + var nameSource = FirstDefined( + JsVal.Prop(token, "name"), JsonValue.Create(config.Name), symbolSource); + var decimalsSource = FirstDefined( + JsVal.Prop(token, "decimals"), + meta != null ? (JsVal.Prop(meta, "decimals") ?? JsVal.Prop(meta, "precision") + ?? JsVal.Prop(meta, "scale") ?? JsVal.Prop(meta, "decimal")) : null); + var symbol = JsVal.ToJsString(symbolSource ?? JsonValue.Create(config.Symbol)).ToUpperInvariant(); + var name = JsVal.ToJsString(nameSource ?? JsonValue.Create(symbol)); + var decimals = ParseMaybeNumber(decimalsSource); + + AddResult(new ExternalWallet + { + Address = JsVal.ToJsString(addressCandidate), + Chain = chain, Symbol = symbol, Name = name, Decimals = decimals, + }); + } + } + + return order.Select(k => results[k]).ToList(); + } + + // ---- layer builders -------------------------------------------------- + + internal static List BuildPointsLayer(JsonNode? pointsData, JsonNode? marketData, string currency) + { + if (pointsData == null || !JsJson.IsTruthy(pointsData)) return new List(); + + var possible = new[] + { + JsVal.Prop(pointsData, "points"), JsVal.Prop(pointsData, "balance"), + JsVal.Prop(pointsData, "point_balance"), JsVal.Prop(pointsData, "point"), + JsVal.Prop(pointsData, "total_points"), + }; + + double balance = 0; + foreach (var candidate in possible) + { + var parsed = ParseMaybeNumber(candidate); + if (parsed.HasValue) { balance = parsed.Value; break; } + if (candidate != null && JsVal.IsObjectOrArray(candidate)) + { + var nestedSource = FirstDefined( + JsVal.Prop(candidate, "points"), JsVal.Prop(candidate, "balance"), JsVal.Prop(candidate, "available")); + var nested = ParseMaybeNumber(nestedSource); + if (nested.HasValue) { balance = nested.Value; break; } + } + } + if (!double.IsFinite(balance)) balance = 0; + + var pendingCandidates = new[] + { + "pendingRewards", "pending_rewards", "pending", "pending_points", "pendingPoints", + "pending_token", "pendingToken", "unclaimed", "unclaimed_points", "unclaimedPoints", + "unclaimed_balance", "unclaimedBalance", "rewards", "claims", + }; + + double? pendingRewards = null; + foreach (var key in pendingCandidates) + { + var candidate = JsVal.Prop(pointsData, key); + if (candidate == null) continue; + if (JsVal.IsObjectOrArray(candidate)) + { + var nested = PickFirstNumericValue( + JsVal.Prop(candidate, "points"), JsVal.Prop(candidate, "balance"), + JsVal.Prop(candidate, "amount"), JsVal.Prop(candidate, "value"), + JsVal.Prop(candidate, "pending"), JsVal.Prop(candidate, "total")); + if (nested.HasValue) { pendingRewards = nested.Value; break; } + continue; + } + var parsed = ParseMaybeNumber(candidate); + if (parsed.HasValue) { pendingRewards = parsed.Value; break; } + } + if (pendingRewards.HasValue && !double.IsFinite(pendingRewards.Value)) pendingRewards = null; + + var price = ConvertUsdRateToCurrency(marketData, currency, PointsUsdRate); + var options = new ItemOptions(); + if (pendingRewards.HasValue) options.PendingRewards = pendingRewards.Value; + + return new List + { + MakePortfolioItem("Ecency Points", "POINTS", "points", balance, price, currency, 3, + options, Constants.AssetIconUrls["POINTS"], EcencyActions()), + }; + } + + internal static List BuildHiveLayer(JsonNode? accountData, HiveExplorer.GlobalProps? globalProps, JsonNode? marketData, string currency) + { + if (accountData == null || globalProps == null) return new List(); + + var hiveBalance = PToken(JsVal.Prop(accountData, "balance"), "0 HIVE"); + var hiveSavings = PToken(JsVal.Prop(accountData, "savings_balance"), "0 HIVE"); + var hbdBalance = PToken(JsVal.Prop(accountData, "hbd_balance"), "0 HBD"); + var hbdSavings = PToken(JsVal.Prop(accountData, "savings_hbd_balance"), "0 HBD"); + + var totalVests = PToken(JsVal.Prop(accountData, "vesting_shares"), "0 VESTS"); + var delegatedVests = PToken(JsVal.Prop(accountData, "delegated_vesting_shares"), "0 VESTS"); + var receivedVests = PToken(JsVal.Prop(accountData, "received_vesting_shares"), "0 VESTS"); + + var hivePerMVests = globalProps.HivePerMVests; + double? hbdApr = double.IsFinite(globalProps.HbdApr) ? globalProps.HbdApr : null; + double? hpApr = double.IsFinite(globalProps.HpApr) ? globalProps.HpApr : null; + + var pendingHive = PToken(JsVal.Prop(accountData, "reward_hive_balance"), "0 HIVE"); + var pendingHbd = PToken(JsVal.Prop(accountData, "reward_hbd_balance"), "0 HBD"); + var pendingVests = PToken(JsVal.Prop(accountData, "reward_vesting_balance"), "0 VESTS"); + var pendingHivePower = VestsToHivePower(pendingVests, hivePerMVests); + + var hivePrice = GetTokenPrice(marketData, "hive", currency); + var hbdPrice = GetTokenPrice(marketData, "hbd", currency); + + var extraData = new JsonArray(); + var delegatedHP = VestsToHivePower(delegatedVests, hivePerMVests); + var receivedHP = VestsToHivePower(receivedVests, hivePerMVests); + + var nextWithdrawalStr = JsVal.AsString(JsVal.Prop(accountData, "next_vesting_withdrawal")); + var pwrDwnHoursLeft = HoursDifferentialFromIso(nextWithdrawalStr); + var isPoweringDown = pwrDwnHoursLeft > 0; + + var nextVestingSharesWithdrawal = isPoweringDown + ? Math.Min( + PToken(JsVal.Prop(accountData, "vesting_withdraw_rate"), "0 VESTS"), + (NumberOf(JsVal.Prop(accountData, "to_withdraw")) - NumberOf(JsVal.Prop(accountData, "withdrawn"))) / 1e6) + : 0; + var nextVestingSharesWithdrawalHive = isPoweringDown + ? VestsToHivePower(nextVestingSharesWithdrawal, hivePerMVests) + : 0; + + var availableHp = VestsToHivePower(totalVests, hivePerMVests); + var ownedNetHp = availableHp - delegatedHP - nextVestingSharesWithdrawalHive; + var effectiveNetHp = ownedNetHp + receivedHP; + var availableLiquidHp = Math.Max(ownedNetHp, 0); + + if (receivedHP != 0) + extraData.Add(new JsonObject { ["dataKey"] = "received_hive_power", ["value"] = $"+ {IntlFormat(receivedHP)} HP" }); + if (delegatedHP != 0) + extraData.Add(new JsonObject { ["dataKey"] = "delegated_hive_power", ["value"] = $"- {IntlFormat(delegatedHP)} HP" }); + if (nextVestingSharesWithdrawalHive != 0) + extraData.Add(new JsonObject + { + ["dataKey"] = "powering_down_hive_power", + ["value"] = $"- {IntlFormat(nextVestingSharesWithdrawalHive)} HP", + ["subValue"] = $"{JsRound(pwrDwnHoursLeft)}h", + }); + extraData.Add(new JsonObject { ["dataKey"] = "net_hive_power", ["value"] = $"{IntlFormat(effectiveNetHp)} HP" }); + + var stakedOptions = new ItemOptions { PendingRewards = pendingHivePower, Staked = availableHp }; + if (hpApr.HasValue) stakedOptions.Apr = hpApr.Value; + + var stakedHiveItem = MakePortfolioItem("Staked Hive", "HP", "hive", 0, hivePrice, currency, 3, + stakedOptions, Constants.AssetIconUrls["HIVE"], HpActions(), extraData); + stakedHiveItem["liquid"] = availableLiquidHp; + stakedHiveItem["liquidFiat"] = availableLiquidHp * hivePrice; + + var hbdOptions = new ItemOptions { Savings = hbdSavings, PendingRewards = pendingHbd }; + if (hbdApr.HasValue) hbdOptions.Apr = hbdApr.Value; + + return new List + { + stakedHiveItem, + MakePortfolioItem("Hive", "HIVE", "hive", hiveBalance, hivePrice, currency, 3, + new ItemOptions { Savings = hiveSavings, PendingRewards = pendingHive }, + Constants.AssetIconUrls["HIVE"], BuildHiveActions(hiveSavings)), + MakePortfolioItem("Hive Dollar", "HBD", "hive", hbdBalance, hbdPrice, currency, 3, + hbdOptions, Constants.AssetIconUrls["HBD"], BuildHbdActions(hbdSavings)), + }; + } + + internal static List BuildEngineLayer(JsonNode? engineData, JsonNode? marketData, string currency, double hivePrice, bool onlyEnabled = false, HashSet? allowedSymbols = null) + { + var items = new List(); + if (engineData is not JsonArray arr || arr.Count == 0) return items; + + foreach (var token in arr) + { + if (token == null || !JsJson.IsTruthy(token)) continue; + + var symbolStr = JsVal.AsString(JsVal.Prop(token, "symbol")); + var nameStr = JsVal.AsString(JsVal.Prop(token, "name")); + var rawSymbol = !string.IsNullOrEmpty(symbolStr) ? symbolStr! + : !string.IsNullOrEmpty(nameStr) ? nameStr! : ""; + var symbolKey = rawSymbol.Length > 0 ? rawSymbol.ToUpperInvariant() : ""; + + if (onlyEnabled) + { + if (symbolKey.Length == 0 || allowedSymbols == null || !allowedSymbols.Contains(symbolKey)) continue; + } + + var balance = ParseMaybeNumber(JsVal.Prop(token, "balance")) ?? 0; + var staked = ParseMaybeNumber(JsVal.Prop(token, "stakedBalance")) ?? 0; + var ownStake = ParseMaybeNumber(JsVal.Prop(token, "stake")) ?? staked; + + var tokenPrice = JsVal.AsNumber(JsVal.Prop(token, "tokenPrice")) ?? 0; + var priceInHive = tokenPrice > 0 ? tokenPrice : 0; + var fiatRate = hivePrice * priceInHive; + + var symbol = !string.IsNullOrEmpty(symbolStr) ? symbolStr! : rawSymbol; + var name = !string.IsNullOrEmpty(nameStr) ? nameStr! : symbol; + var iconStr = JsVal.AsString(JsVal.Prop(token, "icon")); + var iconUrl = !string.IsNullOrEmpty(iconStr) ? iconStr! : Constants.AssetIconUrls["ENGINE_PLACEHOLDER"]; + var precision = (int)(JsVal.AsNumber(JsVal.Prop(token, "precision")) ?? 0); + + var pendingRewards = JsVal.AsNumber(JsVal.Prop(token, "pendingRewards")); + + var itemOptions = new ItemOptions { Staked = staked }; + if (pendingRewards.HasValue) itemOptions.PendingRewards = pendingRewards.Value; + + var actions = new JsonArray { new JsonObject { ["id"] = "transfer" } }; + var stakingEnabled = JsVal.AsBool(JsVal.Prop(token, "stakingEnabled")) ?? false; + if (stakingEnabled) + { + actions.Add(new JsonObject { ["id"] = "stake" }); + if (ownStake > 0) actions.Add(new JsonObject { ["id"] = "unstake" }); + } + if (JsJson.IsTruthy(JsVal.Prop(token, "delegationEnabled"))) + { + actions.Add(new JsonObject { ["id"] = "delegate" }); + actions.Add(new JsonObject { ["id"] = "undelegate" }); + } + + var delegationsInNode = JsVal.Prop(token, "delegationsIn"); + var delegationsOutNode = JsVal.Prop(token, "delegationsOut"); + var extraData = new JsonArray + { + new JsonObject { ["dataKey"] = "delegations_in", ["value"] = NotZeroStr(delegationsInNode) }, + new JsonObject { ["dataKey"] = "delegations_out", ["value"] = NotZeroStr(delegationsOutNode) }, + }; + + items.Add(MakePortfolioItem( + !string.IsNullOrEmpty(name) ? name : symbol, + !string.IsNullOrEmpty(symbol) ? symbol : (!string.IsNullOrEmpty(name) ? name : "ENGINE"), + "engine", balance, fiatRate, currency, precision, itemOptions, iconUrl, actions, extraData)); + } + + return items; + } + + // `${x}` when x !== 0, else '0.00' (JS: 0 !== 0 is false so numeric 0 -> '0.00') + private static string NotZeroStr(JsonNode? node) + { + var num = JsVal.AsNumber(node); + if (num.HasValue) return num.Value != 0 ? JsVal.JsNumberToString(num.Value) : "0.00"; + // non-number: `${value}` template coercion; `!== 0` is true for non-numbers + return JsVal.ToJsString(node); + } + + internal static List BuildSpkLayer(JsonNode? spkData, JsonNode? marketData, string currency) + { + var items = new List(); + if (spkData == null || !JsVal.IsObjectOrArray(spkData)) return items; + + var balanceSource = JsVal.Prop(spkData, "balance") is JsonObject b ? (JsonNode)b + : JsVal.Prop(spkData, "balances") is JsonObject b2 ? b2 + : new JsonObject(); + + double? ReadValue(params string[] keys) + { + var candidates = new List(); + foreach (var key in keys) + { + if (JsVal.HasProp(balanceSource, key)) candidates.Add(JsVal.Prop(balanceSource, key)); + if (JsVal.HasProp(spkData, key)) candidates.Add(JsVal.Prop(spkData, key)); + if (JsVal.Prop(spkData, "account") is JsonObject acc && JsVal.HasProp(acc, key)) candidates.Add(JsVal.Prop(acc, key)); + if (JsVal.Prop(spkData, "power") is JsonObject pow && JsVal.HasProp(pow, key)) candidates.Add(JsVal.Prop(pow, key)); + } + return PickFirstNumericValue(candidates.ToArray()); + } + + var spkBalance = ReadValue("spk", "SPK", "balance_spk", "liquid_spk"); + if (spkBalance.HasValue) + { + var spkPrice = GetTokenPrice(marketData, "spk", currency); + items.Add(MakePortfolioItem("SPK", "SPK", "spk", spkBalance.Value, spkPrice, currency, 3, + new ItemOptions(), Constants.AssetIconUrls["SPK"], SpkActions())); + } + + var larynxBalance = ReadValue("larynx", "LARYNX"); + var larynxPower = ReadValue("larynx_power", "larynxPower", "LARYNX_POWER"); + + // larynx power breakdown — JS: spkData.poweredUp / 1000, granted?.t / 1000, ... + var poweredUp = JsVal.AsNumber(JsVal.Prop(spkData, "poweredUp")); + var larPower = (poweredUp ?? double.NaN) / 1000; // undefined/1000 -> NaN + var grantedT = JsVal.AsNumber(JsVal.Prop(JsVal.Prop(spkData, "granted"), "t")); + var grantedPwr = grantedT is > 0 or < 0 ? grantedT!.Value / 1000 : 0; // granted?.t ? .../1000 : 0 + var grantingT = JsVal.AsNumber(JsVal.Prop(JsVal.Prop(spkData, "granting"), "t")); + var grantingPwr = grantingT is > 0 or < 0 ? grantingT!.Value / 1000 : 0; + var netSpkPower = larPower + grantedPwr + grantingPwr; + + var extraData = new JsonArray(); + var powerDowns = JsVal.Prop(spkData, "power_downs"); + if (JsJson.IsTruthy(powerDowns)) + { + var count = powerDowns is JsonObject pdo ? pdo.Count : 0; + extraData.Add(new JsonObject { ["dataKey"] = "scheduled_power_downs", ["value"] = count.ToString() }); + } + extraData.Add(new JsonObject { ["dataKey"] = "delegated_larynx_power", ["value"] = $"{ToFixed(grantedPwr, 3)} LP" }); + extraData.Add(new JsonObject { ["dataKey"] = "delegating_larynx_power", ["value"] = $"- {ToFixed(grantingPwr, 3)} LP" }); + extraData.Add(new JsonObject { ["dataKey"] = "total_larynx_power", ["value"] = $"{ToFixed(netSpkPower, 3)} LP" }); + + if (larynxBalance.HasValue || larynxPower.HasValue) + { + var liquid = larynxBalance ?? 0; + var staked = larynxPower ?? 0; + var larynxPrice = GetTokenPrice(marketData, "larynx", currency); + items.Add(MakePortfolioItem("LARYNX", "LARYNX", "spk", liquid, larynxPrice, currency, 3, + new ItemOptions { Staked = staked }, Constants.AssetIconUrls["SPK_PLACEHOLDER"], LarynxActions(), extraData)); + } + + return items; + } + + internal static async Task> ProcessWithConcurrencyLimit(IReadOnlyList items, Func> processor, int concurrencyLimit) + { + var results = new R[items.Count]; + using var gate = new SemaphoreSlim(concurrencyLimit); + var tasks = new List(); + for (var i = 0; i < items.Count; i++) + { + var index = i; + await gate.WaitAsync(); + tasks.Add(Task.Run(async () => + { + try { results[index] = await processor(items[index]); } + finally { gate.Release(); } + })); + } + await Task.WhenAll(tasks); + return results.ToList(); + } + + internal static async Task> BuildChainLayer(JsonNode? accountData, JsonNode? marketData, string currency, bool? onlyEnabled) + { + var wallets = ExtractExternalWallets(accountData, onlyEnabled == true); + if (wallets.Count == 0) return new List(); + + var items = await ProcessWithConcurrencyLimit(wallets, async wallet => + { + var chain = wallet.Chain.ToLowerInvariant(); + var config = ChainConfigs[chain]; + var decimals = (int)(wallet.Decimals ?? config.Decimals); + + try + { + var data = await PrivateApi.FetchChainBalance(chain, wallet.Address); + var balance = ConvertChainBalanceToAmount(data, decimals); + var price = GetTokenPrice(marketData, wallet.Symbol, currency); + var iconUrl = config.IconUrl ?? Constants.AssetIconUrls["CHAIN_PLACEHOLDER"]; + + return MakePortfolioItem( + !string.IsNullOrEmpty(wallet.Name) ? wallet.Name : config.Name, + !string.IsNullOrEmpty(wallet.Symbol) ? wallet.Symbol : config.Symbol, + "chain", balance, price, currency, decimals, + new ItemOptions { Address = wallet.Address }, iconUrl, ChainActions()); + } + catch (Exception err) + { + var errorMessage = err.Message ?? "Chain balance request failed"; + Console.WriteLine($"Failed to fetch external wallet balance {chain} {wallet.Address}"); + var price = GetTokenPrice(marketData, wallet.Symbol, currency); + return MakePortfolioItem( + !string.IsNullOrEmpty(wallet.Name) ? wallet.Name : config.Name, + !string.IsNullOrEmpty(wallet.Symbol) ? wallet.Symbol : config.Symbol, + "chain", 0, price, currency, decimals, + new ItemOptions { Address = wallet.Address, Error = errorMessage }, + config.IconUrl ?? Constants.AssetIconUrls["CHAIN_PLACEHOLDER"], ChainActions()); + } + }, 3); + + return items.Where(x => x != null).ToList(); + } + + // ---- JS numeric string formatting ------------------------------------ + + // Intl.NumberFormat().format(x): en-US grouping, up to 3 fraction digits. + private static string IntlFormat(double x) + { + if (double.IsNaN(x)) return "NaN"; + if (double.IsInfinity(x)) return x > 0 ? "∞" : "-∞"; + var nfi = (NumberFormatInfo)CultureInfo.InvariantCulture.NumberFormat.Clone(); + nfi.NumberGroupSeparator = ","; + nfi.NumberDecimalSeparator = "."; + var rounded = Math.Round(x, 3, MidpointRounding.AwayFromZero); + return rounded.ToString("#,##0.###", nfi); + } + + // Number.prototype.toFixed(digits) + private static string ToFixed(double x, int digits) + { + if (double.IsNaN(x)) return "NaN"; + return x.ToString("F" + digits, CultureInfo.InvariantCulture); + } + + // Math.round: half rounds toward +Infinity + private static long JsRound(double x) => (long)Math.Floor(x + 0.5); + + // getHoursDifferntial(new Date(`${iso}.000Z`), new Date()): hours until iso. + private static double HoursDifferentialFromIso(string? iso) + { + if (iso == null) return double.NaN; // new Date("undefined.000Z") -> Invalid -> NaN + if (!DateTimeOffset.TryParse(iso + ".000Z", CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var target)) + return double.NaN; + var now = DateTimeOffset.UtcNow; + return (target.ToUnixTimeMilliseconds() - now.ToUnixTimeMilliseconds()) / (60.0 * 60.0 * 1000.0); + } +} diff --git a/dotnet/EcencyApi/Handlers/WalletApi.Market.cs b/dotnet/EcencyApi/Handlers/WalletApi.Market.cs new file mode 100644 index 00000000..08089bb6 --- /dev/null +++ b/dotnet/EcencyApi/Handlers/WalletApi.Market.cs @@ -0,0 +1,579 @@ +using System.Text.RegularExpressions; +using System.Text.Json.Nodes; +using EcencyApi.Infrastructure; + +namespace EcencyApi.Handlers; + +/// +/// Port of wallet-api.ts lines 1-799: token-action builders, chain config, +/// currency normalization, and the market-data price extraction/conversion +/// helpers. All arithmetic mirrors JS double semantics; JSON is JsonNode. +/// +public static partial class WalletApi +{ + // ---- token action builders (each returns [{id}, ...]) ---------------- + + private static JsonArray Actions(params string[] ids) + { + var arr = new JsonArray(); + foreach (var id in ids) arr.Add(new JsonObject { ["id"] = id }); + return arr; + } + + private static readonly string[] EcencyActionIds = { "ecency_point_transfer", "promote", "boost" }; + internal static JsonArray EcencyActions() => Actions(EcencyActionIds); + + internal static JsonArray BuildHiveActions(double savings) + { + var ids = new List { "transfer", "transfer_to_savings", "transfer_to_vesting" }; + if (savings > 0) ids.Add("transfer_from_savings"); + ids.Add("swap_token"); + ids.Add("recurrent_transfer"); + return Actions(ids.ToArray()); + } + + internal static JsonArray HpActions() => Actions("delegate_vesting_shares", "withdraw_vesting"); + + internal static JsonArray BuildHbdActions(double savings) + { + var ids = new List { "transfer", "transfer_to_savings", "convert" }; + if (savings > 0) ids.Add("transfer_from_savings"); + ids.Add("swap_token"); + ids.Add("recurrent_transfer"); + return Actions(ids.ToArray()); + } + + internal static JsonArray SpkActions() => Actions("spkcc_spk_send"); + + internal static JsonArray LarynxActions() => Actions( + "transfer_larynx_spk", "spkcc_send", "spkcc_power_grant", "spkcc_power_up", "spkcc_power_down"); + + internal static JsonArray ChainActions() => Actions("receive"); + + internal const double PointsUsdRate = 0.002; + + // ---- chain config ---------------------------------------------------- + + internal sealed record ChainConfig(string Name, string Symbol, int Decimals, string[]? Aliases, string? IconUrl); + + internal static readonly Dictionary ChainConfigs = new() + { + ["btc"] = new("Bitcoin", "BTC", 8, new[] { "bitcoin" }, Constants.AssetIconUrls["BTC"]), + ["eth"] = new("Ethereum", "ETH", 18, null, Constants.AssetIconUrls["ETH"]), + ["bnb"] = new("BNB Chain", "BNB", 18, null, Constants.AssetIconUrls["BNB"]), + ["sol"] = new("Solana", "SOL", 9, null, Constants.AssetIconUrls["SOL"]), + }; + + private static readonly Dictionary ChainSymbolLookup = BuildLookup(); + + private static Dictionary BuildLookup() + { + var lookup = new Dictionary(); + foreach (var (key, config) in ChainConfigs) + { + var baseSymbol = config.Symbol.ToLowerInvariant(); + if (!lookup.ContainsKey(baseSymbol)) lookup[baseSymbol] = (key, config); + lookup[key.ToLowerInvariant()] = (key, config); + if (config.Aliases != null) + { + foreach (var alias in config.Aliases) + { + var normalized = alias.ToLowerInvariant(); + if (!lookup.ContainsKey(normalized)) lookup[normalized] = (key, config); + } + } + } + return lookup; + } + + /// firstDefined(...values): first value that is not undefined/null. + internal static JsonNode? FirstDefined(params JsonNode?[] values) + { + foreach (var v in values) + { + if (v != null) return v; + } + return null; + } + + internal static (string Key, ChainConfig Config)? ResolveChainConfig(JsonNode? value, JsonNode? fallback = null) + { + foreach (var candidate in new[] { value, fallback }) + { + if (candidate == null) continue; + var normalized = JsVal.ToJsString(candidate).Trim().ToLowerInvariant(); + if (normalized.Length == 0) continue; + if (ChainSymbolLookup.TryGetValue(normalized, out var match)) return match; + } + return null; + } + + // ---- numeric parsing ------------------------------------------------- + + private static readonly Regex NumberInStringRegex = new(@"-?\d+(?:\.\d+)?", RegexOptions.Compiled); + + /// parseMaybeNumber(unknown): number|null with JS Number() + regex fallback. + internal static double? ParseMaybeNumber(JsonNode? value) + { + var num = JsVal.AsNumber(value); + if (num.HasValue) return num.Value; // finite number + if (JsVal.IsNumber(value)) return null; // non-finite number -> null + + var s = JsVal.AsString(value); + if (s == null) return null; + var trimmed = s.Trim(); + if (trimmed.Length == 0) return null; + + var direct = JsVal.NumberCoerce(trimmed); + if (!double.IsNaN(direct)) return direct; + + var m = NumberInStringRegex.Match(trimmed); + if (m.Success) + { + var parsed = JsVal.NumberCoerce(m.Value); + return double.IsNaN(parsed) ? null : parsed; + } + return null; + } + + internal static double? PickFirstNumericValue(params JsonNode?[] candidates) + { + foreach (var c in candidates) + { + var parsed = ParseMaybeNumber(c); + if (parsed.HasValue) return parsed.Value; + } + return null; + } + + private static readonly Regex IntegerRegex = new(@"^-?\d+$", RegexOptions.Compiled); + + internal static double ConvertBaseUnitsToAmount(string? value, int decimals) + { + if (string.IsNullOrEmpty(value)) return 0; + var normalized = value.Trim(); + + if (IntegerRegex.IsMatch(normalized)) + { + var negative = normalized.StartsWith("-"); + var digits = negative ? normalized[1..] : normalized; + var padded = digits.PadLeft(decimals + 1, '0'); + var integerPart = padded[..(padded.Length - decimals)]; + if (integerPart.Length == 0) integerPart = "0"; + var fractionalPart = padded[(padded.Length - decimals)..].TrimEnd('0'); + var combined = (negative ? "-" : "") + integerPart; + var formatted = fractionalPart.Length > 0 ? $"{combined}.{fractionalPart}" : combined; + var parsed = JsVal.NumberCoerce(formatted); + return double.IsNaN(parsed) ? 0 : parsed; + } + + var numeric = JsVal.NumberCoerce(normalized); + return double.IsNaN(numeric) ? 0 : numeric; + } + + private static readonly Dictionary UnitDecimals = new() + { + ["wei"] = 18, ["lamports"] = 9, ["sun"] = 6, ["nanotons"] = 9, ["octas"] = 8, ["satoshi"] = 8, + }; + + internal static double ConvertChainBalanceToAmount(ChainBalanceResponse? balanceResponse, int decimals) + { + if (balanceResponse == null) return 0; + + var unit = balanceResponse.Unit; + var normalizedUnit = unit.Trim().ToLowerInvariant(); + var resolvedDecimals = UnitDecimals.TryGetValue(normalizedUnit, out var ud) ? ud : decimals; + + var balance = balanceResponse.Balance; + if (balance == null) return 0; + + var trimmed = balance.Trim(); + if (trimmed.Length == 0) return 0; + + if (normalizedUnit == "btc") + { + var parsed = JsVal.NumberCoerce(trimmed); + return double.IsNaN(parsed) ? 0 : parsed; + } + + return ConvertBaseUnitsToAmount(trimmed, resolvedDecimals); + } + + internal static string NormalizeCurrency(JsonNode? currency) => NormalizeCurrency(JsVal.AsString(currency)); + + internal static string NormalizeCurrency(string? currency) + { + if (currency == null) return "usd"; + var t = currency.Trim().ToLowerInvariant(); + return t.Length > 0 ? t : "usd"; + } + + // ---- price extraction ------------------------------------------------ + + internal static double GetUsdToCurrencyRate(JsonNode? marketData, string currency) + { + var currencyKey = currency.ToLowerInvariant(); + if (currencyKey == "usd") return 1.0; + + var containers = CollectContainers(marketData, 3); + var referenceTokens = new[] { "hive", "btc", "eth", "hbd" }; + + foreach (var reference in referenceTokens) + { + var tokenKey = reference.ToLowerInvariant(); + var candidates = new List(); + + foreach (var container in containers) + { + if (container == null || !JsVal.IsObjectOrArray(container)) continue; + + if (container is JsonArray arr) + { + foreach (var entry in arr) + { + if (entry == null || !JsVal.IsObjectOrArray(entry)) continue; + var symbolKeys = new[] { "symbol", "token", "name", "id", "ticker" }; + var symbolVal = symbolKeys + .Select(k => JsVal.AsString(JsVal.Prop(entry, k))) + .FirstOrDefault(v => v != null && v.Trim().ToLowerInvariant() == tokenKey); + if (symbolVal != null) candidates.Add(entry); + } + } + else + { + var directMatch = JsVal.Prop(container, tokenKey) ?? JsVal.Prop(container, reference.ToUpperInvariant()); + if (directMatch != null) candidates.Add(directMatch); + } + } + + double? priceInUsd = null; + double? priceInCurrency = null; + + foreach (var candidate in candidates) + { + if (priceInUsd is null or 0) + priceInUsd = ExtractPriceFromValue(candidate, "usd"); + if (priceInCurrency is null or 0) + priceInCurrency = ExtractPriceFromValue(candidate, currencyKey); + if (priceInUsd is > 0 && priceInCurrency is > 0) break; + } + + if (priceInUsd is > 0 && priceInCurrency is > 0) + return priceInCurrency.Value / priceInUsd.Value; + } + + return 0; + } + + /// parseBoolean(unknown): true/false/undefined(null). + internal static bool? ParseBoolean(JsonNode? value) + { + if (value == null) return null; + var b = JsVal.AsBool(value); + if (b.HasValue) return b; + + var num = JsVal.AsNumber(value); + if (num.HasValue) + { + if (num.Value == 1) return true; + if (num.Value == 0) return false; + } + + var s = JsVal.AsString(value); + if (s != null) + { + var normalized = s.Trim().ToLowerInvariant(); + if (normalized.Length == 0) return null; + if (normalized is "true" or "1" or "yes" or "on") return true; + if (normalized is "false" or "0" or "no" or "off") return false; + } + return null; + } + + internal static List CreateCurrencyKeyVariants(string currencyKey, string? baseKey = null) + { + var normalizedCurrency = currencyKey.ToLowerInvariant(); + var upperCurrency = normalizedCurrency.ToUpperInvariant(); + var capitalizedCurrency = normalizedCurrency.Length > 0 + ? char.ToUpperInvariant(normalizedCurrency[0]) + normalizedCurrency[1..] + : normalizedCurrency; + + var currencyVariants = new[] { normalizedCurrency, upperCurrency, capitalizedCurrency }; + + if (string.IsNullOrEmpty(baseKey)) + return currencyVariants.Distinct().ToList(); + + var normalizedBase = baseKey.ToLowerInvariant(); + var upperBase = normalizedBase.ToUpperInvariant(); + var capitalizedBase = normalizedBase.Length > 0 + ? char.ToUpperInvariant(normalizedBase[0]) + normalizedBase[1..] + : normalizedBase; + + var baseVariants = new[] { normalizedBase, upperBase, capitalizedBase }; + var separators = new[] { "", "_", "-", ".", "/" }; + var results = new List(); + var seen = new HashSet(); + void Add(string s) { if (seen.Add(s)) results.Add(s); } + + foreach (var cv in currencyVariants) + foreach (var bv in baseVariants) + foreach (var sep in separators) + { + Add($"{cv}{sep}{bv}"); + Add($"{bv}{sep}{cv}"); + } + + return results; + } + + internal static double? ExtractPriceFromValue(JsonNode? value, string currencyKey, HashSet? visited = null) + { + var num = JsVal.AsNumber(value); + if (num.HasValue) return num.Value; + if (JsVal.IsNumber(value)) return null; // non-finite number + + var str = JsVal.AsString(value); + if (str != null) + { + var parsed = ParseMaybeNumber(value); + return parsed; + } + + if (value == null || !JsVal.IsObjectOrArray(value)) return null; + + visited ??= new HashSet(ReferenceEqualityComparer.Instance); + if (!visited.Add(value)) return null; + + // token.quotes.{currency}.price + var quotes = JsVal.Prop(value, "quotes"); + if (quotes is JsonObject) + { + var currencyQuote = JsVal.Prop(quotes, currencyKey); + if (currencyQuote is JsonObject) + { + var price = JsVal.AsNumber(JsVal.Prop(currencyQuote, "price")); + if (price.HasValue) return price.Value; + } + } + + if (value is JsonArray arr) + { + foreach (var entry in arr) + { + var result = ExtractPriceFromValue(entry, currencyKey, visited); + if (result.HasValue) return result; + } + return null; + } + + foreach (var key in CreateCurrencyKeyVariants(currencyKey)) + { + if (JsVal.HasProp(value, key)) + { + var result = ExtractPriceFromValue(JsVal.Prop(value, key), currencyKey, visited); + if (result.HasValue) return result; + } + } + + var baseKeys = new[] { "price", "rate", "value", "amount", "last", "lastPrice", "lastValue" }; + foreach (var baseKey in baseKeys) + { + foreach (var key in CreateCurrencyKeyVariants(currencyKey, baseKey)) + { + if (JsVal.HasProp(value, key)) + { + var result = ExtractPriceFromValue(JsVal.Prop(value, key), currencyKey, visited); + if (result.HasValue) return result; + } + } + } + + foreach (var baseKey in baseKeys) + { + if (JsVal.HasProp(value, baseKey)) + { + var result = ExtractPriceFromValue(JsVal.Prop(value, baseKey), currencyKey, visited); + if (result.HasValue) return result; + } + } + + var nestedKeys = new[] + { + "data", "result", "results", "quote", "quotes", "current", "current_price", + "currentPrice", "market", "markets", "metrics", "stats", "values", "priceData", "price_data", + }; + foreach (var nestedKey in nestedKeys) + { + if (JsVal.HasProp(value, nestedKey)) + { + var result = ExtractPriceFromValue(JsVal.Prop(value, nestedKey), currencyKey, visited); + if (result.HasValue) return result; + } + } + + return null; + } + + internal static List CollectContainers(JsonNode? root, int depth, HashSet? visited = null) + { + if (root == null || !JsVal.IsObjectOrArray(root)) return new List(); + visited ??= new HashSet(ReferenceEqualityComparer.Instance); + if (!visited.Add(root) || depth < 0) return new List(); + + var results = new List { root }; + if (depth == 0) return results; + + var nestedKeys = new[] + { + "data", "result", "results", "payload", "response", "market", "markets", "prices", + "priceData", "price_data", "tokens", "tokenPrices", "token_prices", "tokenprices", + "items", "entries", "list", "assets", "values", + }; + + foreach (var key in nestedKeys) + { + var nested = JsVal.Prop(root, key); + if (nested != null && JsVal.IsObjectOrArray(nested)) + results.AddRange(CollectContainers(nested, depth - 1, visited)); + } + + if (root is JsonArray arr) + { + foreach (var entry in arr) + { + if (entry != null && JsVal.IsObjectOrArray(entry)) + results.AddRange(CollectContainers(entry, depth - 1, visited)); + } + } + + return results; + } + + internal static double GetTokenPrice(JsonNode? marketData, string token, string currency) + { + if (marketData == null || string.IsNullOrEmpty(token)) return 0; + + var currencyKey = currency.ToLowerInvariant(); + var tokenKey = token.ToLowerInvariant(); + var upperToken = token.ToUpperInvariant(); + + if (tokenKey == currencyKey) return 1.0; + + if (currencyKey != "usd") + { + var tokenData = JsVal.Prop(marketData, tokenKey) ?? JsVal.Prop(marketData, upperToken); + var quotesNode = JsVal.Prop(tokenData, "quotes"); + var availableQuotes = quotesNode is JsonObject qo ? string.Join(",", qo.Select(kv => kv.Key)) : ""; + Console.WriteLine($"Looking for {tokenKey} price in {currencyKey}. Available quotes: {availableQuotes}"); + } + + var containers = CollectContainers(marketData, 3); + var candidates = new List(); + void AddCandidate(JsonNode? v) { if (v != null) candidates.Add(v); } + + var candidateListKeys = new[] + { + "tokens", "tokenPrices", "token_prices", "tokenprices", "prices", + "markets", "market", "items", "entries", "list", "assets", + }; + var symbolKeys = new[] { "symbol", "token", "name", "id", "ticker" }; + + foreach (var container in containers) + { + if (container == null) continue; + + if (container is JsonArray arr) + { + foreach (var entry in arr) + { + if (entry == null || !JsVal.IsObjectOrArray(entry)) continue; + var symbolCandidate = FirstDefined(symbolKeys.Select(k => JsVal.Prop(entry, k)).ToArray()); + var sc = JsVal.AsString(symbolCandidate); + if (sc != null && sc.Trim().ToLowerInvariant() == tokenKey) AddCandidate(entry); + } + continue; + } + + AddCandidate(JsVal.Prop(container, tokenKey)); + AddCandidate(JsVal.Prop(container, upperToken)); + if (token != tokenKey && token != upperToken) AddCandidate(JsVal.Prop(container, token)); + + foreach (var listKey in candidateListKeys) + { + var nestedList = JsVal.Prop(container, listKey); + if (nestedList == null) continue; + + if (nestedList is JsonArray nl) + { + foreach (var entry in nl) + { + if (entry == null || !JsVal.IsObjectOrArray(entry)) continue; + var symbolCandidate = FirstDefined(symbolKeys.Select(k => JsVal.Prop(entry, k)).ToArray()); + var sc = JsVal.AsString(symbolCandidate); + if (sc != null && sc.Trim().ToLowerInvariant() == tokenKey) AddCandidate(entry); + } + } + else if (JsVal.IsObjectOrArray(nestedList)) + { + AddCandidate(JsVal.Prop(nestedList, tokenKey)); + AddCandidate(JsVal.Prop(nestedList, upperToken)); + if (token != tokenKey && token != upperToken) AddCandidate(JsVal.Prop(nestedList, token)); + } + } + } + + foreach (var candidate in candidates) + { + var price = ExtractPriceFromValue(candidate, currencyKey); + if (price is > 0) return price.Value; + } + + if (currencyKey != "usd") + { + var priceInUsd = GetTokenPrice(marketData, token, "usd"); + if (priceInUsd > 0) + { + var usdToCurrencyRate = GetUsdToCurrencyRate(marketData, currencyKey); + if (usdToCurrencyRate > 0) return priceInUsd * usdToCurrencyRate; + } + } + + return 0; + } + + internal static double ConvertUsdRateToCurrency(JsonNode? marketData, string currency, double usdRate) + { + var normalizedCurrency = NormalizeCurrency(currency); + + if (!double.IsFinite(usdRate) || usdRate <= 0) return 0; + if (normalizedCurrency == "usd") return usdRate; + + var referenceTokens = new[] { "hive", "hbd", "btc", "eth" }; + foreach (var reference in referenceTokens) + { + var priceInUsd = GetTokenPrice(marketData, reference, "usd"); + var priceInTarget = GetTokenPrice(marketData, reference, normalizedCurrency); + if (priceInUsd > 0 && priceInTarget > 0) + { + var converted = usdRate * (priceInTarget / priceInUsd); + if (double.IsFinite(converted) && converted > 0) return converted; + } + } + + var direct = GetTokenPrice(marketData, "usd", normalizedCurrency); + if (direct > 0 && double.IsFinite(direct)) + { + var converted = usdRate * direct; + if (double.IsFinite(converted) && converted > 0) return converted; + } + + var inverse = GetTokenPrice(marketData, normalizedCurrency, "usd"); + if (inverse > 0 && double.IsFinite(inverse)) + { + var converted = usdRate / inverse; + if (double.IsFinite(converted) && converted > 0) return converted; + } + + return usdRate; + } +} diff --git a/dotnet/EcencyApi/Infrastructure/ApiClient.cs b/dotnet/EcencyApi/Infrastructure/ApiClient.cs new file mode 100644 index 00000000..1034a6fd --- /dev/null +++ b/dotnet/EcencyApi/Infrastructure/ApiClient.cs @@ -0,0 +1,176 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace EcencyApi.Infrastructure; + +/// +/// Port of src/server/helper.ts apiRequest / promoted-entries helpers. +/// PRIVATE_API_AUTH is a base64-encoded JSON object of extra headers; when it +/// can't be decoded the request fails like the Node version does. +/// +public static class ApiClient +{ + public sealed class ApiAuthException : Exception + { + public ApiAuthException() : base("Api auth couldn't be create!") { } + } + + private static Dictionary? MakeApiAuth() + { + var encoded = Config.PrivateApiAuth.Trim(); + if (encoded.Length == 0) + { + return null; + } + + try + { + var buffer = B64u.DecodeLenient(encoded); + if (buffer == null || buffer.Length == 0) + { + return null; + } + + var parsed = JsonNode.Parse(Encoding.UTF8.GetString(buffer)); + if (parsed is not JsonObject obj) + { + return null; + } + + var headers = new Dictionary(); + foreach (var kv in obj) + { + headers[kv.Key] = kv.Value switch + { + JsonValue v when v.TryGetValue(out var s) => s, + null => "null", + _ => kv.Value.ToJsonString(), + }; + } + return headers; + } + catch (JsonException) + { + return null; + } + } + + /// + /// apiRequest(endpoint, method, extraHeaders, payload, params, timeout). + /// Throws ApiAuthException when PRIVATE_API_AUTH is unusable (Node rejects + /// the promise, which pipe() turns into a 500). + /// + public static Task ApiRequest( + string endpoint, + HttpMethod method, + IEnumerable>? extraHeaders = null, + JsonNode? payload = null, + IEnumerable>? query = null, + int timeoutMs = Upstream.DefaultTimeoutMs) + { + var apiAuth = MakeApiAuth(); + if (apiAuth == null) + { + Console.Error.WriteLine("Api auth couldn't be create!"); + throw new ApiAuthException(); + } + + var url = $"{Config.PrivateApiAddr}/{endpoint}"; + + var headers = new List>(); + foreach (var kv in apiAuth) + { + headers.Add(kv); + } + if (extraHeaders != null) + { + headers.AddRange(extraHeaders); + } + + return Upstream.BaseApiRequest(url, method, headers, payload, query, timeoutMs); + } + + /// fetchPromotedEntries + getPromotedEntries (5-minute cached, shuffled). + public static async Task GetPromotedEntries(int limit, int shortContent) + { + var cacheKey = $"promotedentries-{shortContent}-{limit}"; + var promoted = MemCache.Get(cacheKey); + + if (promoted == null) + { + try + { + promoted = await FetchPromotedEntries(limit, shortContent); + if (promoted.Count > 0) + { + MemCache.Set(cacheKey, promoted, 300); + } + } + catch (Exception e) + { + Console.WriteLine($"warn: failed to fetch promoted {e.Message}"); + promoted = new JsonArray(); + } + } + + Shuffle(promoted); + return promoted; + } + + private static async Task FetchPromotedEntries(int limit, int shortContent) + { + var resp = await ApiRequest( + $"promoted-posts?limit={limit}&short_content={shortContent}", HttpMethod.Get); + + if (resp.Json is not JsonArray list) + { + return new JsonArray(); + } + + // random pick 18 (matches list.sort(() => Math.random() - 0.5).filter(i < 18)) + Shuffle(list); + + var result = new JsonArray(); + var taken = 0; + foreach (var item in list.ToArray()) + { + if (taken >= 18) break; + if (item is JsonObject o && o.TryGetPropertyValue("post_data", out var postData) && postData != null) + { + o.Remove("post_data"); + result.Add(postData); + taken++; + } + else + { + // items without post_data are filtered out but still consumed a slot + // in the Node version's filter(x, i => i < 18) BEFORE the map — mirror that: + taken++; + } + } + + return result; + } + + private static void Shuffle(JsonArray arr) + { + // Fisher-Yates; Node uses sort(() => Math.random() - 0.5), which is a + // (biased) shuffle — randomness is the observable behavior, not the bias. + var items = arr.ToArray(); + foreach (var item in items) + { + arr.Remove(item); + } + var rng = Random.Shared; + for (var i = items.Length - 1; i > 0; i--) + { + var j = rng.Next(i + 1); + (items[i], items[j]) = (items[j], items[i]); + } + foreach (var item in items) + { + arr.Add(item); + } + } +} diff --git a/dotnet/EcencyApi/Infrastructure/B64u.cs b/dotnet/EcencyApi/Infrastructure/B64u.cs new file mode 100644 index 00000000..eb272cdb --- /dev/null +++ b/dotnet/EcencyApi/Infrastructure/B64u.cs @@ -0,0 +1,59 @@ +using System.Text; + +namespace EcencyApi.Infrastructure; + +/// +/// Port of the b64u helpers (auth-api.ts / common/util/b64.ts). +/// Encoding: standard base64 of the UTF-8 bytes, then + -> -, / -> _, = -> . +/// (the client-side b64uEnc uses the same mapping, so tokens round-trip). +/// +public static class B64u +{ + public static string Encode(string str) + { + var b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(str)); + return b64.Replace('+', '-').Replace('/', '_').Replace('=', '.'); + } + + /// + /// Normalize base64url back to standard base64 and decode to UTF-8. Mirrors + /// the normalization used by validateCode/decodeToken: - -> +, _ -> /, . -> =. + /// Returns null when the input is not valid base64 (Node's Buffer.from is + /// lenient — it skips invalid chars — so we replicate that leniency). + /// + public static byte[]? DecodeLenient(string code) + { + var normalized = code.Replace('-', '+').Replace('_', '/').Replace('.', '='); + + // Node Buffer.from(str, "base64") tolerates missing padding and stray + // characters. Strip anything outside the base64 alphabet, then fix padding. + var sb = new StringBuilder(normalized.Length); + foreach (var c in normalized) + { + if (c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '+' or '/') + { + sb.Append(c); + } + else if (c == '=') + { + break; // padding starts — Node stops decoding at padding + } + } + + // Node decodes as many complete groups as possible; a single leftover + // base64 char contributes nothing. + var len = sb.Length - (sb.Length % 4 == 1 ? 1 : 0); + sb.Length = len; + var rem = len % 4; + if (rem > 0) sb.Append('=', 4 - rem); + + try + { + return Convert.FromBase64String(sb.ToString()); + } + catch (FormatException) + { + return null; + } + } +} diff --git a/dotnet/EcencyApi/Infrastructure/HiveCrypto.cs b/dotnet/EcencyApi/Infrastructure/HiveCrypto.cs new file mode 100644 index 00000000..d8adeff9 --- /dev/null +++ b/dotnet/EcencyApi/Infrastructure/HiveCrypto.cs @@ -0,0 +1,228 @@ +using System.Text; +using NBitcoin.Secp256k1; +using Org.BouncyCastle.Crypto.Digests; +using SHA256 = System.Security.Cryptography.SHA256; + +namespace EcencyApi.Infrastructure; + +/// +/// Port of the @hiveio/dhive crypto the Node service uses: key-from-login +/// derivation, Graphene-canonical ECDSA signing (with dhive's exact +/// deterministic-nonce retry loop, so signatures are byte-identical), and +/// public-key recovery for HiveSigner code validation. Verified against +/// dhive-generated golden vectors in EcencyApi.Tests. +/// +public static class HiveCrypto +{ + private const string PublicKeyPrefix = "STM"; + private const string Base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + + // ---- key derivation ------------------------------------------------- + + /// dhive PrivateKey.fromLogin: sha256(username + role + password). + public static ECPrivKey FromLogin(string username, string password, string role = "posting") + { + var seed = username + role + password; + var secret = SHA256.HashData(Encoding.UTF8.GetBytes(seed)); + return ECPrivKey.Create(secret); + } + + /// dhive PrivateKey.toString(): WIF (0x80 || key || dsha256 checksum). + public static string ToWif(ECPrivKey key) + { + Span secret = stackalloc byte[32]; + key.WriteToSpan(secret); + + var payload = new byte[33]; + payload[0] = 0x80; + secret.CopyTo(payload.AsSpan(1)); + + var checksum = SHA256.HashData(SHA256.HashData(payload)); + var full = new byte[37]; + payload.CopyTo(full, 0); + Array.Copy(checksum, 0, full, 33, 4); + + return Base58Encode(full); + } + + /// dhive PublicKey.toString(): STM + base58(pub33 || ripemd160(pub33)[0..4]). + public static string PublicKeyToString(ECPubKey pubKey) + { + Span compressed = stackalloc byte[33]; + pubKey.WriteToSpan(true, compressed, out _); + + var checksum = Ripemd160(compressed); + var full = new byte[37]; + compressed.CopyTo(full); + Array.Copy(checksum, 0, full, 33, 4); + + return PublicKeyPrefix + Base58Encode(full); + } + + public static string PublicKeyFromLogin(string username, string password, string role = "posting") + => PublicKeyToString(FromLogin(username, password, role).CreatePubKey()); + + // ---- signing -------------------------------------------------------- + + /// + /// dhive PrivateKey.sign(digest): RFC6979 deterministic ECDSA with extra + /// entropy sha256(digest || attemptByte), retried until the signature is + /// Graphene-canonical. Returns the 65-byte hex string ((recid+31) || r || s) + /// that dhive's Signature.toString() produces. + /// + public static string Sign(ECPrivKey key, ReadOnlySpan digest32) + { + if (digest32.Length != 32) + { + throw new ArgumentException("digest must be 32 bytes", nameof(digest32)); + } + + var digestArr = digest32.ToArray(); + Span compact = stackalloc byte[64]; + var attempts = 0; + + while (true) + { + attempts++; + if (attempts > 255) + { + throw new InvalidOperationException("could not produce canonical signature"); + } + + // dhive: options.data = sha256(Buffer.concat([digest, Buffer.alloc(1, attempts)])) + var ndataInput = new byte[33]; + digestArr.CopyTo(ndataInput, 0); + ndataInput[32] = (byte)attempts; + var extraEntropy = SHA256.HashData(ndataInput); + + if (!key.TrySignECDSA(digestArr, new RFC6979NonceFunction(extraEntropy), + out var recid, out var sig) || sig == null) + { + continue; + } + + sig.WriteCompactToSpan(compact); + + if (IsCanonical(compact)) + { + var result = new byte[65]; + result[0] = (byte)(recid + 31); + compact.CopyTo(result.AsSpan(1)); + return Convert.ToHexStringLower(result); + } + } + } + + /// Graphene canonical-signature check (dhive isCanonicalSignature). + private static bool IsCanonical(ReadOnlySpan c) => + (c[0] & 0x80) == 0 + && !(c[0] == 0 && (c[1] & 0x80) == 0) + && (c[32] & 0x80) == 0 + && !(c[32] == 0 && (c[33] & 0x80) == 0); + + // ---- recovery ------------------------------------------------------- + + /// + /// dhive Signature.fromString(sig).recover(digest).toString(): recover the + /// signer's public key from a 65-byte hex signature. Returns null when the + /// signature is malformed or recovery fails (callers treat that as invalid). + /// + public static string? RecoverPublicKey(string signatureHex, ReadOnlySpan digest32) + { + byte[] raw; + try + { + raw = Convert.FromHexString(signatureHex); + } + catch (FormatException) + { + return null; + } + + if (raw.Length != 65 || digest32.Length != 32) + { + return null; + } + + var recid = raw[0] - 31; + if (recid < 0 || recid > 3) + { + return null; + } + + if (!SecpRecoverableECDSASignature.TryCreateFromCompact( + raw.AsSpan(1), recid, out var recSig) || recSig == null) + { + return null; + } + + if (!ECPubKey.TryRecover(Context.Instance, recSig, digest32, out var pubKey) || pubKey == null) + { + return null; + } + + return PublicKeyToString(pubKey); + } + + // ---- hashing / encoding ---------------------------------------------- + + public static byte[] Sha256Utf8(string message) => + SHA256.HashData(Encoding.UTF8.GetBytes(message)); + + private static byte[] Ripemd160(ReadOnlySpan data) + { + var digest = new RipeMD160Digest(); + digest.BlockUpdate(data.ToArray(), 0, data.Length); + var output = new byte[20]; + digest.DoFinal(output, 0); + return output; + } + + public static string Base58Encode(ReadOnlySpan data) + { + // count leading zeros + var zeros = 0; + while (zeros < data.Length && data[zeros] == 0) + { + zeros++; + } + + var input = data.ToArray(); + var encoded = new char[data.Length * 2]; // plenty + var outputStart = encoded.Length; + + for (var inputStart = zeros; inputStart < input.Length;) + { + encoded[--outputStart] = Base58Alphabet[DivMod(input, inputStart, 256, 58)]; + if (input[inputStart] == 0) + { + inputStart++; + } + } + + while (outputStart < encoded.Length && encoded[outputStart] == Base58Alphabet[0]) + { + outputStart++; + } + + for (; zeros > 0; zeros--) + { + encoded[--outputStart] = Base58Alphabet[0]; + } + + return new string(encoded, outputStart, encoded.Length - outputStart); + } + + private static byte DivMod(byte[] number, int firstDigit, int baseIn, int baseOut) + { + var remainder = 0; + for (var i = firstDigit; i < number.Length; i++) + { + var digit = number[i] & 0xFF; + var temp = remainder * baseIn + digit; + number[i] = (byte)(temp / baseOut); + remainder = temp % baseOut; + } + return (byte)remainder; + } +} diff --git a/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs b/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs new file mode 100644 index 00000000..2bd6e036 --- /dev/null +++ b/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs @@ -0,0 +1,387 @@ +using System.Globalization; +using System.Text; +using System.Text.Json.Nodes; + +namespace EcencyApi.Infrastructure; + +/// +/// JSON-RPC client for Hive nodes with health-aware failover, adopting the +/// proven design of @ecency/sdk's NodeHealthTracker (simplified for a proxy +/// that makes a handful of call shapes): +/// +/// - Per-node health state: consecutive failures, rate-limit parking, and a +/// latency EWMA used to order the pool best-first. +/// - 429 parks a node for the server's Retry-After when present, else an +/// escalating window (10s doubling to 60s); the escalation streak resets +/// after 120s without a throttle. Parked nodes sort last. +/// - A node with a recent failure (30s window) is deprioritized behind clean +/// nodes, so one bad response moves traffic away without banning the node. +/// - Healthy nodes are ordered by latency EWMA (alpha 0.3, trusted after 3 +/// samples, stale after 5 minutes); unproven nodes score a neutral prior +/// (1s) so an unknown node is explored before a proven-slow one. Config +/// order breaks ties, so cold start behaves exactly like the configured list. +/// - RPC-level errors (JSON error field) surface immediately without failover +/// (dhive semantics — an application error, not an unhealthy node). +/// +/// Not adopted (overkill at proxy call rates): request hedging, per-API failure +/// profiles, and head-block staleness checks. +/// +public sealed class HiveRpcClient +{ + private const int RateLimitBaseMs = 10_000; + private const int RateLimitMaxMs = 60_000; + private const int RateLimitStreakResetMs = 120_000; + private const int RecentFailureWindowMs = 30_000; + private const double LatencyEwmaAlpha = 0.3; + private const int LatencyMinSamples = 3; + private const int LatencyMaxAgeMs = 5 * 60_000; + private const double LatencyUnprovenPriorMs = 1_000; + private const int SlowFailureFloorMs = 2_000; + + private sealed class NodeHealth + { + public int ConsecutiveFailures; + public long LastFailureAtMs; + public long RateLimitedUntilMs; + public int RateLimitStreak; + public long LastRateLimitAtMs; + public double? EwmaLatencyMs; + public int LatencySampleCount; + public long LatencyUpdatedAtMs; + } + + private readonly string[] _nodes; + private readonly int _timeoutMs; + private readonly int _failoverThreshold; + private readonly NodeHealth[] _health; + private readonly object _lock = new(); + private int _seq; + + // timeoutMs 2000 / failoverThreshold 2 mirror the dhive Client options the + // Node service constructed its clients with. + public HiveRpcClient(string[] nodes, int timeoutMs = 2000, int failoverThreshold = 2) + { + _nodes = nodes; + _timeoutMs = timeoutMs; + _failoverThreshold = Math.Max(1, failoverThreshold); + _health = new NodeHealth[nodes.Length]; + for (var i = 0; i < nodes.Length; i++) + { + _health[i] = new NodeHealth(); + } + } + + public sealed class RpcException : Exception + { + public RpcException(string message) : base(message) { } + } + + private static long NowMs => Environment.TickCount64; + + // ---- health bookkeeping (lock-guarded; contention is negligible) ------ + + private void RecordSuccess(int nodeIndex, double elapsedMs) + { + lock (_lock) + { + var h = _health[nodeIndex]; + h.ConsecutiveFailures = 0; + h.RateLimitStreak = 0; + RecordLatency(h, elapsedMs); + } + } + + private void RecordFailure(int nodeIndex, double elapsedMs) + { + lock (_lock) + { + var h = _health[nodeIndex]; + h.ConsecutiveFailures++; + h.LastFailureAtMs = NowMs; + // A genuinely slow failure (timeout / slow 5xx) is also a latency + // signal; an instant refusal is not (a *down* node isn't "slow"). + if (elapsedMs >= SlowFailureFloorMs) + { + RecordLatency(h, elapsedMs); + } + } + } + + private void RecordRateLimited(int nodeIndex, int? retryAfterMs) + { + lock (_lock) + { + var h = _health[nodeIndex]; + var now = NowMs; + h.ConsecutiveFailures++; + h.LastFailureAtMs = now; + if (now - h.LastRateLimitAtMs > RateLimitStreakResetMs) + { + h.RateLimitStreak = 0; + } + var parkMs = retryAfterMs + ?? Math.Min(RateLimitBaseMs << Math.Min(h.RateLimitStreak, 3), RateLimitMaxMs); + h.RateLimitedUntilMs = now + parkMs; + h.RateLimitStreak++; + h.LastRateLimitAtMs = now; + } + } + + private static void RecordLatency(NodeHealth h, double elapsedMs) + { + var now = NowMs; + // A stale profile restarts from scratch so an idle process re-learns + // instead of ranking on old data. + if (h.LatencyUpdatedAtMs > 0 && now - h.LatencyUpdatedAtMs > LatencyMaxAgeMs) + { + h.EwmaLatencyMs = null; + h.LatencySampleCount = 0; + } + h.EwmaLatencyMs = h.EwmaLatencyMs is { } prev + ? LatencyEwmaAlpha * elapsedMs + (1 - LatencyEwmaAlpha) * prev + : elapsedMs; + h.LatencySampleCount++; + h.LatencyUpdatedAtMs = now; + } + + /// + /// Node indices ordered best-first: unparked nodes sorted by + /// (recent-failure tier, latency score, config index); parked + /// (rate-limited) nodes appended last as a final resort. A recovered node + /// re-enters the healthy tiers as soon as its windows lapse. + /// + private List OrderedNodeIndices() + { + lock (_lock) + { + var now = NowMs; + return Enumerable.Range(0, _nodes.Length) + .Select(i => + { + var h = _health[i]; + var parked = h.RateLimitedUntilMs > now; + var recentFailure = h.ConsecutiveFailures > 0 + && now - h.LastFailureAtMs < RecentFailureWindowMs; + var latencyUsable = h.EwmaLatencyMs is not null + && h.LatencySampleCount >= LatencyMinSamples + && now - h.LatencyUpdatedAtMs <= LatencyMaxAgeMs; + var score = latencyUsable ? h.EwmaLatencyMs!.Value : LatencyUnprovenPriorMs; + return (Index: i, Parked: parked, RecentFailure: recentFailure, Score: score); + }) + .OrderBy(x => x.Parked) + .ThenBy(x => x.RecentFailure) + .ThenBy(x => x.Score) + .ThenBy(x => x.Index) + .Select(x => x.Index) + .ToList(); + } + } + + // ---- calls ------------------------------------------------------------- + + public async Task Call(string api, string method, JsonNode @params) + { + var request = new JsonObject + { + ["id"] = Interlocked.Increment(ref _seq), + ["jsonrpc"] = "2.0", + ["method"] = "call", + ["params"] = new JsonArray(api, method, @params), + }; + var body = request.ToJsonString(); + + Exception? lastError = null; + + foreach (var nodeIndex in OrderedNodeIndices()) + { + var node = _nodes[nodeIndex]; + + for (var attempt = 0; attempt < _failoverThreshold; attempt++) + { + var started = NowMs; + try + { + var result = await CallNode(node, body); + RecordSuccess(nodeIndex, NowMs - started); + return result; + } + catch (RpcException) + { + // The node answered; the error is the application's. No + // failover (dhive semantics), and no failure mark. + RecordSuccess(nodeIndex, NowMs - started); + throw; + } + catch (NodeUnavailableException e) + { + lastError = e; + if (e.IsRateLimit) + { + RecordRateLimited(nodeIndex, e.RetryAfterMs); + } + else + { + RecordFailure(nodeIndex, NowMs - started); + } + if (e.AdvanceImmediately) + { + break; // don't retry a throttled/overloaded node + } + } + catch (Exception e) + { + lastError = e; + RecordFailure(nodeIndex, NowMs - started); + } + } + } + + // Every node exhausted — surface the last transport error (dhive throws + // after cycling the whole list). + throw lastError ?? new InvalidOperationException("no RPC nodes configured"); + } + + /// A node is unhealthy/unreachable; try the next one. Distinct from + /// RpcException (a real RPC-level error that must not fail over). + private sealed class NodeUnavailableException : Exception + { + public bool AdvanceImmediately { get; } + public bool IsRateLimit { get; } + public int? RetryAfterMs { get; } + public Exception? Cause { get; private set; } + + public NodeUnavailableException(string message, bool advanceImmediately, + bool isRateLimit = false, int? retryAfterMs = null) : base(message) + { + AdvanceImmediately = advanceImmediately; + IsRateLimit = isRateLimit; + RetryAfterMs = retryAfterMs; + } + + public NodeUnavailableException WithInner(Exception inner) { Cause = inner; return this; } + } + + // Overload statuses mean "this node is throttling/failing at the edge" — + // skip it now rather than burning a retry that will fail the same way. + private static bool IsOverloadStatus(int status) => status is 429 or 502 or 503 or 504; + + /// Retry-After: delta-seconds or an HTTP date (RFC 9110). + private static int? ParseRetryAfterMs(string? header) + { + if (string.IsNullOrWhiteSpace(header)) return null; + var t = header.Trim(); + if (int.TryParse(t, NumberStyles.None, CultureInfo.InvariantCulture, out var seconds)) + { + return seconds >= 0 ? seconds * 1000 : null; + } + if (DateTimeOffset.TryParse(t, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var when)) + { + var ms = (when - DateTimeOffset.UtcNow).TotalMilliseconds; + return ms > 0 ? (int)Math.Min(ms, int.MaxValue) : 0; + } + return null; + } + + private async Task CallNode(string node, string body) + { + using var req = new HttpRequestMessage(HttpMethod.Post, node) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; + + using var cts = new CancellationTokenSource(_timeoutMs); + HttpResponseMessage resp; + try + { + resp = await Upstream.Http.SendAsync(req, cts.Token); + } + catch (OperationCanceledException e) when (cts.IsCancellationRequested) + { + throw new NodeUnavailableException($"RPC node {node} timed out", advanceImmediately: false).WithInner(e); + } + catch (HttpRequestException e) + { + throw new NodeUnavailableException($"RPC node {node} unreachable: {e.Message}", advanceImmediately: false).WithInner(e); + } + + using (resp) + { + if (!resp.IsSuccessStatusCode) + { + var status = (int)resp.StatusCode; + var retryAfter = status == 429 + ? ParseRetryAfterMs(resp.Headers.TryGetValues("Retry-After", out var vals) + ? vals.FirstOrDefault() + : null) + : null; + throw new NodeUnavailableException( + $"RPC node {node} returned {status}", + advanceImmediately: IsOverloadStatus(status), + isRateLimit: status == 429, + retryAfterMs: retryAfter); + } + + var text = await resp.Content.ReadAsStringAsync(cts.Token); + JsonNode? parsed; + try + { + parsed = JsonNode.Parse(text); + } + catch (System.Text.Json.JsonException e) + { + throw new NodeUnavailableException($"RPC node {node} returned non-JSON", advanceImmediately: false).WithInner(e); + } + + if (parsed is JsonObject obj && obj.TryGetPropertyValue("error", out var error) && error != null) + { + var message = error["message"]?.GetValue() ?? error.ToJsonString(); + throw new RpcException(message); + } + + return parsed?["result"]; + } + } + + // ---- typed helpers matching the dhive calls the handlers make -------- + + public async Task GetAccounts(IEnumerable names) + { + var nameArr = new JsonArray(); + foreach (var n in names) + { + // A null name serializes to JSON null (matches dhive: getAccounts([undefined]) + // -> params [null]). Hive's get_accounts(["null"]) is a real account (@null); + // get_accounts([null]) is empty — so this distinction is load-bearing. + nameArr.Add(n is null ? null : JsonValue.Create(n)); + } + var result = await Call("condenser_api", "get_accounts", new JsonArray(nameArr)); + return result as JsonArray; + } + + public Task GetDynamicGlobalProperties() => + Call("condenser_api", "get_dynamic_global_properties", new JsonArray()); +} + +/// +/// The shared RPC client. The Node service built two dhive Clients +/// (private-api.ts and hive-explorer.ts, the latter with hapi.ecency.com +/// first) — that node has since been decommissioned, so both collapse into +/// one client with a single shared health state. +/// +public static class HiveClients +{ + public static readonly HiveRpcClient Default = new(new[] + { + "https://api.hive.blog", + "https://techcoderx.com", + "https://api.deathwing.me", + "https://rpc.mahdiyari.info", + "https://hive-api.arcange.eu", + "https://api.openhive.network", + "https://hiveapi.actifit.io", + "https://hive-api.3speak.tv", + "https://api.syncad.com", + "https://api.c0ff33a.uk", + }); +} diff --git a/dotnet/EcencyApi/Infrastructure/HttpContextExtensions.cs b/dotnet/EcencyApi/Infrastructure/HttpContextExtensions.cs new file mode 100644 index 00000000..4c7411e2 --- /dev/null +++ b/dotnet/EcencyApi/Infrastructure/HttpContextExtensions.cs @@ -0,0 +1,102 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace EcencyApi.Infrastructure; + +/// +/// Request/response helpers that replicate the Express behaviors the handlers +/// rely on (express.json body parsing, res.send/res.json semantics). +/// +public static class HttpContextExtensions +{ + /// Thrown when the request body is malformed JSON; the outer error + /// middleware turns it into 500 "Server Error" exactly like the Node app's + /// global error handler does with body-parser SyntaxErrors. + public sealed class BodyParseException : Exception + { + public BodyParseException(Exception inner) : base("entity.parse.failed", inner) { } + } + + /// + /// express.json() semantics: only parses when the Content-Type is JSON-ish, + /// an absent/empty body yields an empty object, malformed JSON throws. + /// Handlers can therefore always treat the result as `req.body`. + /// + public static async Task ReadBody(this HttpContext ctx) + { + var contentType = ctx.Request.ContentType ?? ""; + var isJson = contentType.Contains("json", StringComparison.OrdinalIgnoreCase); + + // express.urlencoded({limit:'50mb'}) is registered alongside express.json, + // so form posts populate req.body with string values. Parse flat key=value + // pairs (qs "extended" nesting like a[b]=c is not replicated; no client + // sends nested forms). + if (!isJson && contentType.Contains("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)) + { + var form = await ctx.Request.ReadFormAsync(); + var obj = new JsonObject(); + foreach (var kv in form) + { + obj[kv.Key] = kv.Value.Count > 0 ? kv.Value[^1] : ""; + } + return obj; + } + + if (!isJson || ctx.Request.ContentLength is 0) + { + return new JsonObject(); + } + + using var reader = new StreamReader(ctx.Request.Body); + var text = await reader.ReadToEndAsync(); + + if (string.IsNullOrWhiteSpace(text)) + { + return new JsonObject(); + } + + try + { + var node = JsonNode.Parse(text); + // express.json({strict: true}) (default) only accepts objects/arrays; + // handlers all destructure objects, so coerce non-objects to {} the + // same way a non-object body would fail destructuring gracefully. + return node as JsonObject ?? new JsonObject(); + } + catch (JsonException e) + { + throw new BodyParseException(e); + } + } + + /// res.status(code).send(text) — Express string send (text/html). + public static async Task SendText(this HttpContext ctx, int status, string text) + { + ctx.Response.StatusCode = status; + ctx.Response.ContentType = "text/html; charset=utf-8"; + await ctx.Response.WriteAsync(text); + } + + /// res.status(code).send(obj) / res.json(obj). + public static async Task SendJson(this HttpContext ctx, int status, JsonNode? node) + { + ctx.Response.StatusCode = status; + ctx.Response.ContentType = "application/json; charset=utf-8"; + await ctx.Response.WriteAsync(node?.ToJsonString(JsonOpts) ?? "null"); + } + + private static readonly JsonSerializerOptions JsonOpts = new() + { + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + /// Convenience: string body field (undefined -> null). + public static string? Str(this JsonObject body, string key) => + body.TryGetPropertyValue(key, out var v) && v is JsonValue val && val.TryGetValue(out var s) + ? s + : null; + + /// Raw node for a body field (undefined -> null). + public static JsonNode? Field(this JsonObject body, string key) => + body.TryGetPropertyValue(key, out var v) ? v : null; +} diff --git a/dotnet/EcencyApi/Infrastructure/JsJson.cs b/dotnet/EcencyApi/Infrastructure/JsJson.cs new file mode 100644 index 00000000..f1f571d5 --- /dev/null +++ b/dotnet/EcencyApi/Infrastructure/JsJson.cs @@ -0,0 +1,285 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace EcencyApi.Infrastructure; + +/// +/// JavaScript-compatible JSON serialization for . +/// +/// The Node service hashes JSON.stringify() output for HiveSigner code +/// signing/verification (auth-api.ts hsTokenCreate, private-api.ts +/// validateCode). Byte-for-byte parity with V8's JSON.stringify therefore +/// matters: property insertion order is preserved, no whitespace, JS string +/// escaping rules (short escapes for \b \t \n \f \r, \u00XX for other control +/// chars, lone surrogates escaped, everything else raw UTF-8), and JS number +/// formatting (shortest round-trip, integral doubles without a decimal point). +/// Verified against dhive-generated golden vectors in EcencyApi.Tests. +/// +public static class JsJson +{ + public static string Stringify(JsonNode? node) + { + var sb = new StringBuilder(); + WriteValue(sb, node); + return sb.ToString(); + } + + private static void WriteValue(StringBuilder sb, JsonNode? node) + { + switch (node) + { + case null: + sb.Append("null"); + break; + case JsonObject obj: + sb.Append('{'); + var first = true; + foreach (var kv in obj) + { + if (!first) sb.Append(','); + first = false; + WriteString(sb, kv.Key); + sb.Append(':'); + WriteValue(sb, kv.Value); + } + sb.Append('}'); + break; + case JsonArray arr: + sb.Append('['); + for (var i = 0; i < arr.Count; i++) + { + if (i > 0) sb.Append(','); + WriteValue(sb, arr[i]); + } + sb.Append(']'); + break; + case JsonValue value: + WritePrimitive(sb, value); + break; + } + } + + private static void WritePrimitive(StringBuilder sb, JsonValue value) + { + if (value.TryGetValue(out var s)) + { + WriteString(sb, s); + return; + } + + if (value.TryGetValue(out var b)) + { + sb.Append(b ? "true" : "false"); + return; + } + + // Parsed values are backed by JsonElement; programmatically built ones + // by CLR primitives. Handle both. + if (value.TryGetValue(out var el)) + { + if (el.ValueKind == JsonValueKind.Number) + { + sb.Append(FormatNumber(el.GetDouble())); + return; + } + sb.Append(el.GetRawText()); + return; + } + + if (value.TryGetValue(out var l)) + { + sb.Append(l.ToString(CultureInfo.InvariantCulture)); + return; + } + + if (value.TryGetValue(out var n)) + { + sb.Append(n.ToString(CultureInfo.InvariantCulture)); + return; + } + + if (value.TryGetValue(out var dbl)) + { + sb.Append(FormatNumber(dbl)); + return; + } + + if (value.TryGetValue(out var dec)) + { + sb.Append(FormatNumber((double)dec)); + return; + } + + // Fallback (shouldn't happen for JSON trees) + sb.Append(value.ToJsonString()); + } + + /// + /// Format a number exactly the way V8 does (ECMA-262 Number::toString, which + /// JSON.stringify uses): shortest round-trip digits, fixed-point notation for + /// decimal exponents in (-6, 21], scientific ("d.ddde±X") outside that range. + /// .NET's "R" is shortest-round-trip too but switches to scientific at + /// different thresholds (e.g. 1e20 -> "1E+20" while JS prints + /// "100000000000000000000"), so the digits are re-rendered per the JS rules. + /// Verified against node-generated vectors in EcencyApi.Tests. + /// + internal static string FormatNumber(double d) + { + if (double.IsNaN(d) || double.IsInfinity(d)) + { + return "null"; // JSON.stringify(NaN/Infinity) -> null + } + + if (d == 0) + { + return "0"; // covers -0: JSON.stringify(-0) === "0" + } + + var negative = d < 0; + var r = Math.Abs(d).ToString("R", CultureInfo.InvariantCulture); + + // Decompose the shortest-round-trip form into significant digits and the + // decimal exponent n, where value = 0.digits * 10^n (ECMA notation). + string digits; + int n; + var eIdx = r.IndexOf('E'); + if (eIdx >= 0) + { + var mant = r[..eIdx]; + var exp = int.Parse(r[(eIdx + 1)..], CultureInfo.InvariantCulture); + var dot = mant.IndexOf('.'); + var mdigits = dot >= 0 ? mant.Remove(dot, 1) : mant; + var intLen = dot >= 0 ? dot : mant.Length; + digits = mdigits.TrimEnd('0'); + if (digits.Length == 0) digits = "0"; + n = exp + intLen; + } + else + { + var dot = r.IndexOf('.'); + if (dot < 0) + { + digits = r.TrimEnd('0'); + if (digits.Length == 0) digits = "0"; + n = r.Length; + } + else + { + var mdigits = r.Remove(dot, 1); + var firstSig = 0; + while (firstSig < mdigits.Length && mdigits[firstSig] == '0') firstSig++; + digits = mdigits[firstSig..].TrimEnd('0'); + if (digits.Length == 0) digits = "0"; + n = dot - firstSig; + } + } + + var k = digits.Length; + string result; + if (k <= n && n <= 21) + { + result = digits + new string('0', n - k); + } + else if (n is > 0 and <= 21) + { + result = digits[..n] + "." + digits[n..]; + } + else if (n is > -6 and <= 0) + { + result = "0." + new string('0', -n) + digits; + } + else + { + var e = n - 1; + var mantissa = k == 1 ? digits : digits[..1] + "." + digits[1..]; + result = mantissa + "e" + (e >= 0 ? "+" : "-") + + Math.Abs(e).ToString(CultureInfo.InvariantCulture); + } + + return negative ? "-" + result : result; + } + + private static void WriteString(StringBuilder sb, string s) + { + sb.Append('"'); + for (var i = 0; i < s.Length; i++) + { + var c = s[i]; + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\b': sb.Append("\\b"); break; + case '\f': sb.Append("\\f"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (c < 0x20) + { + sb.Append("\\u").Append(((int)c).ToString("x4")); + } + else if (char.IsHighSurrogate(c)) + { + // Well-formed JSON.stringify (ES2019): paired surrogates pass + // through raw, lone surrogates are escaped. + if (i + 1 < s.Length && char.IsLowSurrogate(s[i + 1])) + { + sb.Append(c).Append(s[i + 1]); + i++; + } + else + { + sb.Append("\\u").Append(((int)c).ToString("x4")); + } + } + else if (char.IsLowSurrogate(c)) + { + sb.Append("\\u").Append(((int)c).ToString("x4")); + } + else + { + sb.Append(c); + } + break; + } + } + sb.Append('"'); + } + + /// Truthiness of a JsonNode per JavaScript `if (x)` semantics. + public static bool IsTruthy(JsonNode? node) + { + switch (node) + { + case null: + return false; + case JsonObject: + case JsonArray: + return true; + case JsonValue v: + if (v.TryGetValue(out var s)) return s.Length > 0; + if (v.TryGetValue(out var b)) return b; + // Parsed values are element-backed; programmatically built ones + // are CLR-backed and would throw on GetValue(). + if (v.TryGetValue(out var el)) + { + if (el.ValueKind == JsonValueKind.Number) + { + var d = el.GetDouble(); + return d != 0 && !double.IsNaN(d); + } + return el.ValueKind != JsonValueKind.Null; + } + if (v.TryGetValue(out var l)) return l != 0; + if (v.TryGetValue(out var i)) return i != 0; + if (v.TryGetValue(out var dbl)) return dbl != 0 && !double.IsNaN(dbl); + if (v.TryGetValue(out var dec)) return dec != 0; + return true; + default: + return false; + } + } +} diff --git a/dotnet/EcencyApi/Infrastructure/JsVal.cs b/dotnet/EcencyApi/Infrastructure/JsVal.cs new file mode 100644 index 00000000..32d3afeb --- /dev/null +++ b/dotnet/EcencyApi/Infrastructure/JsVal.cs @@ -0,0 +1,207 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace EcencyApi.Infrastructure; + +/// +/// JavaScript value semantics over System.Text.Json.Nodes — the primitives the +/// wallet portfolio port leans on: typeof checks, property access that yields +/// null for missing keys, and the parseFloat / Number() coercions (which differ: +/// Number("") is 0, parseFloat("") is NaN; Number("12abc") is NaN, parseFloat +/// takes the leading number). Kept separate so the behavior is testable in isolation. +/// +public static class JsVal +{ + // ---- typeof ---------------------------------------------------------- + + public static bool IsString(JsonNode? n) => + n is JsonValue v && v.TryGetValue(out var e) + ? e.ValueKind == JsonValueKind.String + : n is JsonValue v2 && v2.TryGetValue(out _); + + public static bool IsNumber(JsonNode? n) + { + if (n is not JsonValue v) return false; + if (v.TryGetValue(out var e)) return e.ValueKind == JsonValueKind.Number; + return v.TryGetValue(out _) || v.TryGetValue(out _) || v.TryGetValue(out _); + } + + public static bool IsBool(JsonNode? n) + { + if (n is not JsonValue v) return false; + if (v.TryGetValue(out var e)) + return e.ValueKind is JsonValueKind.True or JsonValueKind.False; + return v.TryGetValue(out _); + } + + /// typeof x === "object" (JS): true for arrays and objects, NOT null. + public static bool IsObjectOrArray(JsonNode? n) => n is JsonObject or JsonArray; + + // ---- accessors ------------------------------------------------------- + + /// The JSON string value, or null when the node is not a string. + public static string? AsString(JsonNode? n) + { + if (n is JsonValue v) + { + if (v.TryGetValue(out var s)) return s; + if (v.TryGetValue(out var e) && e.ValueKind == JsonValueKind.String) + return e.GetString(); + } + return null; + } + + /// The finite double value, or null when not a JS-finite number. + public static double? AsNumber(JsonNode? n) + { + if (n is not JsonValue v) return null; + double d; + if (v.TryGetValue(out var e)) + { + if (e.ValueKind != JsonValueKind.Number) return null; + d = e.GetDouble(); + } + else if (v.TryGetValue(out var dd)) d = dd; + else if (v.TryGetValue(out var l)) d = l; + else if (v.TryGetValue(out var i)) d = i; + else return null; + return double.IsFinite(d) ? d : null; + } + + public static bool? AsBool(JsonNode? n) + { + if (n is not JsonValue v) return null; + if (v.TryGetValue(out var b)) return b; + if (v.TryGetValue(out var e)) + { + if (e.ValueKind == JsonValueKind.True) return true; + if (e.ValueKind == JsonValueKind.False) return false; + } + return null; + } + + /// obj[key] — null when n is not an object or the key is absent. + public static JsonNode? Prop(JsonNode? n, string key) => + n is JsonObject o && o.TryGetPropertyValue(key, out var v) ? v : null; + + /// Whether n is an object that owns key (Object.prototype.hasOwnProperty). + public static bool HasProp(JsonNode? n, string key) => + n is JsonObject o && o.ContainsKey(key); + + // ---- coercions ------------------------------------------------------- + + /// JS parseFloat: leading optional sign, digits, decimal, exponent; NaN otherwise. + public static double ParseFloat(string? s) + { + if (s == null) return double.NaN; + var i = 0; + var n = s.Length; + while (i < n && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r' || s[i] == '\f' || s[i] == '\v')) i++; + var start = i; + if (i < n && (s[i] == '+' || s[i] == '-')) i++; + + // Infinity + if (i < n && (s[i] == 'I') && s.AsSpan(i).StartsWith("Infinity")) + { + var sign = s[start] == '-' ? -1.0 : 1.0; + return sign * double.PositiveInfinity; + } + + var digitsStart = i; + while (i < n && char.IsAsciiDigit(s[i])) i++; + if (i < n && s[i] == '.') + { + i++; + while (i < n && char.IsAsciiDigit(s[i])) i++; + } + if (i == digitsStart || (i == digitsStart + 1 && s[digitsStart] == '.')) + { + // no digits consumed + return double.NaN; + } + // exponent + if (i < n && (s[i] == 'e' || s[i] == 'E')) + { + var save = i; + i++; + if (i < n && (s[i] == '+' || s[i] == '-')) i++; + if (i < n && char.IsAsciiDigit(s[i])) + { + while (i < n && char.IsAsciiDigit(s[i])) i++; + } + else + { + i = save; // exponent not well-formed; stop before 'e' + } + } + + var slice = s.Substring(start, i - start); + return double.TryParse(slice, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) + ? d + : double.NaN; + } + + /// JS Number(string): whole (trimmed) string must be numeric; "" -> 0; else NaN. + public static double NumberCoerce(string? s) + { + if (s == null) return double.NaN; + var t = s.Trim(); + if (t.Length == 0) return 0; + if (t is "Infinity" or "+Infinity") return double.PositiveInfinity; + if (t == "-Infinity") return double.NegativeInfinity; + // hex / binary / octal literals + if (t.Length > 2 && t[0] == '0') + { + var p = char.ToLowerInvariant(t[1]); + try + { + if (p == 'x') return (double)Convert.ToInt64(t[2..], 16); + if (p == 'o') return (double)Convert.ToInt64(t[2..], 8); + if (p == 'b') return (double)Convert.ToInt64(t[2..], 2); + } + catch { return double.NaN; } + } + return double.TryParse(t, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) + ? d + : double.NaN; + } + + /// JS String(x) coercion for the values wallet code interpolates. + public static string ToJsString(JsonNode? n) + { + switch (n) + { + case null: + return "null"; // String(null); undefined is handled by callers as "undefined" + case JsonArray arr: + return string.Join(",", arr.Select(e => e is null ? "" : ToJsString(e))); + case JsonObject: + return "[object Object]"; + case JsonValue v: + if (v.TryGetValue(out var s)) return s; + if (v.TryGetValue(out var b)) return b ? "true" : "false"; + var num = AsNumber(n); + if (num.HasValue) return JsNumberToString(num.Value); + if (v.TryGetValue(out var e)) + { + if (e.ValueKind == JsonValueKind.Null) return "null"; + if (e.ValueKind == JsonValueKind.Number) return JsNumberToString(e.GetDouble()); + } + return v.ToString(); + default: + return ""; + } + } + + /// Number -> string per ECMA-262 Number::toString (same digits and + /// fixed/scientific thresholds as V8; shared with JsJson.FormatNumber). + public static string JsNumberToString(double d) + { + if (double.IsNaN(d)) return "NaN"; + if (double.IsPositiveInfinity(d)) return "Infinity"; + if (double.IsNegativeInfinity(d)) return "-Infinity"; + return JsJson.FormatNumber(d); + } +} diff --git a/dotnet/EcencyApi/Infrastructure/MemCache.cs b/dotnet/EcencyApi/Infrastructure/MemCache.cs new file mode 100644 index 00000000..7bfd6993 --- /dev/null +++ b/dotnet/EcencyApi/Infrastructure/MemCache.cs @@ -0,0 +1,67 @@ +using System.Collections.Concurrent; +using System.Text.Json.Nodes; + +namespace EcencyApi.Infrastructure; + +/// +/// In-process cache mirroring node-cache semantics as used by src/server/cache.ts: +/// stdTTL 0 (entries without a TTL never expire), lazy expiry on read plus a +/// periodic sweep (checkperiod 600). node-cache clones values on get/set +/// (useClones: true), which the Node code relies on — e.g. getPromotedEntries +/// shuffles the array it gets back without corrupting the cached copy — so +/// JsonNode values are deep-cloned on both store and read. +/// +public static class MemCache +{ + private sealed record Entry(object Value, long ExpiresAtMs); + + private static readonly ConcurrentDictionary Store = new(); + + private static readonly Timer Sweeper = new(_ => Sweep(), null, + TimeSpan.FromSeconds(600), TimeSpan.FromSeconds(600)); + + private static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + /// 0 = never expires (node-cache stdTTL semantics) + public static void Set(string key, object value, double ttlSeconds = 0) + { + var stored = value is JsonNode node ? node.DeepClone() : value; + var expires = ttlSeconds > 0 ? NowMs + (long)(ttlSeconds * 1000) : long.MaxValue; + Store[key] = new Entry(stored, expires); + } + + public static T? Get(string key) where T : class + { + if (!Store.TryGetValue(key, out var entry)) + { + return null; + } + + if (entry.ExpiresAtMs <= NowMs) + { + Store.TryRemove(key, out _); + return null; + } + + if (entry.Value is JsonNode node) + { + return node.DeepClone() as T; + } + + return entry.Value as T; + } + + public static void Del(string key) => Store.TryRemove(key, out _); + + private static void Sweep() + { + var now = NowMs; + foreach (var kv in Store) + { + if (kv.Value.ExpiresAtMs <= now) + { + Store.TryRemove(kv.Key, out _); + } + } + } +} diff --git a/dotnet/EcencyApi/Infrastructure/Upstream.cs b/dotnet/EcencyApi/Infrastructure/Upstream.cs new file mode 100644 index 00000000..77cd8537 --- /dev/null +++ b/dotnet/EcencyApi/Infrastructure/Upstream.cs @@ -0,0 +1,278 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace EcencyApi.Infrastructure; + +/// +/// Result of an upstream call. Mirrors the slice of AxiosResponse the Node +/// handlers actually use: status, parsed JSON body (axios responseType "json" +/// falls back to the raw string when the body isn't valid JSON), and headers. +/// HTTP error statuses never throw (axios validateStatus: () => true). +/// +public sealed class UpstreamResponse +{ + public required int Status { get; init; } + + /// Parsed JSON body, or null when the body wasn't valid JSON (see RawText). + public JsonNode? Json { get; init; } + + /// Set when the body wasn't parseable JSON (axios keeps the raw string). + public string? RawText { get; init; } + + public required HttpResponseHeaders2 Headers { get; init; } + + public bool BodyIsJson => RawText == null; +} + +/// Case-insensitive header lookup (axios lower-cases header names). +public sealed class HttpResponseHeaders2 +{ + private readonly Dictionary _headers; + + public HttpResponseHeaders2(HttpResponseMessage resp) + { + _headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var h in resp.Headers) + { + _headers[h.Key] = string.Join(", ", h.Value); + } + foreach (var h in resp.Content.Headers) + { + _headers[h.Key] = string.Join(", ", h.Value); + } + } + + public string? Get(string name) => _headers.TryGetValue(name, out var v) ? v : null; +} + +/// Thrown for transport-level failures; maps to pipe()'s 504/500 split. +public sealed class UpstreamTimeoutException : Exception +{ + public UpstreamTimeoutException(string url, Exception inner) + : base($"Upstream timeout: {url}", inner) { } +} + +/// +/// Port of src/server/util.ts baseApiRequest/pipe. +/// +public static class Upstream +{ + public const int DefaultTimeoutMs = 8000; + + public static readonly HttpClient Http = new(new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + AutomaticDecompression = DecompressionMethods.All, + AllowAutoRedirect = true, + MaxConnectionsPerServer = 512, + }) + { + // Per-request timeouts via CTS; never use the client-wide timeout. + Timeout = Timeout.InfiniteTimeSpan, + }; + + /// + /// Mirror of baseApiRequest(url, method, headers, payload, params, timeout). + /// Always sends a JSON body ({} minimum) like axios does with data: {...payload} — + /// including on GET — so upstreams observe identical requests. + /// Query params are appended axios-style. Any HTTP status is returned, transport + /// errors throw (timeout -> UpstreamTimeoutException -> 504 in Pipe). + /// + public static async Task BaseApiRequest( + string url, + HttpMethod method, + IEnumerable>? headers = null, + JsonNode? payload = null, + IEnumerable>? query = null, + int timeoutMs = DefaultTimeoutMs) + { + var finalUrl = AppendQuery(url, query); + + using var req = new HttpRequestMessage(method, finalUrl); + + var bodyJson = payload?.ToJsonString(RawJsonOptions) ?? "{}"; + req.Content = new StringContent(bodyJson, Encoding.UTF8); + // axios sends bare "application/json" (no charset); keep upstream + // requests byte-identical to the Node service. + req.Content.Headers.ContentType = + new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + + if (headers != null) + { + foreach (var (name, value) in headers) + { + if (name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)) + { + req.Content.Headers.ContentType = + System.Net.Http.Headers.MediaTypeHeaderValue.Parse(value); + } + else + { + req.Headers.TryAddWithoutValidation(name, value); + } + } + } + + using var cts = new CancellationTokenSource(timeoutMs); + HttpResponseMessage resp; + try + { + resp = await Http.SendAsync(req, HttpCompletionOption.ResponseContentRead, cts.Token); + } + catch (OperationCanceledException e) when (cts.IsCancellationRequested) + { + throw new UpstreamTimeoutException(url, e); + } + + using (resp) + { + var bytes = await resp.Content.ReadAsByteArrayAsync(); + var status = (int)resp.StatusCode; + var respHeaders = new HttpResponseHeaders2(resp); + + // axios responseType "json": try to parse; fall back to raw text. + var text = Encoding.UTF8.GetString(bytes); + if (text.Length == 0) + { + // axios turns an empty body into an empty string + return new UpstreamResponse { Status = status, RawText = "", Headers = respHeaders }; + } + + try + { + var node = JsonNode.Parse(text, documentOptions: new JsonDocumentOptions + { + AllowTrailingCommas = false, + }); + return new UpstreamResponse { Status = status, Json = node, Headers = respHeaders }; + } + catch (JsonException) + { + return new UpstreamResponse { Status = status, RawText = text, Headers = respHeaders }; + } + } + } + + private static readonly JsonSerializerOptions RawJsonOptions = new() + { + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + public static string AppendQuery(string url, IEnumerable>? query) + { + if (query == null) + { + return url; + } + + var parts = new List(); + foreach (var (key, value) in query) + { + if (value == null) + { + continue; // axios skips null/undefined params + } + parts.Add($"{Uri.EscapeDataString(key)}={Uri.EscapeDataString(value)}"); + } + + if (parts.Count == 0) + { + return url; + } + + var sep = url.Contains('?') ? '&' : '?'; + return url + sep + string.Join("&", parts); + } + + /// + /// Port of pipe(): forward the upstream response to the client with Express + /// res.send() semantics — objects/arrays/bools serialize as JSON + /// (application/json), a bare JSON number is sent as its string form + /// (matching the explicit number->toString in pipe), strings are sent raw + /// (text/html like Express), transport errors become 504 "Upstream Timeout" + /// or 500 "Server Error". + /// + public static async Task Pipe(Task upstreamTask, HttpContext ctx) + { + UpstreamResponse r; + try + { + r = await upstreamTask; + } + catch (UpstreamTimeoutException e) + { + Console.Error.WriteLine($"pipe(): upstream timeout: {e.Message}"); + if (!ctx.Response.HasStarted) + { + ctx.Response.StatusCode = 504; + await ctx.Response.WriteAsync("Upstream Timeout"); + } + return; + } + catch (Exception e) + { + Console.Error.WriteLine($"pipe(): error while processing API call: {e.Message}"); + if (!ctx.Response.HasStarted) + { + ctx.Response.StatusCode = 500; + await ctx.Response.WriteAsync("Server Error"); + } + return; + } + + if (ctx.Response.HasStarted) + { + Console.Error.WriteLine("pipe(): headers already sent, skipping response"); + return; + } + + await SendLikeExpress(ctx, r.Status, r.Json, r.RawText); + } + + /// Express res.status(status).send(data) for an axios-parsed body. + public static async Task SendLikeExpress(HttpContext ctx, int status, JsonNode? json, string? rawText) + { + ctx.Response.StatusCode = status; + + if (rawText != null) + { + // Non-JSON upstream body: axios kept the raw string; res.send(string) -> text/html + ctx.Response.ContentType = "text/html; charset=utf-8"; + await ctx.Response.WriteAsync(rawText); + return; + } + + if (json == null || (json is JsonValue nv && nv.TryGetValue(out var ne) + && ne.ValueKind == JsonValueKind.Null)) + { + // Upstream body "null" parses to JS null; Express res.send(null) + // sends an EMPTY body with no Content-Type (verified against the + // Node service). Do the same. + return; + } + + if (json is JsonValue value) + { + var el = value.GetValue(); + switch (el.ValueKind) + { + case JsonValueKind.Number: + // pipe(): typeof data === 'number' -> res.send(data.toString()) + ctx.Response.ContentType = "text/html; charset=utf-8"; + await ctx.Response.WriteAsync(JsJson.Stringify(json)); + return; + case JsonValueKind.String: + // res.send("str") sends the raw string as text/html + ctx.Response.ContentType = "text/html; charset=utf-8"; + await ctx.Response.WriteAsync(value.GetValue()); + return; + } + } + + // Objects, arrays, booleans -> res.json() + ctx.Response.ContentType = "application/json; charset=utf-8"; + await ctx.Response.WriteAsync(json!.ToJsonString(RawJsonOptions)); + } +} diff --git a/dotnet/EcencyApi/Models/HiveEngine.cs b/dotnet/EcencyApi/Models/HiveEngine.cs new file mode 100644 index 00000000..27bfe189 --- /dev/null +++ b/dotnet/EcencyApi/Models/HiveEngine.cs @@ -0,0 +1,123 @@ +using System.Text.Json.Nodes; +using EcencyApi.Infrastructure; + +namespace EcencyApi.Models; + +/// +/// Port of src/models/hiveEngine.types.ts + converters.ts. The dynamic +/// Hive-Engine payloads stay as JsonNode trees; the converters replicate the +/// exact field mapping and parseFloat/Number coercions the TS applies. +/// +public static class HiveEngine +{ + // enum string values used when building JSON-RPC payloads + public const string MethodFind = "find"; + public const string MethodFindOne = "findOne"; + public const string JsonRpc2 = "2.0"; + public const string ContractTokens = "tokens"; + public const string ContractMarket = "market"; + public const string TableBalances = "balances"; + public const string TableTokens = "tokens"; + public const string TableMetrics = "metrics"; + public const string IdOne = "1"; + + /// convertEngineToken — merges balance/token/metrics/status into a HiveEngineToken shape. + public static JsonObject? ConvertEngineToken(JsonNode? balanceObj, JsonNode? token, JsonNode? metrics, JsonNode? tokenStatus) + { + if (balanceObj == null || !JsVal.IsObjectOrArray(balanceObj)) + { + // !balanceObj in JS is only true for null/undefined; a present object passes. + if (balanceObj == null) return null; + } + + JsonNode? tokenMetadata = null; + var metadataStr = JsVal.AsString(JsVal.Prop(token, "metadata")); + if (token != null && !string.IsNullOrEmpty(metadataStr)) + { + try + { + tokenMetadata = JsonNode.Parse(metadataStr!); + } + catch + { + Console.WriteLine($"failed to parse token metadata {JsVal.AsString(JsVal.Prop(token, "symbol"))}"); + tokenMetadata = null; + } + } + + var stake = ParseFloatOrZero(JsVal.Prop(balanceObj, "stake")); + var delegationsIn = ParseFloatOrZero(JsVal.Prop(balanceObj, "delegationsIn")); + var delegationsOut = ParseFloatOrZero(JsVal.Prop(balanceObj, "delegationsOut")); + var balance = ParseFloatOrZero(JsVal.Prop(balanceObj, "balance")); + + var tokenPrice = metrics != null ? ParseFloatRaw(JsVal.Prop(metrics, "lastPrice")) : 0; + var percentChange = metrics != null ? ParseFloatRaw(JsVal.Prop(metrics, "priceChangePercent")) : 0; + var volume24h = metrics != null ? ParseFloatRaw(JsVal.Prop(metrics, "volume")) : 0; + + var pendingRewards = tokenStatus != null ? (JsVal.AsNumber(JsVal.Prop(tokenStatus, "pendingRewards")) ?? 0) : 0; + var unclaimedBalance = tokenStatus != null + ? $"{JsVal.JsNumberToString(pendingRewards)} {JsVal.ToJsString(JsVal.Prop(tokenStatus, "symbol"))}" + : ""; + + return new JsonObject + { + ["symbol"] = JsVal.Prop(balanceObj, "symbol")?.DeepClone(), + ["name"] = JsVal.AsString(JsVal.Prop(token, "name")) ?? "", + ["icon"] = JsVal.AsString(JsVal.Prop(tokenMetadata, "icon")) ?? "", + ["precision"] = JsVal.AsNumber(JsVal.Prop(token, "precision")) ?? 0, + ["stakingEnabled"] = JsVal.AsBool(JsVal.Prop(token, "stakingEnabled")) ?? false, + ["delegationEnabled"] = JsVal.AsBool(JsVal.Prop(token, "delegationEnabled")) ?? false, + ["stakedBalance"] = stake, + ["unclaimedBalance"] = unclaimedBalance, + ["pendingRewards"] = pendingRewards, + ["balance"] = balance, + ["stake"] = stake, + ["delegationsIn"] = delegationsIn, + ["delegationsOut"] = delegationsOut, + ["tokenPrice"] = tokenPrice, + ["percentChange"] = percentChange, + ["volume24h"] = volume24h, + }; + } + + /// convertRewardsStatus — SCOT rewards row -> TokenStatus. + public static JsonObject ConvertRewardsStatus(JsonNode? rawData) + { + var pendingToken = JsVal.AsNumber(JsVal.Prop(rawData, "pending_token")); + var precision = JsVal.AsNumber(JsVal.Prop(rawData, "precision")); + // pending_token / 10 ** precision — JS arithmetic (NaN propagates if missing) + var pt = pendingToken ?? double.NaN; + var pr = precision ?? double.NaN; + var pendingRewards = pt / Math.Pow(10, pr); + + return new JsonObject + { + ["symbol"] = JsVal.Prop(rawData, "symbol")?.DeepClone(), + ["pendingToken"] = NumOrRaw(pendingToken, JsVal.Prop(rawData, "pending_token")), + ["precision"] = NumOrRaw(precision, JsVal.Prop(rawData, "precision")), + ["pendingRewards"] = double.IsNaN(pendingRewards) ? null : pendingRewards, + }; + } + + // parseFloat(x) || 0 (JS: NaN and 0 both fall to 0) + private static double ParseFloatOrZero(JsonNode? n) + { + var f = ParseFloatRaw(n); + return f == 0 || double.IsNaN(f) ? 0 : f; + } + + // parseFloat(x): if the node is a string use JS parseFloat; if a number, + // parseFloat(number) stringifies then parses (identity for finite numbers). + private static double ParseFloatRaw(JsonNode? n) + { + var s = JsVal.AsString(n); + if (s != null) return JsVal.ParseFloat(s); + var num = JsVal.AsNumber(n); + if (num.HasValue) return num.Value; + // parseFloat(undefined/null/object) -> NaN + return double.NaN; + } + + private static JsonNode? NumOrRaw(double? parsed, JsonNode? raw) => + parsed.HasValue ? parsed.Value : raw?.DeepClone(); +} diff --git a/dotnet/EcencyApi/Program.cs b/dotnet/EcencyApi/Program.cs new file mode 100644 index 00000000..6a12b292 --- /dev/null +++ b/dotnet/EcencyApi/Program.cs @@ -0,0 +1,84 @@ +using EcencyApi; +using EcencyApi.Handlers; +using Microsoft.Extensions.FileProviders; + +// Docker HEALTHCHECK entrypoint: poll our own /healthcheck.json, exit 0/1. +// Mirrors the Node healthCheck.js behavior without needing curl in the image. +if (args.Contains("--healthcheck")) +{ + try + { + var hcPort = int.TryParse(Environment.GetEnvironmentVariable("API_PORT"), out var hp) ? hp : 4000; + using var hc = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + var resp = await hc.GetAsync($"http://localhost:{hcPort}/healthcheck.json"); + return (int)resp.StatusCode == 200 ? 0 : 1; + } + catch + { + return 1; + } +} + +var builder = WebApplication.CreateBuilder(args); + +builder.WebHost.ConfigureKestrel(options => +{ + // express.json({limit: '50mb'}) + options.Limits.MaxRequestBodySize = 50 * 1024 * 1024; + options.AddServerHeader = false; // .disable("x-powered-by") equivalent hygiene +}); + +builder.Services.AddCors(); + +builder.Logging.ClearProviders(); +builder.Logging.AddSimpleConsole(); + +var app = builder.Build(); + +// Global error handling — the Node app's error middleware sends a plain +// 500 "Server Error" for anything thrown by handlers (including body-parser +// SyntaxErrors), so mirror that exactly. +app.Use(async (ctx, next) => +{ + try + { + await next(); + } + catch (Exception e) + { + Console.Error.WriteLine($"Unhandled server error: {e}"); + if (!ctx.Response.HasStarted) + { + ctx.Response.StatusCode = 500; + ctx.Response.ContentType = "text/html; charset=utf-8"; + await ctx.Response.WriteAsync("Server Error"); + } + } +}); + +// cors() — allow-all, like the Node service +app.UseCors(policy => policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()); + +// express.static(RAZZLE_PUBLIC_DIR) — serve the repo's public/ folder +var publicDir = Environment.GetEnvironmentVariable("PUBLIC_DIR") + ?? Path.Combine(AppContext.BaseDirectory, "public"); +if (Directory.Exists(publicDir)) +{ + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = new PhysicalFileProvider(publicDir), + ServeUnknownFileTypes = true, + }); +} + +Routes.Map(app); + +var port = int.TryParse(Environment.GetEnvironmentVariable("API_PORT"), out var p) ? p : 4000; + +Console.WriteLine($"> Started on port {port}"); +Console.WriteLine( + $"> account-create captcha: {(Config.CaptchaMode == "off" ? "OFF (break-glass)" : "hard")}"); + +app.Run($"http://0.0.0.0:{port}"); + +return 0; diff --git a/dotnet/EcencyApi/public/favicon.ico b/dotnet/EcencyApi/public/favicon.ico new file mode 100644 index 00000000..1a4beb57 Binary files /dev/null and b/dotnet/EcencyApi/public/favicon.ico differ diff --git a/dotnet/EcencyApi/public/favicon.png b/dotnet/EcencyApi/public/favicon.png new file mode 100644 index 00000000..31444c32 Binary files /dev/null and b/dotnet/EcencyApi/public/favicon.png differ diff --git a/dotnet/EcencyApi/public/logo512.png b/dotnet/EcencyApi/public/logo512.png new file mode 100644 index 00000000..752be7f0 Binary files /dev/null and b/dotnet/EcencyApi/public/logo512.png differ diff --git a/dotnet/EcencyApi/public/robots.txt b/dotnet/EcencyApi/public/robots.txt new file mode 100644 index 00000000..54fb9891 --- /dev/null +++ b/dotnet/EcencyApi/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * + diff --git a/dotnet/README.md b/dotnet/README.md new file mode 100644 index 00000000..545e734f --- /dev/null +++ b/dotnet/README.md @@ -0,0 +1,285 @@ +# vision-api — C# / ASP.NET Core port + +A behavior-preserving rewrite of the Node/Express `vision-api` proxy in C# +(.NET 10, ASP.NET Core minimal API). It is a **drop-in replacement**: same +port (4000), same `/healthcheck.json` contract, same environment variables, and +byte-for-byte identical HTTP responses across the deterministic request surface. + +## Why C# + +`vision-api` is a thin, latency-sensitive proxy in front of the internal API, +the search backend, Hive RPC nodes, and various chain/engine endpoints. ASP.NET +Core's Kestrel + `SocketsHttpHandler` connection pooling, real multithreading +(no single event-loop bottleneck), and lower/steadier GC give it more headroom +per replica than the single-threaded Node build. Nothing here is CPU-bound; the +win is a faster, more predictable proxy under concurrent load. + +## Layout + +``` +dotnet/ + EcencyApi/ the service + Program.cs host, middleware, static files, error handler + Config.cs mirror of src/config.ts (same env vars + defaults) + Handlers/ + Routes.cs 1:1 map of src/server/index.tsx route table + SearchApi.cs search-api.ts + AuthApi.cs auth-api.ts (HiveSigner code create/refresh) + PrivateApi.*.cs private-api.ts, split by concern + WalletApi.*.cs wallet-api.ts (market helpers, portfolio layers, engine) + Announcements.cs / Spotlights.cs / HiveExplorer.cs / Constants.cs / ChainProviders.cs + Infrastructure/ + Upstream.cs axios baseApiRequest + Express pipe() parity + ApiClient.cs helper.ts apiRequest (PRIVATE_API_AUTH header injection) + HiveRpcClient.cs dhive-style Client with node failover (see below) + HiveCrypto.cs dhive crypto: key-from-login, canonical ECDSA, recovery + JsJson.cs JS-identical JSON.stringify (for signed messages) + JsVal.cs JS parseFloat / Number / typeof coercions + B64u.cs, MemCache.cs, HttpContextExtensions.cs + Models/HiveEngine.cs hiveEngine.types.ts + converters.ts + EcencyApi.Tests/ xUnit: crypto golden vectors, failover, JS semantics + parity/ differential HTTP parity harness (Node vs C#) + tools/gen-vectors.js regenerates crypto golden vectors from dhive + Dockerfile drop-in image build +``` + +## Node failover (health-aware, adopted from @ecency/sdk) + +`HiveRpcClient` keeps the dhive `Client` parameters the Node service used +(`timeout: 2000`, `failoverThreshold: 2`) but replaces dhive's simple ring +failover with a health tracker adopted from the vision-next SDK's +`NodeHealthTracker` (simplified for a proxy's call rates): + +- **Per-node health state**: consecutive failures, rate-limit parking, and a + latency EWMA order the pool best-first on every call. +- **429 parks the node** for the server's `Retry-After` when present, else an + escalating window (10s doubling to 60s max; the streak resets after 120s + without a throttle). Parked nodes sort last but remain a final resort. +- **Recent failures deprioritize** (30s window) — one bad response moves + traffic away without banning the node; it re-enters when the window lapses. +- **Latency EWMA ranking** (alpha 0.3, trusted after 3 samples, stale after + 5 min): a proven-slow node (>1s) is demoted behind unexplored nodes; config + order breaks ties so cold start behaves like the configured list. +- **Overload responses** (429/502/503/504) advance to the next node + immediately — no wasted local retry on a throttled node. +- **RPC-level errors** (a JSON `error` field) surface without failover — an + application error, not an unhealthy node (matches dhive). + +Not adopted from the SDK (overkill at proxy call rates, documented for later: +request hedging, per-API failure profiles, head-block staleness checks). + +The chain-balance providers (`ChainProviders.cs` / `PrivateApi.Chain.cs`) have +their own equivalent provider-pool failover for the EVM/Solana/BTC endpoints. + +Covered by `EcencyApi.Tests/HiveRpcFailoverTests.cs` (rate-limit parking, +failure deprioritization, timeout rollover, EWMA demotion, all-down error). + +## Verifying identical responses + +Two layers of verification: + +**1. Unit tests** (`dotnet test`, 40 tests): +- Crypto is checked **byte-for-byte against dhive**. `tools/gen-vectors.js` runs + the exact `@hiveio/dhive` the Node service uses and emits golden vectors + (key-from-login, canonical ECDSA signatures, public-key recovery, the full + `hs-token-create` flow, and `validateCode` re-serialization); + `HiveCryptoTests` asserts the C# output matches. +- `JsValTests` pin the JS coercion edge cases (`Number("") === 0` but + `parseFloat("")` is `NaN`, etc.) that the wallet numeric parity depends on. +- `HiveRpcFailoverTests` exercise the failover against local stub nodes. + +**2. Differential HTTP parity** (`parity/`): fires an identical catalog of 305 +request variants (every route × empty/populated/bad-auth bodies, plus error and +fallback probes) at the running Node image and the C# build, then diffs status, +content-type, and body. + +```bash +# 1. mock upstream (records every proxied request) +python3 parity/mock_upstream.py & + +# 2. run Node reference and C# build against the same mock (see parity/ for env) +# Node on :14000, C# on :14001 + +# 3. capture + diff (node2 = second Node run, marks nondeterministic cases loose) +python3 parity/driver.py run node http://127.0.0.1:14000 +python3 parity/driver.py run node2 http://127.0.0.1:14000 +python3 parity/driver.py run csharp http://127.0.0.1:14001 +python3 parity/driver.py diff node csharp node2 +``` + +Latest result: **0 real mismatches / 305 cases.** 6 cases compare "loose" +(status + content-type only) because they are inherently nondeterministic — +timestamped HiveSigner tokens, random promoted-entry shuffles, and live +portfolio data. The full `portfolio-v2` aggregation was additionally compared +field-by-field against the Node output for a real account and matches to full +double precision (HP/vesting math, delegation adjustments, APR, prices). + +## Measured performance vs the Node build + +Both services benchmarked side by side on the same host against the same local +upstream stub (identical ~1.6KB JSON responses), autocannon, 10s runs after +warmup, zero errors in every run. "Constrained" applies the production stack +limits (0.9 CPU / 2GB) to both via cgroups. + +| scenario | node req/s | C# req/s | node p50/p99 | C# p50/p99 | C# vs node | +|---|---|---|---|---|---| +| health (no upstream), c=64 | 3,556 | 28,520 | 16 / 31 ms | 2 / 4 ms | **8.0x** | +| proxied POST, c=64 | 995 | 16,589 | 63 / 96 ms | 3 / 11 ms | **16.7x** | +| proxied POST, c=8 | 1,052 | 13,601 | 7 / 14 ms | <1 / 1 ms | **12.9x** | +| hs-token-create (secp256k1), c=16 | 2,324 | 14,563 | 6 / 16 ms | <1 / 3 ms | **6.3x** | +| **constrained 0.9 CPU:** health c=64 | 2,318 | 9,596 | 20 / 81 ms | 3 / 80 ms | **4.1x** | +| **constrained 0.9 CPU:** proxied POST c=64 | 606 | 1,838 | 93 / 237 ms | 23 / 107 ms | **3.0x** | + +Memory (RSS): Node ~45 MiB idle / ~49 MiB under load; C# ~76 MiB idle / +~100-155 MiB under load (Server GC trades memory for throughput; it sizes to +the cgroup limit in a container). Both fit trivially in the 2GB stack limit. + +Notes on why the gap is big: Kestrel uses all cores (Node is one event loop), +`SocketsHttpHandler` pools upstream connections (the Node service opens a new +TCP connection per proxied request — Node 16 default agent has keep-alive off), +and there's no Express middleware overhead. The constrained rows are the +prod-realistic numbers: **~3x throughput and ~2-4x lower p50 latency at the +same CPU budget**. + +## Known intentional divergences + +- **`/auth-api/hs-token-refresh` with a missing `code`.** The Node handler calls + `code.replace(...)` on `undefined`, throwing inside an async Express handler — + an unhandled rejection that leaves the request hanging with no response. The + C# port returns `401 Unauthorized` instead. +- **`/private-api/request-delete`** now returns the account-deletion + acknowledgment stub (200 `{status, body}`). Hive accounts cannot be deleted + on-chain; the endpoint exists to satisfy the app-store account-deletion + requirement, but the old route table pointed it at the report handler, whose + validation rejected the mobile payload with 400. +- **`/private-api/post-reblogs` and `/private-api/post-reblog-count` were + removed.** They had no client callers and no traffic, and read `:author` / + `:permlink` route params their POST routes never declared, so they always + queried `undefined/undefined` upstream. + +All three are recorded in the parity harness (`KNOWN_DIVERGENCES` / +route-table-driven catalog). + +## Remaining edges (low-risk, documented) + +- `Intl.NumberFormat()` and `toFixed(3)` HP/LP display strings in the portfolio + `extraData` use an en-US formatter; these only appear on the live-data + portfolio endpoints (loose-compared) and were validated to match on real data. +- Duplicate query-string keys are forwarded first-value-wins (Express would + array-serialize); none of the proxied endpoints use repeated params. + +## Getting started + +### Prerequisites + +.NET 10 SDK (only for building from source — the Docker image needs nothing +but Docker). Install on Linux with Microsoft's install script: + +```bash +curl -fsSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 10.0 --install-dir ~/.dotnet +export PATH="$HOME/.dotnet:$PATH" +dotnet --version # 10.0.x +``` + +Or grab an installer from https://dotnet.microsoft.com/download/dotnet/10.0. + +### Build and test + +Run from this `dotnet/` directory: + +```bash +dotnet build EcencyApi/EcencyApi.csproj # compile +dotnet test EcencyApi.Tests/EcencyApi.Tests.csproj # 40 tests: crypto vectors, failover, JS semantics +``` + +### Run locally + +Configuration comes entirely from environment variables — the same set, with +the same defaults, as the Node service (`Config.cs` mirrors `src/config.ts`): + +| variable | purpose | default | +|---|---|---| +| `API_PORT` | listen port | `4000` | +| `PRIVATE_API_ADDR` | internal private API base URL | placeholder | +| `PRIVATE_API_AUTH` | base64-encoded JSON object of auth headers for the private API | placeholder | +| `HIVESIGNER_SECRET` | HiveSigner OAuth client secret | placeholder | +| `SEARCH_API_ADDR` / `SEARCH_API_SECRET` | search backend + token | placeholder | +| `STRIPE_INTERNAL_SECRET` | shared secret for the Stripe money endpoints; unset = those routes fail closed with 503 | unset | +| `TURNSTILE_SECRET` | Cloudflare Turnstile secret for account-create captcha | unset | +| `CAPTCHA_MODE` | `hard` (default) or `off` (operator break-glass) | `hard` | +| `BLOCKSTREAM_CLIENT_ID` / `BLOCKSTREAM_CLIENT_SECRET` | optional Blockstream enterprise esplora auth | unset | +| `HELIUS_API_KEY` | optional extra Solana RPC provider | unset | +| `ETH_RPC_URLS` / `BNB_RPC_URLS` / `SOL_RPC_URLS` / `BTC_ESPLORA_URLS` | comma-separated overrides for the chain provider pools | built-in pools | +| `PUBLIC_DIR` | static assets directory | `/public` | + +```bash +API_PORT=4000 \ +PRIVATE_API_ADDR=https://example.com/api \ +PRIVATE_API_AUTH=$(printf '{"Authorization":"..."}' | base64 -w0) \ +HIVESIGNER_SECRET=... \ +SEARCH_API_ADDR=https://search.example.com \ +SEARCH_API_SECRET=... \ +dotnet run --project EcencyApi -c Release +``` + +Verify it's up: + +```bash +curl http://localhost:4000/healthcheck.json +# {"status":200,"body":{"status":"ok"}} +``` + +### Run in Docker (drop-in for the Node image) + +The image keeps the Node build's contract: port 4000, the same env vars, and a +built-in `HEALTHCHECK` that polls `/healthcheck.json`. + +```bash +docker build -t ecency/api-csharp -f Dockerfile . + +docker run -d --name vapi-csharp -p 4000:4000 \ + -e PRIVATE_API_ADDR=... -e PRIVATE_API_AUTH=... \ + -e HIVESIGNER_SECRET=... \ + -e SEARCH_API_ADDR=... -e SEARCH_API_SECRET=... \ + ecency/api-csharp +``` + +For swarm, `docker-compose.yml` in this directory is a drop-in equivalent of +the repo-root stack file (same service shape, ports, env list, and deploy +policy) pointing at the C# image. + +### Deployment tags & rollback + +CI (`.github/workflows/main.yml`) tests, then builds this Dockerfile on every +merge to main and pushes it as both `ecency/api:latest` and +`ecency/api:sha-`, deploying by immutable digest. Rollback options: + +```bash +# roll back to any previous build (every merge is tagged) +docker service update --image ecency/api:sha- vision_vapi + +# roll all the way back to the last Node build (preserved once, before the +# first C# image overwrote :latest) +docker service update --image ecency/api:node-legacy vision_vapi +``` + +The deploy hosts prune unused local images after each rollout, so rollback +pulls from the registry — which is why every build gets a durable tag. + +### Publish a self-contained build (no Docker) + +```bash +dotnet publish EcencyApi/EcencyApi.csproj -c Release -o /opt/vapi-csharp +API_PORT=4000 ... dotnet /opt/vapi-csharp/EcencyApi.dll +``` + +### Regenerate crypto golden vectors + +Only needed if the dhive dependency of the Node service changes: + +```bash +# point at a checkout that has the Node service's node_modules installed +VAPI_NODE_MODULES=/path/to/vision-api/node_modules \ + node tools/gen-vectors.js > EcencyApi.Tests/fixtures/crypto-vectors.json +dotnet test EcencyApi.Tests/EcencyApi.Tests.csproj +``` diff --git a/dotnet/docker-compose.yml b/dotnet/docker-compose.yml new file mode 100644 index 00000000..12c3ac47 --- /dev/null +++ b/dotnet/docker-compose.yml @@ -0,0 +1,53 @@ +# Drop-in equivalent of the repo-root docker-compose.yml, pointing at the C# +# image. Same service name, port, env, and swarm deploy policy so it can replace +# the Node image in the vision_vapi stack with no other changes. +version: '3.7' + +services: + app: + image: ecency/api-csharp:latest + build: + context: . + dockerfile: Dockerfile + environment: + - PRIVATE_API_ADDR + - PRIVATE_API_AUTH + - HIVESIGNER_SECRET + - SEARCH_API_ADDR + - SEARCH_API_SECRET + - STRIPE_INTERNAL_SECRET + - TURNSTILE_SECRET + - CAPTCHA_MODE + - BLOCKSTREAM_CLIENT_ID + - BLOCKSTREAM_CLIENT_SECRET + - HELIUS_API_KEY + - ETH_RPC_URLS + - BNB_RPC_URLS + - SOL_RPC_URLS + - BTC_ESPLORA_URLS + ports: + - "4000:4000" + networks: + default: null + deploy: + replicas: 1 + resources: + limits: + cpus: "0.9" + memory: 2048M + update_config: + parallelism: 1 + order: start-first + failure_action: rollback + delay: 10s + rollback_config: + parallelism: 0 + order: stop-first + restart_policy: + condition: any + delay: 5s + max_attempts: 5 + window: 15s +networks: + default: + name: vision-api diff --git a/dotnet/parity/README.md b/dotnet/parity/README.md new file mode 100644 index 00000000..7a5e7d98 --- /dev/null +++ b/dotnet/parity/README.md @@ -0,0 +1,22 @@ +# Differential parity harness + +Proves the C# port returns responses identical to the Node reference across the +whole route surface. + +- `mock_upstream.py` — a stub for every upstream (private API, search, chain + RPC/esplora) that records each proxied request and returns deterministic + bodies, so both services observe identical upstreams. +- `driver.py` — generates a catalog of 305 request variants from the Express + route table (`src/server/index.tsx`), fires it at a target, and diffs two runs. + +```bash +python3 driver.py catalog # inspect the generated cases +python3 driver.py run # capture responses -> run-.json +python3 driver.py diff [loose] # compare; `loose` marks nondeterministic cases +``` + +`diff node csharp node2` compares C# against Node, using a second Node run +(`node2`) to auto-detect inherently nondeterministic cases (timestamped tokens, +random shuffles, live portfolio data) and compare those on status + content-type +only. Known intentional divergences are listed in `KNOWN_DIVERGENCES` in +`driver.py`. A clean run reports `REAL mismatches 0`. diff --git a/dotnet/parity/driver.py b/dotnet/parity/driver.py new file mode 100644 index 00000000..0fe2b6ff --- /dev/null +++ b/dotnet/parity/driver.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +"""Differential parity driver: fire an identical request catalog at the Node +reference and the C# candidate, then diff responses + recorded upstream traffic. + +Usage: + driver.py catalog -> print the generated catalog + driver.py run -> run catalog against a target, save results + driver.py diff -> compare two result files (auto-loose on + cases where two same-target runs differ) +""" +import os +import json +import re +import sys +import time +import urllib.parse +import urllib.request +import urllib.error +from pathlib import Path + +HERE = Path(__file__).parent +# The Express route table drives the catalog. Defaults to the TS source two +# levels up (repo/src/server/index.tsx); override with VAPI_INDEX_TSX. +INDEX_TSX = Path(os.environ.get( + "VAPI_INDEX_TSX", HERE.parent.parent / "src" / "server" / "index.tsx")) +MOCK = os.environ.get("VAPI_MOCK_URL", "http://127.0.0.1:15999") + +# ---------------------------------------------------------------- catalog --- + +PARAM_VALUES = { + "username": "good-karma", + "chain": "eth", + "address": "0x1111111111111111111111111111111111111111", + "duration": "week", + "fiat": "usd", + "token": "hive", +} + +# Route-specific populated bodies (minimal variant is always {}). +BODIES = { + "/search-api/search": {"q": "ecency", "sort": "newest", "hide_low": "0", + "votes": 0, "include_nsfw": 0, "since": ""}, + "/search-api/search-follower": {"q": "e", "following": "good-karma"}, + "/search-api/search-following": {"q": "e", "follower": "good-karma"}, + "/search-api/search-account": {"q": "ecency", "limit": 5, "random": 0}, + "/search-api/search-tag": {"q": "hive", "limit": 0, "random": 1}, + "/search-api/search-path": {"q": "@good-karma/about"}, + "/search-api/similar": {"author": "good-karma", "permlink": "about", + "tags": [], "since": None}, + "/auth-api/hs-token-refresh": {"code": "bm90LWEtdG9rZW4"}, + "/auth-api/hs-token-create": {"username": "alice", + "password": "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", + "app": "ecency.app"}, + "/private-api/comment-history": {"author": "good-karma", "permlink": "about", + "onlyMeta": ""}, + "/private-api/points": {"username": "good-karma"}, + "/private-api/point-list": {"username": "good-karma", "type": 0}, + "/private-api/account-create": {"username": "newuser123", "email": "x@example.com", + "referral": "", "captchaToken": "tok"}, + "/private-api/account-create-friend": {"username": "newuser123", + "email": "x@example.com", "friend": True}, + "/private-api/subscribe": {"email": "x@example.com"}, + "/private-api/notifications": {"code": "invalid", "filter": "", "since": 0, + "limit": 50, "user": "good-karma"}, + "/private-api/report": {"type": "content", "data": {"a": 1}}, + "/private-api/request-delete": {"code": "invalid"}, + "/private-api/post-reblogs": {"author": "good-karma", "permlink": "about"}, + "/private-api/post-reblog-count": {"author": "good-karma", "permlink": "about"}, + "/private-api/post-tips": {"author": "good-karma", "permlink": "about"}, + "/private-api/wallets-add": {"code": "invalid", "username": "x", + "token": "BTC", "address": "bc1qxyz", "meta": {}}, + "/private-api/wallets-chkuser": {"username": "good-karma"}, + "/private-api/engine-api": {"jsonrpc": "2.0", "method": "find", + "params": {"contract": "tokens", "table": "balances", + "query": {"account": "good-karma"}}, "id": 1}, + "/private-api/stripe-create-intent": {"code": "invalid", "amount": 4.99, + "currency": "usd", "gift_recipient": " Bob ", + "gift_message": "hi"}, + "/private-api/stripe-order-status": {"code": "invalid", "order_id": "ord_1"}, + "/private-api/streak-freeze/buy": {"code": "invalid", "idempotency_key": " "}, + "/wallet-api/portfolio": {"username": "good-karma", "currency": "usd"}, + "/wallet-api/portfolio-v2": {"username": "good-karma", "currency": "usd"}, + "/private-api/rpc/eth": {"jsonrpc": "2.0", "method": "eth_getBalance", + "params": ["0x1111111111111111111111111111111111111111", "latest"], + "id": 1}, +} + +QUERIES = { + "/private-api/leaderboard/week": "?dummy=1", + "/private-api/waves/feed": "?host=ecency.waves&limit=20&page=2", + "/private-api/waves/shorts": "?host=leothreads&limit=0", + "/private-api/waves/tags": "?host=ecency.waves", + "/private-api/waves/account": "?host=ecency.waves&username=good-karma", + "/private-api/waves/following": "?host=ecency.waves&username=good-karma", + "/private-api/waves/trending/tags": "?host=ecency.waves&hours=24", + "/private-api/waves/trending/authors": "?host=ecency.waves", + "/private-api/promoted-entries": "?limit=10&short_content=1", + "/private-api/engine-chart-api": "?symbol=LEO&interval=daily", + "/private-api/engine-account-history": "?account=good-karma&symbol=LEO&limit=20", + "/private-api/market-data/usd/hive": "", + "/private-api/pub-notifications/good-karma": "", +} + +AUTH_CODE_PROBE = {"code": "eyJub3QiOiJ2YWxpZCJ9"} # decodes to {"not":"valid"} -> structure fail + + +def load_routes(): + """Parse .get/.post route lines out of index.tsx.""" + src = INDEX_TSX.read_text() + routes = [] + for m in re.finditer(r'\.(get|post)\("(\^?[^"]+?)\$?",', src): + method, path = m.group(1).upper(), m.group(2).lstrip("^") + if path in ("*",): + continue + # substitute :params (incl. regex-constrained ones) + concrete = re.sub(r":(\w+)(\([^)]*\))?", lambda p: PARAM_VALUES.get(p.group(1), "x"), path) + routes.append({"method": method, "path": concrete, "template": path}) + return routes + + +def build_catalog(): + cases = [] + seen = set() + for r in load_routes(): + key = (r["method"], r["path"]) + if key in seen: + continue + seen.add(key) + base = r["path"] + QUERIES.get(r["path"], "") + if r["method"] == "POST": + cases.append({"id": f"{r['path']}::min", "method": "POST", "url": base, "body": {}}) + body = BODIES.get(r["path"], {"code": "invalid-code", "probe": True}) + cases.append({"id": f"{r['path']}::pop", "method": "POST", "url": base, "body": body}) + if "code" in json.dumps(body): + cases.append({"id": f"{r['path']}::badcode", "method": "POST", "url": base, + "body": {**body, **AUTH_CODE_PROBE}}) + else: + cases.append({"id": f"{r['path']}::get", "method": "GET", "url": base, "body": None}) + # error-shape probes through the generic proxy path (upstream status/body forwarding) + cases.append({"id": "fallback::root", "method": "GET", "url": "/", "body": None}) + cases.append({"id": "fallback::deep", "method": "GET", "url": "/no/such/route", "body": None}) + cases.append({"id": "health::get", "method": "GET", "url": "/healthcheck.json", "body": None}) + cases.append({"id": "badjson::post", "method": "POST", "url": "/private-api/points", + "body": None, "raw_body": '{"broken": '}) + return cases + + +# -------------------------------------------------------------------- run --- + +def mark(case_id, client): + try: + urllib.request.urlopen(f"{MOCK}/__marker__?id={urllib.parse.quote(case_id)}&client={client}", timeout=5) + except Exception: + pass + + +def run(name, base): + results = {} + cases = build_catalog() + for c in cases: + mark(c["id"], name) + url = base + c["url"] + data = None + headers = {} + if c["method"] == "POST": + raw = c.get("raw_body") + data = raw.encode() if raw is not None else json.dumps(c["body"]).encode() + headers["Content-Type"] = "application/json" + t0 = time.time() + status, ctype, body = -1, "", "" + # Retry transport-level failures (connection refused / reset under rapid + # sequential load) — these are harness noise, not a parity signal. HTTP + # error statuses are real responses and are NOT retried. + for attempt in range(4): + req = urllib.request.Request(url, data=data, method=c["method"], headers=headers) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + body = resp.read().decode("utf-8", "replace") + status, ctype = resp.status, resp.headers.get("Content-Type", "") + break + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", "replace") + status, ctype = e.code, e.headers.get("Content-Type", "") + break + except Exception as e: + body, status, ctype = f"__transport_error__: {e}", -1, "" + time.sleep(0.25 * (attempt + 1)) + results[c["id"]] = { + "status": status, + "contentType": ctype.split(";")[0].strip(), + "body": body, + "ms": round((time.time() - t0) * 1000), + } + out = HERE / f"run-{name}.json" + out.write_text(json.dumps(results, indent=1, sort_keys=True)) + print(f"{name}: {len(results)} cases -> {out}") + + +# ------------------------------------------------------------------- diff --- + +def norm_body(text): + try: + return json.loads(text) + except ValueError: + return text + + +# Cases where the C# port intentionally differs from Node (Node bugs the port fixes). +KNOWN_DIVERGENCES = { + "/auth-api/hs-token-refresh::min": + "Node hangs the socket (unhandled promise rejection: code.replace on undefined) " + "when `code` is missing; the C# port returns 401 Unauthorized instead.", + "/private-api/request-delete::min": + "Old route table sent request-delete to the report handler (400 for the mobile " + "payload); it now returns the account-deletion acknowledgment stub (200).", + "/private-api/request-delete::pop": + "Same request-delete rerouting as ::min.", + "/private-api/request-delete::badcode": + "Same request-delete rerouting as ::min.", +} + + +def diff(a_name, b_name, loose_name=None): + a = json.loads((HERE / f"run-{a_name}.json").read_text()) + b = json.loads((HERE / f"run-{b_name}.json").read_text()) + loose = set() + if loose_name: + l = json.loads((HERE / f"run-{loose_name}.json").read_text()) + for k in a: + if k in l and (a[k]["status"], norm_body(a[k]["body"])) != (l[k]["status"], norm_body(l[k]["body"])): + loose.add(k) + mismatches = [] + known = [] + for k in sorted(set(a) | set(b)): + if k in KNOWN_DIVERGENCES: + known.append({"case": k, "reason": KNOWN_DIVERGENCES[k]}) + continue + ra, rb = a.get(k), b.get(k) + if ra is None or rb is None: + mismatches.append({"case": k, "kind": "missing", "a": bool(ra), "b": bool(rb)}) + continue + if k in loose: + if ra["status"] != rb["status"] or ra["contentType"] != rb["contentType"]: + mismatches.append({"case": k, "kind": "loose-status", + "a": [ra["status"], ra["contentType"]], + "b": [rb["status"], rb["contentType"]]}) + continue + if ra["status"] != rb["status"]: + mismatches.append({"case": k, "kind": "status", "a": ra["status"], "b": rb["status"], + "abody": ra["body"][:200], "bbody": rb["body"][:200]}) + continue + if ra["contentType"] != rb["contentType"]: + mismatches.append({"case": k, "kind": "content-type", + "a": ra["contentType"], "b": rb["contentType"]}) + continue + if norm_body(ra["body"]) != norm_body(rb["body"]): + mismatches.append({"case": k, "kind": "body", + "a": ra["body"][:300], "b": rb["body"][:300]}) + print(json.dumps({"cases": len(set(a) | set(b)), "loose": sorted(loose), + "knownDivergences": known, "mismatches": mismatches}, indent=1)) + return 1 if mismatches else 0 + + +if __name__ == "__main__": + cmd = sys.argv[1] + if cmd == "catalog": + print(json.dumps(build_catalog(), indent=1)) + elif cmd == "run": + run(sys.argv[2], sys.argv[3]) + elif cmd == "diff": + sys.exit(diff(sys.argv[2], sys.argv[3], sys.argv[4] if len(sys.argv) > 4 else None)) diff --git a/dotnet/parity/mock_upstream.py b/dotnet/parity/mock_upstream.py new file mode 100644 index 00000000..6cf68a9d --- /dev/null +++ b/dotnet/parity/mock_upstream.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Mock upstream for vapi parity testing. + +Serves as PRIVATE_API_ADDR (/api/...), SEARCH_API_ADDR (/search/...) and the +chain RPC/esplora pools. Every request is recorded to a JSONL log keyed by an +X-Parity-Marker propagated... (markers actually ride inside bodies/queries since +the proxies don't forward custom headers) — pairing is done by (path, body hash). +Responses are deterministic echoes so both proxies see identical upstreams. +""" +import json +import os +import re +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urlparse, parse_qsl + +LOG_PATH = os.environ.get( + "VAPI_UPSTREAM_LOG", str(Path(__file__).resolve().parent / "upstream-log.jsonl")) +LOCK = threading.Lock() + + +def record(entry): + with LOCK: + with open(LOG_PATH, "a") as f: + f.write(json.dumps(entry, sort_keys=True) + "\n") + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *args): + pass + + def _handle(self): + parsed = urlparse(self.path) + path = parsed.path + query = sorted(parse_qsl(parsed.query, keep_blank_values=True)) + length = int(self.headers.get("Content-Length") or 0) + raw_body = self.rfile.read(length) if length else b"" + try: + body = json.loads(raw_body) if raw_body else None + except ValueError: + body = {"__raw__": raw_body.decode("utf-8", "replace")} + + # Headers that matter for parity (auth/content negotiation); skip + # transport noise (host, content-length, connection, user-agent...). + interesting = {} + for name in ("Authorization", "Content-Type", "Accept"): + v = self.headers.get(name) + if v: + interesting[name.lower()] = v + for name, v in self.headers.items(): + if name.lower().startswith("x-") and name.lower() != "x-parity-client": + interesting[name.lower()] = v + + client = self.headers.get("X-Parity-Client") or "unknown" + record({ + "client": client, + "method": self.command, + "path": path, + "query": query, + "headers": interesting, + "body": body, + }) + + status, payload = self.route(path, query, body) + data = json.dumps(payload).encode() if not isinstance(payload, (bytes, str)) else ( + payload.encode() if isinstance(payload, str) else payload) + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def route(self, path, query, body): + # Canned shapes for endpoints whose responses the proxy post-processes. + if re.match(r"^/api/promoted-posts", path): + return 200, [ + {"author": f"user{i}", "permlink": f"post-{i}", + "post_data": {"author": f"user{i}", "permlink": f"post-{i}", "title": f"T{i}"}} + for i in range(24) + ] + if path.endswith("/eth-rpc") or path.endswith("/bnb-rpc"): + return 200, {"jsonrpc": "2.0", "id": 1, "result": "0x2386f26fc10000"} + if path.endswith("/sol-rpc"): + return 200, {"jsonrpc": "2.0", "id": 1, + "result": {"context": {"slot": 12345}, "value": 2039280}} + if "/esplora/address/" in path: + return 200, { + "address": path.rsplit("/", 1)[-1], + "chain_stats": {"funded_txo_sum": 700000, "spent_txo_sum": 100000, + "funded_txo_count": 3, "spent_txo_count": 1, "tx_count": 4}, + "mempool_stats": {"funded_txo_sum": 5000, "spent_txo_sum": 0, + "funded_txo_count": 1, "spent_txo_count": 0, "tx_count": 1}, + } + # error-shape probes + if path == "/api/parity-404": + return 404, {"error": "not found"} + if path == "/api/parity-500": + return 500, {"error": "boom"} + if path == "/api/parity-nonjson": + return 200, "plain text, not json" + if path == "/api/parity-number": + return 200, 42 + # generic deterministic echo + return 200, {"ok": True, "echo": {"method": self.command, "path": path, + "query": query, "body": body}} + + do_GET = _handle + do_POST = _handle + do_PUT = _handle + do_DELETE = _handle + + +if __name__ == "__main__": + open(LOG_PATH, "w").close() + ThreadingHTTPServer.request_queue_size = 256 + server = ThreadingHTTPServer(("127.0.0.1", 15999), Handler) + print("mock upstream on 127.0.0.1:15999") + server.serve_forever() diff --git a/dotnet/tools/gen-vectors.js b/dotnet/tools/gen-vectors.js new file mode 100644 index 00000000..3f14c6dc --- /dev/null +++ b/dotnet/tools/gen-vectors.js @@ -0,0 +1,124 @@ +/** + * Golden test-vector generator for the C# port. + * + * Runs against the exact dhive/js-base64 versions the Node service uses, so the + * C# HiveCrypto implementation can be verified byte-for-byte: + * node dotnet/tools/gen-vectors.js > dotnet/EcencyApi.Tests/fixtures/crypto-vectors.json + * + * All credentials below are synthetic — never put real account data here. + */ +const path = require("path"); +const moduleRoot = process.env.VAPI_NODE_MODULES || path.resolve(__dirname, "../../node_modules"); +const { PrivateKey, Signature, cryptoUtils } = require(path.join(moduleRoot, "@hiveio/dhive")); +const { Base64 } = require(path.join(moduleRoot, "js-base64")); + +const b64uLookup = { "/": "_", _: "/", "+": "-", "-": "+", "=": ".", ".": "=" }; +const b64uEnc = (str) => Base64.encode(str).replace(/(\+|\/|=)/g, (m) => b64uLookup[m]); + +const logins = [ + { username: "alice", password: "P5JRFhxvW9zZ1QqWzSp6ZoPhq6yGKPrM", role: "posting" }, + { username: "bob.tester", password: "hunter2hunter2", role: "posting" }, + { username: "carol-x1", password: "correct horse battery staple", role: "active" }, + { username: "dave", password: "🔑 unicode pässwörd", role: "posting" }, + { username: "e", password: "x", role: "memo" }, +]; + +const messages = [ + "hello world", + "", + "a", + JSON.stringify({ signed_message: { type: "code", app: "ecency.app" }, authors: ["alice"], timestamp: 1751900000 }), + "The quick brown fox jumps over the lazy dog", + "unicode: ñçü 漢字 🚀", + "x".repeat(1000), +]; + +const out = { fromLogin: [], sign: [], recover: [], b64u: [], hsTokenCreate: [] }; + +for (const l of logins) { + const key = PrivateKey.fromLogin(l.username, l.password, l.role); + out.fromLogin.push({ + ...l, + wif: key.toString(), + publicKey: key.createPublic().toString(), + }); +} + +let i = 0; +for (const l of logins) { + const key = PrivateKey.fromLogin(l.username, l.password, l.role); + for (const m of messages) { + // extra numbered variants so at least some vectors need >1 canonicality attempt + for (const suffix of ["", ` #${i++}`, ` !${i++}`]) { + const msg = m + suffix; + const digest = cryptoUtils.sha256(msg); + const sig = key.sign(digest); + const sigStr = sig.toString(); + out.sign.push({ + username: l.username, password: l.password, role: l.role, + message: msg, + digestHex: digest.toString("hex"), + signature: sigStr, + }); + out.recover.push({ + signature: sigStr, + digestHex: digest.toString("hex"), + recoveredPublicKey: Signature.fromString(sigStr).recover(digest).toString(), + }); + } + } +} + +for (const s of ["hello", "", "a", "ab", "abc", '{"json":true}', "unicode ñ 漢字 🚀", "??>>~~``", "x".repeat(300)]) { + out.b64u.push({ input: s, encoded: b64uEnc(s) }); +} + +// Full hsTokenCreate replication with pinned timestamps (handler uses "now"; +// tests inject the timestamp). +for (const [l, app, timestamp] of [ + [logins[0], "ecency.app", 1751900000], + [logins[1], "ecency.app", 1700000001], + [logins[3], "some.other.app", 1751912345], +]) { + const messageObj = { signed_message: { type: "code", app }, authors: [`${l.username}`], timestamp }; + const hash = cryptoUtils.sha256(JSON.stringify(messageObj)); + const privateKey = PrivateKey.fromLogin(l.username, l.password, "posting"); + const signature = privateKey.sign(hash).toString(); + messageObj.signatures = [signature]; + out.hsTokenCreate.push({ + username: l.username, password: l.password, app, timestamp, + signedJson: JSON.stringify(messageObj), + code: b64uEnc(JSON.stringify(messageObj)), + }); +} + + +// validateCode-style re-serialization vectors: token JSON (as a client might +// b64u-encode it) -> the exact rawMessage Node re-serializes and hashes. +// Exercises nested key-order preservation, fractional timestamps, unicode. +const tokenJsons = [ + '{"signed_message":{"type":"code","app":"ecency.app"},"authors":["alice"],"timestamp":1751900000,"signatures":["ab"]}', + '{"signed_message":{"app":"ecency.app","type":"code"},"authors":["bob.tester"],"timestamp":1751900000.123,"signatures":["cd"]}', + '{"timestamp":1700000000.5,"signatures":["ef"],"signed_message":{"type":"login","app":"éçency 漢字"},"authors":["carol-x1"]}', + '{"signed_message":{"type":"code","app":"x","extra":{"z":1,"a":[1.5,2e3,0.001]}},"authors":["dave","eve"],"timestamp":1e10,"signatures":["01"]}', +]; +out.validateCodeRaw = tokenJsons.map((t) => { + const decoded = JSON.parse(t); + const { signed_message, authors, timestamp } = decoded; + const rawMessage = JSON.stringify({ signed_message, authors, timestamp }); + const digest = cryptoUtils.sha256(rawMessage); + return { tokenJson: t, rawMessage, digestHex: digest.toString("hex") }; +}); + + +// JS number-formatting vectors: JSON round-trip through V8 (the oracle for +// JsJson.FormatNumber's fixed/scientific thresholds). +const numberCases = [ + 0, -0, 1, -1, 42, 1.5, -2.5, 0.1, 1e6, 123456789, + 1e20, 1e21, 5e21, -1e21, 1e-6, 0.000001, 1e-7, -1.5e-7, 0.0000015, + 123456789012345680000, 6.02e23, 1e-300, 1.7976931348623157e308, + 5e-324, 1751900000.123, 2 ** 53, 2 ** 53 + 2, +]; +out.numberFormat = numberCases.map((v) => ({ value: v, text: JSON.stringify(v) })); + +process.stdout.write(JSON.stringify(out, null, 1)); diff --git a/src/server/handlers/private-api.ts b/src/server/handlers/private-api.ts index 87b5e3dc..69bb6eb6 100644 --- a/src/server/handlers/private-api.ts +++ b/src/server/handlers/private-api.ts @@ -1817,16 +1817,6 @@ export const requestDelete = async (req: express.Request, res: express.Response) }); }; -export const reblogs = async (req: express.Request, res: express.Response) => { - const { author, permlink } = req.params; - pipe(apiRequest(`post-reblogs/${author}/${permlink}`, "GET"), res); -}; - -export const reblogCount = async (req: express.Request, res: express.Response) => { - const { author, permlink } = req.params; - pipe(apiRequest(`post-reblog-count/${author}/${permlink}`, "GET"), res); -}; - export const tips = async (req: express.Request, res: express.Response) => { const { author, permlink } = req.body; pipe(apiRequest(`post-tips/${author}/${permlink}`, "GET"), res); diff --git a/src/server/index.tsx b/src/server/index.tsx index dfe26f07..13fd848d 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -80,9 +80,10 @@ server .post("^/private-api/subscribe$", privateApi.subscribeNewsletter) .post("^/private-api/notifications$", privateApi.notifications) .post("^/private-api/report$", privateApi.report) - .post("^/private-api/request-delete$", privateApi.report) - .post("^/private-api/post-reblogs$", privateApi.reblogs) - .post("^/private-api/post-reblog-count$", privateApi.reblogCount) + // request-delete is the app-store account-deletion acknowledgment stub + // (accounts cannot be deleted on-chain); it was misrouted to `report`, + // whose validation 400'd the mobile payload. + .post("^/private-api/request-delete$", privateApi.requestDelete) .post("^/private-api/post-tips$", privateApi.tips) .post("^/private-api/chats-get$", privateApi.chatsGet)