using Microsoft.Data.Sqlite; using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.RegularExpressions; try { var c = V.Config(); var action = args.FirstOrDefault() ?? "help"; if (action == "selftest") { V.Selftest(); return; } if (action == "serve") { var builder = WebApplication.CreateBuilder(Array.Empty()); builder.Logging.ClearProviders(); builder.WebHost.UseUrls("http://127.0.0.1:" + (args.ElementAtOrDefault(1) ?? "8083")); builder.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = 16384); var app = builder.Build(); app.MapPost("/notify", async (HttpRequest req) => { try { if (!(req.ContentType ?? "").StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)) return Results.Text("fail", statusCode: 400); var form = await req.ReadFormAsync(); if (form.Any(x => x.Value.Count != 1)) return Results.Text("fail", statusCode: 400); using var d = V.Db(c); V.Apply(d, form.ToDictionary(x => x.Key, x => x.Value[0]!), c["PAY_KEY"]); return Results.Text("success"); } catch (InvalidDataException) { return Results.Text("fail", statusCode: 400); } catch { return Results.Text("fail", statusCode: 503); } }); Console.WriteLine("Local /notify; use HTTPS proxy. Run inspect to view verified records."); await app.RunAsync(); return; } using var db = V.Db(c); object result; if (action == "inspect") result = V.Inspect(db); else if (action == "balance") result = await V.Request("querybalance", new(), c); else if (action is "create" or "demo") { V.Check(!c["PAY_NOTIFY_URL"].Contains("YOUR-MERCHANT-HOST"), "Set PAY_NOTIFY_URL to your HTTPS receiver first"); V.Check(new[] { "2911", "2912", "2913", "2901", "2902", "2903" }.Contains(c["PAY_PTYPE"]), "Invalid channel"); V.Units(c["PAY_MONEY"], 2); var n = args.ElementAtOrDefault(1) ?? "CV2" + Guid.NewGuid().ToString("N")[..26]; V.Exec(db, "INSERT INTO orders(sh_order,money,ptype) VALUES (@p0,@p1,@p2)", n, c["PAY_MONEY"], c["PAY_PTYPE"]); Console.WriteLine("Saved merchant order: " + n); var r = await V.Request("index", new() { ["ptype"] = c["PAY_PTYPE"], ["money"] = c["PAY_MONEY"], ["order_sn"] = n, ["goods_desc"] = "", ["client_ip"] = "", ["format"] = "json", ["notify_url"] = c["PAY_NOTIFY_URL"], ["crypto_callback_version"] = "2" }, c); V.Save(db, n, r, false); if (action == "demo") r["query_result"] = await V.Request("query", new() { ["out_order_sn"] = n }, c); result = r; } else if (action == "query") { var n = args[1]; var r = await V.Request("query", new() { ["out_order_sn"] = n }, c); V.Save(db, n, r, true); result = r; } else { Console.WriteLine("selftest | balance | demo | create [ORDER] | query ORDER | serve [PORT] | inspect"); return; } Console.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); } catch (Exception e) { Console.Error.WriteLine("ERROR: " + e.Message + "; query saved order before retrying creation."); Environment.ExitCode = 1; } static class V { public static readonly string Root = FindRoot(); static string FindRoot() { if (Environment.GetEnvironmentVariable("PAY_CRYPTO_ROOT") is string root) return root; for (var p = new DirectoryInfo(Directory.GetCurrentDirectory()); p != null; p = p.Parent) { if (File.Exists(Path.Combine(p.FullName, "schema.sql"))) return p.FullName; if (File.Exists(Path.Combine(p.FullName, "crypto-v2/schema.sql"))) return Path.Combine(p.FullName, "crypto-v2"); } throw new Exception("Run from crypto-v2 directory"); } static readonly string[] Fields = "callback_version event_id event_seq status mch_id pt_order sh_order money pay_money goods_desc time ptype network is_testnet asset address decimals expected_amount_raw amount_raw tx_hash event_index payment_detected confirmed expires_at reason sign".Split(' '); static readonly string[] Identity = "mch_id pt_order sh_order money pay_money ptype network is_testnet asset address decimals expected_amount_raw".Split(' '); public static void Check(bool condition, string message) { if (!condition) throw new InvalidDataException(message); } public static Dictionary Config() { var c = JsonSerializer.Deserialize>(File.ReadAllText(Path.Combine(Root, "config.json")))!; foreach (var k in c.Keys.Append("PAY_STATE_DIR").ToArray()) if (Environment.GetEnvironmentVariable(k) is string s) c[k] = s; return c; } public static SqliteConnection Db(Dictionary c) { var dir = c.GetValueOrDefault("PAY_STATE_DIR", Path.Combine(Root, ".state/csharp")); Directory.CreateDirectory(dir); var d = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = Path.Combine(dir, "orders.sqlite"), DefaultTimeout = 5 }.ToString()); d.Open(); Exec(d, File.ReadAllText(Path.Combine(Root, "schema.sql"))); return d; } static SqliteCommand Cmd(SqliteConnection d, string sql, params object[] values) { var q = d.CreateCommand(); q.CommandText = sql; for (int i = 0; i < values.Length; i++) q.Parameters.AddWithValue("@p" + i, values[i]); return q; } public static void Exec(SqliteConnection d, string sql, params object[] values) { using var q = Cmd(d, sql, values); q.ExecuteNonQuery(); } static List> Rows(SqliteConnection d, string sql, params object[] values) { using var q = Cmd(d, sql, values); using var r = q.ExecuteReader(); var list = new List>(); while (r.Read()) { var row = new Dictionary(); for (int i = 0; i < r.FieldCount; i++) row[r.GetName(i)] = r.IsDBNull(i) ? null : r.GetValue(i); list.Add(row); } return list; } static string S(Dictionary row, string k) => Convert.ToString(row[k], CultureInfo.InvariantCulture) ?? ""; static string Canon(Dictionary p) => string.Join("&", p.Where(x => x.Key != "sign").OrderBy(x => x.Key, StringComparer.Ordinal).Select(x => x.Key + "=" + x.Value)); public static string Sign(Dictionary p, string key) => Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(Canon(p) + "&key=" + key))).ToLowerInvariant(); public static string Units(string value, int decimals) { Check(Regex.IsMatch(value, @"\A(0|[1-9][0-9]*)(\.[0-9]+)?\z") && decimals >= 0 && decimals <= 36, "Invalid amount"); var a = value.Split('.'); var f = a.Length > 1 ? a[1] : ""; Check(f.Length <= decimals, "Too many decimals"); var n = (a[0] + f.PadRight(decimals, '0')).TrimStart('0'); return n == "" ? "0" : n; } public static void Save(SqliteConnection d, string n, JsonNode response, bool query) { var local = Rows(d, "SELECT * FROM orders WHERE sh_order=@p0", n).SingleOrDefault(); Check(local != null, "Unknown local order"); var data = response["data"]!; if (query) { Check(data["out_order_sn"]!.ToString() == n && data["orders"]!.AsArray().Count == 1, "Ambiguous query"); data = data["orders"]![0]!; } var c = data["crypto"]!; Check(c["callback_version"]?.ToString() == "2", "Order did not enable v2 callbacks"); var pt = (query ? c["ptype"] : data["ptype"])!.ToString(); Check(pt == S(local!, "ptype") && Units(data["money"]!.ToString(), 2) == Units(S(local!, "money"), 2) && Units(S(local!, "money"), int.Parse(c["decimals"]!.ToString())) == c["expected_units"]!.ToString(), "Response mismatch"); var e = new Dictionary(); foreach (var k in new[] { "network", "is_testnet", "asset", "address", "decimals" }) e[k] = c[k]!.ToString(); e["mch_id"] = c["merchant_id"]!.ToString(); e["pt_order"] = data["order_sn"]!.ToString(); e["sh_order"] = n; e["money"] = data["money"]!.ToString(); e["pay_money"] = (data["pay_money"] ?? data["money"])!.ToString(); e["ptype"] = pt; e["expected_amount_raw"] = c["expected_units"]!.ToString(); Check(S(local!, "expected") == "" || JsonNode.DeepEquals(JsonNode.Parse(S(local!, "expected")), JsonSerializer.SerializeToNode(e)), "Saved snapshot cannot change"); Exec(d, "UPDATE orders SET expected=@p0 WHERE sh_order=@p1", JsonSerializer.Serialize(e), n); } public static void Apply(SqliteConnection d, Dictionary p, string key) { Check(Fields.ToHashSet(StringComparer.Ordinal).SetEquals(p.Keys) && p.Values.All(x => x != null), "Invalid fields"); Check(Regex.IsMatch(p["sign"], @"\A[a-f0-9]{32}\z") && CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(p["sign"]), Encoding.ASCII.GetBytes(Sign(p, key))), "Invalid signature"); Check(p["callback_version"] == "2" && Regex.IsMatch(p["event_id"], @"\Acn_[a-f0-9]{32}\z") && Regex.IsMatch(p["event_seq"], @"\A[1-9][0-9]{0,9}\z") && long.Parse(p["event_seq"]) <= 2147483647, "Invalid event"); var seq = int.Parse(p["event_seq"]); var status = p["status"]; Check(new[] { "expired", "confirming", "confirmed" }.Contains(status) && new[] { "0", "1" }.Contains(p["payment_detected"]) && p["confirmed"] == (status == "confirmed" ? "1" : "0") && (status == "expired" || p["payment_detected"] == "1"), "Invalid phase"); if (p["payment_detected"] == "1") Check(p["amount_raw"] == p["expected_amount_raw"] && p["tx_hash"] != "" && p["event_index"] != "", "Transfer mismatch"); else Check(p["amount_raw"] == "" && p["tx_hash"] == "" && p["event_index"] == "", "Unexpected transfer"); var stream = p["reason"] == "test_callback" ? "test" : "payment"; var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(Canon(p)))).ToLowerInvariant(); Exec(d, "BEGIN IMMEDIATE"); try { var order = Rows(d, "SELECT expected FROM orders WHERE sh_order=@p0", p["sh_order"]).SingleOrDefault(); Check(order != null && S(order, "expected") != "", "Unknown order"); var e = JsonSerializer.Deserialize>(S(order!, "expected"))!; Check(Identity.All(k => e.GetValueOrDefault(k) == p[k]), "Order mismatch"); var old = Rows(d, "SELECT digest FROM events WHERE event_id=@p0", p["event_id"]).SingleOrDefault(); if (old != null) { Check(S(old, "digest") == digest, "Conflicting replay"); Exec(d, "COMMIT"); return; } Check(Rows(d, "SELECT 1 FROM events WHERE sh_order=@p0 AND stream=@p1 AND seq=@p2", p["sh_order"], stream, seq).Count == 0, "Conflicting sequence"); Exec(d, "INSERT OR IGNORE INTO states(sh_order,stream) VALUES (@p0,@p1)", p["sh_order"], stream); var previous = Rows(d, "SELECT seq,status FROM states WHERE sh_order=@p0 AND stream=@p1", p["sh_order"], stream).Single(); var prevSeq = int.Parse(S(previous, "seq")); Check(!(seq > prevSeq && S(previous, "status") == "confirmed" && status != "confirmed"), "State regression"); Exec(d, "INSERT INTO events VALUES (@p0,@p1,@p2,@p3,@p4)", p["event_id"], p["sh_order"], stream, seq, digest); if (seq > prevSeq) { Exec(d, "UPDATE states SET seq=@p0,status=@p1 WHERE sh_order=@p2 AND stream=@p3", seq, status, p["sh_order"], stream); if (status == "confirmed") Exec(d, "INSERT OR IGNORE INTO outbox VALUES (@p0,@p1,@p2)", p["sh_order"], stream, p["event_id"]); } // Persist business intent atomically. The external worker must also deduplicate. Exec(d, "COMMIT"); } catch { Exec(d, "ROLLBACK"); throw; } } public static Dictionary Inspect(SqliteConnection d) => new[] { "states", "events", "outbox" }.ToDictionary(t => t, t => (object)Rows(d, "SELECT * FROM " + t)); public static void Selftest() { var f = JsonNode.Parse(File.ReadAllText(Path.Combine(Root, "fixtures.json")))!; var dir = Path.Combine(Path.GetTempPath(), "crypto-v2-" + Guid.NewGuid().ToString("N")); try { using (var d = Db(new() { ["PAY_STATE_DIR"] = dir })) { foreach (var node in f["orders"]!.AsArray()) { var e = node!.Deserialize>()!; Exec(d, "INSERT INTO orders VALUES (@p0,@p1,@p2,@p3)", e["sh_order"], e["money"], e["ptype"], JsonSerializer.Serialize(e)); } foreach (var node in f["cases"]!.AsArray()) { var test = node!; var before = JsonSerializer.SerializeToNode(Inspect(d)); bool ok; try { Apply(d, test["payload"]!.Deserialize>()!, f["key"]!.ToString()); ok = true; } catch (InvalidDataException) { ok = false; } Check(ok == test["accept"]!.GetValue() && (ok || JsonNode.DeepEquals(before, JsonSerializer.SerializeToNode(Inspect(d)))), test["name"]!.ToString()); } Check(JsonNode.DeepEquals(JsonSerializer.SerializeToNode(Inspect(d)), f["final"]), "Persistence mismatch"); foreach (var pt in new[]{"2911", "2912", "2913"}) { var created=JsonNode.Parse(File.ReadAllText(Path.Combine(Root, "responses/"+pt+"-create.json")))!; var queried=JsonNode.Parse(File.ReadAllText(Path.Combine(Root, "responses/"+pt+"-query.json")))!; var number=queried["data"]!["out_order_sn"]!.ToString(); Exec(d,"INSERT INTO orders(sh_order,money,ptype) VALUES (@p0,@p1,@p2)",number,"1.00",pt); Save(d,number,created,false); Save(d,number,queried,true); } Console.WriteLine("PASS " + f["cases"]!.AsArray().Count + " callback cases, rollback, state and outbox"); } } finally { SqliteConnection.ClearAllPools(); if (Directory.Exists(dir)) Directory.Delete(dir, true); } } public static async Task Request(string action, Dictionary p, Dictionary c) { var url = new Uri(c["PAY_GATEWAY"]); Check(url.Scheme == "https" && url.Query == "" && url.Fragment == "" && url.UserInfo == "", "HTTPS gateway required"); p["mch_id"] = c["PAY_MCH_ID"]; p["time"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); p["sign"] = Sign(p, c["PAY_KEY"]); using var http = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) { Timeout = TimeSpan.FromSeconds(25) }; using var reply = await http.PostAsync(url.AbsoluteUri + "?c=Pay&a=" + action, new FormUrlEncodedContent(p)); var r = JsonNode.Parse(await reply.Content.ReadAsStringAsync())!; Check((int)reply.StatusCode == 200 && r["code"]!.ToString() == "1", "HTTP " + (int)reply.StatusCode + ": " + r["msg"]); return r; } }