@flop-labs/cli 0.4.0 → 0.4.1

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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ and this package follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.1] - 2026-11-08
11
+
12
+ ### Added
13
+
14
+ - Gateway renewal follows the same operator-owned static-to-public handoffs as the gateway,
15
+ retaining admission expiry, identity checks and static budget ceilings.
16
+
17
+ - Gateway renewal consumes optional dynamic admissions, verifies their pinned identity and
18
+ uses channel-signed paid conformance probes without extending admission or location evidence.
19
+
20
+ ### Fixed
21
+
22
+ - Report session failures as structured nonzero errors with channel recovery guidance.
23
+ - Route package bug reports to the public network issue forms.
24
+
25
+ - Require a fresh dynamic admission before expiry-bound renewal probes, even when a static
26
+ route shadows that slot; retain operator handoff withdrawal checks.
27
+
28
+ - Bound unreachable RPC connections and release wallet-creation connections after errors.
29
+ - Report balance totals including reserves and expose frozen funds; retain the live validator
30
+ minimum in JSON, human and agent output, including an explicit offline state.
31
+ - Resolve exact model names before hash queries and carry the resolved hash into chat.
32
+ - Preflight caller-supplied HTLC appraisal commitments against finalized governed state;
33
+ missing profiles remain unavailable. Forward the appraisal tuple through chat's agent
34
+ inference handoff. Surface faucet readiness and server retry guidance.
35
+
10
36
  ## [0.4.0] - 2026-10-28
11
37
 
12
38
  ### Added
package/README.md CHANGED
@@ -405,8 +405,8 @@ From a source checkout, replace `flop` with `node sdk/cli/dist/cli.js` after
405
405
  the build above.
406
406
  The alternate public hosted launcher requires managed-home preparation described
407
407
  in the public guide; an immediate Desktop launch is not qualified. With the
408
- tailnet `--gateway`, the devnet operator must approve your Tailscale user ID
409
- first. The CLI then obtains a seven-day, 25 FLOP model-scoped allowance and
408
+ tailnet `--gateway`, your Tailscale user ID must be approved first; request it with an
409
+ [access request](https://github.com/flop-labs/network/issues/new?template=access.yml). The CLI then obtains a seven-day, 25 FLOP model-scoped allowance and
410
410
  stores the key in `~/.flop/devnet-agent-key.json` (mode 0600). Later launches
411
411
  and other local agent clients check and reuse the cached key.
412
412
  If the local key is lost or rejected, enrollment rotates the bearer while preserving
@@ -441,7 +441,7 @@ tailnet.
441
441
  #### Local OpenAI Proxy
442
442
  Expose a local OpenAI-compatible API (like LM Studio / Ollama's local server)
443
443
  where **every request is submitted and settled on-chain under your signer**.
444
- The proxy estimates G_n, submits the PoUI task, waits for a TEE miner to settle
444
+ The proxy estimates G_n, submits the PoI task, waits for a TEE miner to settle
445
445
  it, then returns the result — all behind the OpenAI REST schema:
446
446
  ```bash
447
447
  flop proxy # http://127.0.0.1:3201/v1
@@ -497,4 +497,6 @@ Operator images: use `ghcr.io/flop-labs/flop-core/cli:stable` (minimum 0.1.172),
497
497
  `ghcr.io/flop-labs/flop-core/miner-worker:stable` (minimum 0.1.172), and
498
498
  `ghcr.io/flop-labs/flop-core/miner-vllm-toploc:stable` (minimum 0.1.172).
499
499
  For a reproducible pin, use the version and digest in [releases.json](https://flop.network/releases.json).
500
- `just miner-pack /tmp/flop-miner-pack` copies that manifest and fills the worker and backend image pins.
500
+ `just miner-pack /tmp/flop-miner-pack` checks that manifest against the minimums, copies it, and sets the images to `:stable`.
501
+ The pack is for the public path, without the tailnet: public RPC, mailbox and sponsor, and
502
+ `docker-compose.tls.yml` for the session endpoint.
package/dist/cli.js CHANGED
@@ -5019,9 +5019,12 @@ function table(headers, rows) {
5019
5019
  }
5020
5020
  function inferError(error) {
5021
5021
  const message = error instanceof Error ? error.message : String(error);
5022
- if (/ECONNREFUSED|WebSocket|disconnected|connect.*(failed|error)|network error/i.test(message)) {
5022
+ if (/ECONNREFUSED|WebSocket|disconnected|RPC unreachable|connect.*(failed|error)|network error/i.test(message)) {
5023
5023
  return { code: "RPC_UNREACHABLE", message, retryable: true };
5024
5024
  }
5025
+ if (error && typeof error === "object" && "code" in error && typeof error.code === "string") {
5026
+ return { code: error.code, message, retryable: false };
5027
+ }
5025
5028
  if (/insufficient custody|shards confirmed/i.test(message)) {
5026
5029
  return { code: "DA_CUSTODY_FAILED", message, retryable: true };
5027
5030
  }
@@ -5119,13 +5122,14 @@ async function createClient(rpcUrl, signer) {
5119
5122
  return FlopClient7.create(opts);
5120
5123
  }
5121
5124
  async function createReadOnlyClient(rpcUrl) {
5122
- const [{ ApiPromise: ApiPromise2, HttpProvider: HttpProvider2, WsProvider: WsProvider2 }, { FlopClient: FlopClient7 }] = await Promise.all([
5123
- import("@polkadot/api"),
5124
- import("@flop-labs/sdk")
5125
- ]);
5126
- const provider = rpcUrl.startsWith("ws") ? new WsProvider2(rpcUrl) : new HttpProvider2(rpcUrl);
5127
- const api = await ApiPromise2.create({ provider, noInitWarn: true });
5128
- return FlopClient7.fromApiReadOnly(api);
5125
+ const { connectFlopApi, FlopClient: FlopClient7 } = await import("@flop-labs/sdk");
5126
+ const api = await connectFlopApi(rpcUrl);
5127
+ try {
5128
+ return FlopClient7.fromApiReadOnly(api);
5129
+ } catch (error) {
5130
+ await api.disconnect();
5131
+ throw error;
5132
+ }
5129
5133
  }
5130
5134
 
5131
5135
  // src/opts.ts
@@ -5208,21 +5212,26 @@ function parsePositiveInt(value, name) {
5208
5212
  // src/commands/balance.ts
5209
5213
  function balanceCmd() {
5210
5214
  return new Command("balance").description("Check account balance").argument("<address>", "Account address (SS58)").option("--json", "Output as JSON").action(async (address, opts, command) => {
5215
+ let client;
5211
5216
  try {
5212
5217
  const rpc = getRpc(command);
5213
5218
  const json = getJson(command);
5214
- const client = await createClient(rpc);
5219
+ client = await createClient(rpc);
5215
5220
  const info = await client.api.query.system.account(address);
5221
+ const free = BigInt(info.data.free.toString());
5222
+ const reserved = BigInt(info.data.reserved.toString());
5216
5223
  const result = {
5217
5224
  address,
5218
5225
  free: info.data.free.toString(),
5219
5226
  reserved: info.data.reserved.toString(),
5220
- total: info.data.free.toString()
5227
+ total: (free + reserved).toString(),
5228
+ frozen: info.data.frozen.toString()
5221
5229
  };
5222
5230
  print(result, json);
5223
- await client.disconnect();
5224
5231
  } catch (e) {
5225
5232
  commandError(e);
5233
+ } finally {
5234
+ await client?.disconnect();
5226
5235
  }
5227
5236
  });
5228
5237
  }
@@ -5588,7 +5597,15 @@ function sessionRunCmd() {
5588
5597
  completion_tokens: result.receipts[0].completionTokens?.toString()
5589
5598
  }, getJson(command));
5590
5599
  } catch (error) {
5591
- commandError(error, { next: walletPath ? `wallet saved at ${walletPath}; inspect the channel before retrying` : "check miner endpoint, registry identity, signer balance and channel state" });
5600
+ const channelId = error && typeof error === "object" && "channelId" in error && error.channelId instanceof Uint8Array ? `0x${Buffer.from(error.channelId).toString("hex")}` : null;
5601
+ printError({
5602
+ code: "SESSION_FAILED",
5603
+ message: error instanceof Error ? error.message : String(error),
5604
+ channel: channelId,
5605
+ recovery: channelId ? "inspect finalized channel and terminal events before retrying" : "no channel reported",
5606
+ ...walletPath ? { wallet: walletPath } : {}
5607
+ }, "json");
5608
+ process.exitCode = 1;
5592
5609
  } finally {
5593
5610
  await client?.disconnect();
5594
5611
  }
@@ -5599,10 +5616,11 @@ function sessionRunCmd() {
5599
5616
  import { Command as Command11 } from "commander";
5600
5617
  function walletAgentCreateCmd() {
5601
5618
  return new Command11("create").description("Create an agent wallet").argument("<agent>", "Agent address (SS58)").option("--daily-cap <cap>", "Daily cap in FLOP", "50").option("--tx-limit <limit>", "Per-tx limit in FLOP", "5").option("--json", "Output as JSON").action(async (agent, opts, command) => {
5619
+ let client;
5602
5620
  try {
5603
5621
  const rpc = getRpc(command);
5604
5622
  const { createAgentWallet: createAgentWallet2 } = await import("@flop-labs/sdk");
5605
- const client = await createClient(rpc, getSigner(command));
5623
+ client = await createClient(rpc, getSigner(command));
5606
5624
  const flopToRaw3 = (flop) => BigInt(Math.floor(parseFloat(flop) * 1e18));
5607
5625
  const txHash = await createAgentWallet2(client.api, client.signer, {
5608
5626
  agent,
@@ -5618,9 +5636,10 @@ function walletAgentCreateCmd() {
5618
5636
  },
5619
5637
  getJson(command)
5620
5638
  );
5621
- await client.disconnect();
5622
5639
  } catch (e) {
5623
- commandError(e, { next: "check signer balance and agent address; retry" });
5640
+ commandError(e, { next: "inspect finalized agentWallet.wallets for this agent before retrying creation" });
5641
+ } finally {
5642
+ await client?.disconnect();
5624
5643
  }
5625
5644
  });
5626
5645
  }
@@ -6000,6 +6019,7 @@ import { constants, accessSync, existsSync } from "node:fs";
6000
6019
  import http from "node:http";
6001
6020
  import https from "node:https";
6002
6021
  import path from "node:path";
6022
+ import { watchTransaction } from "@flop-labs/sdk";
6003
6023
  function printRolePlan(plan, output) {
6004
6024
  const mode = resolveOutputMode(output);
6005
6025
  if (mode === "agent") {
@@ -6010,7 +6030,8 @@ function printRolePlan(plan, output) {
6010
6030
  steps: plan.steps.map(({ phase, command, mutates }) => ({ phase, command, mutates })),
6011
6031
  ...extended.config_path ? { config_path: extended.config_path } : {},
6012
6032
  ...extended.mcp_config ? { mcp_config: extended.mcp_config } : {},
6013
- ...plan.gpu_inventory ? { gpu_inventory: plan.gpu_inventory } : {}
6033
+ ...plan.gpu_inventory ? { gpu_inventory: plan.gpu_inventory } : {},
6034
+ ...plan.role === "validator" ? { live_minimum_stake: plan.live_minimum_stake ?? null } : {}
6014
6035
  }, mode);
6015
6036
  return;
6016
6037
  }
@@ -6020,6 +6041,7 @@ function printRolePlan(plan, output) {
6020
6041
  }
6021
6042
  console.log(`${plan.role} onboarding (${plan.mode})`);
6022
6043
  console.log(plan.summary);
6044
+ if (plan.role === "validator") console.log(`Live minimum stake: ${plan.live_minimum_stake === void 0 ? "not queried (--offline)" : `${plan.live_minimum_stake} FLOP`}`);
6023
6045
  for (const [index, step] of plan.steps.entries()) {
6024
6046
  console.log(` ${index + 1}. ${step.phase}: ${step.command}`);
6025
6047
  console.log(` ${step.purpose}${step.mutates ? " [changes state]" : ""}`);
@@ -6120,7 +6142,7 @@ async function probeHttpUrl(id, url, required = true, timeoutMs = 3e3) {
6120
6142
  return;
6121
6143
  }
6122
6144
  const transport = parsed.protocol === "https:" ? https : http;
6123
- const request2 = transport.request(parsed, { method: "GET", timeout: timeoutMs }, (response) => {
6145
+ const request3 = transport.request(parsed, { method: "GET", timeout: timeoutMs }, (response) => {
6124
6146
  response.resume();
6125
6147
  const reachable = (response.statusCode ?? 500) < 500;
6126
6148
  resolve9({
@@ -6131,8 +6153,8 @@ async function probeHttpUrl(id, url, required = true, timeoutMs = 3e3) {
6131
6153
  ...!reachable ? { next: `check ${url}; retry doctor` } : {}
6132
6154
  });
6133
6155
  });
6134
- request2.on("timeout", () => request2.destroy(new Error("timeout")));
6135
- request2.on("error", (error) => {
6156
+ request3.on("timeout", () => request3.destroy(new Error("timeout")));
6157
+ request3.on("error", (error) => {
6136
6158
  resolve9({
6137
6159
  id,
6138
6160
  status: required ? "fail" : "warn",
@@ -6141,7 +6163,7 @@ async function probeHttpUrl(id, url, required = true, timeoutMs = 3e3) {
6141
6163
  next: `check ${url}; retry doctor`
6142
6164
  });
6143
6165
  });
6144
- request2.end();
6166
+ request3.end();
6145
6167
  });
6146
6168
  }
6147
6169
  async function probeRpc(api, rpc) {
@@ -6167,25 +6189,8 @@ async function probeRpc(api, rpc) {
6167
6189
  }
6168
6190
  }
6169
6191
  async function submitExtrinsic(api, signer, extrinsic) {
6170
- return new Promise((resolve9, reject) => {
6171
- let unsubscribe;
6172
- extrinsic.signAndSend(signer, (result) => {
6173
- if (!result.status?.isInBlock && !result.status?.isFinalized) return;
6174
- unsubscribe?.();
6175
- if (result.dispatchError) {
6176
- if (result.dispatchError.isModule) {
6177
- const decoded = api.registry.findMetaError(result.dispatchError.asModule);
6178
- reject(new Error(`${decoded.section}.${decoded.name}`));
6179
- } else {
6180
- reject(new Error(result.dispatchError.toString()));
6181
- }
6182
- return;
6183
- }
6184
- resolve9((result.txHash ?? extrinsic.hash).toHex());
6185
- }).then((fn) => {
6186
- unsubscribe = fn;
6187
- }).catch(reject);
6188
- });
6192
+ const result = await watchTransaction(extrinsic, signer, { api });
6193
+ return (result.txHash ?? extrinsic.hash).toHex();
6189
6194
  }
6190
6195
  function unwrapStorage(raw) {
6191
6196
  if (!raw || raw.isNone) return null;
@@ -9525,7 +9530,7 @@ function modelGetCmd() {
9525
9530
  const sdk = await import("@flop-labs/sdk");
9526
9531
  client = await createClient(getRpc(command));
9527
9532
  const registry = new sdk.OnChainModelRegistry(client.api);
9528
- const model = await registry.getModel(identifier) ?? await registry.getModelByName(identifier);
9533
+ const model = /^0x[0-9a-f]{64}$/i.test(identifier) ? await registry.getModel(identifier) : await registry.getModelByName(identifier);
9529
9534
  if (!model) throw new Error(`model not found: ${identifier}`);
9530
9535
  const precision = opts.precision;
9531
9536
  const [measuredRoot, sources] = await Promise.all([
@@ -9559,7 +9564,7 @@ function modelFetchCmd() {
9559
9564
  const sdk = await import("@flop-labs/sdk");
9560
9565
  client = await createClient(getRpc(command));
9561
9566
  const registry = new sdk.OnChainModelRegistry(client.api);
9562
- const model = await registry.getModel(identifier) ?? await registry.getModelByName(identifier);
9567
+ const model = /^0x[0-9a-f]{64}$/i.test(identifier) ? await registry.getModel(identifier) : await registry.getModelByName(identifier);
9563
9568
  if (!model) throw new Error(`model not found: ${identifier}`);
9564
9569
  const expected = await sdk.readMeasuredRoot(client.api, model.hash, opts.precision);
9565
9570
  if (!expected) {
@@ -9968,6 +9973,12 @@ function buildMinerInitPlan(options) {
9968
9973
  summary: mode === "tdx" ? "Prepare the measured TDX guest, attest it, then activate the on-chain miner separately." : mode === "cloud" ? "Provision a non-attested inference backend, then connect miner-worker explicitly." : "Start a local measured-manifest simulation before using real stake or hardware.",
9969
9974
  gpu_inventory: gpu,
9970
9975
  steps: [
9976
+ {
9977
+ phase: "devnet host",
9978
+ command: "sudo ./install.sh <machine-name>",
9979
+ purpose: "On an Ubuntu 24.04 GPU host, use the operator-issued pack and https://flop.network/skills/flop-miner-devnet/SKILL.md. The pack pins the registered model; the following generic steps apply to other setups.",
9980
+ mutates: true
9981
+ },
9971
9982
  {
9972
9983
  phase: "inventory",
9973
9984
  command: "flop gpu --json",
@@ -10024,7 +10035,7 @@ function minerInitCmd() {
10024
10035
  "Expected GPU inventory; repeat for mixed-SKU hosts",
10025
10036
  collectGpu,
10026
10037
  []
10027
- ).option("--model <id>", "Inference model", "Qwen/Qwen2.5-7B-Instruct").option("--json", "Output as JSON").action((opts, command) => {
10038
+ ).option("--model <id>", "Inference model", "Qwen/Qwen2.5-0.5B-Instruct").option("--json", "Output as JSON").action((opts, command) => {
10028
10039
  const instance = Number(opts.instance);
10029
10040
  if (!Number.isInteger(instance) || instance < 0) {
10030
10041
  commandError(new Error("--instance must be a non-negative integer"));
@@ -10303,7 +10314,7 @@ function validatorInitCmd() {
10303
10314
  let client;
10304
10315
  try {
10305
10316
  let minimumStake;
10306
- if (!opts.offline) {
10317
+ if (!getCommandOption(command, "offline", false)) {
10307
10318
  const rpc = getRpc(command);
10308
10319
  client = await createClient(rpc);
10309
10320
  const { getMinimumStake } = await import("@flop-labs/sdk");
@@ -10517,9 +10528,8 @@ import { Command as Command39 } from "commander";
10517
10528
 
10518
10529
  // src/network.ts
10519
10530
  async function probeRpcChain(network, rpc) {
10520
- const { ApiPromise: ApiPromise2, HttpProvider: HttpProvider2, WsProvider: WsProvider2 } = await import("@polkadot/api");
10521
- const provider = rpc.startsWith("ws") ? new WsProvider2(rpc) : new HttpProvider2(rpc);
10522
- const api = await ApiPromise2.create({ provider, noInitWarn: true });
10531
+ const { connectFlopApi } = await import("@flop-labs/sdk");
10532
+ const api = await connectFlopApi(rpc);
10523
10533
  try {
10524
10534
  const chain = (await api.rpc.system.chain()).toString();
10525
10535
  return {
@@ -10539,7 +10549,14 @@ async function probeRpcChain(network, rpc) {
10539
10549
  // src/commands/network-status.ts
10540
10550
  function networkStatusCmd(probeRpc2 = probeRpcChain, probeFaucet = async (endpoint) => {
10541
10551
  const { getFaucetChainInfo } = await import("@flop-labs/sdk");
10542
- return getFaucetChainInfo({ endpoint });
10552
+ const identity = await getFaucetChainInfo({ endpoint });
10553
+ try {
10554
+ const response = await fetch(`${endpoint.replace(/\/$/, "")}/health`, { signal: AbortSignal.timeout(15e3) });
10555
+ const health = await response.json();
10556
+ return { ...identity, ready: typeof health.ready === "boolean" ? health.ready : null, dependencies: health.dependencies };
10557
+ } catch (error) {
10558
+ return { ...identity, ready: null, readinessError: error instanceof Error ? error.message : String(error) };
10559
+ }
10543
10560
  }) {
10544
10561
  return new Command39("status").description("Show resolved network endpoints and live chain identity").option("--offline", "Resolve configured endpoints without connecting").option("--json", "Output as JSON").action(async (opts, command) => {
10545
10562
  const resolved = resolveNetwork(command);
@@ -10582,6 +10599,9 @@ function networkStatusCmd(probeRpc2 = probeRpcChain, probeFaucet = async (endpoi
10582
10599
  faucet: resolved.profile.faucet ? {
10583
10600
  url: resolved.profile.faucet,
10584
10601
  reachable: opts.offline ? null : faucetInfo !== null,
10602
+ ready: faucetInfo?.ready ?? null,
10603
+ dependencies: faucetInfo?.dependencies,
10604
+ readiness_error: faucetInfo?.readinessError,
10585
10605
  network: faucetInfo?.network,
10586
10606
  genesis_hash: faucetInfo?.genesisHash,
10587
10607
  matches_rpc: rpcInfo && faucetInfo ? rpcInfo.genesisHash.toLowerCase() === faucetInfo.genesisHash.toLowerCase() : null,
@@ -10714,7 +10734,7 @@ function conversationPrompt(messages) {
10714
10734
 
10715
10735
  // src/commands/chat.ts
10716
10736
  function chatCmd() {
10717
- return new Command41("chat").description("Start or resume a persistent FLOP chat, optionally through a bounded session").argument("[prompt]", "One-shot prompt; omit for interactive chat").option("--conversation <id>", "Resume a conversation ID").option("--model <id>", "Registered model ID", "llama-3-70b-instruct").option("--session-owner <address>", "Use --signer as this owner's session delegate").option("--max-flop <n>", "Per-turn FLOP cap", "5").option("--skip-model-check", "Allow an unregistered model (local development only)").option("--history", "List stored conversations without connecting").option("--delete <id>", "Delete a stored conversation without connecting").option("--store <path>", "Conversation store path", join8(homedir4(), ".flop", "conversations.json")).option("--json", "Output as JSON").action(async (prompt, opts, command) => {
10737
+ return new Command41("chat").description("Start or resume a persistent FLOP chat, optionally through a bounded session").argument("[prompt]", "One-shot prompt; omit for interactive chat").option("--conversation <id>", "Resume a conversation ID").option("--model <id>", "32-byte model hash or exact registered name from flop model list").option("--session-owner <address>", "Use --signer as this owner's session delegate").option("--max-flop <n>", "Per-turn FLOP cap", "5").option("--tee-requirements <hex>", "Registered HTLC appraisal commitments (100-byte SCALE hex)", process.env.FLOP_HTLC_TEE_REQUIREMENTS).option("--skip-model-check", "Allow an unregistered model (local development only)").option("--history", "List stored conversations without connecting").option("--delete <id>", "Delete a stored conversation without connecting").option("--store <path>", "Conversation store path", join8(homedir4(), ".flop", "conversations.json")).option("--json", "Output as JSON").action(async (prompt, opts, command) => {
10718
10738
  const store = new ConversationStore(opts.store);
10719
10739
  let agent;
10720
10740
  try {
@@ -10747,19 +10767,28 @@ function chatCmd() {
10747
10767
  const now = (/* @__PURE__ */ new Date()).toISOString();
10748
10768
  const conversation = existing ?? {
10749
10769
  id: randomUUID4(),
10750
- model: opts.model,
10770
+ model: opts.model ?? "",
10751
10771
  sessionOwner: opts.sessionOwner,
10752
10772
  createdAt: now,
10753
10773
  updatedAt: now,
10754
10774
  messages: []
10755
10775
  };
10756
- if (!opts.skipModelCheck) {
10776
+ if (!conversation.model) throw new Error("Supply --model with a hash or exact registered name from `flop model list`");
10777
+ {
10757
10778
  const sdk = await import("@flop-labs/sdk");
10758
10779
  const check = await createClient(getRpc(command));
10759
10780
  try {
10760
- const registry = new sdk.OnChainModelRegistry(check.api);
10761
- const model = await registry.getModel(conversation.model) ?? await registry.getModelByName(conversation.model);
10762
- if (!model) throw new Error(`model not found: ${conversation.model}`);
10781
+ if (!opts.skipModelCheck) {
10782
+ const registry = new sdk.OnChainModelRegistry(check.api);
10783
+ const model = /^0x[0-9a-f]{64}$/i.test(conversation.model) ? await registry.getModel(conversation.model) : await registry.getModelByName(conversation.model);
10784
+ if (!model) throw new Error(`model not found: ${conversation.model}; use a hash or exact name from flop model list`);
10785
+ conversation.model = model.hash;
10786
+ }
10787
+ if (!opts.teeRequirements) throw new Error("HTLC chat requires --tee-requirements from a registered EconomicCritical profile; check the network's attestation prerequisites before funding or signing");
10788
+ if (/^0x[0-9a-f]{200}$/i.test(opts.teeRequirements) && /^0x[0-9a-f]{64}$/i.test(conversation.model) && opts.teeRequirements.slice(74, 138).toLowerCase() !== conversation.model.slice(2).toLowerCase()) {
10789
+ throw new Error("HTLC model commitment must match the resolved --model hash");
10790
+ }
10791
+ await sdk.assertHtlcAppraisalProfile(check.api, opts.teeRequirements);
10763
10792
  } finally {
10764
10793
  await check.disconnect();
10765
10794
  }
@@ -10779,7 +10808,8 @@ function chatCmd() {
10779
10808
  conversation.messages.push({ role: "user", content: input, at: (/* @__PURE__ */ new Date()).toISOString() });
10780
10809
  const result = await agent.inference(conversationPrompt(conversation.messages), {
10781
10810
  model: conversation.model,
10782
- maxCost: maxFlop
10811
+ maxCost: maxFlop,
10812
+ teeRequirements: { appraisal: opts.teeRequirements }
10783
10813
  });
10784
10814
  conversation.messages.push({
10785
10815
  role: "assistant",
@@ -11601,18 +11631,19 @@ function verificationStatus(model, modelJson) {
11601
11631
  }
11602
11632
  async function fetchMinerSessionInfo(endpoint, authToken, signal) {
11603
11633
  const infoUrl = `${endpoint.replace(/\/+$/, "")}/session/info`;
11604
- const anonymous = await fetch(infoUrl, { signal });
11634
+ const anonymous = await fetch(infoUrl, { signal, redirect: "error" });
11605
11635
  await anonymous.body?.cancel();
11606
11636
  if (anonymous.status !== 401) throw new Error("miner /session/info does not enforce bearer authentication");
11607
- const response = await fetch(infoUrl, { headers: { Authorization: `Bearer ${authToken}` }, signal });
11637
+ const response = await fetch(infoUrl, { redirect: "error", headers: { Authorization: `Bearer ${authToken}` }, signal });
11608
11638
  if (!response.ok) throw new Error(`miner /session/info returned HTTP ${response.status}`);
11609
- return await response.json();
11639
+ const { computeChannel: computeChannel7 } = await import("@flop-labs/sdk");
11640
+ return JSON.parse(await computeChannel7.readMinerResponse(response, 16 * 1024));
11610
11641
  }
11611
- async function readChainSnapshot(api, miner) {
11642
+ async function readChainSnapshot(api, miner, pinnedHead) {
11612
11643
  const identity = miner.modelIdentity;
11613
11644
  const precision = PRECISIONS4[identity.precision];
11614
11645
  if (!precision) throw new Error(`miner precision is invalid: ${identity.precision}`);
11615
- const finalizedHead = await api.rpc.chain.getFinalizedHead();
11646
+ const finalizedHead = pinnedHead ?? await api.rpc.chain.getFinalizedHead();
11616
11647
  const at = await api.at(finalizedHead);
11617
11648
  const modelRegistry = at.query.modelRegistry;
11618
11649
  const hwCalibration = at.query.hwCalibration;
@@ -11688,8 +11719,8 @@ function gatewayAllowlistCmd() {
11688
11719
 
11689
11720
  // src/commands/gateway-renew.ts
11690
11721
  import { open as open3, readFile as readFile11, rename as rename9, rm as rm4, writeFile as writeFile12 } from "node:fs/promises";
11691
- import { createHash as createHash5 } from "node:crypto";
11692
- import { request } from "node:http";
11722
+ import { createHash as createHash6 } from "node:crypto";
11723
+ import { request as request2 } from "node:http";
11693
11724
  import { setTimeout as delay } from "node:timers/promises";
11694
11725
  import { Keyring as Keyring5 } from "@polkadot/keyring";
11695
11726
  import { decodeAddress as decodeAddress2, randomAsU8a } from "@polkadot/util-crypto";
@@ -11885,6 +11916,267 @@ function qualificationSignedToolCalls(frames) {
11885
11916
  });
11886
11917
  }
11887
11918
 
11919
+ // ../gateway-core/src/miner-admissions.ts
11920
+ import { closeSync, fstatSync, openSync, readSync, statSync } from "node:fs";
11921
+ import { createHash as createHash5 } from "node:crypto";
11922
+ var MAX_ADMITTED_MINERS = 256;
11923
+ var MAX_FILE_BYTES = 512 * 1024;
11924
+ var HASH4 = /^0x[0-9a-f]{64}$/;
11925
+ var minerSlot = (miner) => `${miner.minerAddress}:${miner.modelIdentity.requestedSlug}`;
11926
+ var staticMinerSha256 = (miner) => createHash5("sha256").update(JSON.stringify(miner)).digest("hex");
11927
+ var sameModel = (a, b) => Object.keys(a.modelIdentity).every((key) => a.modelIdentity[key] === b.modelIdentity[key]);
11928
+ var MinerRouting = class {
11929
+ constructor(statics, path5) {
11930
+ this.statics = statics;
11931
+ this.path = path5;
11932
+ }
11933
+ statics;
11934
+ path;
11935
+ stamp;
11936
+ entries = [];
11937
+ error;
11938
+ revision = 0;
11939
+ read(dynamic, now = Date.now()) {
11940
+ if (this.path) {
11941
+ let stamp;
11942
+ try {
11943
+ const stat = statSync(this.path);
11944
+ stamp = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
11945
+ } catch {
11946
+ stamp = "missing";
11947
+ }
11948
+ if (stamp !== this.stamp) {
11949
+ this.stamp = stamp;
11950
+ this.revision++;
11951
+ this.entries = [];
11952
+ this.error = void 0;
11953
+ let fd;
11954
+ try {
11955
+ fd = openSync(this.path, "r");
11956
+ const stat = fstatSync(fd);
11957
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES) throw new Error();
11958
+ const buffer = Buffer.alloc(MAX_FILE_BYTES + 1);
11959
+ const size = readSync(fd, buffer, 0, buffer.length, 0);
11960
+ if (size > MAX_FILE_BYTES) throw new Error();
11961
+ const value = JSON.parse(buffer.subarray(0, size).toString("utf8"));
11962
+ if (value.schema !== "flop-static-miner-handoffs/v1" || !Array.isArray(value.entries) || value.entries.length > MAX_ADMITTED_MINERS) throw new Error();
11963
+ const slots2 = /* @__PURE__ */ new Set();
11964
+ for (const entry of value.entries) {
11965
+ if (!entry || typeof entry.minerAddress !== "string" || !entry.minerAddress || typeof entry.requestedSlug !== "string" || !entry.requestedSlug || !/^[0-9a-f]{64}$/.test(entry.staticSha256)) throw new Error();
11966
+ const url = new URL(entry.endpoint);
11967
+ if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw new Error();
11968
+ const slot = `${entry.minerAddress}:${entry.requestedSlug}`;
11969
+ if (slots2.has(slot)) throw new Error();
11970
+ slots2.add(slot);
11971
+ }
11972
+ this.entries = value.entries;
11973
+ } catch {
11974
+ this.error = new Error("static miner handoffs file is missing or invalid; new routing is disabled");
11975
+ } finally {
11976
+ if (fd !== void 0) closeSync(fd);
11977
+ }
11978
+ }
11979
+ if (this.error) throw this.error;
11980
+ }
11981
+ const handed = /* @__PURE__ */ new Map();
11982
+ for (const entry of this.entries) {
11983
+ const slot = `${entry.minerAddress}:${entry.requestedSlug}`;
11984
+ const sources = this.statics.filter((miner) => minerSlot(miner) === slot);
11985
+ const source = sources[0];
11986
+ if (sources.length !== 1 || source.onDemand || source.admissionExpiresAt || staticMinerSha256(source) !== entry.staticSha256 || source.endpoint === entry.endpoint || !Number.isFinite(source.maxBudgetFlop) || source.maxBudgetFlop <= 0 || !Number.isSafeInteger(source.maxDurationBlocks) || source.maxDurationBlocks < 1) throw new Error("static miner handoff source differs; new routing is disabled");
11987
+ const target = dynamic.find((miner) => minerSlot(miner) === slot && miner.endpoint === entry.endpoint && miner.enclaveKey === source.enclaveKey && sameModel(source, miner) && miner.channelSigned && !!miner.admissionExpiresAt && Date.parse(miner.admissionExpiresAt) > now);
11988
+ handed.set(slot, target ? {
11989
+ ...target,
11990
+ maxBudgetFlop: Math.min(source.maxBudgetFlop, target.maxBudgetFlop),
11991
+ maxDurationBlocks: Math.min(source.maxDurationBlocks, target.maxDurationBlocks)
11992
+ } : void 0);
11993
+ }
11994
+ const slots = new Set(this.statics.map(minerSlot));
11995
+ const selected = [];
11996
+ for (const miner of this.statics) {
11997
+ if (!handed.has(minerSlot(miner))) selected.push(miner);
11998
+ else {
11999
+ const target = handed.get(minerSlot(miner));
12000
+ if (target) selected.push(target);
12001
+ }
12002
+ }
12003
+ return [...selected, ...dynamic.filter((miner) => !slots.has(minerSlot(miner)))];
12004
+ }
12005
+ };
12006
+ var MinerAdmissions = class {
12007
+ constructor(path5, policy) {
12008
+ this.path = path5;
12009
+ this.policy = policy;
12010
+ }
12011
+ path;
12012
+ policy;
12013
+ stamp;
12014
+ entries = [];
12015
+ revision = 0;
12016
+ read(now = Date.now()) {
12017
+ let stamp;
12018
+ try {
12019
+ const stat = statSync(this.path);
12020
+ stamp = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
12021
+ } catch {
12022
+ stamp = "missing";
12023
+ }
12024
+ if (stamp !== this.stamp) {
12025
+ this.stamp = stamp;
12026
+ this.revision++;
12027
+ this.entries = [];
12028
+ let fd;
12029
+ try {
12030
+ fd = openSync(this.path, "r");
12031
+ const stat = fstatSync(fd);
12032
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES) throw new Error("admissions file too large");
12033
+ const buffer = Buffer.alloc(MAX_FILE_BYTES + 1);
12034
+ const size = readSync(fd, buffer, 0, buffer.length, 0);
12035
+ if (size > MAX_FILE_BYTES) throw new Error("admissions file too large");
12036
+ const file = JSON.parse(buffer.subarray(0, size).toString("utf8"));
12037
+ if (file.schema !== "flop-miner-admissions/v1" || !Number.isFinite(Date.parse(file.generatedAt)) || !Array.isArray(file.entries) || file.entries.length > MAX_ADMITTED_MINERS) throw new Error("invalid admissions snapshot");
12038
+ const slots = /* @__PURE__ */ new Set();
12039
+ const entries = file.entries.map((entry) => {
12040
+ const id = entry.modelIdentity;
12041
+ const url = new URL(entry.endpoint);
12042
+ const accepted = Date.parse(entry.adAcceptedAt), expiry = Date.parse(entry.admissionExpiresAt);
12043
+ if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash || typeof entry.minerAddress !== "string" || !entry.minerAddress || !HASH4.test(entry.enclaveKey) || !id || ![id.modelHash, id.decodePolicyHash, id.measuredRoot].every((h) => HASH4.test(h)) || ![id.requestedSlug, id.registryModel, id.registryVersion, id.backendModelName].every((s) => typeof s === "string" && s.length > 0) || !Number.isInteger(id.precision) || id.precision < 0 || id.precision > 5 || !/^[1-9]\d*$/.test(entry.calibrationCapGflopsPerSec) || !Number.isFinite(accepted) || !Number.isFinite(expiry) || accepted > now || expiry <= accepted || expiry > accepted + 36e5 || entry.observation?.countryCode !== null && !/^[A-Z]{2}$/.test(entry.observation?.countryCode) || entry.observation?.regional !== false || entry.observation.observedAt !== entry.adAcceptedAt) throw new Error("invalid admission entry");
12044
+ requireWorkerMinimum(entry.worker?.version, entry.worker?.revision);
12045
+ const slot = minerSlot(entry);
12046
+ if (slots.has(slot)) throw new Error("duplicate admission slot");
12047
+ slots.add(slot);
12048
+ return {
12049
+ endpoint: entry.endpoint,
12050
+ authToken: "",
12051
+ channelSigned: true,
12052
+ minerAddress: entry.minerAddress,
12053
+ enclaveKey: entry.enclaveKey,
12054
+ modelIdentity: id,
12055
+ ...this.policy,
12056
+ admissionExpiresAt: entry.admissionExpiresAt,
12057
+ deployment: {
12058
+ countryCode: entry.observation.countryCode ?? "ZZ",
12059
+ source: "mailbox-observed:non-regional",
12060
+ observedAt: entry.adAcceptedAt,
12061
+ expiresAt: entry.admissionExpiresAt
12062
+ }
12063
+ };
12064
+ });
12065
+ this.entries = entries;
12066
+ } catch {
12067
+ } finally {
12068
+ if (fd !== void 0) closeSync(fd);
12069
+ }
12070
+ }
12071
+ return this.entries.filter((entry) => Date.parse(entry.admissionExpiresAt) > now);
12072
+ }
12073
+ };
12074
+
12075
+ // ../gateway-core/src/miner-peer.ts
12076
+ import { BlockList, isIP } from "node:net";
12077
+ import { lookup } from "node:dns/promises";
12078
+ import { request } from "node:https";
12079
+ import { Readable } from "node:stream";
12080
+ var NOT_PUBLIC = new BlockList();
12081
+ for (const [net, bits] of [
12082
+ ["0.0.0.0", 8],
12083
+ ["10.0.0.0", 8],
12084
+ ["100.64.0.0", 10],
12085
+ ["127.0.0.0", 8],
12086
+ ["169.254.0.0", 16],
12087
+ ["172.16.0.0", 12],
12088
+ ["192.0.0.0", 24],
12089
+ ["192.0.2.0", 24],
12090
+ ["192.168.0.0", 16],
12091
+ ["198.18.0.0", 15],
12092
+ ["198.51.100.0", 24],
12093
+ ["203.0.113.0", 24],
12094
+ ["224.0.0.0", 4],
12095
+ ["240.0.0.0", 4]
12096
+ ]) NOT_PUBLIC.addSubnet(net, bits, "ipv4");
12097
+ for (const [net, bits] of [
12098
+ // No ::ffff:0:0/96 rule: BlockList also checks IPv4 addresses against IPv4-mapped rules, so it
12099
+ // would match every IPv4 address. A mapped address is judged by its IPv4 part instead.
12100
+ ["::", 128],
12101
+ ["::1", 128],
12102
+ ["64:ff9b::", 96],
12103
+ ["100::", 64],
12104
+ ["2001:db8::", 32],
12105
+ ["fc00::", 7],
12106
+ ["fe80::", 10],
12107
+ ["ff00::", 8]
12108
+ ]) NOT_PUBLIC.addSubnet(net, bits, "ipv6");
12109
+ function isPublicAddress(ip) {
12110
+ const family = isIP(ip);
12111
+ if (family === 0) return false;
12112
+ if (family === 6) {
12113
+ const canonical = new URL(`http://[${ip}]`).hostname.slice(1, -1);
12114
+ const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(canonical);
12115
+ if (mapped) {
12116
+ const [high, low] = [parseInt(mapped[1], 16), parseInt(mapped[2], 16)];
12117
+ return isPublicAddress(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
12118
+ }
12119
+ }
12120
+ return !NOT_PUBLIC.check(ip, family === 4 ? "ipv4" : "ipv6");
12121
+ }
12122
+ var EndpointRefusal = class extends Error {
12123
+ };
12124
+ var systemResolve = (host) => lookup(host, { all: true, verbatim: true });
12125
+ async function resolvePublic(host, resolve9 = systemResolve) {
12126
+ const family = isIP(host);
12127
+ const addresses = family ? [{ address: host, family }] : await resolve9(host);
12128
+ if (addresses.length === 0 || !addresses.every((a) => isPublicAddress(a.address))) {
12129
+ throw new EndpointRefusal(`${host} does not resolve to public addresses only`);
12130
+ }
12131
+ return addresses[0];
12132
+ }
12133
+ function minerPeerFetch(endpoint, dynamic, before = () => {
12134
+ }, resolve9 = systemResolve, send2 = request) {
12135
+ return async (input, init = {}) => {
12136
+ before();
12137
+ const url = new URL(String(input));
12138
+ if (url.origin !== new URL(endpoint).origin) throw new EndpointRefusal("miner request escaped its origin");
12139
+ if (!dynamic) return fetch(input, { ...init, redirect: "error" });
12140
+ if (url.protocol !== "https:" || url.username || url.password) throw new EndpointRefusal("dynamic miner requires HTTPS");
12141
+ const signal = init.signal ?? AbortSignal.timeout(3e4);
12142
+ const pinned = await new Promise((accept, reject) => {
12143
+ const abort = () => reject(signal.reason);
12144
+ signal.addEventListener("abort", abort, { once: true });
12145
+ resolvePublic(url.hostname.replace(/^\[|\]$/g, ""), resolve9).then(accept, reject).finally(() => signal.removeEventListener("abort", abort));
12146
+ if (signal.aborted) abort();
12147
+ });
12148
+ before();
12149
+ signal.throwIfAborted();
12150
+ return new Promise((accept, reject) => {
12151
+ const req = send2(url, {
12152
+ method: init.method ?? "GET",
12153
+ headers: Object.fromEntries(new Headers(init.headers).entries()),
12154
+ signal,
12155
+ agent: false,
12156
+ lookup: (_host, options, callback) => {
12157
+ if (options.all) callback(null, [pinned]);
12158
+ else callback(null, pinned.address, pinned.family);
12159
+ }
12160
+ }, (res) => {
12161
+ const status = res.statusCode;
12162
+ if (status >= 300 && status < 400) {
12163
+ res.destroy();
12164
+ req.destroy();
12165
+ reject(new EndpointRefusal("miner redirect refused"));
12166
+ return;
12167
+ }
12168
+ const headers = new Headers();
12169
+ for (const [name, value] of Object.entries(res.headers)) {
12170
+ if (value !== void 0) headers.set(name, Array.isArray(value) ? value.join(", ") : value);
12171
+ }
12172
+ accept(new Response([204, 205].includes(status) ? null : Readable.toWeb(res), { status, headers }));
12173
+ });
12174
+ req.on("error", (error) => reject(signal.aborted ? signal.reason : error));
12175
+ req.end(init.body);
12176
+ });
12177
+ };
12178
+ }
12179
+
11888
12180
  // src/commands/gateway-renew.ts
11889
12181
  import { Command as Command47 } from "commander";
11890
12182
 
@@ -12151,7 +12443,7 @@ function tailnetNodeRef(source) {
12151
12443
  }
12152
12444
  function readTailscaleStatus(socketPath) {
12153
12445
  return new Promise((resolve9, reject) => {
12154
- const req = request({
12446
+ const req = request2({
12155
12447
  socketPath,
12156
12448
  path: "/localapi/v0/status",
12157
12449
  headers: { Host: "local-tailscaled.sock", "Sec-Tailscale": "localapi" },
@@ -12253,6 +12545,12 @@ async function renewGatewayEvidence(miners, records, observe) {
12253
12545
  const node = tailnetNodeRef(declared.source);
12254
12546
  let provider;
12255
12547
  let session;
12548
+ if (miner.admissionExpiresAt) {
12549
+ await observe.miner(miner);
12550
+ if (Date.parse(miner.admissionExpiresAt) <= observe.now()) throw new Error("miner admission expired");
12551
+ observed.push(miner);
12552
+ continue;
12553
+ }
12256
12554
  if (node) {
12257
12555
  session = await observe.miner(miner);
12258
12556
  provider = await observe.tailnet(node);
@@ -12467,11 +12765,75 @@ async function waitForSettlementAllowance(api, channelId, aggregateGn, deadline)
12467
12765
  }
12468
12766
  throw new Error("settlement allowance did not arrive before the probe deadline");
12469
12767
  }
12768
+ async function renewalMinerInfo(api, miner, fetchImpl = fetch) {
12769
+ const head = await api.rpc.chain.getFinalizedHead();
12770
+ const at = await api.at(head);
12771
+ const registered = await at.query.minerStaking.miners(miner.minerAddress);
12772
+ const active = registered?.isSome && registered.unwrap().isActive;
12773
+ if (!(active?.isTrue ?? active === true)) throw new Error("miner is not registered and active at finality");
12774
+ const encoded = await api.rpc.state.call("TrustRootApi_accepted_calibration_cap", `0x80${hex3(decodeAddress2(miner.minerAddress)).slice(2)}`, head);
12775
+ const cap = encoded.toU8a(true);
12776
+ if (cap.length !== 9 || cap[0] !== 1 || new DataView(cap.buffer, cap.byteOffset + 1, 8).getBigUint64(0, true) <= 0n) throw new Error("miner has no finalized calibrated capacity");
12777
+ const identity = {
12778
+ ...miner.modelIdentity,
12779
+ modelHash: bytes3(miner.modelIdentity.modelHash),
12780
+ decodePolicyHash: bytes3(miner.modelIdentity.decodePolicyHash),
12781
+ measuredRoot: bytes3(miner.modelIdentity.measuredRoot)
12782
+ };
12783
+ if (!miner.channelSigned) {
12784
+ const anonymous = await fetchImpl(`${miner.endpoint.replace(/\/$/, "")}/session/info`, { redirect: "error", signal: AbortSignal.timeout(3e4) });
12785
+ await anonymous.body?.cancel();
12786
+ if (anonymous.status !== 401) throw new Error("miner /session/info does not enforce authentication");
12787
+ }
12788
+ const info = miner.channelSigned ? await computeChannel6.fetchProvenSessionInfo(miner.endpoint, {
12789
+ genesisHash: api.genesisHash.toU8a(),
12790
+ account: decodeAddress2(miner.minerAddress),
12791
+ minerAddress: miner.minerAddress,
12792
+ enclaveKey: bytes3(miner.enclaveKey),
12793
+ modelIdentity: identity
12794
+ }, fetchImpl, AbortSignal.timeout(3e4)) : await computeChannel6.fetchMinerSessionInfo(
12795
+ miner.endpoint,
12796
+ { Authorization: `Bearer ${miner.authToken}` },
12797
+ AbortSignal.timeout(3e4),
12798
+ fetchImpl
12799
+ );
12800
+ requireWorkerMinimum(info.image?.version, info.image?.revision);
12801
+ const wire = {
12802
+ ...info,
12803
+ is_ready: info.isReady,
12804
+ enclaveKey: hex3(info.enclaveKey),
12805
+ modelHash: hex3(info.modelHash),
12806
+ modelIdentity: {
12807
+ ...info.modelIdentity,
12808
+ modelHash: hex3(info.modelIdentity.modelHash),
12809
+ decodePolicyHash: hex3(info.modelIdentity.decodePolicyHash),
12810
+ measuredRoot: hex3(info.modelIdentity.measuredRoot)
12811
+ }
12812
+ };
12813
+ const snapshot = await readChainSnapshot(api, wire, head.toHex());
12814
+ if (miner.channelSigned && snapshot.calibration) wire.calibration = {
12815
+ ...snapshot.calibration,
12816
+ ready: true,
12817
+ state: "active",
12818
+ currentBlock: snapshot.finalizedBlock
12819
+ };
12820
+ assertMinerMatchesChain(wire, snapshot);
12821
+ if (wire.minerAddress !== miner.minerAddress || wire.enclaveKey !== miner.enclaveKey || Object.keys(miner.modelIdentity).some((field2) => wire.modelIdentity[field2] !== miner.modelIdentity[field2])) throw new Error("miner no longer advertises its allowlisted identity");
12822
+ return { ...info, calibration: wire.calibration };
12823
+ }
12824
+ function renewalAdmissions(env, chainPriced) {
12825
+ if (!env.GATEWAY_ADMISSIONS_FILE) return void 0;
12826
+ const maxBudgetFlop = Number(env.GATEWAY_ADMISSION_MAX_BUDGET_FLOP), maxDurationBlocks = Number(env.GATEWAY_ADMISSION_MAX_DURATION_BLOCKS);
12827
+ if (!chainPriced || !Number.isFinite(maxBudgetFlop) || maxBudgetFlop <= 0 || !Number.isSafeInteger(maxDurationBlocks) || maxDurationBlocks <= 0) throw new Error("dynamic admission requires chain pricing and explicit gateway budget/duration policy");
12828
+ return new MinerAdmissions(env.GATEWAY_ADMISSIONS_FILE, { maxBudgetFlop, maxDurationBlocks });
12829
+ }
12470
12830
  async function probeToolsConformance(input) {
12471
12831
  const { api, chain, miner, record } = input;
12472
12832
  const deadline = Date.now() + PROBE_TIMEOUT_MS;
12473
- const headers = { Authorization: `Bearer ${miner.authToken}` };
12474
- const info = await computeChannel6.fetchMinerSessionInfo(miner.endpoint, headers, AbortSignal.timeout(3e4));
12833
+ const fetchImpl = minerPeerFetch(miner.endpoint, !!miner.admissionExpiresAt, input.assertAdmission);
12834
+ const headers = miner.channelSigned ? {} : { Authorization: `Bearer ${miner.authToken}` };
12835
+ const info = await renewalMinerInfo(api, miner, fetchImpl);
12836
+ let requestHeaders;
12475
12837
  const pinned = miner.modelIdentity;
12476
12838
  const advertised = info.modelIdentity;
12477
12839
  if (info.minerAddress !== miner.minerAddress || hex3(info.enclaveKey) !== miner.enclaveKey.toLowerCase() || !advertised || advertised.requestedSlug !== pinned.requestedSlug || advertised.registryModel !== pinned.registryModel || advertised.registryVersion !== pinned.registryVersion || hex3(advertised.modelHash) !== pinned.modelHash || advertised.precision !== pinned.precision || hex3(advertised.decodePolicyHash) !== pinned.decodePolicyHash || hex3(advertised.measuredRoot) !== pinned.measuredRoot || advertised.backendModelName !== pinned.backendModelName) {
@@ -12493,6 +12855,8 @@ async function probeToolsConformance(input) {
12493
12855
  let sequence = 0;
12494
12856
  const transport = new computeChannel6.HttpSessionTransport(miner.endpoint, PROBE_TIMEOUT_MS, {
12495
12857
  headers,
12858
+ fetchImpl,
12859
+ requestHeaders: (method, path5, body) => requestHeaders?.(method, path5, body) ?? {},
12496
12860
  onFrame: (frame) => {
12497
12861
  if (frame.sequence !== sequence++) throw new Error("tools probe frame sequence mismatch");
12498
12862
  if (frame.type === "chunk") frames.push({ kind: frame.kind, data: bytes3(frame.data) });
@@ -12500,6 +12864,7 @@ async function probeToolsConformance(input) {
12500
12864
  });
12501
12865
  const agentKey = computeChannel6.SessionKey.generate();
12502
12866
  const identity = { ...pinned, modelHash: bytes3(pinned.modelHash), decodePolicyHash: bytes3(pinned.decodePolicyHash), measuredRoot: bytes3(pinned.measuredRoot) };
12867
+ input.assertAdmission?.();
12503
12868
  const session = await computeChannel6.ChannelSession.open({
12504
12869
  chain,
12505
12870
  transport,
@@ -12526,7 +12891,15 @@ async function probeToolsConformance(input) {
12526
12891
  let verdict;
12527
12892
  let receipt;
12528
12893
  try {
12529
- await computeChannel6.notifyMinerOpen(miner.endpoint, session.channelId, agentKey.publicKey, headers, AbortSignal.timeout(3e4));
12894
+ if (miner.channelSigned) requestHeaders = await computeChannel6.channelSessionAuth(
12895
+ miner.endpoint,
12896
+ session.channelId,
12897
+ agentKey,
12898
+ info,
12899
+ fetchImpl,
12900
+ AbortSignal.timeout(3e4)
12901
+ );
12902
+ await computeChannel6.notifyMinerOpen(miner.endpoint, session.channelId, agentKey.publicKey, headers, AbortSignal.timeout(3e4), requestHeaders, fetchImpl);
12530
12903
  receipt = await session.chatTurn({
12531
12904
  version,
12532
12905
  model: record.openrouter_slug,
@@ -12549,7 +12922,7 @@ async function probeToolsConformance(input) {
12549
12922
  try {
12550
12923
  await waitForSettlementAllowance(api, session.channelId, receipt.gN, deadline);
12551
12924
  const settled = await session.closeCooperative(async (claim) => {
12552
- const wire = await computeChannel6.notifyMinerClose(miner.endpoint, claim, headers, AbortSignal.timeout(3e4));
12925
+ const wire = await computeChannel6.notifyMinerClose(miner.endpoint, claim, headers, AbortSignal.timeout(3e4), requestHeaders, fetchImpl);
12553
12926
  return wire ? {
12554
12927
  channelId: bytes3(wire.channelId),
12555
12928
  minerPaid: BigInt(wire.minerPaid),
@@ -12595,7 +12968,7 @@ function gatewayRenewCmd() {
12595
12968
  throw new Error("receipt-only mode requires the receipt/hash and cannot run direct probes");
12596
12969
  }
12597
12970
  const raw = await readFile11(opts.qualificationReceipt);
12598
- if (raw.length > 8e6 || createHash5("sha256").update(raw).digest("hex") !== opts.qualificationReceiptSha256) throw new Error("qualification receipt bytes differ");
12971
+ if (raw.length > 8e6 || createHash6("sha256").update(raw).digest("hex") !== opts.qualificationReceiptSha256) throw new Error("qualification receipt bytes differ");
12599
12972
  const value = JSON.parse(raw.toString("utf8"));
12600
12973
  const records2 = parseCanonicalCatalog(envJson(opts.catalogEnv), { chainPriced: opts.catalogPricing === "chain" });
12601
12974
  const record = records2.find((item) => item.openrouter_slug === value.model);
@@ -12612,7 +12985,12 @@ function gatewayRenewCmd() {
12612
12985
  return;
12613
12986
  }
12614
12987
  if (!opts.allowlistEnv || !opts.providerKeyEnv || !opts.probeSignerEnv || !opts.probeEscrow) throw new Error("direct renewal requires allowlist, provider, signer and escrow options");
12615
- const miners = envJson(opts.allowlistEnv);
12988
+ const staticMiners = envJson(opts.allowlistEnv);
12989
+ const admissions = renewalAdmissions(process.env, opts.catalogPricing === "chain");
12990
+ const handoffs = process.env.GATEWAY_STATIC_HANDOFFS_FILE?.trim();
12991
+ if (handoffs && !admissions) throw new Error("static handoffs require dynamic admissions");
12992
+ const routing = new MinerRouting(staticMiners, handoffs);
12993
+ const miners = routing.read(admissions?.read() ?? []);
12616
12994
  if (!Array.isArray(miners) || miners.length === 0) throw new Error(`${opts.allowlistEnv} must list at least one miner`);
12617
12995
  const records = parseCanonicalCatalog(envJson(opts.catalogEnv), { chainPriced: opts.catalogPricing === "chain" });
12618
12996
  const providerKey = envSecret(opts.providerKeyEnv);
@@ -12626,24 +13004,40 @@ function gatewayRenewCmd() {
12626
13004
  await computeChannel6.ready();
12627
13005
  const signer = new Keyring5({ type: "sr25519" }).addFromUri(signerUri);
12628
13006
  const chain = new computeChannel6.ComputeChannelChainClient(new computeChannel6.FlopChainSubmitter(api, signer, true));
13007
+ const assertAdmission = (miner) => {
13008
+ if (!miner.admissionExpiresAt) return;
13009
+ const dynamic = admissions?.read() ?? [];
13010
+ const matches = (item) => !!item && item.enclaveKey === miner.enclaveKey && item.endpoint === miner.endpoint && JSON.stringify(item.modelIdentity) === JSON.stringify(miner.modelIdentity);
13011
+ if (!matches(dynamic.find((item) => minerSlot(item) === minerSlot(miner))) || !matches(routing.read(dynamic).find((item) => minerSlot(item) === minerSlot(miner)))) throw new Error("miner admission expired or key revoked");
13012
+ };
12629
13013
  const report = await renewGatewayEvidence(miners, records, {
12630
13014
  now: Date.now,
12631
13015
  provider: (ref) => observeGpuAiInstance(ref, providerKey),
12632
13016
  tailnet: tailnetObserver(opts.tailscaleSocket),
12633
13017
  miner: async (miner) => {
12634
- const info = await fetchMinerSessionInfo(miner.endpoint, miner.authToken, AbortSignal.timeout(3e4));
13018
+ const proven = await renewalMinerInfo(api, miner, minerPeerFetch(miner.endpoint, !!miner.admissionExpiresAt, () => assertAdmission(miner)));
13019
+ const info = {
13020
+ ...proven,
13021
+ is_ready: proven.isReady,
13022
+ enclaveKey: hex3(proven.enclaveKey),
13023
+ modelHash: hex3(proven.modelHash),
13024
+ modelIdentity: {
13025
+ ...proven.modelIdentity,
13026
+ modelHash: hex3(proven.modelIdentity.modelHash),
13027
+ decodePolicyHash: hex3(proven.modelIdentity.decodePolicyHash),
13028
+ measuredRoot: hex3(proven.modelIdentity.measuredRoot)
13029
+ }
13030
+ };
12635
13031
  const pinned = miner.modelIdentity;
12636
13032
  const advertised = info.modelIdentity;
12637
13033
  if (info.minerAddress !== miner.minerAddress || info.enclaveKey?.toLowerCase() !== miner.enclaveKey.toLowerCase() || !advertised || Object.keys(pinned).some((key) => advertised[key] !== pinned[key])) {
12638
13034
  throw new Error("miner no longer advertises its allowlisted identity");
12639
13035
  }
12640
- const snapshot = await readChainSnapshot(api, info);
12641
- assertMinerMatchesChain(info, snapshot);
12642
- return `authenticated /session/info matches its pin and finalized block ${snapshot.finalizedBlock}`;
13036
+ return "authenticated session identity matches its pin and finalized registry/calibration";
12643
13037
  },
12644
13038
  tools: async (miner, record) => {
12645
13039
  await assertProbeSignerFunded(api, signer.address, escrow);
12646
- return probeToolsConformance({ api, chain, miner, record, escrow });
13040
+ return probeToolsConformance({ api, chain, miner, record, escrow, assertAdmission: () => assertAdmission(miner) });
12647
13041
  }
12648
13042
  });
12649
13043
  const exitCode = await finishRenewal(report, opts.out, getJson(command));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flop-labs/cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "FLOP Network CLI — interact with FLOP from the terminal",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "repository": {
@@ -22,8 +22,8 @@
22
22
  "access": "public"
23
23
  },
24
24
  "dependencies": {
25
- "@flop-labs/client": "^0.2.1",
26
- "@flop-labs/sdk": "^0.4.0",
25
+ "@flop-labs/client": "^0.2.2",
26
+ "@flop-labs/sdk": "^0.5.1",
27
27
  "@polkadot/api": "^17.0.1",
28
28
  "@polkadot/keyring": "^14.0.3",
29
29
  "@polkadot/util-crypto": "^14.0.3",
@@ -42,12 +42,15 @@
42
42
  "engines": {
43
43
  "node": ">=22.0.0"
44
44
  },
45
+ "bugs": {
46
+ "url": "https://github.com/flop-labs/network/issues/new/choose"
47
+ },
45
48
  "scripts": {
46
49
  "build": "tsc --noEmit && node scripts/bundle.mjs",
47
50
  "start": "node dist/cli.js",
48
51
  "dev": "tsx src/cli.ts",
49
52
  "typecheck": "tsc --noEmit",
50
- "test:unit": "vitest run tests/unit",
53
+ "test:unit": "vitest run tests/unit --testTimeout=20000 --hookTimeout=20000",
51
54
  "build:public": "node scripts/build-public.mjs",
52
55
  "test:public": "node --test scripts/*.test.mjs",
53
56
  "verify:public": "node scripts/verify-public.mjs"