From 92063fbda04c0af9d7e5a8b9ff087f47ee4e038f Mon Sep 17 00:00:00 2001 From: ecency Date: Fri, 10 Jul 2026 13:33:11 +0000 Subject: [PATCH 1/8] =?UTF-8?q?feat(dotnet):=20C#=20port=20foundation=20?= =?UTF-8?q?=E2=80=94=20infra,=20crypto=20verified=20against=20dhive=20gold?= =?UTF-8?q?en=20vectors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotnet/.gitignore | 3 + dotnet/EcencyApi.Tests/EcencyApi.Tests.csproj | 24 + dotnet/EcencyApi.Tests/HiveCryptoTests.cs | 122 ++ .../fixtures/crypto-vectors.json | 1472 +++++++++++++++++ dotnet/EcencyApi/Config.cs | 34 + dotnet/EcencyApi/EcencyApi.csproj | 16 + dotnet/EcencyApi/Handlers/Constants.cs | 102 ++ dotnet/EcencyApi/Handlers/Routes.cs | 55 + dotnet/EcencyApi/Infrastructure/ApiClient.cs | 176 ++ dotnet/EcencyApi/Infrastructure/B64u.cs | 59 + dotnet/EcencyApi/Infrastructure/HiveCrypto.cs | 228 +++ .../EcencyApi/Infrastructure/HiveRpcClient.cs | 150 ++ .../Infrastructure/HttpContextExtensions.cs | 87 + dotnet/EcencyApi/Infrastructure/JsJson.cs | 217 +++ dotnet/EcencyApi/Infrastructure/MemCache.cs | 67 + dotnet/EcencyApi/Infrastructure/Upstream.cs | 265 +++ dotnet/EcencyApi/Program.cs | 65 + dotnet/tools/gen-vectors.js | 95 ++ 18 files changed, 3237 insertions(+) create mode 100644 dotnet/.gitignore create mode 100644 dotnet/EcencyApi.Tests/EcencyApi.Tests.csproj create mode 100644 dotnet/EcencyApi.Tests/HiveCryptoTests.cs create mode 100644 dotnet/EcencyApi.Tests/fixtures/crypto-vectors.json create mode 100644 dotnet/EcencyApi/Config.cs create mode 100644 dotnet/EcencyApi/EcencyApi.csproj create mode 100644 dotnet/EcencyApi/Handlers/Constants.cs create mode 100644 dotnet/EcencyApi/Handlers/Routes.cs create mode 100644 dotnet/EcencyApi/Infrastructure/ApiClient.cs create mode 100644 dotnet/EcencyApi/Infrastructure/B64u.cs create mode 100644 dotnet/EcencyApi/Infrastructure/HiveCrypto.cs create mode 100644 dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs create mode 100644 dotnet/EcencyApi/Infrastructure/HttpContextExtensions.cs create mode 100644 dotnet/EcencyApi/Infrastructure/JsJson.cs create mode 100644 dotnet/EcencyApi/Infrastructure/MemCache.cs create mode 100644 dotnet/EcencyApi/Infrastructure/Upstream.cs create mode 100644 dotnet/EcencyApi/Program.cs create mode 100644 dotnet/tools/gen-vectors.js 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/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..9bdb8c81 --- /dev/null +++ b/dotnet/EcencyApi.Tests/HiveCryptoTests.cs @@ -0,0 +1,122 @@ +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)); + } + } + + [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/fixtures/crypto-vectors.json b/dotnet/EcencyApi.Tests/fixtures/crypto-vectors.json new file mode 100644 index 00000000..d7321d45 --- /dev/null +++ b/dotnet/EcencyApi.Tests/fixtures/crypto-vectors.json @@ -0,0 +1,1472 @@ +{ + "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.." + } + ] +} \ 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..9319c6ad --- /dev/null +++ b/dotnet/EcencyApi/EcencyApi.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + enable + true + true + + + + + + + + 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/Routes.cs b/dotnet/EcencyApi/Handlers/Routes.cs new file mode 100644 index 00000000..db9b7b59 --- /dev/null +++ b/dotnet/EcencyApi/Handlers/Routes.cs @@ -0,0 +1,55 @@ +using EcencyApi.Infrastructure; + +namespace EcencyApi.Handlers; + +/// +/// 1:1 port of the route table in src/server/index.tsx. Handler modules are +/// static partial classes; every route maps to the same path and method as the +/// Express original. +/// +public static class Routes +{ + public static void Map(WebApplication app) + { + // NOTE: handler route groups are wired here as the port lands; the + // health check and fallback below match fallback.tsx today. + + // Health check for docker swarm + app.MapGet("/healthcheck.json", async ctx => + { + await ctx.SendJson(200, new System.Text.Json.Nodes.JsonObject + { + ["status"] = 200, + ["body"] = new System.Text.Json.Nodes.JsonObject { ["status"] = "ok" }, + }); + }); + + // For all other GET paths — the razzle template page + app.MapFallback(async ctx => + { + if (!HttpMethods.IsGet(ctx.Request.Method)) + { + ctx.Response.StatusCode = 404; + return; + } + await ctx.SendText(200, FallbackHtml); + }); + } + + /// Rendered output of template.tsx (static markup, no client JS). + private const string FallbackHtml = """ + + + + + + + + Ecency Api + + +
Hello there! This is Vision API.
+ + + """; +} 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..2da2bb02 --- /dev/null +++ b/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs @@ -0,0 +1,150 @@ +using System.Text; +using System.Text.Json.Nodes; + +namespace EcencyApi.Infrastructure; + +/// +/// Minimal dhive-Client equivalent: JSON-RPC over HTTP against a preference- +/// ordered node list with sticky failover (timeout 2000ms, failoverThreshold 2 — +/// two failed attempts on a node advance to the next; RPC-level errors surface +/// immediately and do not fail over, matching dhive). +/// +public sealed class HiveRpcClient +{ + private readonly string[] _nodes; + private readonly int _timeoutMs; + private int _currentIndex; + private int _seq; + + public HiveRpcClient(string[] nodes, int timeoutMs = 2000) + { + _nodes = nodes; + _timeoutMs = timeoutMs; + } + + public sealed class RpcException : Exception + { + public RpcException(string message) : base(message) { } + } + + 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; + + // walk the ring starting from the sticky index; 2 attempts per node + for (var n = 0; n < _nodes.Length; n++) + { + var nodeIndex = (Volatile.Read(ref _currentIndex) + n) % _nodes.Length; + var node = _nodes[nodeIndex]; + + for (var attempt = 0; attempt < 2; attempt++) + { + try + { + var result = await CallNode(node, body); + Volatile.Write(ref _currentIndex, nodeIndex); + return result; + } + catch (RpcException) + { + // dhive surfaces RPC errors without failover + Volatile.Write(ref _currentIndex, nodeIndex); + throw; + } + catch (Exception e) + { + lastError = e; + } + } + } + + throw lastError ?? new InvalidOperationException("no RPC nodes configured"); + } + + 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); + using var resp = await Upstream.Http.SendAsync(req, cts.Token); + + if (!resp.IsSuccessStatusCode) + { + throw new HttpRequestException($"RPC node {node} returned {(int)resp.StatusCode}"); + } + + var text = await resp.Content.ReadAsStringAsync(cts.Token); + var parsed = JsonNode.Parse(text); + + 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) + { + nameArr.Add(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 two client instances the Node service builds (node lists differ). +public static class HiveClients +{ + /// private-api.ts client (no hapi.ecency.com; used by validateCode). + 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", + }); + + /// hive-explorer.ts client (prefers our own node first). + public static readonly HiveRpcClient Explorer = new(new[] + { + "https://hapi.ecency.com", + "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..2122c7c6 --- /dev/null +++ b/dotnet/EcencyApi/Infrastructure/HttpContextExtensions.cs @@ -0,0 +1,87 @@ +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); + + 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..425216c9 --- /dev/null +++ b/dotnet/EcencyApi/Infrastructure/JsJson.cs @@ -0,0 +1,217 @@ +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 the way V8's JSON.stringify would after JSON.parse: + /// numbers become doubles, printed in shortest round-trip form; integral + /// values print without a fractional part; exponent (rare) uses "e+"/"e-". + /// + private static string FormatNumber(double d) + { + + if (double.IsNaN(d) || double.IsInfinity(d)) + { + return "null"; // JSON.stringify(NaN/Infinity) -> null + } + + // .NET Core 3.0+ default double formatting is shortest round-trippable, + // same algorithm family as V8. Normalize exponent casing/format. + var text = d.ToString("R", CultureInfo.InvariantCulture); + + if (text.Contains('E')) + { + // .NET: "1E+21" -> JS: "1e+21" + text = text.Replace("E", "e"); + } + + return text; + } + + 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; + var el = v.GetValue(); + if (el.ValueKind == JsonValueKind.Number) + { + var d = el.GetDouble(); + return d != 0 && !double.IsNaN(d); + } + return el.ValueKind != JsonValueKind.Null; + default: + return false; + } + } +} 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..1bf79ccc --- /dev/null +++ b/dotnet/EcencyApi/Infrastructure/Upstream.cs @@ -0,0 +1,265 @@ +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, "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 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, null -> res.json() + ctx.Response.ContentType = "application/json; charset=utf-8"; + await ctx.Response.WriteAsync(json?.ToJsonString(RawJsonOptions) ?? "null"); + } +} diff --git a/dotnet/EcencyApi/Program.cs b/dotnet/EcencyApi/Program.cs new file mode 100644 index 00000000..63e04c84 --- /dev/null +++ b/dotnet/EcencyApi/Program.cs @@ -0,0 +1,65 @@ +using EcencyApi; +using EcencyApi.Handlers; +using Microsoft.Extensions.FileProviders; + +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}"); diff --git a/dotnet/tools/gen-vectors.js b/dotnet/tools/gen-vectors.js new file mode 100644 index 00000000..2c32a5b9 --- /dev/null +++ b/dotnet/tools/gen-vectors.js @@ -0,0 +1,95 @@ +/** + * 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)), + }); +} + +process.stdout.write(JSON.stringify(out, null, 1)); From b36b459ea668526ff6c691947657591bbbd425db Mon Sep 17 00:00:00 2001 From: ecency Date: Fri, 10 Jul 2026 21:28:46 +0000 Subject: [PATCH 2/8] =?UTF-8?q?feat(dotnet):=20complete=20handler=20port?= =?UTF-8?q?=20=E2=80=94=20all=20115=20routes,=20wallet=20aggregation=20byt?= =?UTF-8?q?e-exact=20vs=20Node?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Port remaining wallet-api (market helpers, portfolio layers, engine + portfolioV2) - Add JsVal (JS parseFloat/Number/typeof semantics over JsonNode) - Wire full route table with Express-faithful fallback (GET template / 404 finalhandler) - Fix getAccount null-vs-undefined: missing username -> RPC [null] not @null account - Differential parity harness: 305 request variants, 0 real mismatches vs Node image --- dotnet/Dockerfile | 30 + dotnet/EcencyApi.Tests/HiveCryptoTests.cs | 24 + .../fixtures/crypto-vectors.json | 22 + dotnet/EcencyApi/EcencyApi.csproj | 4 + dotnet/EcencyApi/Handlers/Announcements.cs | 47 + dotnet/EcencyApi/Handlers/AuthApi.cs | 121 ++ dotnet/EcencyApi/Handlers/ChainProviders.cs | 156 +++ dotnet/EcencyApi/Handlers/HiveExplorer.cs | 356 +++++ .../EcencyApi/Handlers/PrivateApi.Accounts.cs | 234 ++++ dotnet/EcencyApi/Handlers/PrivateApi.Chain.cs | 1167 +++++++++++++++++ dotnet/EcencyApi/Handlers/PrivateApi.Core.cs | 256 ++++ dotnet/EcencyApi/Handlers/PrivateApi.Feeds.cs | 370 ++++++ dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs | 553 ++++++++ .../EcencyApi/Handlers/PrivateApi.Payments.cs | 491 +++++++ .../Handlers/PrivateApi.UserData1.cs | 507 +++++++ .../Handlers/PrivateApi.UserData2.cs | 365 ++++++ dotnet/EcencyApi/Handlers/Routes.cs | 267 +++- dotnet/EcencyApi/Handlers/SearchApi.cs | 174 +++ dotnet/EcencyApi/Handlers/Spotlights.cs | 266 ++++ dotnet/EcencyApi/Handlers/WalletApi.Engine.cs | 439 +++++++ dotnet/EcencyApi/Handlers/WalletApi.Layers.cs | 737 +++++++++++ dotnet/EcencyApi/Handlers/WalletApi.Market.cs | 579 ++++++++ .../EcencyApi/Infrastructure/HiveRpcClient.cs | 7 +- dotnet/EcencyApi/Infrastructure/JsVal.cs | 208 +++ dotnet/EcencyApi/Models/HiveEngine.cs | 123 ++ dotnet/EcencyApi/Program.cs | 19 + dotnet/EcencyApi/public/favicon.ico | Bin 0 -> 2710 bytes dotnet/EcencyApi/public/favicon.png | Bin 0 -> 2688 bytes dotnet/EcencyApi/public/logo512.png | Bin 0 -> 122964 bytes dotnet/EcencyApi/public/robots.txt | 2 + dotnet/tools/gen-vectors.js | 18 + 31 files changed, 7512 insertions(+), 30 deletions(-) create mode 100644 dotnet/Dockerfile create mode 100644 dotnet/EcencyApi/Handlers/Announcements.cs create mode 100644 dotnet/EcencyApi/Handlers/AuthApi.cs create mode 100644 dotnet/EcencyApi/Handlers/ChainProviders.cs create mode 100644 dotnet/EcencyApi/Handlers/HiveExplorer.cs create mode 100644 dotnet/EcencyApi/Handlers/PrivateApi.Accounts.cs create mode 100644 dotnet/EcencyApi/Handlers/PrivateApi.Chain.cs create mode 100644 dotnet/EcencyApi/Handlers/PrivateApi.Core.cs create mode 100644 dotnet/EcencyApi/Handlers/PrivateApi.Feeds.cs create mode 100644 dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs create mode 100644 dotnet/EcencyApi/Handlers/PrivateApi.Payments.cs create mode 100644 dotnet/EcencyApi/Handlers/PrivateApi.UserData1.cs create mode 100644 dotnet/EcencyApi/Handlers/PrivateApi.UserData2.cs create mode 100644 dotnet/EcencyApi/Handlers/SearchApi.cs create mode 100644 dotnet/EcencyApi/Handlers/Spotlights.cs create mode 100644 dotnet/EcencyApi/Handlers/WalletApi.Engine.cs create mode 100644 dotnet/EcencyApi/Handlers/WalletApi.Layers.cs create mode 100644 dotnet/EcencyApi/Handlers/WalletApi.Market.cs create mode 100644 dotnet/EcencyApi/Infrastructure/JsVal.cs create mode 100644 dotnet/EcencyApi/Models/HiveEngine.cs create mode 100644 dotnet/EcencyApi/public/favicon.ico create mode 100644 dotnet/EcencyApi/public/favicon.png create mode 100644 dotnet/EcencyApi/public/logo512.png create mode 100644 dotnet/EcencyApi/public/robots.txt diff --git a/dotnet/Dockerfile b/dotnet/Dockerfile new file mode 100644 index 00000000..b80e2d5e --- /dev/null +++ b/dotnet/Dockerfile @@ -0,0 +1,30 @@ +# 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 ./ + +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/HiveCryptoTests.cs b/dotnet/EcencyApi.Tests/HiveCryptoTests.cs index 9bdb8c81..91286e07 100644 --- a/dotnet/EcencyApi.Tests/HiveCryptoTests.cs +++ b/dotnet/EcencyApi.Tests/HiveCryptoTests.cs @@ -107,6 +107,30 @@ public void HsTokenCreate_FullFlow_MatchesNode() } } + [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))); + } + } + [Theory] [InlineData("{\"a\":1,\"b\":\"x\"}")] [InlineData("{\"b\":2,\"a\":1}")] // property order preserved, not sorted diff --git a/dotnet/EcencyApi.Tests/fixtures/crypto-vectors.json b/dotnet/EcencyApi.Tests/fixtures/crypto-vectors.json index d7321d45..5f840780 100644 --- a/dotnet/EcencyApi.Tests/fixtures/crypto-vectors.json +++ b/dotnet/EcencyApi.Tests/fixtures/crypto-vectors.json @@ -1468,5 +1468,27 @@ "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" + } ] } \ No newline at end of file diff --git a/dotnet/EcencyApi/EcencyApi.csproj b/dotnet/EcencyApi/EcencyApi.csproj index 9319c6ad..fd8862aa 100644 --- a/dotnet/EcencyApi/EcencyApi.csproj +++ b/dotnet/EcencyApi/EcencyApi.csproj @@ -13,4 +13,8 @@ + + + + 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/HiveExplorer.cs b/dotnet/EcencyApi/Handlers/HiveExplorer.cs new file mode 100644 index 00000000..75f4d4b5 --- /dev/null +++ b/dotnet/EcencyApi/Handlers/HiveExplorer.cs @@ -0,0 +1,356 @@ +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.Explorer handles +/// failover across the list, preferring our own node first) 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.Explorer.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.Explorer.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..135cb37f --- /dev/null +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs @@ -0,0 +1,553 @@ +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); + } + + public static async Task Reblogs(HttpContext ctx) + { + // Route is wired as POST /private-api/post-reblogs with no :author/:permlink + // params, so req.params is empty in Node and the upstream path is literally + // "post-reblogs/undefined/undefined" — replicated as-is. + var author = MiscRouteParam(ctx, "author"); + var permlink = MiscRouteParam(ctx, "permlink"); + await Upstream.Pipe(ApiClient.ApiRequest($"post-reblogs/{author}/{permlink}", HttpMethod.Get), ctx); + } + + public static async Task ReblogCount(HttpContext ctx) + { + // Same empty-req.params situation as Reblogs (POST route without params). + var author = MiscRouteParam(ctx, "author"); + var permlink = MiscRouteParam(ctx, "permlink"); + await Upstream.Pipe(ApiClient.ApiRequest($"post-reblog-count/{author}/{permlink}", HttpMethod.Get), ctx); + } + + 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 index db9b7b59..0ecfbbdc 100644 --- a/dotnet/EcencyApi/Handlers/Routes.cs +++ b/dotnet/EcencyApi/Handlers/Routes.cs @@ -1,55 +1,266 @@ +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. Handler modules are -/// static partial classes; every route maps to the same path and method as the -/// Express original. +/// 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) { - // NOTE: handler route groups are wired here as the port lands; the - // health check and fallback below match fallback.tsx today. + // ---- 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); - // Health check for docker swarm + // ---- 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); + app.MapPost("/private-api/request-delete", PrivateApi.Report); + app.MapPost("/private-api/post-reblogs", PrivateApi.Reblogs); + app.MapPost("/private-api/post-reblog-count", PrivateApi.ReblogCount); + 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 System.Text.Json.Nodes.JsonObject + await ctx.SendJson(200, new JsonObject { ["status"] = 200, - ["body"] = new System.Text.Json.Nodes.JsonObject { ["status"] = "ok" }, + ["body"] = new JsonObject { ["status"] = "ok" }, }); }); - // For all other GET paths — the razzle template page + // ---- Fallback ---- + // GET/HEAD -> the template page (Express .get("*", fallbackHandler)). + // Everything else -> Express's default finalhandler 404. app.MapFallback(async ctx => { - if (!HttpMethods.IsGet(ctx.Request.Method)) + var method = ctx.Request.Method; + if (HttpMethods.IsGet(method) || HttpMethods.IsHead(method)) { - ctx.Response.StatusCode = 404; + ctx.Response.StatusCode = 200; + ctx.Response.ContentType = "text/html; charset=utf-8"; + if (!HttpMethods.IsHead(method)) + { + await ctx.Response.WriteAsync(TemplateHtml); + } return; } - await ctx.SendText(200, FallbackHtml); + + 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); }); } - /// Rendered output of template.tsx (static markup, no client JS). - private const string FallbackHtml = """ - - - - - - - - Ecency Api - - -
Hello there! This is Vision API.
- - - """; + /// + /// 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/HiveRpcClient.cs b/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs index 2da2bb02..d426b7e6 100644 --- a/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs +++ b/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs @@ -99,12 +99,15 @@ public RpcException(string message) : base(message) { } // ---- typed helpers matching the dhive calls the handlers make -------- - public async Task GetAccounts(IEnumerable names) + public async Task GetAccounts(IEnumerable names) { var nameArr = new JsonArray(); foreach (var n in names) { - nameArr.Add(n); + // 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; diff --git a/dotnet/EcencyApi/Infrastructure/JsVal.cs b/dotnet/EcencyApi/Infrastructure/JsVal.cs new file mode 100644 index 00000000..12f2830b --- /dev/null +++ b/dotnet/EcencyApi/Infrastructure/JsVal.cs @@ -0,0 +1,208 @@ +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 the way JS does (shortest round-trip, integral without ".0"). + public static string JsNumberToString(double d) + { + if (double.IsNaN(d)) return "NaN"; + if (double.IsPositiveInfinity(d)) return "Infinity"; + if (double.IsNegativeInfinity(d)) return "-Infinity"; + var text = d.ToString("R", CultureInfo.InvariantCulture); + if (text.Contains('E')) text = text.Replace("E", "e"); + return text; + } +} 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 index 63e04c84..6a12b292 100644 --- a/dotnet/EcencyApi/Program.cs +++ b/dotnet/EcencyApi/Program.cs @@ -2,6 +2,23 @@ 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 => @@ -63,3 +80,5 @@ $"> 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 0000000000000000000000000000000000000000..1a4beb57306db9c4f681734e1e97317473e314d2 GIT binary patch literal 2710 zcmeHJ=QkUS8x5@$qxLE_s%qA5&6*)ZMXZLXSz0k`x71d|9zjz?jMP@Ms8O0CRWB7p ziCIPMQdO&d-v8qF;XTiD?>+b2Z}&VO9sq#qVmu!ME?5{41q1-N0RTXfxtS3&kpIE~ zn2o^(78mMyfyeZhF1YvLZ^ngC1y~sAQGF%KL|zyMKd@~80KmxcFQ@>R0$u=sy4c** z3Um7XmHJECHZ6^y~Idl&$mCk+;|cKe!Gnux!3Q>Xzwv z61Xu)_}6va^-#KDeaPU6Pg;J}yVkEWhXiYBHp%#|y~F`7WRKmwrYTOQe-N-2DeU4J zTKx^BEOHOhNwqIWZ=8;W83yhu)ja+1P_)zC8ZPwPcg&@7MOPs*o3D%}eg% zued>5JeuV<7^Rj9Z<~EI&Ln1W&pz6*?>2CaP0iq_U|1hWhd(AtAm_l|G%mM&NQ0a-{vf6zm)TK^yS7lxHDVv98C>9)-8k>*IdxhUebJ%S~ry= ztK^$OC3G+8+x|F=LmUWZ$d+lm8M5NfF=2Z|J#Gm_;Gx8oMl1}ZHI$7%#;*5y{8hH* zj^xlj>?E*51m!AlKvgRoMY#~JiLBw{RI%(GM~Avg;@o?{UI95?a%^OKE|R7Trbj2K zT-IP_NUG>Q{f4zlPOAi$7bBNUDLt}5)P$rSGg7_~n)=2tF)LR_R;oycuJEzF_@l10 zG?DOr{}JGIi^iE}&62%-#>Nx7Qo}5JdY|v+Og$X1{YBJEuU^qZSJlf$b4ad^_*53x8C2WJ z9IFR%>~llNkreaV+%0#7m0fz9$!vML?Eyjiq`kw@E)uHz_4X1Rln--C+gbs3LMi*T zwkca*O&YW1yUCnY%M>BjfHdu#Izv-mLPh(~tWoV0^#;GRbAuiDoHh339!{KTSX&2% zLG-)O4gW|G4xAF`TRkw}e&C^Y%JWHbA}8{)sL^owuF-F{gt1^EOkN`}=DklqrF+0K zZ@Q?1G;>5o4{tx@b9r$PTi|Oq6N&5~DW?1Ro(Fe~C;~|8Zd2_luaE^xD*QW-6|mSq zV?HAd3W=@vTU42~XF@1;@Rmx1JTbF|XG7>c&pDqk_)+Y)F2}Jeo|QanVJ2g1*n02& zrX6zjdeu(3nUD2bcTRL1xV!l#H*MAS=N!Ecax4B& z$0Xo+hs-Ft;qO(#D$tKBf$UAB!JXZ#sTLV-Ql~lw zoL6ixtDyY=!Az#?jJ|I1C<_Wv2B+g$ic8}HiOdl-$-wXdt2zvX>?W+QN><#`Xe&GP z_Y)nltYx|7jfK;rb>ONaq$7FNa`s8xwiE_QM%ahutA4WcEDSmUnP}Xiw@HXa47*wj zgzVFI0D4Ljlo=e^v=X(!Ct{G93&oO^7LH>&;~Li@b`Ph4ZFT58^NB0Je#*b1_APyj7R|!-h z*@$R73B?I>F+QVp&3@%I&%P!ze;^*mql7F3K`vrUvN_oMdm!ByRpx$i#_K?Gdk9PkBE>kxYm7I`E-qN z@CAve3?@MJzSZpX73WB+7fmCi?$F*!etA%j@b`2=dQ3?edQ9_g*R$w)Jo+XoorNQ0 zJogyBXWe)X^f6gJZ{TQaWZXG^4!JuU;DEr2$lc79zSAEtMikolLzBZ+6!Slh_|K~V literal 0 HcmV?d00001 diff --git a/dotnet/EcencyApi/public/favicon.png b/dotnet/EcencyApi/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..31444c327b226b5543c57d7a700d7ec220536bec GIT binary patch literal 2688 zcmeHJ_d67hAHS4!D0@XB>8$KBv+mq+a?a|E%qVAO7m*dt9)~WRGcH?Z$cQdn@*#&4 zXC`Dvl9jK|fARg{^E|Kdyr0)^ujlo{JHgZh$_5k!0ssIuLm1fX%stNdi0S;9cJKX7 zJF|2CW>CFzU&%5NX9u${%-SCSVBz|g=K#1oegJ^3(A3xhaux#M6aV4A2L3-ADE})N ze>Ty%05cPF03-(cy7d!n<8*oGEk4c{rNa&^nXP?((|9xiRv&ov*EQYM5XM1$c>j@i zN^Zrw=C4!xBui-y$=Hsa_&y$Vm(AUV32xSZY>_XLI3+Z+dh3cW%H2<;RKFa)emoLt z5U{OO_2k0?v2KSO&X3duas+U(SHS`R49SLIU5k*s4%~shg(Y_%KA!a+ zuYju?H@E4inIVaukB&@=t#71z`?-3A+gQQy9LF`Ve9rKwva#KVQad;o<&&bU^q0nt zh|+xqgV}xB(bCKlrM;7lY5&e;rKxvYv`{GJxI!P*w{60a0vlJ8QkQ6k=QUKGhP_+m z?C~K9Yu0U0Ab9T zGW9otm;AWKtPki$%@MXl1bL|*kA!FqWD*bYt3B?2l`VNAxU~1%Nt|$DxiSJ!)q+4% z&PQpYs|2`J%)3XiA#Rccw=S5cf0m~l2i1;;qUnt5(n%Q1q3gtqDC$u|rE?+4Zw%ryvSnnY3UnCrAK6Jf>_|xw4eRwA0$ww#&j@Nq&V}j! zV-s5?R$y^>6&CIhJC~(xJp&$HRS~zh^2S(I#iFjGsD{>-{Wgf6XGWr$m!in*l_Bt6 z!Q?7*QfGeDydbMfKy;F2;>TNhzJ|irD&j&eFlfCxZkUN&mtplLf|}_U?DW&t9@`Wf zWY{rze>Y|A;zI7uV_tf8i|xCpUO1RR^R&e#Uv!zmv>eT_yQ4=w*99L)v8~M9bW>Q` zX0jU3lxN)R6Shm(*&ptpU`k(aE}$T}NXL|oC15*(wp(qTwDHxbK2yGv%3ZNY6JhsH z(ax$dF!mvpwGK=}t0(Bz1f`woY+$GC(MNX)60C#TI!K&tuQTJ|kGQJ=6GA=9d#0QB z-PMlyK1q&cMO+Yr4wi01e{;l*1d);Q8Ua!7z5UDG{1^FC#q6co!qd9=d*Po;3j;X< zUb`BBG6SVp@8x>z-G^2#1fSWXYx4Ze$4Dkv)i~3!>M>u^t6eMim&46zVn-5z{&GfEA2)u-gnh0-l6Z# z`kU;OW$T|aOy21ANUF&#O`HoNG;S#GJJsW-gsr_OpLeL_vLi?kwXBx-;lApwssnjn z^x&5y-+nj=-#twKhLlhNq)$Z=D5#+9_x+H@u$p*5Qv?bZN8}$cq3KFgdmtcIjF^!| zxcEm@Q1knK*M+$*h4dJ>>mht>c~j1D4ya&UZmyHhPH0pjRdf|r%D+ifCxt=_(zb?QH@TMt z1}no-i5CltV*(g3Y2h)tcZ`@18x#6Z*jPpooU0=W7C@ z;Ph++o~kr=7}XwAzY@N^KM8E9!RDBbUHbJ?{uRyV%Z^}0k;eF;-x@FLgk~5w=zzg# zY}jj5Yex)om|mDPsJEUwpdY)CV!OJtVj~B)hZwqmF!@v{*>D^|xXQ!wl))wQmFJXK zI&X%@d4#j}<;W>jVo3cW^tCO0S@Q?;opqV}SA-ra8+MvJF)TrKr1 zTZhl!cc%UAZSkUVH?pN~_xg{JMYjG>W~YDG{>X2gJDj<)&r&fYagvE2e+r@r=W9ru zOr%ZPZwf$h<9)S>U&Efbtzm^@kE|q?;-CT F{{hqs&J_Rv literal 0 HcmV?d00001 diff --git a/dotnet/EcencyApi/public/logo512.png b/dotnet/EcencyApi/public/logo512.png new file mode 100644 index 0000000000000000000000000000000000000000..752be7f0129ccc9ac7a2840d296db784423daa22 GIT binary patch literal 122964 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 P?q_l3=H-HmE>ZsjrT#q) literal 0 HcmV?d00001 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/tools/gen-vectors.js b/dotnet/tools/gen-vectors.js index 2c32a5b9..fab6618f 100644 --- a/dotnet/tools/gen-vectors.js +++ b/dotnet/tools/gen-vectors.js @@ -92,4 +92,22 @@ for (const [l, app, timestamp] of [ }); } + +// 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") }; +}); + process.stdout.write(JSON.stringify(out, null, 1)); From 050b62ca2c20f8a08ccf9fbbfe25440a264cff2e Mon Sep 17 00:00:00 2001 From: ecency Date: Fri, 10 Jul 2026 21:33:36 +0000 Subject: [PATCH 3/8] feat(dotnet): explicit rate-limit failover, test suite, parity harness, docs - HiveRpcClient: 429/502/503/504 advance to next node immediately (no wasted retry on a throttled node); transient errors retry per failoverThreshold; sticky healthy node; RPC-level errors still surface without failover - Tests (40): dhive crypto golden vectors, RPC failover (rate-limit/timeout/ sticky/all-down), JS coercion semantics (parseFloat/Number/base-units) - Commit the differential parity harness (305 variants, 0 real mismatches) - README + docker-compose for drop-in deployment --- .../EcencyApi.Tests/HiveRpcFailoverTests.cs | 151 ++++++++++ dotnet/EcencyApi.Tests/JsValTests.cs | 109 ++++++++ dotnet/EcencyApi/EcencyApi.csproj | 4 + .../EcencyApi/Infrastructure/HiveRpcClient.cs | 92 ++++-- dotnet/README.md | 140 ++++++++++ dotnet/docker-compose.yml | 53 ++++ dotnet/parity/README.md | 22 ++ dotnet/parity/driver.py | 264 ++++++++++++++++++ dotnet/parity/mock_upstream.py | 118 ++++++++ 9 files changed, 937 insertions(+), 16 deletions(-) create mode 100644 dotnet/EcencyApi.Tests/HiveRpcFailoverTests.cs create mode 100644 dotnet/EcencyApi.Tests/JsValTests.cs create mode 100644 dotnet/README.md create mode 100644 dotnet/docker-compose.yml create mode 100644 dotnet/parity/README.md create mode 100644 dotnet/parity/driver.py create mode 100644 dotnet/parity/mock_upstream.py diff --git a/dotnet/EcencyApi.Tests/HiveRpcFailoverTests.cs b/dotnet/EcencyApi.Tests/HiveRpcFailoverTests.cs new file mode 100644 index 00000000..e1cfbbdd --- /dev/null +++ b/dotnet/EcencyApi.Tests/HiveRpcFailoverTests.cs @@ -0,0 +1,151 @@ +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 + { + 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 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/EcencyApi.csproj b/dotnet/EcencyApi/EcencyApi.csproj index fd8862aa..b22327a5 100644 --- a/dotnet/EcencyApi/EcencyApi.csproj +++ b/dotnet/EcencyApi/EcencyApi.csproj @@ -17,4 +17,8 @@ + + + + diff --git a/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs b/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs index d426b7e6..d86efacc 100644 --- a/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs +++ b/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs @@ -13,13 +13,17 @@ public sealed class HiveRpcClient { private readonly string[] _nodes; private readonly int _timeoutMs; + private readonly int _failoverThreshold; private int _currentIndex; private int _seq; - public HiveRpcClient(string[] nodes, int timeoutMs = 2000) + // timeoutMs 2000 / failoverThreshold 2 mirror the dhive Client options the + // Node service constructs its two clients with. + public HiveRpcClient(string[] nodes, int timeoutMs = 2000, int failoverThreshold = 2) { _nodes = nodes; _timeoutMs = timeoutMs; + _failoverThreshold = Math.Max(1, failoverThreshold); } public sealed class RpcException : Exception @@ -40,13 +44,18 @@ public RpcException(string message) : base(message) { } Exception? lastError = null; - // walk the ring starting from the sticky index; 2 attempts per node + // Walk the ring starting from the sticky index. Transient failures + // (timeout / network / 5xx) get up to `failoverThreshold` attempts on a + // node before advancing; rate-limit / overload responses (429, 503, 502, + // 504) advance IMMEDIATELY — a throttled node will not recover in the + // few ms a local retry would take, so retrying it just wastes budget. + // On success the node becomes sticky so later calls prefer it. for (var n = 0; n < _nodes.Length; n++) { var nodeIndex = (Volatile.Read(ref _currentIndex) + n) % _nodes.Length; var node = _nodes[nodeIndex]; - for (var attempt = 0; attempt < 2; attempt++) + for (var attempt = 0; attempt < _failoverThreshold; attempt++) { try { @@ -56,10 +65,16 @@ public RpcException(string message) : base(message) { } } catch (RpcException) { - // dhive surfaces RPC errors without failover + // dhive surfaces RPC-level errors without failover (a real + // application error, not an unhealthy node). Volatile.Write(ref _currentIndex, nodeIndex); throw; } + catch (NodeUnavailableException e) + { + lastError = e; + if (e.AdvanceImmediately) break; // don't retry a throttled/overloaded node + } catch (Exception e) { lastError = e; @@ -67,9 +82,27 @@ public RpcException(string message) : base(message) { } } } + // 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 Exception? Cause { get; private set; } + public NodeUnavailableException(string message, bool advanceImmediately) : base(message) + => AdvanceImmediately = advanceImmediately; + public NodeUnavailableException WithInner(Exception inner) { Cause = inner; return this; } + } + + // Statuses that mean "this node is throttling/overloaded" — skip it now + // rather than burning a retry that will also be throttled. + private static bool IsOverloadStatus(int status) => + status is 429 or 502 or 503 or 504; + private async Task CallNode(string node, string body) { using var req = new HttpRequestMessage(HttpMethod.Post, node) @@ -78,23 +111,50 @@ public RpcException(string message) : base(message) { } }; using var cts = new CancellationTokenSource(_timeoutMs); - using var resp = await Upstream.Http.SendAsync(req, cts.Token); - - if (!resp.IsSuccessStatusCode) + HttpResponseMessage resp; + try { - throw new HttpRequestException($"RPC node {node} returned {(int)resp.StatusCode}"); + resp = await Upstream.Http.SendAsync(req, cts.Token); } - - var text = await resp.Content.ReadAsStringAsync(cts.Token); - var parsed = JsonNode.Parse(text); - - if (parsed is JsonObject obj && obj.TryGetPropertyValue("error", out var error) && error != null) + catch (OperationCanceledException e) when (cts.IsCancellationRequested) { - var message = error["message"]?.GetValue() ?? error.ToJsonString(); - throw new RpcException(message); + // Timed out — the node is slow; retry once then advance. + 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); } - return parsed?["result"]; + using (resp) + { + if (!resp.IsSuccessStatusCode) + { + var status = (int)resp.StatusCode; + throw new NodeUnavailableException( + $"RPC node {node} returned {status}", advanceImmediately: IsOverloadStatus(status)); + } + + var text = await resp.Content.ReadAsStringAsync(cts.Token); + JsonNode? parsed; + try + { + parsed = JsonNode.Parse(text); + } + catch (System.Text.Json.JsonException e) + { + // A node that returns non-JSON (e.g. an HTML error page) is unhealthy. + 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 -------- diff --git a/dotnet/README.md b/dotnet/README.md new file mode 100644 index 00000000..a6db0de5 --- /dev/null +++ b/dotnet/README.md @@ -0,0 +1,140 @@ +# 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 (dhive parity + rate-limit handling) + +`HiveRpcClient` reproduces the dhive `Client` behavior the Node service relies +on (`timeout: 2000`, `failoverThreshold: 2`) and adds explicit rate-limit +handling: + +- Requests walk the node ring from a **sticky index** (the last node that + answered), so a healthy node keeps serving subsequent calls. +- **Transient failures** (timeout, network error, 5xx, non-JSON body) get up to + `failoverThreshold` attempts on a node before advancing. +- **Rate-limit / overload** responses (429, 502, 503, 504) advance to the next + node **immediately** — a throttled node won't recover in the few ms a local + retry would take. +- **RPC-level errors** (a JSON `error` field) surface without failover — that's + a real application error, not an unhealthy node (matches dhive). + +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 rollover, +sticky node, timeout rollover, 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). + +## 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. This is the single deliberate + behavior change (a latent Node bug); it's recorded in the parity harness. + +## 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. + +## Build & run + +```bash +dotnet build EcencyApi/EcencyApi.csproj +dotnet test EcencyApi.Tests/EcencyApi.Tests.csproj +API_PORT=4000 PRIVATE_API_ADDR=... dotnet run --project EcencyApi + +# container (same image contract as the Node build) +docker build -t ecency/api-csharp -f Dockerfile . +``` + +Environment variables are unchanged from the Node service (see `Config.cs` / +`docker-compose.yml`): `PRIVATE_API_ADDR`, `PRIVATE_API_AUTH`, +`HIVESIGNER_SECRET`, `SEARCH_API_ADDR`, `SEARCH_API_SECRET`, +`STRIPE_INTERNAL_SECRET`, `TURNSTILE_SECRET`, `CAPTCHA_MODE`, +`BLOCKSTREAM_CLIENT_ID/SECRET`, `HELIUS_API_KEY`, +`ETH_RPC_URLS`, `BNB_RPC_URLS`, `SOL_RPC_URLS`, `BTC_ESPLORA_URLS`. 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..e126e3ce --- /dev/null +++ b/dotnet/parity/driver.py @@ -0,0 +1,264 @@ +#!/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.", +} + + +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..d2e5432d --- /dev/null +++ b/dotnet/parity/mock_upstream.py @@ -0,0 +1,118 @@ +#!/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 re +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse, parse_qsl + +LOG_PATH = "/tmp/claude-0/-root/f686fea4-ddd5-474b-b474-899ed779389c/scratchpad/parity/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() From c42b1004be33cd4dd1605d800e623821988240fe Mon Sep 17 00:00:00 2001 From: ecency Date: Sat, 11 Jul 2026 05:16:21 +0000 Subject: [PATCH 4/8] docs(dotnet): add measured Node-vs-C# benchmark results --- dotnet/README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/dotnet/README.md b/dotnet/README.md index a6db0de5..81102326 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -105,6 +105,33 @@ 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 From 4a023aba3788b7783fa4b66efb7e1d0fe7c755e7 Mon Sep 17 00:00:00 2001 From: ecency Date: Sat, 11 Jul 2026 05:30:43 +0000 Subject: [PATCH 5/8] docs(dotnet): full setup/run instructions (SDK, local, Docker, swarm, publish) --- dotnet/README.md | 102 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 91 insertions(+), 11 deletions(-) diff --git a/dotnet/README.md b/dotnet/README.md index 81102326..2e47b60e 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -148,20 +148,100 @@ same CPU budget**. - Duplicate query-string keys are forwarded first-value-wins (Express would array-serialize); none of the proxied endpoints use repeated params. -## Build & run +## 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 -dotnet build EcencyApi/EcencyApi.csproj -dotnet test EcencyApi.Tests/EcencyApi.Tests.csproj -API_PORT=4000 PRIVATE_API_ADDR=... dotnet run --project EcencyApi +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`. -# container (same image contract as the Node build) +```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 ``` -Environment variables are unchanged from the Node service (see `Config.cs` / -`docker-compose.yml`): `PRIVATE_API_ADDR`, `PRIVATE_API_AUTH`, -`HIVESIGNER_SECRET`, `SEARCH_API_ADDR`, `SEARCH_API_SECRET`, -`STRIPE_INTERNAL_SECRET`, `TURNSTILE_SECRET`, `CAPTCHA_MODE`, -`BLOCKSTREAM_CLIENT_ID/SECRET`, `HELIUS_API_KEY`, -`ETH_RPC_URLS`, `BNB_RPC_URLS`, `SOL_RPC_URLS`, `BTC_ESPLORA_URLS`. +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. + +### 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 +``` From a14be94f6e62a0c014f577ea73171018ad36e79a Mon Sep 17 00:00:00 2001 From: ecency Date: Sat, 11 Jul 2026 05:53:45 +0000 Subject: [PATCH 6/8] ci: build, test and deploy the C# service on merge - Gate PRs and deploys on dotnet test - Build dotnet/Dockerfile as ecency/api:latest + ecency/api:sha- (every merge gets a durable rollback tag; deploy stays digest-pinned) - Preserve the last Node build as ecency/api:node-legacy before first overwrite - Drop the Node yarn/build jobs from CI (Node source stays in-repo) - .dockerignore for the dotnet build context; README rollback instructions --- .github/workflows/main.yml | 72 ++++++++++++++++++++++++-------------- dotnet/.dockerignore | 6 ++++ dotnet/README.md | 18 ++++++++++ 3 files changed, 70 insertions(+), 26 deletions(-) create mode 100644 dotnet/.dockerignore diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1fc11913..8c126efa 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. @@ -11,24 +12,24 @@ concurrency: cancel-in-progress: true 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 + - 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 +38,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 +133,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/README.md b/dotnet/README.md index 2e47b60e..6538bef0 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -228,6 +228,24 @@ 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 From 440126b0bcf8caf8e0647e1b2b432a3e14e306cc Mon Sep 17 00:00:00 2001 From: ecency Date: Sat, 11 Jul 2026 06:31:38 +0000 Subject: [PATCH 7/8] fix(dotnet): review fixes + unify Hive RPC node list - Unify HiveClients into one client/node list (hapi.ecency.com decommissioned; also avoids cold-start timeouts on the dead first node) - Express parity: res.send(null) -> empty body without Content-Type - JsJson.FormatNumber: exact ECMA Number::toString thresholds (fixed notation for exponents in (-6,21], scientific outside), shared with JsVal string coercion; verified against 27 V8-generated vectors - JsJson.IsTruthy: handle CLR-backed JsonValues without throwing - express.urlencoded parity: parse form-encoded request bodies - Upstream requests send bare application/json content-type like axios - Dockerfile: run as the aspnet image's non-root app user - parity/mock_upstream.py: portable log path (env-overridable) --- dotnet/Dockerfile | 4 + dotnet/EcencyApi.Tests/HiveCryptoTests.cs | 13 +++ .../fixtures/crypto-vectors.json | 110 ++++++++++++++++++ dotnet/EcencyApi/Handlers/HiveExplorer.cs | 11 +- .../EcencyApi/Infrastructure/HiveRpcClient.cs | 24 +--- .../Infrastructure/HttpContextExtensions.cs | 15 +++ dotnet/EcencyApi/Infrastructure/JsJson.cs | 102 +++++++++++++--- dotnet/EcencyApi/Infrastructure/JsVal.cs | 7 +- dotnet/EcencyApi/Infrastructure/Upstream.cs | 19 ++- dotnet/parity/mock_upstream.py | 5 +- dotnet/tools/gen-vectors.js | 11 ++ 11 files changed, 272 insertions(+), 49 deletions(-) diff --git a/dotnet/Dockerfile b/dotnet/Dockerfile index b80e2d5e..d1252b8a 100644 --- a/dotnet/Dockerfile +++ b/dotnet/Dockerfile @@ -19,6 +19,10 @@ WORKDIR /app 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 diff --git a/dotnet/EcencyApi.Tests/HiveCryptoTests.cs b/dotnet/EcencyApi.Tests/HiveCryptoTests.cs index 91286e07..1b8775c7 100644 --- a/dotnet/EcencyApi.Tests/HiveCryptoTests.cs +++ b/dotnet/EcencyApi.Tests/HiveCryptoTests.cs @@ -131,6 +131,19 @@ public void ValidateCodeReserialization_MatchesNode() } } + [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 diff --git a/dotnet/EcencyApi.Tests/fixtures/crypto-vectors.json b/dotnet/EcencyApi.Tests/fixtures/crypto-vectors.json index 5f840780..d8d0e6f2 100644 --- a/dotnet/EcencyApi.Tests/fixtures/crypto-vectors.json +++ b/dotnet/EcencyApi.Tests/fixtures/crypto-vectors.json @@ -1490,5 +1490,115 @@ "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/Handlers/HiveExplorer.cs b/dotnet/EcencyApi/Handlers/HiveExplorer.cs index 75f4d4b5..094d6b61 100644 --- a/dotnet/EcencyApi/Handlers/HiveExplorer.cs +++ b/dotnet/EcencyApi/Handlers/HiveExplorer.cs @@ -10,10 +10,9 @@ 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.Explorer handles -/// failover across the list, preferring our own node first) instead of routing -/// through an external REST gateway, which is a single point of failure for the -/// portfolio endpoints. +/// 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 { @@ -23,7 +22,7 @@ public static async Task FetchGlobalProps() { try { - var globalDynamic = await HiveClients.Explorer.GetDynamicGlobalProperties(); + var globalDynamic = await HiveClients.Default.GetDynamicGlobalProperties(); if (!JsJson.IsTruthy(globalDynamic)) { @@ -85,7 +84,7 @@ public static async Task GetAccount(string? username) { try { - var data = await HiveClients.Explorer.GetAccounts(new[] { username }); + var data = await HiveClients.Default.GetAccounts(new[] { username }); if (data is { Count: > 0 }) { diff --git a/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs b/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs index d86efacc..73988af7 100644 --- a/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs +++ b/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs @@ -177,10 +177,14 @@ private static bool IsOverloadStatus(int status) => Call("condenser_api", "get_dynamic_global_properties", new JsonArray()); } -/// The two client instances the Node service builds (node lists differ). +/// +/// 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 sticky failover state. +/// public static class HiveClients { - /// private-api.ts client (no hapi.ecency.com; used by validateCode). public static readonly HiveRpcClient Default = new(new[] { "https://api.hive.blog", @@ -194,20 +198,4 @@ public static class HiveClients "https://api.syncad.com", "https://api.c0ff33a.uk", }); - - /// hive-explorer.ts client (prefers our own node first). - public static readonly HiveRpcClient Explorer = new(new[] - { - "https://hapi.ecency.com", - "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 index 2122c7c6..4c7411e2 100644 --- a/dotnet/EcencyApi/Infrastructure/HttpContextExtensions.cs +++ b/dotnet/EcencyApi/Infrastructure/HttpContextExtensions.cs @@ -27,6 +27,21 @@ 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(); diff --git a/dotnet/EcencyApi/Infrastructure/JsJson.cs b/dotnet/EcencyApi/Infrastructure/JsJson.cs index 425216c9..f1f571d5 100644 --- a/dotnet/EcencyApi/Infrastructure/JsJson.cs +++ b/dotnet/EcencyApi/Infrastructure/JsJson.cs @@ -117,29 +117,88 @@ private static void WritePrimitive(StringBuilder sb, JsonValue value) } /// - /// Format a number the way V8's JSON.stringify would after JSON.parse: - /// numbers become doubles, printed in shortest round-trip form; integral - /// values print without a fractional part; exponent (rare) uses "e+"/"e-". + /// 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. /// - private static string FormatNumber(double d) + internal static string FormatNumber(double d) { - if (double.IsNaN(d) || double.IsInfinity(d)) { return "null"; // JSON.stringify(NaN/Infinity) -> null } - // .NET Core 3.0+ default double formatting is shortest round-trippable, - // same algorithm family as V8. Normalize exponent casing/format. - var text = d.ToString("R", CultureInfo.InvariantCulture); + 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; + } + } - if (text.Contains('E')) + 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) { - // .NET: "1E+21" -> JS: "1e+21" - text = text.Replace("E", "e"); + 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 text; + return negative ? "-" + result : result; } private static void WriteString(StringBuilder sb, string s) @@ -203,13 +262,22 @@ public static bool IsTruthy(JsonNode? node) case JsonValue v: if (v.TryGetValue(out var s)) return s.Length > 0; if (v.TryGetValue(out var b)) return b; - var el = v.GetValue(); - if (el.ValueKind == JsonValueKind.Number) + // Parsed values are element-backed; programmatically built ones + // are CLR-backed and would throw on GetValue(). + if (v.TryGetValue(out var el)) { - var d = el.GetDouble(); - return d != 0 && !double.IsNaN(d); + if (el.ValueKind == JsonValueKind.Number) + { + var d = el.GetDouble(); + return d != 0 && !double.IsNaN(d); + } + return el.ValueKind != JsonValueKind.Null; } - 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 index 12f2830b..32d3afeb 100644 --- a/dotnet/EcencyApi/Infrastructure/JsVal.cs +++ b/dotnet/EcencyApi/Infrastructure/JsVal.cs @@ -195,14 +195,13 @@ public static string ToJsString(JsonNode? n) } } - /// Number -> string the way JS does (shortest round-trip, integral without ".0"). + /// 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"; - var text = d.ToString("R", CultureInfo.InvariantCulture); - if (text.Contains('E')) text = text.Replace("E", "e"); - return text; + return JsJson.FormatNumber(d); } } diff --git a/dotnet/EcencyApi/Infrastructure/Upstream.cs b/dotnet/EcencyApi/Infrastructure/Upstream.cs index 1bf79ccc..77cd8537 100644 --- a/dotnet/EcencyApi/Infrastructure/Upstream.cs +++ b/dotnet/EcencyApi/Infrastructure/Upstream.cs @@ -93,7 +93,11 @@ public static async Task BaseApiRequest( using var req = new HttpRequestMessage(method, finalUrl); var bodyJson = payload?.ToJsonString(RawJsonOptions) ?? "{}"; - req.Content = new StringContent(bodyJson, Encoding.UTF8, "application/json"); + 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) { @@ -240,6 +244,15 @@ public static async Task SendLikeExpress(HttpContext ctx, int status, JsonNode? 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(); @@ -258,8 +271,8 @@ public static async Task SendLikeExpress(HttpContext ctx, int status, JsonNode? } } - // Objects, arrays, booleans, null -> res.json() + // Objects, arrays, booleans -> res.json() ctx.Response.ContentType = "application/json; charset=utf-8"; - await ctx.Response.WriteAsync(json?.ToJsonString(RawJsonOptions) ?? "null"); + await ctx.Response.WriteAsync(json!.ToJsonString(RawJsonOptions)); } } diff --git a/dotnet/parity/mock_upstream.py b/dotnet/parity/mock_upstream.py index d2e5432d..6cf68a9d 100644 --- a/dotnet/parity/mock_upstream.py +++ b/dotnet/parity/mock_upstream.py @@ -8,12 +8,15 @@ 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 = "/tmp/claude-0/-root/f686fea4-ddd5-474b-b474-899ed779389c/scratchpad/parity/upstream-log.jsonl" +LOG_PATH = os.environ.get( + "VAPI_UPSTREAM_LOG", str(Path(__file__).resolve().parent / "upstream-log.jsonl")) LOCK = threading.Lock() diff --git a/dotnet/tools/gen-vectors.js b/dotnet/tools/gen-vectors.js index fab6618f..3f14c6dc 100644 --- a/dotnet/tools/gen-vectors.js +++ b/dotnet/tools/gen-vectors.js @@ -110,4 +110,15 @@ out.validateCodeRaw = tokenJsons.map((t) => { 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)); From 7a8974713f89cea4f390a098a6ef889e365b421e Mon Sep 17 00:00:00 2001 From: ecency Date: Sat, 11 Jul 2026 07:27:39 +0000 Subject: [PATCH 8/8] fix: dead-endpoint cleanup, request-delete stub, SDK-adopted node failover, CI hardening - Remove post-reblogs/post-reblog-count (both impls): no callers, no traffic, and they read route params their POST routes never declared, so every call queried undefined/undefined upstream - Wire request-delete to its account-deletion acknowledgment stub (both impls): Hive accounts cannot be deleted on-chain; the endpoint satisfies the app-store requirement, but the old table sent it to the report handler whose validation 400'd the mobile payload. In the Node build the companion hs-token-refresh missing-code bug crashes the process outright (unhandled rejection), so bad requests bounce the service; the C# port is immune - HiveRpcClient: adopt @ecency/sdk NodeHealthTracker design (simplified): per-node health state, 429 parking with Retry-After + escalating windows, recent-failure deprioritization, latency-EWMA best-first ordering with unproven prior; RPC-level errors still surface without failover - CI: least-privilege permissions, no credential persistence on PR checkouts - Parity: 301 cases, 0 unexplained mismatches; intentional changes recorded in the harness; 42 unit tests --- .github/workflows/main.yml | 8 + .../EcencyApi.Tests/HiveRpcFailoverTests.cs | 34 +++ dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs | 28 +- dotnet/EcencyApi/Handlers/Routes.cs | 10 +- .../EcencyApi/Infrastructure/HiveRpcClient.cs | 244 +++++++++++++++--- dotnet/README.md | 58 +++-- dotnet/parity/driver.py | 7 + src/server/handlers/private-api.ts | 10 - src/server/index.tsx | 7 +- 9 files changed, 327 insertions(+), 79 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8c126efa..b5cf252c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -11,6 +11,11 @@ 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: # Gate every PR and every deploy on the .NET test suite (crypto golden vectors, # RPC failover, JS-semantics parity helpers). @@ -18,6 +23,9 @@ jobs: runs-on: ubuntu-24.04 steps: - 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 diff --git a/dotnet/EcencyApi.Tests/HiveRpcFailoverTests.cs b/dotnet/EcencyApi.Tests/HiveRpcFailoverTests.cs index e1cfbbdd..326eb244 100644 --- a/dotnet/EcencyApi.Tests/HiveRpcFailoverTests.cs +++ b/dotnet/EcencyApi.Tests/HiveRpcFailoverTests.cs @@ -54,6 +54,15 @@ private async Task Loop() 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"); @@ -137,6 +146,31 @@ public async Task TimeoutNode_FailsOverToHealthyNode() 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() { diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs index 135cb37f..09e2344f 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs @@ -239,22 +239,20 @@ public static async Task Report(HttpContext ctx) await Upstream.Pipe(ApiClient.ApiRequest("report", HttpMethod.Post, null, data), ctx); } - public static async Task Reblogs(HttpContext 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) { - // Route is wired as POST /private-api/post-reblogs with no :author/:permlink - // params, so req.params is empty in Node and the upstream path is literally - // "post-reblogs/undefined/undefined" — replicated as-is. - var author = MiscRouteParam(ctx, "author"); - var permlink = MiscRouteParam(ctx, "permlink"); - await Upstream.Pipe(ApiClient.ApiRequest($"post-reblogs/{author}/{permlink}", HttpMethod.Get), ctx); - } - - public static async Task ReblogCount(HttpContext ctx) - { - // Same empty-req.params situation as Reblogs (POST route without params). - var author = MiscRouteParam(ctx, "author"); - var permlink = MiscRouteParam(ctx, "permlink"); - await Upstream.Pipe(ApiClient.ApiRequest($"post-reblog-count/{author}/{permlink}", HttpMethod.Get), ctx); + await ctx.SendJson(200, new JsonObject + { + ["status"] = 200, + ["body"] = new JsonObject { ["status"] = "ok" }, + }); } public static async Task Tips(HttpContext ctx) diff --git a/dotnet/EcencyApi/Handlers/Routes.cs b/dotnet/EcencyApi/Handlers/Routes.cs index 0ecfbbdc..347a6193 100644 --- a/dotnet/EcencyApi/Handlers/Routes.cs +++ b/dotnet/EcencyApi/Handlers/Routes.cs @@ -84,9 +84,13 @@ public static void Map(WebApplication app) app.MapPost("/private-api/subscribe", PrivateApi.SubscribeNewsletter); app.MapPost("/private-api/notifications", PrivateApi.Notifications); app.MapPost("/private-api/report", PrivateApi.Report); - app.MapPost("/private-api/request-delete", PrivateApi.Report); - app.MapPost("/private-api/post-reblogs", PrivateApi.Reblogs); - app.MapPost("/private-api/post-reblog-count", PrivateApi.ReblogCount); + // 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); diff --git a/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs b/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs index 73988af7..2bd6e036 100644 --- a/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs +++ b/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs @@ -1,29 +1,74 @@ +using System.Globalization; using System.Text; using System.Text.Json.Nodes; namespace EcencyApi.Infrastructure; /// -/// Minimal dhive-Client equivalent: JSON-RPC over HTTP against a preference- -/// ordered node list with sticky failover (timeout 2000ms, failoverThreshold 2 — -/// two failed attempts on a node advance to the next; RPC-level errors surface -/// immediately and do not fail over, matching dhive). +/// 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 int _currentIndex; + private readonly NodeHealth[] _health; + private readonly object _lock = new(); private int _seq; // timeoutMs 2000 / failoverThreshold 2 mirror the dhive Client options the - // Node service constructs its two clients with. + // 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 @@ -31,6 +76,109 @@ 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 @@ -44,40 +192,46 @@ public RpcException(string message) : base(message) { } Exception? lastError = null; - // Walk the ring starting from the sticky index. Transient failures - // (timeout / network / 5xx) get up to `failoverThreshold` attempts on a - // node before advancing; rate-limit / overload responses (429, 503, 502, - // 504) advance IMMEDIATELY — a throttled node will not recover in the - // few ms a local retry would take, so retrying it just wastes budget. - // On success the node becomes sticky so later calls prefer it. - for (var n = 0; n < _nodes.Length; n++) + foreach (var nodeIndex in OrderedNodeIndices()) { - var nodeIndex = (Volatile.Read(ref _currentIndex) + n) % _nodes.Length; var node = _nodes[nodeIndex]; for (var attempt = 0; attempt < _failoverThreshold; attempt++) { + var started = NowMs; try { var result = await CallNode(node, body); - Volatile.Write(ref _currentIndex, nodeIndex); + RecordSuccess(nodeIndex, NowMs - started); return result; } catch (RpcException) { - // dhive surfaces RPC-level errors without failover (a real - // application error, not an unhealthy node). - Volatile.Write(ref _currentIndex, nodeIndex); + // 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.AdvanceImmediately) break; // don't retry a throttled/overloaded node + 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); } } } @@ -92,16 +246,42 @@ public RpcException(string message) : base(message) { } 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) : base(message) - => AdvanceImmediately = advanceImmediately; + + 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; } } - // Statuses that mean "this node is throttling/overloaded" — skip it now - // rather than burning a retry that will also be throttled. - private static bool IsOverloadStatus(int status) => - status is 429 or 502 or 503 or 504; + // 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) { @@ -118,7 +298,6 @@ private static bool IsOverloadStatus(int status) => } catch (OperationCanceledException e) when (cts.IsCancellationRequested) { - // Timed out — the node is slow; retry once then advance. throw new NodeUnavailableException($"RPC node {node} timed out", advanceImmediately: false).WithInner(e); } catch (HttpRequestException e) @@ -131,8 +310,16 @@ private static bool IsOverloadStatus(int status) => 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)); + $"RPC node {node} returned {status}", + advanceImmediately: IsOverloadStatus(status), + isRateLimit: status == 429, + retryAfterMs: retryAfter); } var text = await resp.Content.ReadAsStringAsync(cts.Token); @@ -143,7 +330,6 @@ private static bool IsOverloadStatus(int status) => } catch (System.Text.Json.JsonException e) { - // A node that returns non-JSON (e.g. an HTML error page) is unhealthy. throw new NodeUnavailableException($"RPC node {node} returned non-JSON", advanceImmediately: false).WithInner(e); } @@ -181,7 +367,7 @@ private static bool IsOverloadStatus(int status) => /// 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 sticky failover state. +/// one client with a single shared health state. /// public static class HiveClients { diff --git a/dotnet/README.md b/dotnet/README.md index 6538bef0..545e734f 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -43,27 +43,36 @@ dotnet/ Dockerfile drop-in image build ``` -## Node failover (dhive parity + rate-limit handling) - -`HiveRpcClient` reproduces the dhive `Client` behavior the Node service relies -on (`timeout: 2000`, `failoverThreshold: 2`) and adds explicit rate-limit -handling: - -- Requests walk the node ring from a **sticky index** (the last node that - answered), so a healthy node keeps serving subsequent calls. -- **Transient failures** (timeout, network error, 5xx, non-JSON body) get up to - `failoverThreshold` attempts on a node before advancing. -- **Rate-limit / overload** responses (429, 502, 503, 504) advance to the next - node **immediately** — a throttled node won't recover in the few ms a local - retry would take. -- **RPC-level errors** (a JSON `error` field) surface without failover — that's - a real application error, not an unhealthy node (matches dhive). +## 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 rollover, -sticky node, timeout rollover, all-down error). +Covered by `EcencyApi.Tests/HiveRpcFailoverTests.cs` (rate-limit parking, +failure deprioritization, timeout rollover, EWMA demotion, all-down error). ## Verifying identical responses @@ -137,8 +146,19 @@ same CPU budget**. - **`/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. This is the single deliberate - behavior change (a latent Node bug); it's recorded in the parity harness. + 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) diff --git a/dotnet/parity/driver.py b/dotnet/parity/driver.py index e126e3ce..0fe2b6ff 100644 --- a/dotnet/parity/driver.py +++ b/dotnet/parity/driver.py @@ -210,6 +210,13 @@ def norm_body(text): "/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.", } 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)