@hsuite/smart-engines-sdk 3.0.3 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1022,6 +1022,185 @@ function normalizeRegistryEntry(raw) {
1022
1022
  return null;
1023
1023
  }
1024
1024
 
1025
+ // src/discovery/cluster-discovery.ts
1026
+ var ClusterDiscoveryClient = class {
1027
+ bootstrap;
1028
+ cacheTtlMs;
1029
+ fetchTimeoutMs;
1030
+ allowInsecure;
1031
+ trustAnchorClient;
1032
+ cache = null;
1033
+ constructor(config) {
1034
+ if (!config.bootstrap || config.bootstrap.length === 0) {
1035
+ throw new Error("ClusterDiscoveryClient: bootstrap must list at least one seed URL");
1036
+ }
1037
+ if (!config.allowInsecure) {
1038
+ for (const seed of config.bootstrap) {
1039
+ if (!seed.startsWith("https://")) {
1040
+ throw new Error(
1041
+ `ClusterDiscoveryClient: bootstrap seed "${seed}" is not HTTPS. Set allowInsecure=true for local development only.`
1042
+ );
1043
+ }
1044
+ }
1045
+ }
1046
+ this.bootstrap = config.bootstrap;
1047
+ this.cacheTtlMs = config.cacheTtlMs ?? 6e4;
1048
+ this.fetchTimeoutMs = config.fetchTimeoutMs ?? 5e3;
1049
+ this.allowInsecure = config.allowInsecure ?? false;
1050
+ this.trustAnchorClient = config.trustAnchor ? new ValidatorDiscoveryClient(config.trustAnchor) : null;
1051
+ }
1052
+ /**
1053
+ * Fetch the active-cluster set, with caching.
1054
+ *
1055
+ * Tries each bootstrap seed in order until one returns HTTP 200 with a
1056
+ * parseable cluster list. If all seeds fail, throws — callers can
1057
+ * handle by falling back to a previously-cached value if they want,
1058
+ * or by initializing with multiple seeds.
1059
+ */
1060
+ async getClusters(forceRefresh = false) {
1061
+ if (!forceRefresh && this.cache && this.isCacheValid()) {
1062
+ return this.cache.clusters;
1063
+ }
1064
+ const fetched = await this.fetchFromFirstAvailableSeed();
1065
+ const verified = this.trustAnchorClient ? await this.verifyAgainstTrustAnchor(fetched) : fetched;
1066
+ this.cache = { clusters: verified, lastUpdated: Date.now() };
1067
+ return verified;
1068
+ }
1069
+ /**
1070
+ * Random pick over the active-cluster set. Returns `null` when no
1071
+ * cluster passes verification (empty registry / all-rejected anchor).
1072
+ *
1073
+ * Uses `crypto.getRandomValues` when available (browsers, Deno, modern
1074
+ * Node) for cryptographic-strength randomness; `Math.random` is a
1075
+ * fallback for older runtimes where `globalThis.crypto` is undefined.
1076
+ * Discovery isn't cryptographically sensitive — load distribution is
1077
+ * the goal here.
1078
+ */
1079
+ async getRandomCluster(forceRefresh = false) {
1080
+ const clusters = await this.getClusters(forceRefresh);
1081
+ if (clusters.length === 0) return null;
1082
+ return clusters[this.pickRandomIndex(clusters.length)];
1083
+ }
1084
+ /**
1085
+ * Convenience wrapper returning just the gateway URL of a random
1086
+ * active cluster. The single most-used SDK entry point — most
1087
+ * downstream callers want `new SmartEngineClient({ baseUrl: ... })`
1088
+ * and don't care about the rest of the metadata.
1089
+ */
1090
+ async getRandomGatewayUrl(forceRefresh = false) {
1091
+ const cluster = await this.getRandomCluster(forceRefresh);
1092
+ return cluster?.endpoints.gatewayUrl ?? null;
1093
+ }
1094
+ /** Per-clusterId lookup, useful for "stick the SDK to cluster X" flows. */
1095
+ async getClusterById(clusterId, forceRefresh = false) {
1096
+ const clusters = await this.getClusters(forceRefresh);
1097
+ return clusters.find((c) => c.clusterId === clusterId) ?? null;
1098
+ }
1099
+ clearCache() {
1100
+ this.cache = null;
1101
+ }
1102
+ isCacheValid() {
1103
+ if (!this.cache) return false;
1104
+ return Date.now() - this.cache.lastUpdated < this.cacheTtlMs;
1105
+ }
1106
+ async fetchFromFirstAvailableSeed() {
1107
+ const errors = [];
1108
+ for (const seed of this.bootstrap) {
1109
+ try {
1110
+ return await this.fetchClustersFromSeed(seed);
1111
+ } catch (err) {
1112
+ errors.push(`${seed}: ${err.message}`);
1113
+ }
1114
+ }
1115
+ throw new Error(
1116
+ `ClusterDiscoveryClient: no bootstrap seed reachable. Attempts:
1117
+ ${errors.join("\n ")}`
1118
+ );
1119
+ }
1120
+ async fetchClustersFromSeed(seed) {
1121
+ const url = `${seed.replace(/\/$/, "")}/api/v3/discovery/clusters`;
1122
+ const ctrl = new AbortController();
1123
+ const timer = setTimeout(() => ctrl.abort(), this.fetchTimeoutMs);
1124
+ try {
1125
+ const res = await fetch(url, { signal: ctrl.signal });
1126
+ if (!res.ok) {
1127
+ throw new Error(`HTTP ${res.status}`);
1128
+ }
1129
+ const body = await res.json();
1130
+ if (!Array.isArray(body.clusters)) {
1131
+ throw new Error("response missing clusters[]");
1132
+ }
1133
+ return body.clusters.map((c) => this.normalizeClusterEntry(c)).filter((c) => c !== null);
1134
+ } finally {
1135
+ clearTimeout(timer);
1136
+ }
1137
+ }
1138
+ normalizeClusterEntry(raw) {
1139
+ if (typeof raw.clusterId !== "string" || !raw.clusterId) return null;
1140
+ if (!raw.endpoints || typeof raw.endpoints !== "object") return null;
1141
+ const ep = raw.endpoints;
1142
+ if (typeof ep.gatewayUrl !== "string" || !ep.gatewayUrl) return null;
1143
+ if (!this.allowInsecure && !ep.gatewayUrl.startsWith("https://")) {
1144
+ return null;
1145
+ }
1146
+ const nodeIds = Array.isArray(raw.nodeIds) ? raw.nodeIds.filter((n) => typeof n === "string") : [];
1147
+ return {
1148
+ clusterId: raw.clusterId,
1149
+ endpoints: {
1150
+ clusterId: raw.clusterId,
1151
+ gatewayUrl: ep.gatewayUrl,
1152
+ harborUrl: typeof ep.harborUrl === "string" ? ep.harborUrl : void 0,
1153
+ natsUrl: typeof ep.natsUrl === "string" ? ep.natsUrl : void 0,
1154
+ publicIp: typeof ep.publicIp === "string" ? ep.publicIp : void 0,
1155
+ region: typeof ep.region === "string" ? ep.region : void 0
1156
+ },
1157
+ nodeIds
1158
+ };
1159
+ }
1160
+ /**
1161
+ * Cross-check the HTTP-fetched cluster set against the HCS validator
1162
+ * registry. Clusters whose nodeIds are not on-chain are dropped.
1163
+ *
1164
+ * Two failure modes are deliberately silent here:
1165
+ * - the HCS read itself throws → original list is returned
1166
+ * un-verified. Trust-anchor is advisory; a network partition
1167
+ * between SDK and Hedera mirror shouldn't strand the SDK.
1168
+ * - the HCS read returns empty (mirror node lag, wrong topicId)
1169
+ * → original list is returned. Better to keep working off
1170
+ * possibly-stale-but-real data than reject every cluster.
1171
+ *
1172
+ * Both behaviors are why this is called "trust *anchor*", not
1173
+ * "trust gate". Crypto-strong gating is a follow-up.
1174
+ */
1175
+ async verifyAgainstTrustAnchor(clusters) {
1176
+ if (!this.trustAnchorClient || clusters.length === 0) return clusters;
1177
+ let onChainNodeIds;
1178
+ try {
1179
+ const validators = await this.trustAnchorClient.getValidators();
1180
+ if (validators.length === 0) return clusters;
1181
+ onChainNodeIds = new Set(validators.map((v) => v.nodeId));
1182
+ } catch {
1183
+ return clusters;
1184
+ }
1185
+ return clusters.filter((c) => {
1186
+ if (c.nodeIds.length === 0) {
1187
+ return false;
1188
+ }
1189
+ return c.nodeIds.some((id) => onChainNodeIds.has(id));
1190
+ });
1191
+ }
1192
+ pickRandomIndex(n) {
1193
+ if (n <= 1) return 0;
1194
+ const g = globalThis.crypto;
1195
+ if (g?.getRandomValues) {
1196
+ const buf = new Uint32Array(1);
1197
+ g.getRandomValues(buf);
1198
+ return buf[0] % n;
1199
+ }
1200
+ return Math.floor(Math.random() * n);
1201
+ }
1202
+ };
1203
+
1025
1204
  // src/auth/validator-auth.ts
1026
1205
  var SUPPORTED_AUTH_CHAINS = [
1027
1206
  "hedera",
@@ -1832,6 +2011,167 @@ var SnapshotsClient = class {
1832
2011
  }
1833
2012
  };
1834
2013
 
2014
+ // src/historical-balance/historical-balance-client.ts
2015
+ var DEFAULT_TIMEOUT_MS = 3e4;
2016
+ var ENDPOINT_PATH = "/api/v3/historical-balance/query";
2017
+ var HistoricalBalanceClientError = class extends Error {
2018
+ constructor(message, statusCode, details) {
2019
+ super(message);
2020
+ this.statusCode = statusCode;
2021
+ this.details = details;
2022
+ this.name = "HistoricalBalanceClientError";
2023
+ }
2024
+ statusCode;
2025
+ details;
2026
+ };
2027
+ var HistoricalBalanceClient = class _HistoricalBalanceClient {
2028
+ // Standalone-mode fields (set when the client builds its own fetch).
2029
+ baseUrl;
2030
+ authToken;
2031
+ apiKey;
2032
+ timeoutMs;
2033
+ fetchImpl;
2034
+ // Sub-client-mode field (set when wired via SmartEngineClient).
2035
+ http;
2036
+ constructor(config) {
2037
+ if ("http" in config) {
2038
+ this.http = config.http;
2039
+ this.timeoutMs = DEFAULT_TIMEOUT_MS;
2040
+ return;
2041
+ }
2042
+ if (!config.baseUrl) {
2043
+ throw new HistoricalBalanceClientError(
2044
+ "HistoricalBalanceClient: baseUrl is required for standalone construction",
2045
+ 400
2046
+ );
2047
+ }
2048
+ this.baseUrl = config.baseUrl.replace(/\/+$/, "");
2049
+ this.authToken = config.authToken;
2050
+ this.apiKey = config.apiKey;
2051
+ this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
2052
+ this.fetchImpl = config.fetchImpl;
2053
+ }
2054
+ /**
2055
+ * Factory used by `SmartEngineClient` to wire this sub-client onto the
2056
+ * parent's shared `HttpClient` (which already carries auth + baseUrl + a
2057
+ * `/api/v3` prefix). Routes through `/historical-balance/query` since the
2058
+ * parent's HttpClient is rooted at `/api/v3`.
2059
+ */
2060
+ static fromHttp(http) {
2061
+ return new _HistoricalBalanceClient({ http });
2062
+ }
2063
+ /**
2064
+ * Query a point-in-time balance.
2065
+ *
2066
+ * The validator returns the on-chain bigint as a base-10 decimal string.
2067
+ * Callers needing arithmetic precision should wrap the result:
2068
+ *
2069
+ * ```ts
2070
+ * const { balance } = await client.historicalBalance.getBalance({ ... });
2071
+ * const value = BigInt(balance);
2072
+ * ```
2073
+ */
2074
+ async getBalance(params) {
2075
+ this.validateParams(params);
2076
+ if (this.http) {
2077
+ return this.http.post("/historical-balance/query", params);
2078
+ }
2079
+ return this.standaloneFetch(params);
2080
+ }
2081
+ validateParams(params) {
2082
+ if (!params || typeof params !== "object") {
2083
+ throw new HistoricalBalanceClientError(
2084
+ "HistoricalBalanceClient.getBalance: params object is required",
2085
+ 400
2086
+ );
2087
+ }
2088
+ const { chain, entityId, account, atTimestamp } = params;
2089
+ if (chain !== "hedera" && chain !== "xrpl" && chain !== "polkadot") {
2090
+ throw new HistoricalBalanceClientError(
2091
+ `HistoricalBalanceClient.getBalance: unsupported chain "${String(chain)}" (expected hedera | xrpl | polkadot)`,
2092
+ 400
2093
+ );
2094
+ }
2095
+ if (typeof entityId !== "string" || entityId.length === 0) {
2096
+ throw new HistoricalBalanceClientError(
2097
+ "HistoricalBalanceClient.getBalance: entityId must be a non-empty string",
2098
+ 400
2099
+ );
2100
+ }
2101
+ if (typeof account !== "string" || account.length === 0) {
2102
+ throw new HistoricalBalanceClientError(
2103
+ "HistoricalBalanceClient.getBalance: account must be a non-empty string",
2104
+ 400
2105
+ );
2106
+ }
2107
+ if (typeof atTimestamp !== "number" || !Number.isInteger(atTimestamp) || atTimestamp <= 0) {
2108
+ throw new HistoricalBalanceClientError(
2109
+ "HistoricalBalanceClient.getBalance: atTimestamp must be a positive integer (unix seconds)",
2110
+ 400
2111
+ );
2112
+ }
2113
+ }
2114
+ async standaloneFetch(params) {
2115
+ const url = `${this.baseUrl}${ENDPOINT_PATH}`;
2116
+ const headers = {
2117
+ "Content-Type": "application/json",
2118
+ Accept: "application/json"
2119
+ };
2120
+ if (this.authToken) headers.Authorization = `Bearer ${this.authToken}`;
2121
+ if (this.apiKey) headers["X-API-Key"] = this.apiKey;
2122
+ const fetchFn = this.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
2123
+ if (!fetchFn) {
2124
+ throw new HistoricalBalanceClientError(
2125
+ "HistoricalBalanceClient: no fetch implementation available (provide fetchImpl)",
2126
+ 500
2127
+ );
2128
+ }
2129
+ const controller = new AbortController();
2130
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
2131
+ let response;
2132
+ try {
2133
+ response = await fetchFn(url, {
2134
+ method: "POST",
2135
+ headers,
2136
+ body: JSON.stringify(params),
2137
+ signal: controller.signal
2138
+ });
2139
+ } catch (err) {
2140
+ clearTimeout(timer);
2141
+ const message = err instanceof Error ? err.message : String(err);
2142
+ throw new HistoricalBalanceClientError(
2143
+ `HistoricalBalanceClient: transport error: ${message}`,
2144
+ 0,
2145
+ err
2146
+ );
2147
+ }
2148
+ clearTimeout(timer);
2149
+ let body;
2150
+ const text = await response.text();
2151
+ if (text.length > 0) {
2152
+ try {
2153
+ body = JSON.parse(text);
2154
+ } catch (err) {
2155
+ const message = err instanceof Error ? err.message : String(err);
2156
+ throw new HistoricalBalanceClientError(
2157
+ `HistoricalBalanceClient: non-JSON response (status=${response.status}): ${message}`,
2158
+ response.status,
2159
+ { raw: text }
2160
+ );
2161
+ }
2162
+ }
2163
+ if (!response.ok) {
2164
+ const detail = body && typeof body === "object" && "message" in body ? String(body.message) : `HTTP ${response.status}`;
2165
+ throw new HistoricalBalanceClientError(
2166
+ `HistoricalBalanceClient: ${detail}`,
2167
+ response.status,
2168
+ body
2169
+ );
2170
+ }
2171
+ return body;
2172
+ }
2173
+ };
2174
+
1835
2175
  // src/settlement/index.ts
1836
2176
  var SettlementClient = class {
1837
2177
  constructor(http) {
@@ -1866,6 +2206,53 @@ var SettlementClient = class {
1866
2206
  }
1867
2207
  };
1868
2208
 
2209
+ // src/governance/governance-client.ts
2210
+ var GovernanceClient = class {
2211
+ constructor(http) {
2212
+ this.http = http;
2213
+ }
2214
+ http;
2215
+ /**
2216
+ * Dry-run a governance proposal/vote/etc. against `GovernanceMolecule.validate(...)`.
2217
+ *
2218
+ * Returns whatever `ValidationResult` the molecule produced. A `false`
2219
+ * `isValid` is **not** an error — it is a successful "this proposal is
2220
+ * invalid" outcome and is surfaced via the normal return value.
2221
+ *
2222
+ * HTTP errors (400 malformed body, 500 unexpected) bubble up as
2223
+ * `SdkHttpError` via the shared `HttpClient` layer.
2224
+ */
2225
+ async simulate(params) {
2226
+ return this.http.post("/governance/simulate", params);
2227
+ }
2228
+ };
2229
+
2230
+ // src/personhood/personhood-client.ts
2231
+ var PersonhoodClient = class {
2232
+ constructor(http) {
2233
+ this.http = http;
2234
+ }
2235
+ http;
2236
+ /**
2237
+ * Verify a personhood proof for `candidate`.
2238
+ *
2239
+ * Returns the issued cert on accept, `null` on clean rejection. All
2240
+ * other failure modes (validation, transport, 5xx) propagate as
2241
+ * `SdkHttpError`.
2242
+ */
2243
+ async verify(params) {
2244
+ const result = await this.http.post(
2245
+ "/personhood/verify",
2246
+ {
2247
+ candidate: params.candidate,
2248
+ proof: params.proof
2249
+ }
2250
+ );
2251
+ if (result === void 0) return null;
2252
+ return result;
2253
+ }
2254
+ };
2255
+
1869
2256
  // src/client.ts
1870
2257
  var SmartEngineClient = class _SmartEngineClient {
1871
2258
  baseUrl;
@@ -1886,8 +2273,14 @@ var SmartEngineClient = class _SmartEngineClient {
1886
2273
  transactions;
1887
2274
  /** Token holder snapshot generation and retrieval */
1888
2275
  snapshots;
2276
+ /** Historical balance archive reads (chain-native bigint, returned as decimal string) */
2277
+ historicalBalance;
1889
2278
  /** Cross-chain settlement operations */
1890
2279
  settlement;
2280
+ /** Governance proposal dry-run (simulate-only) */
2281
+ governance;
2282
+ /** Personhood verification (HPP one-human-one-member) */
2283
+ personhood;
1891
2284
  constructor(config) {
1892
2285
  this.allowInsecure = config.allowInsecure ?? false;
1893
2286
  this.baseUrl = validateClientUrl(config.baseUrl, this.allowInsecure);
@@ -1908,7 +2301,10 @@ var SmartEngineClient = class _SmartEngineClient {
1908
2301
  this.ipfs = new IPFSClient(this.http);
1909
2302
  this.transactions = new TransactionsClient(this.txHttp);
1910
2303
  this.snapshots = new SnapshotsClient(this.http);
2304
+ this.historicalBalance = HistoricalBalanceClient.fromHttp(this.http);
1911
2305
  this.settlement = new SettlementClient(this.http);
2306
+ this.governance = new GovernanceClient(this.http);
2307
+ this.personhood = new PersonhoodClient(this.http);
1912
2308
  }
1913
2309
  /**
1914
2310
  * Connect to the smart-engines network with auto-discovery and authentication
@@ -1953,6 +2349,67 @@ var SmartEngineClient = class _SmartEngineClient {
1953
2349
  });
1954
2350
  return { client, validator, session };
1955
2351
  }
2352
+ /**
2353
+ * Connect to the smart-engines network via the **service-registry**
2354
+ * (PR-1 of the cluster-discovery arc). Preferred over
2355
+ * {@link connectToNetwork} once the validator pods in the target network
2356
+ * have published their cluster endpoints — the SDK auto-balances across
2357
+ * the active cluster set and rides permissionless cluster join/leave
2358
+ * without code edits.
2359
+ *
2360
+ * Fallback ladder (per `docs/ops/HANDOFF-service-registry-distribution-layer.md` §6):
2361
+ * 1. HTTP fetch `/api/v3/discovery/clusters` from each bootstrap seed.
2362
+ * 2. (Optional) HCS trust-anchor membership cross-check.
2363
+ * 3. Random-pick over the verified set.
2364
+ *
2365
+ * @example
2366
+ * ```ts
2367
+ * const { client, cluster, session } = await SmartEngineClient.connectToCluster({
2368
+ * bootstrap: ['https://sn1.testnet.hsuite.network', 'https://sn2.testnet.hsuite.network'],
2369
+ * chain: 'xrpl',
2370
+ * address: '...',
2371
+ * publicKey: '...',
2372
+ * signFn: async (challenge) => sign(challenge),
2373
+ * });
2374
+ * ```
2375
+ */
2376
+ static async connectToCluster(config) {
2377
+ const allowInsecure = config.allowInsecure ?? false;
2378
+ const discovery = new ClusterDiscoveryClient({
2379
+ bootstrap: config.bootstrap,
2380
+ allowInsecure,
2381
+ trustAnchor: config.trustAnchor ? {
2382
+ network: config.trustAnchor.network,
2383
+ registryTopicId: config.trustAnchor.registryTopicId,
2384
+ mirrorNodeUrl: config.trustAnchor.mirrorNodeUrl,
2385
+ allowInsecure
2386
+ } : void 0
2387
+ });
2388
+ const cluster = await discovery.getRandomCluster();
2389
+ if (!cluster) {
2390
+ throw new SmartEngineError2(
2391
+ "No active clusters available via bootstrap seeds. Check bootstrap URLs and network reachability.",
2392
+ 503
2393
+ );
2394
+ }
2395
+ const gatewayUrl = cluster.endpoints.gatewayUrl;
2396
+ validateClientUrl(gatewayUrl, allowInsecure);
2397
+ const auth = new ValidatorAuthClient({ security: { allowInsecure } });
2398
+ const session = await auth.authenticateWithSigner(
2399
+ gatewayUrl,
2400
+ config.chain,
2401
+ config.address,
2402
+ config.publicKey,
2403
+ config.signFn,
2404
+ config.metadata
2405
+ );
2406
+ const client = new _SmartEngineClient({
2407
+ baseUrl: gatewayUrl,
2408
+ authToken: session.token,
2409
+ allowInsecure
2410
+ });
2411
+ return { client, cluster, session };
2412
+ }
1956
2413
  /** Get the current validator URL */
1957
2414
  getBaseUrl() {
1958
2415
  return this.baseUrl;