// BeatTime API client + local @beat clock — C# (single file, no dependencies). // https://beattime.live // // BeatTime is one universal time: 1000 .beats/day, anchored to UTC, no timezones. // Public, free, read-only API + proof-of-existence. // // Works in Unity (2019.4+, Mono & IL2CPP), .NET 5+/Core/Framework, Godot C#, Xamarin. // Target: .NET Standard 2.0. Drop the file into your project — that's the install. // // // Local clock — no network needed, @beat is derived from the UTC clock: // label.text = BeatTime.LocalNow(); // "@523.45" (call every frame, it's cheap) // double b = BeatTime.LocalBeats(); // 523.4567... // // // Optional server sync (SNTP-style drift correction), then keep ticking locally: // var bt = new BeatTime(); // await bt.SyncClockAsync(); // sets the shared clock offset // // // Open API (methods return the raw JSON string — parse with your JSON lib // // of choice: JsonUtility, Newtonsoft, System.Text.Json): // string json = await bt.NowAsync(); // {"beat":"@523","beat_centi":...} // string beat = await bt.BeatNowAsync(); // "@523" // // // Proof of existence (only the SHA-256 hash is sent — your file stays local): // string digest = BeatTime.Sha256File("contract.pdf"); // await bt.StampAsync(digest); // string cert = bt.CertUrl(digest); // downloadable PDF certificate // // Unity WebGL note: HttpClient is unavailable there — use UnityWebRequest for the // API calls if you need them. The local clock (the usual use) needs no network. // // No API key, no limits beyond fair-use rate limiting. MIT-style: use freely. using System; using System.IO; using System.Net.Http; using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; public sealed class BeatTime { public const string DefaultBaseUrl = "https://beattime.live"; private const double MsPerDay = 86400000.0; private const double MsPerBeat = 86400.0; // 86400000 ms/day / 1000 beats /// Clock offset (server - local) in ms, set by SyncClockAsync(). Shared. public static double OffsetMs = 0.0; private readonly HttpClient _http; private readonly string _base; public BeatTime(string baseUrl = DefaultBaseUrl, double timeoutSeconds = 10.0) { _base = baseUrl.TrimEnd('/'); _http = new HttpClient { Timeout = TimeSpan.FromSeconds(timeoutSeconds) }; } // --- local clock (no network) ---------------------------------------- /// .beat for a unix-ms instant: ms-of-day (UTC) / 86400. public static double BeatsAt(long unixMs) { double d = ((unixMs % (long)MsPerDay) + (long)MsPerDay) % (long)MsPerDay; return d / MsPerBeat; } /// Current .beat [0,1000) from the device clock (+ sync offset). public static double LocalBeats() => BeatsAt(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + (long)OffsetMs); /// Format beats: "@523" or "@523.45" (centibeats, the default). public static string Format(double beats, bool centi = true) { int whole = (int)Math.Floor(beats); string s = "@" + whole.ToString("000"); if (centi) s += "." + ((int)Math.Floor((beats - whole) * 100)).ToString("00"); return s; } /// Current formatted @beat from the device clock, e.g. "@523.45". public static string LocalNow(bool centi = true) => Format(LocalBeats(), centi); // --- open API (raw JSON strings) ------------------------------------- /// Current time: {"beat":"@523","beat_centi":"@523.45","beats":..,"utc":..}. public Task NowAsync() => GetAsync("/api/now/"); /// Just the current "@NNN" (extracted from /api/now/). public async Task BeatNowAsync() => ExtractString(await NowAsync().ConfigureAwait(false), "beat"); /// Convert an instant (ISO 8601; no timezone = UTC) to its @beat. public Task TimeToBeatAsync(string iso8601) => GetAsync("/api/convert/?at=" + Uri.EscapeDataString(iso8601)); /// Convert a beat [0,1000) to UTC time. Optional date (YYYY-MM-DD) and /// tzOffset (minutes, e.g. 120 for UTC+2) to also get the local time. public Task BeatToTimeAsync(double beat, string date = null, int? tzOffset = null) { var q = "/api/convert/?beat=" + beat.ToString(System.Globalization.CultureInfo.InvariantCulture); if (date != null) q += "&date=" + Uri.EscapeDataString(date); if (tzOffset.HasValue) q += "&tz_offset=" + tzOffset.Value; return GetAsync(q); } /// Server time for clock sync: {"server_unix_ms":..,...}. public Task SyncAsync() => GetAsync("/api/sync/"); public Task HealthAsync() => GetAsync("/api/health/"); /// Fetch /api/sync/ and set OffsetMs (SNTP-style, halves the round-trip). /// After this, LocalBeats()/LocalNow() tick on the corrected clock. public async Task SyncClockAsync() { long t0 = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); string json = await SyncAsync().ConfigureAwait(false); long t1 = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); double server = ExtractNumber(json, "server_unix_ms"); OffsetMs = server + (t1 - t0) / 2.0 - t1; return OffsetMs; } // --- proof of existence --------------------------------------------- /// Record a 64-char lowercase hex SHA-256 in the public proof log. /// Idempotent: the first stamp wins. The file never leaves your machine. public Task StampAsync(string digest) => PostAsync("/api/proof/stamp", "{\"digest\":\"" + digest + "\"}"); /// Look up a digest: timestamp, inclusion proof, signature, anchors. public Task VerifyAsync(string digest) => GetAsync("/api/proof/verify?digest=" + Uri.EscapeDataString(digest)); /// URL of the downloadable PDF certificate for a stamped digest. public string CertUrl(string digest) => _base + "/api/proof/cert/" + digest; /// URL of the public verification page for a digest. public string VerifyUrl(string digest) => _base + "/proof/?h=" + digest; /// SHA-256 of a file (streamed) — feed it to StampAsync()/VerifyAsync(). public static string Sha256File(string path) { using (var sha = SHA256.Create()) using (var f = File.OpenRead(path)) { var hash = sha.ComputeHash(f); var sb = new StringBuilder(64); foreach (byte b in hash) sb.Append(b.ToString("x2")); return sb.ToString(); } } // --- plumbing -------------------------------------------------------- private async Task GetAsync(string path) { var r = await _http.GetAsync(_base + path).ConfigureAwait(false); r.EnsureSuccessStatusCode(); return await r.Content.ReadAsStringAsync().ConfigureAwait(false); } private async Task PostAsync(string path, string jsonBody) { var content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); var r = await _http.PostAsync(_base + path, content).ConfigureAwait(false); r.EnsureSuccessStatusCode(); return await r.Content.ReadAsStringAsync().ConfigureAwait(false); } // Minimal flat-key JSON field extraction — avoids a JSON dependency for the // two fields the SDK itself needs. For everything else, parse the raw JSON. private static string ExtractString(string json, string key) { int i = json.IndexOf("\"" + key + "\"", StringComparison.Ordinal); if (i < 0) return null; i = json.IndexOf(':', i) + 1; int a = json.IndexOf('"', i) + 1; int b = json.IndexOf('"', a); return json.Substring(a, b - a); } private static double ExtractNumber(string json, string key) { int i = json.IndexOf("\"" + key + "\"", StringComparison.Ordinal); if (i < 0) return double.NaN; i = json.IndexOf(':', i) + 1; int e = i; while (e < json.Length && (char.IsDigit(json[e]) || json[e] == '.' || json[e] == '-' || json[e] == '+' || json[e] == 'e' || json[e] == 'E' || char.IsWhiteSpace(json[e]))) e++; return double.Parse(json.Substring(i, e - i).Trim(), System.Globalization.CultureInfo.InvariantCulture); } }