// Aere.Cloud - the official .NET client for the Aere Cloud API (Aere Network, chain 2800). // // Same routes and semantics as @aere/cloud (JS), aere-cloud (Python), aere-cloud (Java) and the Go client, 1.7.0. No dependency beyond // the .NET base library. JSON routes return the server's JSON as a JsonElement, so the integrator binds it to whatever types they // already use; the two routes that return FILES (the audit export and the check of a kept export) return typed results with the // digest computed locally from the bytes received. Non-2xx answers are thrown as AereCloudException (HTTP status, server body, path). // // Nota interna: comentariile de implementare sunt in romana fara diacritice, ca in celelalte SDK-uri ale casei; documentatia publica // (comentariile XML ale membrilor publici) e in engleza. using System.Net; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace Aere.Cloud; /// A non-2xx answer from Aere Cloud. 0 means the request never got an answer. public sealed class AereCloudException : Exception { /// HTTP status (0 when there was no answer). public int Status { get; } /// The server's body, as text. public string Body { get; } /// The API path that failed. public string Path { get; } /// Creates the exception. public AereCloudException(int status, string body, string path) : base($"Aere Cloud {status} on {path}: {(string.IsNullOrEmpty(body) ? "request failed" : body)}") { Status = status; Body = body; Path = path; } } /// Options for . public sealed record PerimeterOptions(IReadOnlyList? Hosts = null, bool? Discover = null, bool Attest = false); /// An audit day as a file. For jsonl, says whether the sha256 of the bytes RECEIVED equals the stated /// digest (computed here, not taken on trust); for cef it is null, because the digest is the one of the source file. public sealed record AuditExport(string? Day, string Format, string? Digest, byte[] Bytes, int Entries, bool? Verified) { /// The bytes as UTF-8 text. public string Text => Encoding.UTF8.GetString(Bytes); } /// Does an export you KEPT still match the day we hold ("held"), or the tombstone a deleted day left ("expired")? public sealed record KeptCheck(string Day, string State, string Digest, string Local, bool Matches, string? DeletedAt); /// One Aere Cloud gateway with one API key (ak2800.<address>.<secret>). public sealed class AereCloudClient : IDisposable { /// Version of this client, sent in the User-Agent (the edge refuses generic library agents on every route). public const string Version = "1.7.0"; /// The production gateway. public const string DefaultBaseUrl = "https://cloud.aere.network/v1"; private readonly string _apiKey; private readonly string _baseUrl; private readonly HttpClient _http; /// A client for (default: production). The API key is required. A test may pass its own /// ; the default one does NOT follow redirects, so the key never travels to a host named in a Location. public AereCloudClient(string apiKey, string? baseUrl = null, HttpMessageHandler? handler = null) { if (string.IsNullOrEmpty(apiKey)) throw new ArgumentException("Aere.Cloud: apiKey is required (ak2800.
.)", nameof(apiKey)); _apiKey = apiKey; _baseUrl = (baseUrl ?? DefaultBaseUrl).TrimEnd('/'); // fara redirectari urmate: un 3xx e un raspuns (AereCloudException cu statusul lui), nu un drum spre alta gazda care ar primi cheia _http = new HttpClient(handler ?? new HttpClientHandler { AllowAutoRedirect = false }) { Timeout = TimeSpan.FromSeconds(30) }; } /// public void Dispose() => _http.Dispose(); private sealed record Raspuns(int Status, byte[] Body, Func Antet); private async Task DoAsync(HttpMethod method, string path, object? body, bool auth, CancellationToken ct) { using var req = new HttpRequestMessage(method, _baseUrl + path); req.Headers.UserAgent.ParseAdd("aere-cloud-csharp/" + Version); if (auth) req.Headers.Add("x-api-key", _apiKey); if (body is not null) { req.Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(body)); req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); } HttpResponseMessage r; try { r = await _http.SendAsync(req, ct).ConfigureAwait(false); } catch (HttpRequestException e) { throw new AereCloudException(0, e.Message, path); } // mesajul de retea nu poarta cheia (e in antet) using (r) { var b = await r.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); var h = r.Headers; var hc = r.Content.Headers; string? Antet(string n) => h.TryGetValues(n, out var v) ? v.FirstOrDefault() : hc.TryGetValues(n, out var w) ? w.FirstOrDefault() : null; return new Raspuns((int)r.StatusCode, b, Antet); } } private async Task JsonAsync(HttpMethod method, string path, object? body, bool auth, CancellationToken ct) { var r = await DoAsync(method, path, body, auth, ct).ConfigureAwait(false); if (r.Status < 200 || r.Status >= 300) throw new AereCloudException(r.Status, Encoding.UTF8.GetString(r.Body), path); using var d = JsonDocument.Parse(r.Body.Length == 0 ? "null"u8.ToArray() : r.Body); return d.RootElement.Clone(); } private static string Esc(string s) => Uri.EscapeDataString(s); private static string Sha256Hex(byte[] b) => "0x" + Convert.ToHexString(SHA256.HashData(b)).ToLowerInvariant(); // ---- health, account, JSON-RPC ---- /// GET /health (no key). public Task HealthAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/health", null, false, ct); /// GET /account. public Task AccountAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/account", null, true, ct); /// One JSON-RPC call; returns the whole JSON-RPC envelope. public Task RpcAsync(string method, IReadOnlyList? parameters = null, CancellationToken ct = default) => JsonAsync(HttpMethod.Post, "/rpc", new Dictionary { ["jsonrpc"] = "2.0", ["id"] = 1, ["method"] = method, ["params"] = parameters ?? Array.Empty() }, true, ct); /// eth_blockNumber as an integer. public async Task BlockNumberAsync(CancellationToken ct = default) { var e = await RpcAsync("eth_blockNumber", null, ct).ConfigureAwait(false); if (e.ValueKind != JsonValueKind.Object || !e.TryGetProperty("result", out var r) || r.GetString() is not { } s || !s.StartsWith("0x", StringComparison.Ordinal)) throw new AereCloudException(200, e.GetRawText(), "/rpc"); return Convert.ToUInt64(s[2..], 16); } // ---- post-quantum verification, the Trust API, chain data, notarization, proofs ---- /// POST /pq/verify, payload like {"scheme":"ml-dsa-44","publicKey":"0x..","signature":"0x..","message":"0x.."}. public Task PqVerifyAsync(object payload, CancellationToken ct = default) => JsonAsync(HttpMethod.Post, "/pq/verify", payload, true, ct); /// The Trust API (1.6.0): one AERE Proof Protocol (AIP-23) envelope of any kind -> VALID / INVALID / PARTIAL with the levels /// checked and the command that reproduces the verdict. : 2800 or 28001; 0 = the chain the envelope declares. public Task VerifyAsync(object envelope, ulong chain = 0, CancellationToken ct = default) => JsonAsync(HttpMethod.Post, "/verify" + (chain > 0 ? "?chain=" + chain : ""), envelope, true, ct); /// Identity verification (1.7.0): {presentation, audience, nonce, trustedIssuers?, statusLists?, revocations?, maxAgeSeconds?} -> /// VALID / PARTIAL / INVALID with every row, judged by the published AERE Identity command line. Read "verdict" (PARTIAL: some rows not judged). public Task IdentityVerifyAsync(object request, CancellationToken ct = default) => JsonAsync(HttpMethod.Post, "/identity/verify", request, true, ct); /// Compliance check (1.7.0): {presentation, policy, audience, nonce, statusLists?, revocations?, record?} -> COMPLIANT / NOT_COMPLIANT /// with the reasons and, with record, an AIP-23 record without personal data. public Task ComplianceCheckAsync(object request, CancellationToken ct = default) => JsonAsync(HttpMethod.Post, "/compliance/check", request, true, ct); /// GET /data/head. public Task DataHeadAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/data/head", null, true, ct); /// GET /data/validators. public Task ValidatorsAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/data/validators", null, true, ct); /// GET /data/anchors?limit=. public Task AnchorsAsync(int limit = 10, CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/data/anchors?limit=" + limit, null, true, ct); /// GET /data/anchors/{height}. public Task AnchorAsync(ulong height, CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/data/anchors/" + height, null, true, ct); /// The post-quantum finality point (finalized/safe = the parent of the last certified anchor). public Task FinalityAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/data/finality", null, true, ct); /// GET /data/transfers?address=&limit=. public Task TransfersAsync(string address, int limit = 50, CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/data/transfers?address=" + Esc(address) + "&limit=" + limit, null, true, ct); /// Notarizes a 32-byte digest (0x + 64 hex); the gas is ours. public Task NotarizeAsync(string hash, CancellationToken ct = default) => JsonAsync(HttpMethod.Post, "/notarize", new Dictionary { ["hash"] = hash }, true, ct); /// GET /notarize/{hash}. public Task ProofOfAsync(string hash, CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/notarize/" + Esc(hash), null, true, ct); /// The proof with its post-quantum finality (first appearance, covering anchor, verdict, verification steps). public Task ProofAsync(string hash, CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/proof/" + Esc(hash), null, true, ct); // ---- quantum readiness ---- /// Scans a domain's public TLS edge. Free WITHOUT a key; notarizes the report digest and only then sends the key. public Task ReadinessAsync(string domain, bool fresh = false, bool attest = false, CancellationToken ct = default) { var b = new Dictionary { ["domain"] = domain }; if (fresh) b["fresh"] = true; if (attest) b["attest"] = true; return JsonAsync(HttpMethod.Post, "/pq/readiness", b, attest, ct); } /// GET /pq/readiness/{domain} (no key). public Task ReadinessReportAsync(string domain, CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/pq/readiness/" + Esc(domain), null, false, ct); /// The whole public perimeter of a domain in one keyed report; call again while summary.complete is false. public Task ReadinessPerimeterAsync(string domain, PerimeterOptions? o = null, CancellationToken ct = default) { o ??= new PerimeterOptions(); var b = new Dictionary { ["domain"] = domain }; if (o.Hosts is not null) b["hosts"] = o.Hosts; if (o.Discover == false) b["discover"] = false; if (o.Attest) b["attest"] = true; return JsonAsync(HttpMethod.Post, "/pq/readiness/perimeter", b, true, ct); } /// AIP-21 per-block post-quantum finality. client: "besu", "nethermind" or "both" (with `agreement`). No key. public Task PqFinalityAsync(string block = "latest", string network = "testnet", string client = "besu", CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/pq/finality/" + Esc(block) + "?network=" + Esc(network) + "&client=" + Esc(client), null, false, ct); // ---- audit log ---- /// The paged JSON view of one UTC day (null = today), with the digest of the day file. public Task AuditLogAsync(string? day = null, int offset = 0, int limit = 1000, CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/account/audit?offset=" + offset + "&limit=" + limit + (day is null ? "" : "&day=" + Esc(day)), null, true, ct); /// A day as a file: "jsonl" (the exact bytes of the day file, checked here against the stated digest) or "cef". public async Task AuditExportAsync(string? day = null, string format = "jsonl", CancellationToken ct = default) { var r = await DoAsync(HttpMethod.Get, "/account/audit?format=" + Esc(format) + (day is null ? "" : "&day=" + Esc(day)), null, true, ct).ConfigureAwait(false); if (r.Status < 200 || r.Status >= 300) throw new AereCloudException(r.Status, Encoding.UTF8.GetString(r.Body), "/account/audit"); var digest = r.Antet("x-aere-digest"); bool? verified = format == "jsonl" ? !string.IsNullOrEmpty(digest) && digest == Sha256Hex(r.Body) : null; return new AuditExport(r.Antet("x-aere-day"), format, digest, r.Body, int.TryParse(r.Antet("x-aere-entries"), out var n) ? n : 0, verified); } /// The retention of the audit log, READ-ONLY with the key: retention is SET with a wallet signature in the console, never /// with the key, so a stolen key cannot shorten the trail of its own use. public Task AuditRetentionAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/account/audit/retention", null, true, ct); /// Hashes YOUR bytes locally and compares them with the day we hold, or with the tombstone of a deleted day (410). Any other /// failure is thrown, never reported as "does not match". public async Task AuditVerifyKeptAsync(string day, byte[] kept, CancellationToken ct = default) { var local = Sha256Hex(kept); var r = await DoAsync(HttpMethod.Get, "/account/audit?format=jsonl&day=" + Esc(day), null, true, ct).ConfigureAwait(false); if (r.Status == 410) { try { using var d = JsonDocument.Parse(r.Body); if (d.RootElement.TryGetProperty("expired", out var t) && t.ValueKind == JsonValueKind.Object && t.TryGetProperty("digest", out var dg) && dg.GetString() is { Length: > 0 } digest) { var del = t.TryGetProperty("deletedAt", out var da) ? da.GetString() : null; return new KeptCheck(day, "expired", digest, local, local == digest, del); } } catch (JsonException) { /* un 410 fara piatra de mormant cade mai jos, ca eroare */ } } if (r.Status < 200 || r.Status >= 300) throw new AereCloudException(r.Status, Encoding.UTF8.GetString(r.Body), "/account/audit"); var held = Sha256Hex(r.Body); return new KeptCheck(day, "held", held, local, local == held, null); } // ---- webhooks and gas sponsorship ---- /// GET /webhooks. public Task ListWebhooksAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/webhooks", null, true, ct); /// POST /webhooks. public Task CreateWebhookAsync(object spec, CancellationToken ct = default) => JsonAsync(HttpMethod.Post, "/webhooks", spec, true, ct); /// DELETE /webhooks/{id}. public Task DeleteWebhookAsync(string id, CancellationToken ct = default) => JsonAsync(HttpMethod.Delete, "/webhooks/" + Esc(id), null, true, ct); /// GET /sponsor/health. public Task SponsorHealthAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/sponsor/health", null, true, ct); /// POST /sponsor/createAccount. public Task SponsorCreateAccountAsync(object body, CancellationToken ct = default) => JsonAsync(HttpMethod.Post, "/sponsor/createAccount", body, true, ct); /// A smart account with a POST-QUANTUM owner (Falcon-512, 0x09-prefixed, 897 bytes), gas sponsored; idempotent on (key, salt). public Task SponsorCreatePqAccountAsync(string falconPubKeyHex, ulong salt = 0, CancellationToken ct = default) => JsonAsync(HttpMethod.Post, "/sponsor/createPqAccount", new Dictionary { ["falconPubKey"] = falconPubKeyHex, ["salt"] = salt }, true, ct); /// POST /sponsor/execute. public Task SponsorExecuteAsync(object body, CancellationToken ct = default) => JsonAsync(HttpMethod.Post, "/sponsor/execute", body, true, ct); /// Checks a webhook delivery's HMAC-SHA256 signature in constant time. must be the EXACT bytes /// received, not re-serialized JSON. Never act on a webhook without this. public static bool VerifyWebhook(byte[] rawBody, string? signatureHeader, string secret) { var want = Encoding.ASCII.GetBytes(Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), rawBody)).ToLowerInvariant()); var got = Encoding.ASCII.GetBytes(signatureHeader ?? ""); // FixedTimeEquals intoarce fals pe lungimi diferite fara sa scurteze comparatia pe continut return CryptographicOperations.FixedTimeEquals(want, got); } }