Skip to content

Commit 80fa164

Browse files
authored
Merge pull request #54 from ecency/fix/surrogate-safe-serialization
Surrogate-safe JSON serialization for payloads, responses, and RPC bodies
2 parents 1b94ce5 + b9b3106 commit 80fa164

4 files changed

Lines changed: 16 additions & 16 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ Handlers are `public static async Task Name(HttpContext ctx)` methods on static
6161
4. **Upstream requests always carry a `{}` JSON body** (even GETs) with bare `application/json` content type — this matches what upstreams have always received (verified against recorded production traffic). Review bots repeatedly flag this; it is correct.
6262
5. **`get_accounts` with a missing username must serialize as JSON `[null]`**, never the string `"null"``@null` is a real Hive account.
6363
6. **No hot-path logging.** The service defaults to Warning level and writes ~3 lines at startup; container logs are size-capped but must stay quiet. Don't add `Console.WriteLine` or Information-level logging to request paths.
64+
7. **Numbers are JavaScript doubles end to end — raw-literal preservation is NOT a goal.** The Node service parsed every request body and upstream response with `JSON.parse` (doubles) and re-emitted with `JSON.stringify`, so integers above 2^53 have always rounded on these paths (e.g. `18446744073709551615``18446744073709552000`). `JsJson.Stringify` reproduces this byte-for-byte on purpose. Do not flag double-rounding on payload/response serialization as precision loss, and do not "fix" it by preserving raw literals — either direction breaks parity. The single deliberate exception is the Solana lamports raw-text scan (`PrivateApi.Chain`), which keeps `ToJsonString` because it extracts a u64 from raw text before any parse.
65+
8. **Lone-surrogate `\u` escapes are valid input and output.** JavaScript strings are arbitrary UTF-16, so `JSON.parse`/`JSON.stringify` accept and re-emit lone surrogates; System.Text.Json throws on them in BOTH directions (string materialization AND the writer, regardless of encoder). All string extraction must go through `JsVal.TryGetStringLenient` and all tree serialization of client/upstream data through `JsJson.Stringify` — never raw `GetValue<string>`/`TryGetValue<string>` or `ToJsonString` on such data. Suggesting a switch back to the strict System.Text.Json APIs reintroduces production 500s.
6466

6567
## Hive RPC failover
6668

dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,9 @@ private List<int> OrderedNodeIndices()
188188
["method"] = "call",
189189
["params"] = new JsonArray(api, method, @params),
190190
};
191-
var body = request.ToJsonString();
191+
// JsJson: a lone-surrogate username from a client token must serialize
192+
// (JSON.stringify semantics) instead of throwing in the writer.
193+
var body = JsJson.Stringify(request);
192194

193195
Exception? lastError = null;
194196

dotnet/EcencyApi/Infrastructure/HttpContextExtensions.cs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,19 +77,16 @@ public static async Task SendText(this HttpContext ctx, int status, string text)
7777
await ctx.Response.WriteAsync(text);
7878
}
7979

80-
/// <summary>res.status(code).send(obj) / res.json(obj).</summary>
80+
/// <summary>res.status(code).send(obj) / res.json(obj). Serialized with
81+
/// JsJson (JSON.stringify parity; tolerates lone surrogates that
82+
/// System.Text.Json's writer throws on).</summary>
8183
public static async Task SendJson(this HttpContext ctx, int status, JsonNode? node)
8284
{
8385
ctx.Response.StatusCode = status;
8486
ctx.Response.ContentType = "application/json; charset=utf-8";
85-
await ctx.Response.WriteAsync(node?.ToJsonString(JsonOpts) ?? "null");
87+
await ctx.Response.WriteAsync(node is null ? "null" : JsJson.Stringify(node));
8688
}
8789

88-
private static readonly JsonSerializerOptions JsonOpts = new()
89-
{
90-
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
91-
};
92-
9390
/// <summary>Convenience: string body field (undefined -> null).</summary>
9491
public static string? Str(this JsonObject body, string key) =>
9592
body.TryGetPropertyValue(key, out var v) && v is JsonValue val && JsVal.TryGetStringLenient(val, out var s)

dotnet/EcencyApi/Infrastructure/Upstream.cs

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,10 @@ public static async Task<UpstreamResponse> BaseApiRequest(
9292

9393
using var req = new HttpRequestMessage(method, finalUrl);
9494

95-
var bodyJson = payload?.ToJsonString(RawJsonOptions) ?? "{}";
95+
// JsJson.Stringify (not ToJsonString): System.Text.Json's writer throws on
96+
// lone-surrogate strings that JS handles fine, and JsJson byte-matches the
97+
// JSON.stringify output axios sent upstream.
98+
var bodyJson = payload is null ? "{}" : JsJson.Stringify(payload);
9699
req.Content = new StringContent(bodyJson, Encoding.UTF8);
97100
// axios sends bare "application/json" (no charset); keep upstream
98101
// requests byte-identical to the Node service.
@@ -155,11 +158,6 @@ public static async Task<UpstreamResponse> BaseApiRequest(
155158
}
156159
}
157160

158-
private static readonly JsonSerializerOptions RawJsonOptions = new()
159-
{
160-
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
161-
};
162-
163161
public static string AppendQuery(string url, IEnumerable<KeyValuePair<string, string?>>? query)
164162
{
165163
if (query == null)
@@ -274,8 +272,9 @@ public static async Task SendLikeExpress(HttpContext ctx, int status, JsonNode?
274272
}
275273
}
276274

277-
// Objects, arrays, booleans -> res.json()
275+
// Objects, arrays, booleans -> res.json(); JsJson matches JSON.stringify
276+
// (and tolerates lone surrogates that ToJsonString throws on)
278277
ctx.Response.ContentType = "application/json; charset=utf-8";
279-
await ctx.Response.WriteAsync(json!.ToJsonString(RawJsonOptions));
278+
await ctx.Response.WriteAsync(JsJson.Stringify(json));
280279
}
281280
}

0 commit comments

Comments
 (0)