@secondlayer/shared 13.1.0 → 13.2.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.
@@ -284,7 +284,7 @@ interface ApiKeysTable {
284
284
  rate_limit: Generated<number>;
285
285
  ip_address: string;
286
286
  account_id: string;
287
- product: Generated<"account" | "streams" | "index" | "sentinel" | "docs">;
287
+ product: Generated<"account" | "streams" | "index" | "sentinel" | "docs" | "tenant">;
288
288
  /** Sentinel permission areas; set only on `product = 'sentinel'` keys. */
289
289
  areas: unknown | null;
290
290
  tier: "free" | "build" | "scale" | "enterprise" | "internal" | null;
@@ -284,7 +284,7 @@ interface ApiKeysTable {
284
284
  rate_limit: Generated<number>;
285
285
  ip_address: string;
286
286
  account_id: string;
287
- product: Generated<"account" | "streams" | "index" | "sentinel" | "docs">;
287
+ product: Generated<"account" | "streams" | "index" | "sentinel" | "docs" | "tenant">;
288
288
  /** Sentinel permission areas; set only on `product = 'sentinel'` keys. */
289
289
  areas: unknown | null;
290
290
  tier: "free" | "build" | "scale" | "enterprise" | "internal" | null;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Cache key for a contract read's arguments: SHA-256 over the hex-serialized
3
+ * Clarity args (no `0x`, lowercase), joined by `,`.
4
+ *
5
+ * One function for both sides of a hosted read: the subgraph runtime hashes
6
+ * the values it serialized, the hosted chain-read route hashes the hex it
7
+ * received, and both land on the same `chain_read_cache.args_hash`. A
8
+ * collision-resistant hash, because the hosted cache is shared across
9
+ * tenants: with a weak hash, one tenant could craft args that collide with
10
+ * another's read and pin a wrong answer to it.
11
+ */
12
+ declare function chainReadArgsHash(hexArgs: readonly string[]): string;
13
+ export { chainReadArgsHash };
@@ -0,0 +1,12 @@
1
+ // src/chain-read.ts
2
+ import { createHash } from "node:crypto";
3
+ function chainReadArgsHash(hexArgs) {
4
+ const normalized = hexArgs.map((arg) => arg.replace(/^0x/i, "").toLowerCase()).join(",");
5
+ return createHash("sha256").update(normalized).digest("hex");
6
+ }
7
+ export {
8
+ chainReadArgsHash
9
+ };
10
+
11
+ //# debugId=578F03C6DABB150A64756E2164756E21
12
+ //# sourceMappingURL=chain-read.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/chain-read.ts"],
4
+ "sourcesContent": [
5
+ "import { createHash } from \"node:crypto\";\n\n/**\n * Cache key for a contract read's arguments: SHA-256 over the hex-serialized\n * Clarity args (no `0x`, lowercase), joined by `,`.\n *\n * One function for both sides of a hosted read: the subgraph runtime hashes\n * the values it serialized, the hosted chain-read route hashes the hex it\n * received, and both land on the same `chain_read_cache.args_hash`. A\n * collision-resistant hash, because the hosted cache is shared across\n * tenants: with a weak hash, one tenant could craft args that collide with\n * another's read and pin a wrong answer to it.\n */\nexport function chainReadArgsHash(hexArgs: readonly string[]): string {\n\tconst normalized = hexArgs\n\t\t.map((arg) => arg.replace(/^0x/i, \"\").toLowerCase())\n\t\t.join(\",\");\n\treturn createHash(\"sha256\").update(normalized).digest(\"hex\");\n}\n"
6
+ ],
7
+ "mappings": ";AAAA;AAaO,SAAS,iBAAiB,CAAC,SAAoC;AAAA,EACrE,MAAM,aAAa,QACjB,IAAI,CAAC,QAAQ,IAAI,QAAQ,QAAQ,EAAE,EAAE,YAAY,CAAC,EAClD,KAAK,GAAG;AAAA,EACV,OAAO,WAAW,QAAQ,EAAE,OAAO,UAAU,EAAE,OAAO,KAAK;AAAA;",
8
+ "debugId": "578F03C6DABB150A64756E2164756E21",
9
+ "names": []
10
+ }
@@ -601,7 +601,7 @@ interface ApiKeysTable {
601
601
  rate_limit: Generated<number>;
602
602
  ip_address: string;
603
603
  account_id: string;
604
- product: Generated<"account" | "streams" | "index" | "sentinel" | "docs">;
604
+ product: Generated<"account" | "streams" | "index" | "sentinel" | "docs" | "tenant">;
605
605
  /** Sentinel permission areas; set only on `product = 'sentinel'` keys. */
606
606
  areas: unknown | null;
607
607
  tier: "free" | "build" | "scale" | "enterprise" | "internal" | null;
@@ -285,7 +285,7 @@ interface ApiKeysTable {
285
285
  rate_limit: Generated<number>;
286
286
  ip_address: string;
287
287
  account_id: string;
288
- product: Generated<"account" | "streams" | "index" | "sentinel" | "docs">;
288
+ product: Generated<"account" | "streams" | "index" | "sentinel" | "docs" | "tenant">;
289
289
  /** Sentinel permission areas; set only on `product = 'sentinel'` keys. */
290
290
  areas: unknown | null;
291
291
  tier: "free" | "build" | "scale" | "enterprise" | "internal" | null;
@@ -284,7 +284,7 @@ interface ApiKeysTable {
284
284
  rate_limit: Generated<number>;
285
285
  ip_address: string;
286
286
  account_id: string;
287
- product: Generated<"account" | "streams" | "index" | "sentinel" | "docs">;
287
+ product: Generated<"account" | "streams" | "index" | "sentinel" | "docs" | "tenant">;
288
288
  /** Sentinel permission areas; set only on `product = 'sentinel'` keys. */
289
289
  areas: unknown | null;
290
290
  tier: "free" | "build" | "scale" | "enterprise" | "internal" | null;
@@ -284,7 +284,7 @@ interface ApiKeysTable {
284
284
  rate_limit: Generated<number>;
285
285
  ip_address: string;
286
286
  account_id: string;
287
- product: Generated<"account" | "streams" | "index" | "sentinel" | "docs">;
287
+ product: Generated<"account" | "streams" | "index" | "sentinel" | "docs" | "tenant">;
288
288
  /** Sentinel permission areas; set only on `product = 'sentinel'` keys. */
289
289
  areas: unknown | null;
290
290
  tier: "free" | "build" | "scale" | "enterprise" | "internal" | null;
@@ -1159,6 +1159,12 @@ declare function setContractAbi(db: Kysely<Database>, contractId: string, data:
1159
1159
  inferredStandards?: string[]
1160
1160
  }): Promise<void>;
1161
1161
  /**
1162
+ * Re-classification: replace a contract's inferred standards. Bumps
1163
+ * `abi_fetched_at`, which /v1/contracts serves as `classified_at` (hosted
1164
+ * tenants poll `classified_after`), so changed rows reach them.
1165
+ */
1166
+ declare function setInferredStandards(db: Kysely<Database>, contractId: string, inferred: string[]): Promise<void>;
1167
+ /**
1162
1168
  * A contract reorged out and not (yet) redeployed on the new fork is not on
1163
1169
  * the canonical chain — serve 404, not its stale row/ABI. Matches every other
1164
1170
  * contracts read (list, trait scope, pending-ABI), which already filter.
@@ -1172,6 +1178,8 @@ declare function listContractsByTrait(db: Kysely<Database>, trait: string, opts?
1172
1178
  conformance?: Conformance
1173
1179
  limit?: number
1174
1180
  afterId?: string
1181
+ /** Only contracts classified (ABI stored) at or after this time. */
1182
+ classifiedAfter?: Date
1175
1183
  }): Promise<Contract[]>;
1176
1184
  /**
1177
1185
  * As-of-block trait resolution (B4): contract IDs conforming to `trait` whose
@@ -1186,4 +1194,4 @@ declare function resolveTraitContractIds(db: Kysely<Database>, trait: string, as
1186
1194
  * the new fork and recordContractDeploy re-canonicalizes them.
1187
1195
  */
1188
1196
  declare function markContractsNonCanonical(db: Kysely<Database>, fromBlockHeight: number): Promise<void>;
1189
- export { AbiStatus, Conformance, getContract, listContractsByTrait, listContractsPendingAbi, markContractsNonCanonical, recordContractDeploy, resolveTraitContractIds, setContractAbi };
1197
+ export { AbiStatus, Conformance, getContract, listContractsByTrait, listContractsPendingAbi, markContractsNonCanonical, recordContractDeploy, resolveTraitContractIds, setContractAbi, setInferredStandards };
@@ -39,6 +39,9 @@ async function setContractAbi(db, contractId, data) {
39
39
  ...data.inferredStandards ? { inferred_standards: data.inferredStandards } : {}
40
40
  }).where("contract_id", "=", contractId).execute();
41
41
  }
42
+ async function setInferredStandards(db, contractId, inferred) {
43
+ await db.updateTable("contracts").set({ inferred_standards: inferred, abi_fetched_at: new Date }).where("contract_id", "=", contractId).execute();
44
+ }
42
45
  async function getContract(db, contractId) {
43
46
  return await db.selectFrom("contracts").selectAll().where("contract_id", "=", contractId).where("canonical", "=", true).executeTakeFirst() ?? null;
44
47
  }
@@ -57,6 +60,9 @@ async function listContractsByTrait(db, trait, opts = {}) {
57
60
  }
58
61
  if (opts.afterId)
59
62
  q = q.where("contract_id", ">", opts.afterId);
63
+ if (opts.classifiedAfter) {
64
+ q = q.where("abi_fetched_at", ">=", opts.classifiedAfter);
65
+ }
60
66
  return q.orderBy("contract_id", "asc").limit(opts.limit ?? 100).execute();
61
67
  }
62
68
  async function resolveTraitContractIds(db, trait, asOfBlock) {
@@ -73,8 +79,9 @@ export {
73
79
  markContractsNonCanonical,
74
80
  recordContractDeploy,
75
81
  resolveTraitContractIds,
76
- setContractAbi
82
+ setContractAbi,
83
+ setInferredStandards
77
84
  };
78
85
 
79
- //# debugId=E2278282AC73418664756E2164756E21
86
+ //# debugId=F52EB3C4FFD0BA8464756E2164756E21
80
87
  //# sourceMappingURL=contracts.js.map
@@ -2,10 +2,10 @@
2
2
  "version": 3,
3
3
  "sources": ["../src/db/queries/contracts.ts", "../src/db/jsonb.ts"],
4
4
  "sourcesContent": [
5
- "import { type Kysely, sql } from \"kysely\";\nimport { jsonb } from \"../jsonb.ts\";\nimport type { Contract, Database } from \"../types.ts\";\n\n/**\n * Contract registry queries — backing for trait-based discovery. Populated from\n * contract deploys; ABI fetched async; standards inferred by static analysis.\n */\n\nexport type AbiStatus = \"pending\" | \"fetched\" | \"failed\" | \"unparseable\";\n\n/**\n * Record a contract deploy (idempotent). ABI is fetched separately + async.\n * On conflict the row is re-canonicalized rather than skipped: after a reorg\n * flips contracts non-canonical (markContractsNonCanonical), discovery\n * re-selects any id whose deploy tx exists on the new fork and this upsert\n * restores `canonical` with the fork's height. ABI fields are untouched, so a\n * previously fetched ABI survives the round trip.\n */\nexport async function recordContractDeploy(\n\tdb: Kysely<Database>,\n\trow: { contractId: string; deployer: string; blockHeight: number },\n): Promise<void> {\n\tawait db\n\t\t.insertInto(\"contracts\")\n\t\t.values({\n\t\t\tcontract_id: row.contractId,\n\t\t\tdeployer: row.deployer,\n\t\t\tblock_height: row.blockHeight,\n\t\t})\n\t\t.onConflict((oc) =>\n\t\t\toc.column(\"contract_id\").doUpdateSet({\n\t\t\t\tdeployer: row.deployer,\n\t\t\t\tblock_height: row.blockHeight,\n\t\t\t\tcanonical: true,\n\t\t\t}),\n\t\t)\n\t\t.execute();\n}\n\n/** Store a fetched ABI + the traits/standards derived from it. */\nexport async function setContractAbi(\n\tdb: Kysely<Database>,\n\tcontractId: string,\n\tdata: {\n\t\tabi: unknown | null;\n\t\tstatus: AbiStatus;\n\t\tdeclaredTraits?: string[];\n\t\tinferredStandards?: string[];\n\t},\n): Promise<void> {\n\tawait db\n\t\t.updateTable(\"contracts\")\n\t\t.set({\n\t\t\tabi: data.abi == null ? null : jsonb(data.abi),\n\t\t\tabi_status: data.status,\n\t\t\tabi_fetched_at: new Date(),\n\t\t\t...(data.declaredTraits ? { declared_traits: data.declaredTraits } : {}),\n\t\t\t...(data.inferredStandards\n\t\t\t\t? { inferred_standards: data.inferredStandards }\n\t\t\t\t: {}),\n\t\t})\n\t\t.where(\"contract_id\", \"=\", contractId)\n\t\t.execute();\n}\n\n/**\n * A contract reorged out and not (yet) redeployed on the new fork is not on\n * the canonical chain — serve 404, not its stale row/ABI. Matches every other\n * contracts read (list, trait scope, pending-ABI), which already filter.\n */\nexport async function getContract(\n\tdb: Kysely<Database>,\n\tcontractId: string,\n): Promise<Contract | null> {\n\treturn (\n\t\t(await db\n\t\t\t.selectFrom(\"contracts\")\n\t\t\t.selectAll()\n\t\t\t.where(\"contract_id\", \"=\", contractId)\n\t\t\t.where(\"canonical\", \"=\", true)\n\t\t\t.executeTakeFirst()) ?? null\n\t);\n}\n\n/** Contracts still awaiting an ABI fetch (drives the fetch/backfill worker). */\nexport async function listContractsPendingAbi(\n\tdb: Kysely<Database>,\n\tlimit = 100,\n): Promise<Contract[]> {\n\treturn db\n\t\t.selectFrom(\"contracts\")\n\t\t.selectAll()\n\t\t.where(\"abi_status\", \"=\", \"pending\")\n\t\t.where(\"canonical\", \"=\", true)\n\t\t.orderBy(\"block_height\", \"asc\")\n\t\t.limit(limit)\n\t\t.execute();\n}\n\nexport type Conformance = \"declared\" | \"inferred\" | \"any\";\n\n/** Discovery: contracts matching a trait/standard, by conformance source. */\nexport async function listContractsByTrait(\n\tdb: Kysely<Database>,\n\ttrait: string,\n\topts: { conformance?: Conformance; limit?: number; afterId?: string } = {},\n): Promise<Contract[]> {\n\tconst conformance = opts.conformance ?? \"any\";\n\tlet q = db.selectFrom(\"contracts\").selectAll().where(\"canonical\", \"=\", true);\n\n\tif (conformance === \"declared\") {\n\t\tq = q.where(sql<boolean>`${sql.ref(\"declared_traits\")} @> ARRAY[${trait}]`);\n\t} else if (conformance === \"inferred\") {\n\t\tq = q.where(\n\t\t\tsql<boolean>`${sql.ref(\"inferred_standards\")} @> ARRAY[${trait}]`,\n\t\t);\n\t} else {\n\t\tq = q.where(\n\t\t\tsql<boolean>`(${sql.ref(\"declared_traits\")} @> ARRAY[${trait}] OR ${sql.ref(\"inferred_standards\")} @> ARRAY[${trait}])`,\n\t\t);\n\t}\n\n\tif (opts.afterId) q = q.where(\"contract_id\", \">\", opts.afterId);\n\treturn q\n\t\t.orderBy(\"contract_id\", \"asc\")\n\t\t.limit(opts.limit ?? 100)\n\t\t.execute();\n}\n\n/**\n * As-of-block trait resolution (B4): contract IDs conforming to `trait` whose\n * deploy block ≤ `asOfBlock`. Lets a trait-scoped subgraph reindex a token's\n * full history even if classification lagged its deploy.\n */\nexport async function resolveTraitContractIds(\n\tdb: Kysely<Database>,\n\ttrait: string,\n\tasOfBlock: number,\n): Promise<string[]> {\n\tconst rows = await db\n\t\t.selectFrom(\"contracts\")\n\t\t.select(\"contract_id\")\n\t\t.where(\"canonical\", \"=\", true)\n\t\t.where(\"block_height\", \"<=\", asOfBlock)\n\t\t.where(\n\t\t\tsql<boolean>`(${sql.ref(\"declared_traits\")} @> ARRAY[${trait}] OR ${sql.ref(\"inferred_standards\")} @> ARRAY[${trait}])`,\n\t\t)\n\t\t.execute();\n\treturn rows.map((r) => r.contract_id);\n}\n\n/**\n * Reorg: flip contracts deployed at/above a reorged height to non-canonical.\n * Called from the indexer's handleReorg tx. Recovery is eventual: discovery\n * (discoverDeploys) re-selects non-canonical ids whose deploy tx exists on\n * the new fork and recordContractDeploy re-canonicalizes them.\n */\nexport async function markContractsNonCanonical(\n\tdb: Kysely<Database>,\n\tfromBlockHeight: number,\n): Promise<void> {\n\tawait db\n\t\t.updateTable(\"contracts\")\n\t\t.set({ canonical: false })\n\t\t.where(\"block_height\", \">=\", fromBlockHeight)\n\t\t.execute();\n}\n",
5
+ "import { type Kysely, sql } from \"kysely\";\nimport { jsonb } from \"../jsonb.ts\";\nimport type { Contract, Database } from \"../types.ts\";\n\n/**\n * Contract registry queries — backing for trait-based discovery. Populated from\n * contract deploys; ABI fetched async; standards inferred by static analysis.\n */\n\nexport type AbiStatus = \"pending\" | \"fetched\" | \"failed\" | \"unparseable\";\n\n/**\n * Record a contract deploy (idempotent). ABI is fetched separately + async.\n * On conflict the row is re-canonicalized rather than skipped: after a reorg\n * flips contracts non-canonical (markContractsNonCanonical), discovery\n * re-selects any id whose deploy tx exists on the new fork and this upsert\n * restores `canonical` with the fork's height. ABI fields are untouched, so a\n * previously fetched ABI survives the round trip.\n */\nexport async function recordContractDeploy(\n\tdb: Kysely<Database>,\n\trow: { contractId: string; deployer: string; blockHeight: number },\n): Promise<void> {\n\tawait db\n\t\t.insertInto(\"contracts\")\n\t\t.values({\n\t\t\tcontract_id: row.contractId,\n\t\t\tdeployer: row.deployer,\n\t\t\tblock_height: row.blockHeight,\n\t\t})\n\t\t.onConflict((oc) =>\n\t\t\toc.column(\"contract_id\").doUpdateSet({\n\t\t\t\tdeployer: row.deployer,\n\t\t\t\tblock_height: row.blockHeight,\n\t\t\t\tcanonical: true,\n\t\t\t}),\n\t\t)\n\t\t.execute();\n}\n\n/** Store a fetched ABI + the traits/standards derived from it. */\nexport async function setContractAbi(\n\tdb: Kysely<Database>,\n\tcontractId: string,\n\tdata: {\n\t\tabi: unknown | null;\n\t\tstatus: AbiStatus;\n\t\tdeclaredTraits?: string[];\n\t\tinferredStandards?: string[];\n\t},\n): Promise<void> {\n\tawait db\n\t\t.updateTable(\"contracts\")\n\t\t.set({\n\t\t\tabi: data.abi == null ? null : jsonb(data.abi),\n\t\t\tabi_status: data.status,\n\t\t\tabi_fetched_at: new Date(),\n\t\t\t...(data.declaredTraits ? { declared_traits: data.declaredTraits } : {}),\n\t\t\t...(data.inferredStandards\n\t\t\t\t? { inferred_standards: data.inferredStandards }\n\t\t\t\t: {}),\n\t\t})\n\t\t.where(\"contract_id\", \"=\", contractId)\n\t\t.execute();\n}\n\n/**\n * Re-classification: replace a contract's inferred standards. Bumps\n * `abi_fetched_at`, which /v1/contracts serves as `classified_at` (hosted\n * tenants poll `classified_after`), so changed rows reach them.\n */\nexport async function setInferredStandards(\n\tdb: Kysely<Database>,\n\tcontractId: string,\n\tinferred: string[],\n): Promise<void> {\n\tawait db\n\t\t.updateTable(\"contracts\")\n\t\t.set({ inferred_standards: inferred, abi_fetched_at: new Date() })\n\t\t.where(\"contract_id\", \"=\", contractId)\n\t\t.execute();\n}\n\n/**\n * A contract reorged out and not (yet) redeployed on the new fork is not on\n * the canonical chain — serve 404, not its stale row/ABI. Matches every other\n * contracts read (list, trait scope, pending-ABI), which already filter.\n */\nexport async function getContract(\n\tdb: Kysely<Database>,\n\tcontractId: string,\n): Promise<Contract | null> {\n\treturn (\n\t\t(await db\n\t\t\t.selectFrom(\"contracts\")\n\t\t\t.selectAll()\n\t\t\t.where(\"contract_id\", \"=\", contractId)\n\t\t\t.where(\"canonical\", \"=\", true)\n\t\t\t.executeTakeFirst()) ?? null\n\t);\n}\n\n/** Contracts still awaiting an ABI fetch (drives the fetch/backfill worker). */\nexport async function listContractsPendingAbi(\n\tdb: Kysely<Database>,\n\tlimit = 100,\n): Promise<Contract[]> {\n\treturn db\n\t\t.selectFrom(\"contracts\")\n\t\t.selectAll()\n\t\t.where(\"abi_status\", \"=\", \"pending\")\n\t\t.where(\"canonical\", \"=\", true)\n\t\t.orderBy(\"block_height\", \"asc\")\n\t\t.limit(limit)\n\t\t.execute();\n}\n\nexport type Conformance = \"declared\" | \"inferred\" | \"any\";\n\n/** Discovery: contracts matching a trait/standard, by conformance source. */\nexport async function listContractsByTrait(\n\tdb: Kysely<Database>,\n\ttrait: string,\n\topts: {\n\t\tconformance?: Conformance;\n\t\tlimit?: number;\n\t\tafterId?: string;\n\t\t/** Only contracts classified (ABI stored) at or after this time. */\n\t\tclassifiedAfter?: Date;\n\t} = {},\n): Promise<Contract[]> {\n\tconst conformance = opts.conformance ?? \"any\";\n\tlet q = db.selectFrom(\"contracts\").selectAll().where(\"canonical\", \"=\", true);\n\n\tif (conformance === \"declared\") {\n\t\tq = q.where(sql<boolean>`${sql.ref(\"declared_traits\")} @> ARRAY[${trait}]`);\n\t} else if (conformance === \"inferred\") {\n\t\tq = q.where(\n\t\t\tsql<boolean>`${sql.ref(\"inferred_standards\")} @> ARRAY[${trait}]`,\n\t\t);\n\t} else {\n\t\tq = q.where(\n\t\t\tsql<boolean>`(${sql.ref(\"declared_traits\")} @> ARRAY[${trait}] OR ${sql.ref(\"inferred_standards\")} @> ARRAY[${trait}])`,\n\t\t);\n\t}\n\n\tif (opts.afterId) q = q.where(\"contract_id\", \">\", opts.afterId);\n\tif (opts.classifiedAfter) {\n\t\tq = q.where(\"abi_fetched_at\", \">=\", opts.classifiedAfter);\n\t}\n\treturn q\n\t\t.orderBy(\"contract_id\", \"asc\")\n\t\t.limit(opts.limit ?? 100)\n\t\t.execute();\n}\n\n/**\n * As-of-block trait resolution (B4): contract IDs conforming to `trait` whose\n * deploy block ≤ `asOfBlock`. Lets a trait-scoped subgraph reindex a token's\n * full history even if classification lagged its deploy.\n */\nexport async function resolveTraitContractIds(\n\tdb: Kysely<Database>,\n\ttrait: string,\n\tasOfBlock: number,\n): Promise<string[]> {\n\tconst rows = await db\n\t\t.selectFrom(\"contracts\")\n\t\t.select(\"contract_id\")\n\t\t.where(\"canonical\", \"=\", true)\n\t\t.where(\"block_height\", \"<=\", asOfBlock)\n\t\t.where(\n\t\t\tsql<boolean>`(${sql.ref(\"declared_traits\")} @> ARRAY[${trait}] OR ${sql.ref(\"inferred_standards\")} @> ARRAY[${trait}])`,\n\t\t)\n\t\t.execute();\n\treturn rows.map((r) => r.contract_id);\n}\n\n/**\n * Reorg: flip contracts deployed at/above a reorged height to non-canonical.\n * Called from the indexer's handleReorg tx. Recovery is eventual: discovery\n * (discoverDeploys) re-selects non-canonical ids whose deploy tx exists on\n * the new fork and recordContractDeploy re-canonicalizes them.\n */\nexport async function markContractsNonCanonical(\n\tdb: Kysely<Database>,\n\tfromBlockHeight: number,\n): Promise<void> {\n\tawait db\n\t\t.updateTable(\"contracts\")\n\t\t.set({ canonical: false })\n\t\t.where(\"block_height\", \">=\", fromBlockHeight)\n\t\t.execute();\n}\n",
6
6
  "import { type RawBuilder, sql } from \"kysely\";\n\n/**\n * Safely encode a JS value as a JSONB literal for Kysely inserts/updates.\n * Kysely + postgres.js double-encodes JSON when using parameterized queries\n * with ::jsonb casts. This uses sql.raw to inline a properly escaped literal.\n *\n * Generic parameter lets callers set the RawBuilder's output type so they\n * don't need to cast at the insert site. Default is `unknown` — widen at\n * the call site when the column type is narrower, e.g. `jsonb<MyShape>(...)`.\n */\nexport function jsonb<T = unknown>(value: T): RawBuilder<T> {\n\tconst escaped = JSON.stringify(value, (_k, v) =>\n\t\ttypeof v === \"bigint\" ? v.toString() : v,\n\t).replace(/'/g, \"''\");\n\treturn sql`${sql.raw(`'${escaped}'::jsonb`)}`;\n}\n\n/**\n * Safely parse a JSONB value from the database.\n * Handles double-encoded strings where postgres.js returns a JSON string\n * instead of a parsed object.\n */\nexport function parseJsonb<T = unknown>(value: unknown): T {\n\tif (typeof value === \"string\") {\n\t\ttry {\n\t\t\treturn JSON.parse(value) as T;\n\t\t} catch {\n\t\t\treturn value as T;\n\t\t}\n\t}\n\treturn (value ?? {}) as T;\n}\n"
7
7
  ],
8
- "mappings": "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",
9
- "debugId": "E2278282AC73418664756E2164756E21",
8
+ "mappings": "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",
9
+ "debugId": "F52EB3C4FFD0BA8464756E2164756E21",
10
10
  "names": []
11
11
  }
@@ -284,7 +284,7 @@ interface ApiKeysTable {
284
284
  rate_limit: Generated<number>;
285
285
  ip_address: string;
286
286
  account_id: string;
287
- product: Generated<"account" | "streams" | "index" | "sentinel" | "docs">;
287
+ product: Generated<"account" | "streams" | "index" | "sentinel" | "docs" | "tenant">;
288
288
  /** Sentinel permission areas; set only on `product = 'sentinel'` keys. */
289
289
  areas: unknown | null;
290
290
  tier: "free" | "build" | "scale" | "enterprise" | "internal" | null;
@@ -284,7 +284,7 @@ interface ApiKeysTable {
284
284
  rate_limit: Generated<number>;
285
285
  ip_address: string;
286
286
  account_id: string;
287
- product: Generated<"account" | "streams" | "index" | "sentinel" | "docs">;
287
+ product: Generated<"account" | "streams" | "index" | "sentinel" | "docs" | "tenant">;
288
288
  /** Sentinel permission areas; set only on `product = 'sentinel'` keys. */
289
289
  areas: unknown | null;
290
290
  tier: "free" | "build" | "scale" | "enterprise" | "internal" | null;
@@ -284,7 +284,7 @@ interface ApiKeysTable {
284
284
  rate_limit: Generated<number>;
285
285
  ip_address: string;
286
286
  account_id: string;
287
- product: Generated<"account" | "streams" | "index" | "sentinel" | "docs">;
287
+ product: Generated<"account" | "streams" | "index" | "sentinel" | "docs" | "tenant">;
288
288
  /** Sentinel permission areas; set only on `product = 'sentinel'` keys. */
289
289
  areas: unknown | null;
290
290
  tier: "free" | "build" | "scale" | "enterprise" | "internal" | null;
@@ -284,7 +284,7 @@ interface ApiKeysTable {
284
284
  rate_limit: Generated<number>;
285
285
  ip_address: string;
286
286
  account_id: string;
287
- product: Generated<"account" | "streams" | "index" | "sentinel" | "docs">;
287
+ product: Generated<"account" | "streams" | "index" | "sentinel" | "docs" | "tenant">;
288
288
  /** Sentinel permission areas; set only on `product = 'sentinel'` keys. */
289
289
  areas: unknown | null;
290
290
  tier: "free" | "build" | "scale" | "enterprise" | "internal" | null;
@@ -284,7 +284,7 @@ interface ApiKeysTable {
284
284
  rate_limit: Generated<number>;
285
285
  ip_address: string;
286
286
  account_id: string;
287
- product: Generated<"account" | "streams" | "index" | "sentinel" | "docs">;
287
+ product: Generated<"account" | "streams" | "index" | "sentinel" | "docs" | "tenant">;
288
288
  /** Sentinel permission areas; set only on `product = 'sentinel'` keys. */
289
289
  areas: unknown | null;
290
290
  tier: "free" | "build" | "scale" | "enterprise" | "internal" | null;
@@ -294,7 +294,7 @@ interface ApiKeysTable {
294
294
  rate_limit: Generated<number>;
295
295
  ip_address: string;
296
296
  account_id: string;
297
- product: Generated<"account" | "streams" | "index" | "sentinel" | "docs">;
297
+ product: Generated<"account" | "streams" | "index" | "sentinel" | "docs" | "tenant">;
298
298
  /** Sentinel permission areas; set only on `product = 'sentinel'` keys. */
299
299
  areas: unknown | null;
300
300
  tier: "free" | "build" | "scale" | "enterprise" | "internal" | null;
@@ -284,7 +284,7 @@ interface ApiKeysTable {
284
284
  rate_limit: Generated<number>;
285
285
  ip_address: string;
286
286
  account_id: string;
287
- product: Generated<"account" | "streams" | "index" | "sentinel" | "docs">;
287
+ product: Generated<"account" | "streams" | "index" | "sentinel" | "docs" | "tenant">;
288
288
  /** Sentinel permission areas; set only on `product = 'sentinel'` keys. */
289
289
  areas: unknown | null;
290
290
  tier: "free" | "build" | "scale" | "enterprise" | "internal" | null;
@@ -283,7 +283,7 @@ interface ApiKeysTable {
283
283
  rate_limit: Generated<number>;
284
284
  ip_address: string;
285
285
  account_id: string;
286
- product: Generated<"account" | "streams" | "index" | "sentinel" | "docs">;
286
+ product: Generated<"account" | "streams" | "index" | "sentinel" | "docs" | "tenant">;
287
287
  /** Sentinel permission areas; set only on `product = 'sentinel'` keys. */
288
288
  areas: unknown | null;
289
289
  tier: "free" | "build" | "scale" | "enterprise" | "internal" | null;
@@ -240,6 +240,8 @@ type IndexContractRow = {
240
240
  declared_traits: string[] | null
241
241
  inferred_standards: string[] | null
242
242
  abi_status: string
243
+ /** When the contract's traits were last set (server clock). */
244
+ classified_at: string | null
243
245
  };
244
246
  /** A `/v1/index/sbtc/withdrawals` row: one peg-out rolled up per request. */
245
247
  type IndexSbtcWithdrawalRow = {
@@ -350,8 +352,11 @@ declare class IndexHttpClient {
350
352
  requestId?: number
351
353
  }): Promise<IndexSbtcEventRow[]>;
352
354
  /** Every canonical contract conforming to `trait` (declared or inferred),
353
- * draining `/v1/contracts`'s `next_cursor`. */
354
- listContractsByTrait(trait: string): Promise<IndexContractRow[]>;
355
+ * draining `/v1/contracts`'s `next_cursor`. `classifiedAfter` limits it to
356
+ * contracts classified at or after that time. */
357
+ listContractsByTrait(trait: string, opts?: {
358
+ classifiedAfter?: Date
359
+ }): Promise<IndexContractRow[]>;
355
360
  /** Withdrawals whose BTC sweep crossed the confirmation threshold strictly
356
361
  * after `confirmedAfter`, across all history. */
357
362
  fetchSettledWithdrawals(confirmedAfter: Date): Promise<IndexSbtcWithdrawalRow[]>;
@@ -201,7 +201,7 @@ class IndexHttpClient {
201
201
  }
202
202
  return out.sort((a, b) => a.block_height - b.block_height || a.event_index - b.event_index);
203
203
  }
204
- async listContractsByTrait(trait) {
204
+ async listContractsByTrait(trait, opts = {}) {
205
205
  const out = [];
206
206
  let cursor = null;
207
207
  do {
@@ -209,6 +209,9 @@ class IndexHttpClient {
209
209
  trait,
210
210
  limit: String(CONTRACTS_PAGE_LIMIT)
211
211
  });
212
+ if (opts.classifiedAfter) {
213
+ params.set("classified_after", opts.classifiedAfter.toISOString());
214
+ }
212
215
  if (cursor)
213
216
  params.set("cursor", cursor);
214
217
  const { items, next_cursor } = await this.getPage("/v1/contracts", "contracts", params);
@@ -317,5 +320,5 @@ export {
317
320
  createInternalIndexHttpClient
318
321
  };
319
322
 
320
- //# debugId=E54A5375EB1771BD64756E2164756E21
323
+ //# debugId=53AE43019E152E4064756E2164756E21
321
324
  //# sourceMappingURL=index-http.js.map
@@ -3,9 +3,9 @@
3
3
  "sources": ["../src/index-internal-auth.ts", "../src/index-http.ts"],
4
4
  "sourcesContent": [
5
5
  "// Internal service credentials for first-party consumers of /v1/index and\n// /v1/streams over HTTP (decoder, subgraph processor PublicApiBlockSource).\n// Seeded as internal tenants with NO account_id, so these reads are\n// unmetered (metering gates on account_id). Lives in shared so the API (seed)\n// and the consumers import it without a cycle.\n//\n// Resolution: STREAMS_INTERNAL_API_KEY / INDEX_INTERNAL_API_KEY, then\n// INSTANCE_TOKEN (OSS). No committed fallback. Empty-string env values fall\n// through (`trim()`, then empty): compose interpolates\n// `${STREAMS_INTERNAL_API_KEY:-}` which is \"\" when unset.\n//\n// Archive keys must not use the `sk-sl_` prefix; generate with `sl-int_`.\n// OSS compose should omit the internal env and send INSTANCE_TOKEN.\nexport const INDEX_INTERNAL_TENANT_ID = \"tenant_index_internal\";\nexport const INTERNAL_STREAMS_TENANT_ID = \"tenant_streams_decode_internal\";\n\nconst DEFAULT_INTERNAL_INDEX_BASE_URL = \"http://api:3800\";\n\nconst MISSING_INTERNAL_STREAMS_API_KEY =\n\t\"Set STREAMS_INTERNAL_API_KEY (archive; prefix sl-int_) or INSTANCE_TOKEN (OSS). Empty env no longer falls back to a committed secret.\";\n\nconst MISSING_INTERNAL_INDEX_API_KEY =\n\t\"Set INDEX_INTERNAL_API_KEY (archive; prefix sl-int_) or INSTANCE_TOKEN (OSS). Without one, Index reads carry no credential and platform mode 401s every one of them.\";\n\nexport function defaultInternalIndexApiKey(\n\tenv: NodeJS.ProcessEnv = process.env,\n): string | undefined {\n\tconst internal = env.INDEX_INTERNAL_API_KEY?.trim();\n\tif (internal) return internal;\n\tconst instance = env.INSTANCE_TOKEN?.trim();\n\tif (instance) return instance;\n\treturn undefined;\n}\n\nexport function defaultInternalStreamsApiKey(\n\tenv: NodeJS.ProcessEnv = process.env,\n): string | undefined {\n\tconst internal = env.STREAMS_INTERNAL_API_KEY?.trim();\n\tif (internal) return internal;\n\tconst instance = env.INSTANCE_TOKEN?.trim();\n\tif (instance) return instance;\n\treturn undefined;\n}\n\n/**\n * Platform-mode Index consumer: refuse to run with neither credential.\n *\n * Platform mode requires a credential on every Index read (a keyless read\n * 401s, `api/src/index/auth.ts`) — a first-party consumer with no internal\n * key would fail every request at call time. Fail here at startup instead.\n *\n * Self-hosted (oss/dedicated) instances are not gated, so they must NOT call\n * this — `defaultInternalIndexApiKey()` returning undefined is correct there.\n */\nexport function requireInternalIndexApiKey(\n\tenv: NodeJS.ProcessEnv = process.env,\n): string {\n\tconst key = defaultInternalIndexApiKey(env);\n\tif (!key) throw new Error(MISSING_INTERNAL_INDEX_API_KEY);\n\treturn key;\n}\n\n/** Decoder startup: refuse to run with neither credential. */\nexport function requireInternalStreamsApiKey(\n\tenv: NodeJS.ProcessEnv = process.env,\n): string {\n\tconst key = defaultInternalStreamsApiKey(env);\n\tif (!key) throw new Error(MISSING_INTERNAL_STREAMS_API_KEY);\n\treturn key;\n}\n\n/** Shared env contract for decoder + subgraph HTTP dogfood. */\nexport function defaultInternalIndexBaseUrl(): string {\n\treturn (\n\t\tprocess.env.SUBGRAPH_INDEX_API_URL ||\n\t\tprocess.env.STREAMS_API_URL ||\n\t\tDEFAULT_INTERNAL_INDEX_BASE_URL\n\t);\n}\n",
6
- "import {\n\tdefaultInternalIndexApiKey,\n\tdefaultInternalIndexBaseUrl,\n\tdefaultInternalStreamsApiKey,\n} from \"./index-internal-auth.ts\";\nimport type { RuneStreamsEvent } from \"./streams-rows/events.ts\";\n\n/**\n * Low-level transport for the public Index (`/v1/index`) + Streams clock\n * (`/v1/streams`) HTTP APIs: cursor-paginated reads, tip, reorgs. Lives in\n * `shared` (a leaf both the SDK and the subgraph runtime depend on) so the wire\n * format has one home and no package cycle. The SDK's ergonomic client should\n * eventually consume these row types too (see the plan's convergence task).\n *\n * This is intentionally minimal — just the GETs the subgraph runtime's\n * PublicApiBlockSource needs. It is NOT the SDK's full client (walk/consume/\n * retries/auth resolution).\n */\n\nconst PAGE_LIMIT = 1000;\n\n/** Ceiling on the Index long-poll `wait` param (seconds), shared by client and\n * server so neither drifts from the other. Kept comfortably under app-server\n * Caddy's `reverse_proxy` (no read/write timeout override, so this is a\n * self-imposed ceiling, not one it's protecting against) and Bun's own\n * 90s idleTimeout on the api listener. */\nexport const MAX_INDEX_WAIT_SECONDS = 25;\n\n// Transport resilience for the streams-index data plane. The api runs N>1\n// replicas behind Caddy; during a rolling deploy one replica is briefly\n// unreachable, surfacing as a thrown fetch (connection refused/reset) or a\n// Caddy 502/503/504 while it fails over. Retrying a few times with backoff\n// makes a single-replica recreate transparent to the subgraph-processor /\n// decoder — closing the processors-depend-on-api coupling.\nconst MAX_ATTEMPTS = 4;\nconst RETRY_BASE_MS = 150;\nconst RETRYABLE_STATUS = new Set([502, 503, 504]);\n\nconst delay = (ms: number): Promise<void> =>\n\tnew Promise((resolve) => setTimeout(resolve, ms));\n\n/** A non-2xx Index/Streams response, carrying the status so a caller can\n * distinguish \"the server rejected this request\" (e.g. a `wait`/`from_height`\n * param an older server's `validateQueryParams` doesn't recognize, HTTP 400)\n * from a transient outage — see `getIndexTipEnvelope`'s wait fallback. */\nexport class IndexHttpStatusError extends Error {\n\tconstructor(\n\t\treadonly status: number,\n\t\tmessage: string,\n\t) {\n\t\tsuper(message);\n\t\tthis.name = \"IndexHttpStatusError\";\n\t}\n}\n\n/** The two refusals a metered read can get once an account is past its free\n * allowance (`checkRowsAllowance`). Both are deliberate billing states, not\n * outages: retrying them in a storm only burns the request budget. */\nexport type BillingPausedCode = \"spend_cap_reached\" | \"insufficient_credits\";\n\nconst BILLING_PAUSED_CODES: ReadonlySet<string> = new Set<BillingPausedCode>([\n\t\"spend_cap_reached\",\n\t\"insufficient_credits\",\n]);\n\n/** A read refused with `402` and `{ error: \"spend_cap_reached\" |\n * \"insufficient_credits\" }`. Raised only on an exact body match, so an\n * unmetered (self-host) server never produces it. */\nexport class BillingPausedError extends IndexHttpStatusError {\n\tconstructor(\n\t\treadonly code: BillingPausedCode,\n\t\tmessage: string,\n\t) {\n\t\tsuper(402, message);\n\t\tthis.name = \"BillingPausedError\";\n\t}\n}\n\n/** `BillingPausedError` when `status`/`body` is exactly a billing refusal. */\nfunction billingPausedFrom(\n\tstatus: number,\n\tbody: string,\n\tmessage: string,\n): BillingPausedError | null {\n\tif (status !== 402) return null;\n\ttry {\n\t\tconst code = (JSON.parse(body) as { error?: unknown } | null)?.error;\n\t\tif (typeof code === \"string\" && BILLING_PAUSED_CODES.has(code)) {\n\t\t\treturn new BillingPausedError(code as BillingPausedCode, message);\n\t\t}\n\t} catch {\n\t\t// Not JSON: not a billing refusal.\n\t}\n\treturn null;\n}\n\ntype Envelope<K extends string, T> = {\n\t[P in K]: T[];\n} & { next_cursor: string | null };\n\n// ── Index API wire shapes (single source of truth) ─────────────────────────\nexport type IndexBlockRow = {\n\tblock_height: number;\n\tblock_hash: string;\n\tparent_hash: string;\n\tburn_block_height: number;\n\tburn_block_hash: string | null;\n\t/** Nakamoto StacksBlockId. Absent from pre-2026-07 API deployments. */\n\tindex_block_hash?: string | null;\n\tblock_time: string | null;\n};\n\ntype IndexEventCommon = {\n\tblock_height: number;\n\ttx_id: string;\n\ttx_index?: number;\n\tevent_index: number;\n\tcontract_id: string | null;\n\t/** Submitting-tx context — present only when the read passed `tx_context=true`.\n\t * Lets a consumer build the tx for `ctx.tx` without a separate\n\t * walkTransactions (the ~37x reindex over-fetch; see indexing-speed plan). */\n\ttx_sender?: string | null;\n\ttx_type?: string | null;\n\ttx_status?: string | null;\n\ttx_contract_id?: string | null;\n\ttx_function_name?: string | null;\n};\n\nexport type IndexEventRow = IndexEventCommon &\n\t(\n\t\t| {\n\t\t\t\tevent_type: \"ft_transfer\" | \"ft_mint\" | \"ft_burn\";\n\t\t\t\tasset_identifier: string;\n\t\t\t\tsender?: string;\n\t\t\t\trecipient?: string;\n\t\t\t\tamount: string;\n\t\t }\n\t\t| {\n\t\t\t\tevent_type: \"nft_transfer\" | \"nft_mint\" | \"nft_burn\";\n\t\t\t\tasset_identifier: string;\n\t\t\t\tsender?: string;\n\t\t\t\trecipient?: string;\n\t\t\t\tvalue: string;\n\t\t }\n\t\t| {\n\t\t\t\tevent_type: \"stx_transfer\" | \"stx_mint\" | \"stx_burn\";\n\t\t\t\tsender?: string;\n\t\t\t\trecipient?: string;\n\t\t\t\tamount: string;\n\t\t\t\tmemo?: string | null;\n\t\t }\n\t\t| {\n\t\t\t\tevent_type: \"stx_lock\";\n\t\t\t\tsender: string;\n\t\t\t\tamount: string;\n\t\t\t\tpayload: { unlock_height: string | null };\n\t\t }\n\t\t| {\n\t\t\t\tevent_type: \"print\";\n\t\t\t\tpayload: {\n\t\t\t\t\ttopic: string | null;\n\t\t\t\t\tvalue: unknown;\n\t\t\t\t\traw_value: string | null;\n\t\t\t\t};\n\t\t }\n\t\t| {\n\t\t\t\tevent_type: \"nested_contract_call\";\n\t\t\t\tsender?: string | null;\n\t\t\t\tcaller: string;\n\t\t\t\tfunction_name: string;\n\t\t\t\tfunction_args?: unknown;\n\t\t\t\traw_result?: string | null;\n\t\t }\n\t\t| {\n\t\t\t\tevent_type: \"var_set\";\n\t\t\t\tvar_name: string;\n\t\t\t\traw_value?: string | null;\n\t\t }\n\t\t| {\n\t\t\t\tevent_type: \"map_set\" | \"map_insert\";\n\t\t\t\tmap: string;\n\t\t\t\traw_key?: string | null;\n\t\t\t\traw_value?: string | null;\n\t\t }\n\t\t| {\n\t\t\t\tevent_type: \"map_delete\";\n\t\t\t\tmap: string;\n\t\t\t\traw_key?: string | null;\n\t\t }\n\t);\n\nexport type IndexTransactionRow = {\n\ttx_id: string;\n\tblock_height: number;\n\tblock_time?: string | null;\n\tburn_block_height?: number | null;\n\ttx_index: number;\n\ttx_type: string;\n\tsender: string;\n\tstatus: string;\n\tcontract_call?: {\n\t\tcontract_id: string;\n\t\tfunction_name: string;\n\t\tfunction_args_hex?: string[] | null;\n\t\tresult_hex?: string | null;\n\t} | null;\n\tsmart_contract?: { contract_id: string | null } | null;\n};\n\n/** A `/v1/index/state-writes` row read with `tx_context=true`. */\nexport type IndexStateWriteRow = {\n\tblock_height: number;\n\tordinal: number;\n\t/** Null for block-level writes. */\n\ttx_index: number | null;\n\tkey: string;\n\tvalue_hex: string;\n\ttx_id?: string | null;\n\ttx_sender?: string | null;\n\ttx_type?: string | null;\n\ttx_status?: string | null;\n\ttx_contract_id?: string | null;\n\ttx_function_name?: string | null;\n};\n\n/** A `/v1/index/sbtc/events` row: one decoded sBTC protocol event. Numeric\n * columns are JSON numbers here (the API normalizes them). */\nexport type IndexSbtcEventRow = {\n\tcursor: string;\n\tblock_height: number;\n\tblock_time?: string | null;\n\ttx_id: string;\n\ttx_index: number;\n\tevent_index: number;\n\ttopic: string;\n\trequest_id: number | null;\n\tamount: string | null;\n\tsender: string | null;\n\trecipient_btc_version: number | null;\n\trecipient_btc_hashbytes: string | null;\n\tbitcoin_txid: string | null;\n\toutput_index: number | null;\n\tsweep_txid: string | null;\n\tburn_hash: string | null;\n\tburn_height: number | null;\n\tsigner_bitmap: string | null;\n\tmax_fee: string | null;\n\tfee: string | null;\n\tgovernance_contract_type: number | null;\n\tgovernance_new_contract: string | null;\n\tsigner_aggregate_pubkey: string | null;\n\tsigner_threshold: number | null;\n\tsigner_address: string | null;\n\tsigner_keys_count: number | null;\n};\n\n/** A `/v1/contracts?trait=` row (ABI omitted). */\nexport type IndexContractRow = {\n\tcontract_id: string;\n\tdeployer: string;\n\tblock_height: number;\n\tdeclared_traits: string[] | null;\n\tinferred_standards: string[] | null;\n\tabi_status: string;\n};\n\n/** A `/v1/index/sbtc/withdrawals` row: one peg-out rolled up per request. */\nexport type IndexSbtcWithdrawalRow = {\n\tcursor: string;\n\trequest_id: number;\n\tstatus: \"REQUESTED\" | \"ACCEPTED\" | \"REJECTED\";\n\tamount: string | null;\n\tsender: string | null;\n\trecipient_btc_version: number | null;\n\trecipient_btc_hashbytes: string | null;\n\tsweep_txid: string | null;\n\tsettlement_confirmed: boolean | null;\n\tbtc_confirmations: number | null;\n\tbtc_block_height: number | null;\n\tconfirmed_at: string | null;\n\trequested_at?: string | null;\n\tresolved_at?: string | null;\n};\n\n/** Page size for `/v1/contracts`, its server-side maximum. */\nconst CONTRACTS_PAGE_LIMIT = 500;\n\n/** `to_height` meaning \"through the tip\": the server clamps it to its tip. */\nconst THROUGH_TIP = Number.MAX_SAFE_INTEGER;\n\nexport type StreamsReorgRow = {\n\tid: string;\n\tdetected_at: string;\n\tfork_point_height: number;\n\torphaned_range: { from: string; to: string };\n\tnew_canonical_tip: string;\n};\n\nexport type IndexHttpOptions = {\n\t/** Base URL for /v1/index (the decoded data plane). */\n\tindexBaseUrl: string;\n\t/** Bearer for /v1/index. Empty/unset omits the header. */\n\tindexApiKey?: string;\n\t/** Base URL for /v1/streams (the canonical clock). */\n\tstreamsBaseUrl: string;\n\t/** Bearer for /v1/streams. Empty/unset omits the header. */\n\tstreamsApiKey?: string;\n};\n\n/** `/v1/index/blocks?limit=1`'s envelope — read for its `tip`, not its rows. */\nexport type IndexTipEnvelope = {\n\ttip: {\n\t\tblock_height: number;\n\t\tsource_block_height?: number;\n\t\t/**\n\t\t * Committed height per classic decoded event_type, e.g.\n\t\t * `{ ft_transfer: 950, stx_transfer: 940 }`. `null` means that decoder\n\t\t * has no checkpoint yet (fail-closed for a caller that references it,\n\t\t * not \"committed to height 0\"); a missing key means an older server\n\t\t * that doesn't send this field at all. Absent on an instance built\n\t\t * before this field was added.\n\t\t */\n\t\tdecoded_heights?: Record<string, number | null>;\n\t};\n};\n\nexport class IndexHttpClient {\n\tprivate readonly indexBaseUrl: string;\n\tprivate readonly indexApiKey: string | undefined;\n\tprivate readonly streamsBaseUrl: string;\n\tprivate readonly streamsApiKey: string | undefined;\n\t/** Populated by the last `getIndexTipEnvelope()` call (via `getIndexTip`/\n\t * `getIndexSourceTip`) so `getDecodedHeights()` can read the SAME response\n\t * synchronously instead of making a second request. */\n\tprivate lastTipEnvelope: IndexTipEnvelope | undefined;\n\t/** Flips false the first time a `wait` request 400s — see\n\t * `getIndexTipEnvelope`'s catch. Per-instance, not global: a fresh client\n\t * (e.g. against a different/upgraded server) always tries wait again. */\n\tprivate waitSupported = true;\n\n\tconstructor(opts: IndexHttpOptions) {\n\t\tthis.indexBaseUrl = opts.indexBaseUrl.replace(/\\/+$/, \"\");\n\t\tthis.indexApiKey = opts.indexApiKey ?? defaultInternalIndexApiKey();\n\t\tthis.streamsBaseUrl = opts.streamsBaseUrl.replace(/\\/+$/, \"\");\n\t\tthis.streamsApiKey = opts.streamsApiKey;\n\t}\n\n\tprivate async get<T>(url: string, apiKey: string | undefined): Promise<T> {\n\t\t// Index reads are anon — omit the header entirely when no key is set, so\n\t\t// an empty key reads anonymously rather than 401-ing as an invalid bearer.\n\t\tconst headers: Record<string, string> = apiKey\n\t\t\t? { authorization: `Bearer ${apiKey}` }\n\t\t\t: {};\n\t\tlet lastErr: unknown;\n\t\tfor (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {\n\t\t\tlet res: Response;\n\t\t\ttry {\n\t\t\t\tres = await fetch(url, { headers });\n\t\t\t} catch (err) {\n\t\t\t\t// An explicit abort/cancel is intentional — surface it immediately\n\t\t\t\t// rather than burning the retry budget masking it as transient.\n\t\t\t\tif (err instanceof Error && err.name === \"AbortError\") throw err;\n\t\t\t\t// Otherwise a transport-level failure (connection refused/reset) —\n\t\t\t\t// e.g. an api replica mid-recreate. Retry; the next attempt\n\t\t\t\t// round-robins to a healthy replica.\n\t\t\t\tlastErr = err;\n\t\t\t\tif (attempt >= MAX_ATTEMPTS) break;\n\t\t\t\tawait delay(RETRY_BASE_MS * 2 ** (attempt - 1));\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (!res.ok) {\n\t\t\t\tif (RETRYABLE_STATUS.has(res.status) && attempt < MAX_ATTEMPTS) {\n\t\t\t\t\t// Drain the body so the connection can be reused, then back off.\n\t\t\t\t\tawait res.text().catch(() => {});\n\t\t\t\t\tawait delay(RETRY_BASE_MS * 2 ** (attempt - 1));\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tconst body = await res.text();\n\t\t\t\tconst message = `GET ${url} → ${res.status} ${body}`;\n\t\t\t\tthrow (\n\t\t\t\t\tbillingPausedFrom(res.status, body, message) ??\n\t\t\t\t\tnew IndexHttpStatusError(res.status, message)\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn (await res.json()) as T;\n\t\t}\n\t\tthrow (\n\t\t\tlastErr ?? new Error(`GET ${url} failed after ${MAX_ATTEMPTS} attempts`)\n\t\t);\n\t}\n\n\t/** Fetch a single cursor page of an Index collection. */\n\tprivate async getPage<K extends string, T>(\n\t\tpath: string,\n\t\tkey: K,\n\t\tparams: URLSearchParams,\n\t): Promise<{ items: T[]; next_cursor: string | null }> {\n\t\tconst env: Envelope<K, T> = await this.get(\n\t\t\t`${this.indexBaseUrl}${path}?${params}`,\n\t\t\tthis.indexApiKey,\n\t\t);\n\t\treturn { items: env[key], next_cursor: env.next_cursor };\n\t}\n\n\t/** Drain a cursor-paginated Index collection over [fromHeight, toHeight]. */\n\tprivate async walk<K extends string, T>(\n\t\tpath: string,\n\t\tkey: K,\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t\textraParams: Record<string, string> = {},\n\t): Promise<T[]> {\n\t\tconst out: T[] = [];\n\t\tlet cursor: string | null = null;\n\t\tdo {\n\t\t\tconst params = new URLSearchParams({\n\t\t\t\tto_height: String(toHeight),\n\t\t\t\tlimit: String(PAGE_LIMIT),\n\t\t\t\t...extraParams,\n\t\t\t});\n\t\t\t// from_height and cursor are mutually exclusive — anchor the first page\n\t\t\t// by height, then page forward by cursor only.\n\t\t\tif (cursor) params.set(\"cursor\", cursor);\n\t\t\telse params.set(\"from_height\", String(fromHeight));\n\t\t\tconst { items, next_cursor } = await this.getPage<K, T>(\n\t\t\t\tpath,\n\t\t\t\tkey,\n\t\t\t\tparams,\n\t\t\t);\n\t\t\tout.push(...items);\n\t\t\tcursor = next_cursor;\n\t\t} while (cursor);\n\t\treturn out;\n\t}\n\n\t/**\n\t * Fetch ONE page of contract-call transactions filtered to `contractId`.\n\t * Unlike walk(), this does NOT drain — the caller pages by feeding back\n\t * next_cursor — so a sparse high-volume filter (e.g. a single contract over\n\t * all history) costs one request per batch, not O(all-history) per tick.\n\t * `cursor` is exclusive (rows strictly after it); on the first call pass\n\t * `fromHeight` to anchor the backfill start instead.\n\t */\n\tasync fetchContractCalls(\n\t\tcontractId: string,\n\t\topts: {\n\t\t\ttoHeight: number;\n\t\t\tcursor?: string | null;\n\t\t\tfromHeight?: number;\n\t\t\tlimit?: number;\n\t\t},\n\t): Promise<{\n\t\ttransactions: IndexTransactionRow[];\n\t\tnext_cursor: string | null;\n\t}> {\n\t\tconst params = new URLSearchParams({\n\t\t\tto_height: String(opts.toHeight),\n\t\t\tlimit: String(opts.limit ?? PAGE_LIMIT),\n\t\t\tcontract_id: contractId,\n\t\t});\n\t\tif (opts.cursor) params.set(\"cursor\", opts.cursor);\n\t\telse params.set(\"from_height\", String(opts.fromHeight ?? 0));\n\t\tconst { items, next_cursor } = await this.getPage<\n\t\t\t\"transactions\",\n\t\t\tIndexTransactionRow\n\t\t>(\"/v1/index/transactions\", \"transactions\", params);\n\t\treturn { transactions: items, next_cursor };\n\t}\n\n\twalkBlocks(fromHeight: number, toHeight: number): Promise<IndexBlockRow[]> {\n\t\treturn this.walk<\"blocks\", IndexBlockRow>(\n\t\t\t\"/v1/index/blocks\",\n\t\t\t\"blocks\",\n\t\t\tfromHeight,\n\t\t\ttoHeight,\n\t\t);\n\t}\n\n\t/** Lowest block height in [fromHeight, toHeight] with a matching event, or\n\t * null. One page, limit 1 — built for sparse-scan probes. */\n\tasync firstEventHeight(\n\t\teventType: string,\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t\tcontractId?: string,\n\t): Promise<number | null> {\n\t\tconst params = new URLSearchParams({\n\t\t\tevent_type: eventType,\n\t\t\tfrom_height: String(fromHeight),\n\t\t\tto_height: String(toHeight),\n\t\t\tlimit: \"1\",\n\t\t\t...(contractId ? { contract_id: contractId } : {}),\n\t\t});\n\t\tconst { items } = await this.getPage<\"events\", IndexEventRow>(\n\t\t\t\"/v1/index/events\",\n\t\t\t\"events\",\n\t\t\tparams,\n\t\t);\n\t\treturn items[0]?.block_height ?? null;\n\t}\n\n\twalkEvents(\n\t\teventType: string,\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t\t/** Join the submitting tx so each row carries `tx_*` — lets an event-only\n\t\t * subgraph skip walkTransactions and build `ctx.tx` from the event. */\n\t\twithTx = false,\n\t\t/** Scope the walk to one contract (what a filter pins), so the Index\n\t\t * returns, and meters, only that contract's rows. */\n\t\tcontractId?: string,\n\t): Promise<IndexEventRow[]> {\n\t\treturn this.walk<\"events\", IndexEventRow>(\n\t\t\t\"/v1/index/events\",\n\t\t\t\"events\",\n\t\t\tfromHeight,\n\t\t\ttoHeight,\n\t\t\t{\n\t\t\t\tevent_type: eventType,\n\t\t\t\t...(withTx ? { tx_context: \"true\" } : {}),\n\t\t\t\t...(contractId ? { contract_id: contractId } : {}),\n\t\t\t},\n\t\t);\n\t}\n\n\t/** Every node storage write over the range, each with its writing tx\n\t * (`tx_context=true`); scoped to one contract's keys when given. */\n\twalkStateWrites(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t\tcontractId?: string,\n\t): Promise<IndexStateWriteRow[]> {\n\t\treturn this.walk<\"state_writes\", IndexStateWriteRow>(\n\t\t\t\"/v1/index/state-writes\",\n\t\t\t\"state_writes\",\n\t\t\tfromHeight,\n\t\t\ttoHeight,\n\t\t\t{\n\t\t\t\ttx_context: \"true\",\n\t\t\t\t...(contractId ? { contract_id: contractId } : {}),\n\t\t\t},\n\t\t);\n\t}\n\n\t/** Lowest height the Index serves `state_writes` for, or null when it has\n\t * none (its node does not deliver them). One row. */\n\tasync firstStateWriteHeight(): Promise<number | null> {\n\t\tconst { items } = await this.getPage<\"state_writes\", IndexStateWriteRow>(\n\t\t\t\"/v1/index/state-writes\",\n\t\t\t\"state_writes\",\n\t\t\tnew URLSearchParams({ from_height: \"0\", limit: \"1\" }),\n\t\t);\n\t\treturn items[0]?.block_height ?? null;\n\t}\n\n\t/**\n\t * Canonical sBTC protocol events over [fromHeight, toHeight] for `topics`,\n\t * ordered by (block_height, event_index). One drained walk per topic (the\n\t * endpoint filters on a single topic); `requestId` narrows each walk.\n\t */\n\tasync fetchSbtcEvents(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t\ttopics: readonly string[],\n\t\topts?: { requestId?: number },\n\t): Promise<IndexSbtcEventRow[]> {\n\t\tconst out: IndexSbtcEventRow[] = [];\n\t\tfor (const topic of topics) {\n\t\t\tout.push(\n\t\t\t\t...(await this.walk<\"events\", IndexSbtcEventRow>(\n\t\t\t\t\t\"/v1/index/sbtc/events\",\n\t\t\t\t\t\"events\",\n\t\t\t\t\tfromHeight,\n\t\t\t\t\ttoHeight,\n\t\t\t\t\t{\n\t\t\t\t\t\ttopic,\n\t\t\t\t\t\t...(opts?.requestId !== undefined\n\t\t\t\t\t\t\t? { request_id: String(opts.requestId) }\n\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t},\n\t\t\t\t)),\n\t\t\t);\n\t\t}\n\t\treturn out.sort(\n\t\t\t(a, b) =>\n\t\t\t\ta.block_height - b.block_height || a.event_index - b.event_index,\n\t\t);\n\t}\n\n\t/** Every canonical contract conforming to `trait` (declared or inferred),\n\t * draining `/v1/contracts`'s `next_cursor`. */\n\tasync listContractsByTrait(trait: string): Promise<IndexContractRow[]> {\n\t\tconst out: IndexContractRow[] = [];\n\t\tlet cursor: string | null = null;\n\t\tdo {\n\t\t\tconst params = new URLSearchParams({\n\t\t\t\ttrait,\n\t\t\t\tlimit: String(CONTRACTS_PAGE_LIMIT),\n\t\t\t});\n\t\t\tif (cursor) params.set(\"cursor\", cursor);\n\t\t\tconst { items, next_cursor } = await this.getPage<\n\t\t\t\t\"contracts\",\n\t\t\t\tIndexContractRow\n\t\t\t>(\"/v1/contracts\", \"contracts\", params);\n\t\t\tout.push(...items);\n\t\t\tcursor = next_cursor;\n\t\t} while (cursor);\n\t\treturn out;\n\t}\n\n\t/** Withdrawals whose BTC sweep crossed the confirmation threshold strictly\n\t * after `confirmedAfter`, across all history. */\n\tfetchSettledWithdrawals(\n\t\tconfirmedAfter: Date,\n\t): Promise<IndexSbtcWithdrawalRow[]> {\n\t\treturn this.walk<\"withdrawals\", IndexSbtcWithdrawalRow>(\n\t\t\t\"/v1/index/sbtc/withdrawals\",\n\t\t\t\"withdrawals\",\n\t\t\t0,\n\t\t\tTHROUGH_TIP,\n\t\t\t{\n\t\t\t\tsettlement_confirmed: \"true\",\n\t\t\t\tconfirmed_after: confirmedAfter.toISOString(),\n\t\t\t},\n\t\t);\n\t}\n\n\twalkTransactions(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t): Promise<IndexTransactionRow[]> {\n\t\treturn this.walk<\"transactions\", IndexTransactionRow>(\n\t\t\t\"/v1/index/transactions\",\n\t\t\t\"transactions\",\n\t\t\tfromHeight,\n\t\t\ttoHeight,\n\t\t);\n\t}\n\n\t/** Canonical tip height from the Streams clock. */\n\tasync getStreamsTip(): Promise<number> {\n\t\tconst tip = await this.get<{ block_height: number }>(\n\t\t\t`${this.streamsBaseUrl}/v1/streams/tip`,\n\t\t\tthis.streamsApiKey,\n\t\t);\n\t\treturn Number(tip.block_height) || 0;\n\t}\n\n\t/**\n\t * Highest block height the Index data plane can serve (tip is inline in every\n\t * envelope). This is the data-availability bound — a consumer must not\n\t * process past it, even if the Streams clock is ahead.\n\t *\n\t * `opts.wait` (seconds, clamped to `MAX_INDEX_WAIT_SECONDS`) long-polls:\n\t * the server holds the request until a fresher tip than `opts.knownHeight`\n\t * is available or `wait` elapses. Pass `knownHeight` —\n\t * the last tip THIS caller observed — or `wait` is a no-op (nothing to\n\t * compare against). A caller that only wants the plain tip omits both.\n\t */\n\tasync getIndexTip(opts?: {\n\t\twait?: number;\n\t\tknownHeight?: number;\n\t\t/**\n\t\t * Event types this caller actually reads (e.g. a chain evaluator's\n\t\t * `referencedEventTypes`). Narrows the returned tip — and, together with\n\t\t * `wait`, what counts as \"nothing new\" — to the MIN committed height\n\t\t * over just these types, instead of the global floor over EVERY classic\n\t\t * decoder. Without it, an unrelated decoder committing (any of ~15,\n\t\t * several times a block) moves the global floor and makes an unrelated\n\t\t * `wait` return early — the exact busy-idle pattern this fixes. Omit\n\t\t * when there's no meaningful subset (falls back to the global floor,\n\t\t * unchanged from before this option existed).\n\t\t */\n\t\teventTypes?: readonly string[];\n\t}): Promise<number> {\n\t\t// `tip_only`: this method never reads `blocks[]`, only `tip`, so the\n\t\t// server can skip the row query. It ALSO fixes what `tip_only` exists\n\t\t// for: `blocks.length` is governed by the SOURCE tip (`/blocks` windows\n\t\t// to it), but `knownHeight` here is the DECODED tip this method itself\n\t\t// returns — comparing against raw rows made an idle `wait` return\n\t\t// almost instantly whenever ingest ran even slightly ahead of decode\n\t\t// (routes/index.ts's /blocks handler judges emptiness against\n\t\t// `tip.block_height` directly when `tip_only` is set, sidestepping the\n\t\t// mismatch). Do NOT set this on `getIndexSourceTip` below — its\n\t\t// `knownHeight` baseline is the source tip already, so the row-based\n\t\t// check is the CORRECT one there, not a bug to route around.\n\t\tconst env = await this.getIndexTipEnvelope({\n\t\t\t...opts,\n\t\t\ttipOnly: true,\n\t\t\teventTypes: opts?.eventTypes,\n\t\t});\n\t\treturn Number(env.tip?.block_height) || 0;\n\t}\n\n\t/** Ingest tip. VM rows land with the block; decoded `block_height` can lag.\n\t * Same `wait`/`knownHeight` long-poll contract as {@link getIndexTip}. */\n\tasync getIndexSourceTip(opts?: {\n\t\twait?: number;\n\t\tknownHeight?: number;\n\t}): Promise<number> {\n\t\tconst env = await this.getIndexTipEnvelope(opts);\n\t\treturn Number(env.tip?.source_block_height ?? env.tip?.block_height) || 0;\n\t}\n\n\t/**\n\t * Per-decoder committed heights from the last tip envelope fetched by\n\t * `getIndexTip()`/`getIndexSourceTip()` — reads the cached response, no\n\t * extra request. `undefined` before either has been called, or against an\n\t * older server that doesn't send the field. A caller (the chain evaluator)\n\t * uses this to bound itself by the decoders it actually reads instead of\n\t * the conservative cross-decoder floor `block_height` itself carries.\n\t */\n\tgetDecodedHeights(): Record<string, number | null> | undefined {\n\t\treturn this.lastTipEnvelope?.tip.decoded_heights;\n\t}\n\n\t/** False once a `wait`/`tip_only` request has 400'd on THIS client instance\n\t * (see the catch in `getIndexTipEnvelope`) — an older server that doesn't\n\t * recognize these params. A caller that reuses one client across many\n\t * calls (the chain evaluator) uses this to stop scheduling\n\t * itself as if `wait` actually blocked, instead of re-discovering the 400\n\t * on every single call. */\n\twaitIsSupported(): boolean {\n\t\treturn this.waitSupported;\n\t}\n\n\tprivate async getIndexTipEnvelope(opts?: {\n\t\twait?: number;\n\t\tknownHeight?: number;\n\t\ttipOnly?: boolean;\n\t\teventTypes?: readonly string[];\n\t}): Promise<IndexTipEnvelope> {\n\t\tconst wantsWait = this.waitSupported && (opts?.wait ?? 0) > 0;\n\t\tconst wantsTipOnly = this.waitSupported && Boolean(opts?.tipOnly);\n\t\tconst wantsEventTypes =\n\t\t\tthis.waitSupported && wantsTipOnly && Boolean(opts?.eventTypes?.length);\n\t\tconst params = new URLSearchParams({ limit: \"1\" });\n\t\tif (wantsTipOnly) params.set(\"tip_only\", \"true\");\n\t\t// Only meaningful alongside tip_only — it narrows what the returned\n\t\t// (and wait-compared) tip means, and tip_only is what makes the server\n\t\t// return a tip at all instead of a page of rows.\n\t\tif (wantsEventTypes && opts?.eventTypes) {\n\t\t\tparams.set(\"event_types\", opts.eventTypes.join(\",\"));\n\t\t}\n\t\tif (wantsWait) {\n\t\t\tparams.set(\n\t\t\t\t\"wait\",\n\t\t\t\tString(Math.min(MAX_INDEX_WAIT_SECONDS, Math.max(0, opts?.wait ?? 0))),\n\t\t\t);\n\t\t\t// `from_height` is what makes the wait meaningful here: with no cursor,\n\t\t\t// `/v1/index/blocks?limit=1` always returns a row (the default window's\n\t\t\t// oldest block), so the server would never see \"nothing new\" to wait\n\t\t\t// on. Anchoring one past the last height THIS caller saw makes the page\n\t\t\t// empty until a fresher block lands — the same signal the endpoint\n\t\t\t// already uses for a normal forward walk.\n\t\t\tif (opts?.knownHeight !== undefined) {\n\t\t\t\tparams.set(\"from_height\", String(opts.knownHeight + 1));\n\t\t\t}\n\t\t}\n\t\ttry {\n\t\t\tconst env = await this.get<IndexTipEnvelope>(\n\t\t\t\t`${this.indexBaseUrl}/v1/index/blocks?${params}`,\n\t\t\t\tthis.indexApiKey,\n\t\t\t);\n\t\t\tthis.lastTipEnvelope = env;\n\t\t\treturn env;\n\t\t} catch (err) {\n\t\t\t// An older server's `validateQueryParams` 400s on `wait`/`from_height`/\n\t\t\t// `tip_only`/`event_types` it doesn't recognize. Treat that ONE status\n\t\t\t// as \"this server predates long-poll\", not an outage: disable every\n\t\t\t// extension for the rest of this client's life (event_types is only\n\t\t\t// ever sent alongside tip_only, so gating on `wantsTipOnly` already\n\t\t\t// covers a server that knows `wait`/`tip_only` but not `event_types`\n\t\t\t// yet) and retry plainly, so `FallbackBlockSource` never mistakes a\n\t\t\t// version-skew 400 for the api being down and routes to the DB tap.\n\t\t\t// Gating on `wantsWait || wantsTipOnly` (not `wantsWait` alone)\n\t\t\t// matters mid-rollout: a server already upgraded for `wait` but not\n\t\t\t// yet for `tip_only` still 400s on the one it doesn't know, even on a\n\t\t\t// plain, non-waiting `getIndexTip()` call.\n\t\t\tif (\n\t\t\t\t(wantsWait || wantsTipOnly) &&\n\t\t\t\terr instanceof IndexHttpStatusError &&\n\t\t\t\terr.status === 400\n\t\t\t) {\n\t\t\t\tthis.waitSupported = false;\n\t\t\t\treturn this.getIndexTipEnvelope(opts);\n\t\t\t}\n\t\t\tthrow err;\n\t\t}\n\t}\n\n\t/** Reorgs since a resume token (wall-clock `detected_at`-keyed). `chain`\n\t * defaults to `\"stacks\"` (unchanged); `\"bitcoin\"` reads Runes reorgs\n\t * (`btc_reorgs`, plan 060) instead — a separate feed, own resume tokens. */\n\tasync listReorgs(\n\t\tsince: string,\n\t\tchain: \"stacks\" | \"bitcoin\" = \"stacks\",\n\t): Promise<{ reorgs: StreamsReorgRow[]; next_since: string | null }> {\n\t\tconst params = new URLSearchParams({ since });\n\t\tif (chain === \"bitcoin\") params.set(\"chain\", \"bitcoin\");\n\t\treturn this.get(\n\t\t\t`${this.streamsBaseUrl}/v1/streams/reorgs?${params}`,\n\t\t\tthis.streamsApiKey,\n\t\t);\n\t}\n\n\t/** Bitcoin (Runes) Streams tip height — `chain=bitcoin`'s own clock,\n\t * unrelated to the Stacks tip (plan 060). */\n\tasync getBitcoinStreamsTip(): Promise<number> {\n\t\tconst tip = await this.get<{ block_height: number }>(\n\t\t\t`${this.streamsBaseUrl}/v1/streams/tip?chain=bitcoin`,\n\t\t\tthis.streamsApiKey,\n\t\t);\n\t\treturn Number(tip.block_height) || 0;\n\t}\n\n\t/**\n\t * One page of `chain=bitcoin` Streams events (Runes activity, plan 060's\n\t * evaluator source). Unlike `walkEvents`, this does NOT drain — the caller\n\t * pages by feeding back `next_cursor` — so a tick bounds its own work\n\t * instead of draining an unbounded backlog in one call. `afterCursor`\n\t * (exclusive) takes precedence over `fromHeight` when both are given,\n\t * mirroring the REST endpoint's own `cursor`/`from_height` exclusivity.\n\t */\n\tasync getBitcoinStreamsEventsPage(opts: {\n\t\ttypes: readonly string[];\n\t\ttoHeight: number;\n\t\tafterCursor?: string;\n\t\tfromHeight?: number;\n\t\tlimit?: number;\n\t}): Promise<{ events: RuneStreamsEvent[]; next_cursor: string | null }> {\n\t\tconst params = new URLSearchParams({\n\t\t\tchain: \"bitcoin\",\n\t\t\tto_height: String(opts.toHeight),\n\t\t\tlimit: String(opts.limit ?? PAGE_LIMIT),\n\t\t\ttypes: opts.types.join(\",\"),\n\t\t});\n\t\tif (opts.afterCursor) params.set(\"from_cursor\", opts.afterCursor);\n\t\telse params.set(\"from_height\", String(opts.fromHeight ?? 0));\n\t\treturn this.get(\n\t\t\t`${this.streamsBaseUrl}/v1/streams/events?${params}`,\n\t\t\tthis.streamsApiKey,\n\t\t);\n\t}\n}\n\n/** Decoder + subgraph processor client. One env contract; omits bearer when no key. */\nexport function createInternalIndexHttpClient(): IndexHttpClient {\n\tconst baseUrl = defaultInternalIndexBaseUrl();\n\treturn new IndexHttpClient({\n\t\tindexBaseUrl: baseUrl,\n\t\tstreamsBaseUrl: baseUrl,\n\t\tindexApiKey: defaultInternalIndexApiKey(),\n\t\tstreamsApiKey: defaultInternalStreamsApiKey(),\n\t});\n}\n"
6
+ "import {\n\tdefaultInternalIndexApiKey,\n\tdefaultInternalIndexBaseUrl,\n\tdefaultInternalStreamsApiKey,\n} from \"./index-internal-auth.ts\";\nimport type { RuneStreamsEvent } from \"./streams-rows/events.ts\";\n\n/**\n * Low-level transport for the public Index (`/v1/index`) + Streams clock\n * (`/v1/streams`) HTTP APIs: cursor-paginated reads, tip, reorgs. Lives in\n * `shared` (a leaf both the SDK and the subgraph runtime depend on) so the wire\n * format has one home and no package cycle. The SDK's ergonomic client should\n * eventually consume these row types too (see the plan's convergence task).\n *\n * This is intentionally minimal — just the GETs the subgraph runtime's\n * PublicApiBlockSource needs. It is NOT the SDK's full client (walk/consume/\n * retries/auth resolution).\n */\n\nconst PAGE_LIMIT = 1000;\n\n/** Ceiling on the Index long-poll `wait` param (seconds), shared by client and\n * server so neither drifts from the other. Kept comfortably under app-server\n * Caddy's `reverse_proxy` (no read/write timeout override, so this is a\n * self-imposed ceiling, not one it's protecting against) and Bun's own\n * 90s idleTimeout on the api listener. */\nexport const MAX_INDEX_WAIT_SECONDS = 25;\n\n// Transport resilience for the streams-index data plane. The api runs N>1\n// replicas behind Caddy; during a rolling deploy one replica is briefly\n// unreachable, surfacing as a thrown fetch (connection refused/reset) or a\n// Caddy 502/503/504 while it fails over. Retrying a few times with backoff\n// makes a single-replica recreate transparent to the subgraph-processor /\n// decoder — closing the processors-depend-on-api coupling.\nconst MAX_ATTEMPTS = 4;\nconst RETRY_BASE_MS = 150;\nconst RETRYABLE_STATUS = new Set([502, 503, 504]);\n\nconst delay = (ms: number): Promise<void> =>\n\tnew Promise((resolve) => setTimeout(resolve, ms));\n\n/** A non-2xx Index/Streams response, carrying the status so a caller can\n * distinguish \"the server rejected this request\" (e.g. a `wait`/`from_height`\n * param an older server's `validateQueryParams` doesn't recognize, HTTP 400)\n * from a transient outage — see `getIndexTipEnvelope`'s wait fallback. */\nexport class IndexHttpStatusError extends Error {\n\tconstructor(\n\t\treadonly status: number,\n\t\tmessage: string,\n\t) {\n\t\tsuper(message);\n\t\tthis.name = \"IndexHttpStatusError\";\n\t}\n}\n\n/** The two refusals a metered read can get once an account is past its free\n * allowance (`checkRowsAllowance`). Both are deliberate billing states, not\n * outages: retrying them in a storm only burns the request budget. */\nexport type BillingPausedCode = \"spend_cap_reached\" | \"insufficient_credits\";\n\nconst BILLING_PAUSED_CODES: ReadonlySet<string> = new Set<BillingPausedCode>([\n\t\"spend_cap_reached\",\n\t\"insufficient_credits\",\n]);\n\n/** A read refused with `402` and `{ error: \"spend_cap_reached\" |\n * \"insufficient_credits\" }`. Raised only on an exact body match, so an\n * unmetered (self-host) server never produces it. */\nexport class BillingPausedError extends IndexHttpStatusError {\n\tconstructor(\n\t\treadonly code: BillingPausedCode,\n\t\tmessage: string,\n\t) {\n\t\tsuper(402, message);\n\t\tthis.name = \"BillingPausedError\";\n\t}\n}\n\n/** `BillingPausedError` when `status`/`body` is exactly a billing refusal. */\nfunction billingPausedFrom(\n\tstatus: number,\n\tbody: string,\n\tmessage: string,\n): BillingPausedError | null {\n\tif (status !== 402) return null;\n\ttry {\n\t\tconst code = (JSON.parse(body) as { error?: unknown } | null)?.error;\n\t\tif (typeof code === \"string\" && BILLING_PAUSED_CODES.has(code)) {\n\t\t\treturn new BillingPausedError(code as BillingPausedCode, message);\n\t\t}\n\t} catch {\n\t\t// Not JSON: not a billing refusal.\n\t}\n\treturn null;\n}\n\ntype Envelope<K extends string, T> = {\n\t[P in K]: T[];\n} & { next_cursor: string | null };\n\n// ── Index API wire shapes (single source of truth) ─────────────────────────\nexport type IndexBlockRow = {\n\tblock_height: number;\n\tblock_hash: string;\n\tparent_hash: string;\n\tburn_block_height: number;\n\tburn_block_hash: string | null;\n\t/** Nakamoto StacksBlockId. Absent from pre-2026-07 API deployments. */\n\tindex_block_hash?: string | null;\n\tblock_time: string | null;\n};\n\ntype IndexEventCommon = {\n\tblock_height: number;\n\ttx_id: string;\n\ttx_index?: number;\n\tevent_index: number;\n\tcontract_id: string | null;\n\t/** Submitting-tx context — present only when the read passed `tx_context=true`.\n\t * Lets a consumer build the tx for `ctx.tx` without a separate\n\t * walkTransactions (the ~37x reindex over-fetch; see indexing-speed plan). */\n\ttx_sender?: string | null;\n\ttx_type?: string | null;\n\ttx_status?: string | null;\n\ttx_contract_id?: string | null;\n\ttx_function_name?: string | null;\n};\n\nexport type IndexEventRow = IndexEventCommon &\n\t(\n\t\t| {\n\t\t\t\tevent_type: \"ft_transfer\" | \"ft_mint\" | \"ft_burn\";\n\t\t\t\tasset_identifier: string;\n\t\t\t\tsender?: string;\n\t\t\t\trecipient?: string;\n\t\t\t\tamount: string;\n\t\t }\n\t\t| {\n\t\t\t\tevent_type: \"nft_transfer\" | \"nft_mint\" | \"nft_burn\";\n\t\t\t\tasset_identifier: string;\n\t\t\t\tsender?: string;\n\t\t\t\trecipient?: string;\n\t\t\t\tvalue: string;\n\t\t }\n\t\t| {\n\t\t\t\tevent_type: \"stx_transfer\" | \"stx_mint\" | \"stx_burn\";\n\t\t\t\tsender?: string;\n\t\t\t\trecipient?: string;\n\t\t\t\tamount: string;\n\t\t\t\tmemo?: string | null;\n\t\t }\n\t\t| {\n\t\t\t\tevent_type: \"stx_lock\";\n\t\t\t\tsender: string;\n\t\t\t\tamount: string;\n\t\t\t\tpayload: { unlock_height: string | null };\n\t\t }\n\t\t| {\n\t\t\t\tevent_type: \"print\";\n\t\t\t\tpayload: {\n\t\t\t\t\ttopic: string | null;\n\t\t\t\t\tvalue: unknown;\n\t\t\t\t\traw_value: string | null;\n\t\t\t\t};\n\t\t }\n\t\t| {\n\t\t\t\tevent_type: \"nested_contract_call\";\n\t\t\t\tsender?: string | null;\n\t\t\t\tcaller: string;\n\t\t\t\tfunction_name: string;\n\t\t\t\tfunction_args?: unknown;\n\t\t\t\traw_result?: string | null;\n\t\t }\n\t\t| {\n\t\t\t\tevent_type: \"var_set\";\n\t\t\t\tvar_name: string;\n\t\t\t\traw_value?: string | null;\n\t\t }\n\t\t| {\n\t\t\t\tevent_type: \"map_set\" | \"map_insert\";\n\t\t\t\tmap: string;\n\t\t\t\traw_key?: string | null;\n\t\t\t\traw_value?: string | null;\n\t\t }\n\t\t| {\n\t\t\t\tevent_type: \"map_delete\";\n\t\t\t\tmap: string;\n\t\t\t\traw_key?: string | null;\n\t\t }\n\t);\n\nexport type IndexTransactionRow = {\n\ttx_id: string;\n\tblock_height: number;\n\tblock_time?: string | null;\n\tburn_block_height?: number | null;\n\ttx_index: number;\n\ttx_type: string;\n\tsender: string;\n\tstatus: string;\n\tcontract_call?: {\n\t\tcontract_id: string;\n\t\tfunction_name: string;\n\t\tfunction_args_hex?: string[] | null;\n\t\tresult_hex?: string | null;\n\t} | null;\n\tsmart_contract?: { contract_id: string | null } | null;\n};\n\n/** A `/v1/index/state-writes` row read with `tx_context=true`. */\nexport type IndexStateWriteRow = {\n\tblock_height: number;\n\tordinal: number;\n\t/** Null for block-level writes. */\n\ttx_index: number | null;\n\tkey: string;\n\tvalue_hex: string;\n\ttx_id?: string | null;\n\ttx_sender?: string | null;\n\ttx_type?: string | null;\n\ttx_status?: string | null;\n\ttx_contract_id?: string | null;\n\ttx_function_name?: string | null;\n};\n\n/** A `/v1/index/sbtc/events` row: one decoded sBTC protocol event. Numeric\n * columns are JSON numbers here (the API normalizes them). */\nexport type IndexSbtcEventRow = {\n\tcursor: string;\n\tblock_height: number;\n\tblock_time?: string | null;\n\ttx_id: string;\n\ttx_index: number;\n\tevent_index: number;\n\ttopic: string;\n\trequest_id: number | null;\n\tamount: string | null;\n\tsender: string | null;\n\trecipient_btc_version: number | null;\n\trecipient_btc_hashbytes: string | null;\n\tbitcoin_txid: string | null;\n\toutput_index: number | null;\n\tsweep_txid: string | null;\n\tburn_hash: string | null;\n\tburn_height: number | null;\n\tsigner_bitmap: string | null;\n\tmax_fee: string | null;\n\tfee: string | null;\n\tgovernance_contract_type: number | null;\n\tgovernance_new_contract: string | null;\n\tsigner_aggregate_pubkey: string | null;\n\tsigner_threshold: number | null;\n\tsigner_address: string | null;\n\tsigner_keys_count: number | null;\n};\n\n/** A `/v1/contracts?trait=` row (ABI omitted). */\nexport type IndexContractRow = {\n\tcontract_id: string;\n\tdeployer: string;\n\tblock_height: number;\n\tdeclared_traits: string[] | null;\n\tinferred_standards: string[] | null;\n\tabi_status: string;\n\t/** When the contract's traits were last set (server clock). */\n\tclassified_at: string | null;\n};\n\n/** A `/v1/index/sbtc/withdrawals` row: one peg-out rolled up per request. */\nexport type IndexSbtcWithdrawalRow = {\n\tcursor: string;\n\trequest_id: number;\n\tstatus: \"REQUESTED\" | \"ACCEPTED\" | \"REJECTED\";\n\tamount: string | null;\n\tsender: string | null;\n\trecipient_btc_version: number | null;\n\trecipient_btc_hashbytes: string | null;\n\tsweep_txid: string | null;\n\tsettlement_confirmed: boolean | null;\n\tbtc_confirmations: number | null;\n\tbtc_block_height: number | null;\n\tconfirmed_at: string | null;\n\trequested_at?: string | null;\n\tresolved_at?: string | null;\n};\n\n/** Page size for `/v1/contracts`, its server-side maximum. */\nconst CONTRACTS_PAGE_LIMIT = 500;\n\n/** `to_height` meaning \"through the tip\": the server clamps it to its tip. */\nconst THROUGH_TIP = Number.MAX_SAFE_INTEGER;\n\nexport type StreamsReorgRow = {\n\tid: string;\n\tdetected_at: string;\n\tfork_point_height: number;\n\torphaned_range: { from: string; to: string };\n\tnew_canonical_tip: string;\n};\n\nexport type IndexHttpOptions = {\n\t/** Base URL for /v1/index (the decoded data plane). */\n\tindexBaseUrl: string;\n\t/** Bearer for /v1/index. Empty/unset omits the header. */\n\tindexApiKey?: string;\n\t/** Base URL for /v1/streams (the canonical clock). */\n\tstreamsBaseUrl: string;\n\t/** Bearer for /v1/streams. Empty/unset omits the header. */\n\tstreamsApiKey?: string;\n};\n\n/** `/v1/index/blocks?limit=1`'s envelope — read for its `tip`, not its rows. */\nexport type IndexTipEnvelope = {\n\ttip: {\n\t\tblock_height: number;\n\t\tsource_block_height?: number;\n\t\t/**\n\t\t * Committed height per classic decoded event_type, e.g.\n\t\t * `{ ft_transfer: 950, stx_transfer: 940 }`. `null` means that decoder\n\t\t * has no checkpoint yet (fail-closed for a caller that references it,\n\t\t * not \"committed to height 0\"); a missing key means an older server\n\t\t * that doesn't send this field at all. Absent on an instance built\n\t\t * before this field was added.\n\t\t */\n\t\tdecoded_heights?: Record<string, number | null>;\n\t};\n};\n\nexport class IndexHttpClient {\n\tprivate readonly indexBaseUrl: string;\n\tprivate readonly indexApiKey: string | undefined;\n\tprivate readonly streamsBaseUrl: string;\n\tprivate readonly streamsApiKey: string | undefined;\n\t/** Populated by the last `getIndexTipEnvelope()` call (via `getIndexTip`/\n\t * `getIndexSourceTip`) so `getDecodedHeights()` can read the SAME response\n\t * synchronously instead of making a second request. */\n\tprivate lastTipEnvelope: IndexTipEnvelope | undefined;\n\t/** Flips false the first time a `wait` request 400s — see\n\t * `getIndexTipEnvelope`'s catch. Per-instance, not global: a fresh client\n\t * (e.g. against a different/upgraded server) always tries wait again. */\n\tprivate waitSupported = true;\n\n\tconstructor(opts: IndexHttpOptions) {\n\t\tthis.indexBaseUrl = opts.indexBaseUrl.replace(/\\/+$/, \"\");\n\t\tthis.indexApiKey = opts.indexApiKey ?? defaultInternalIndexApiKey();\n\t\tthis.streamsBaseUrl = opts.streamsBaseUrl.replace(/\\/+$/, \"\");\n\t\tthis.streamsApiKey = opts.streamsApiKey;\n\t}\n\n\tprivate async get<T>(url: string, apiKey: string | undefined): Promise<T> {\n\t\t// Index reads are anon — omit the header entirely when no key is set, so\n\t\t// an empty key reads anonymously rather than 401-ing as an invalid bearer.\n\t\tconst headers: Record<string, string> = apiKey\n\t\t\t? { authorization: `Bearer ${apiKey}` }\n\t\t\t: {};\n\t\tlet lastErr: unknown;\n\t\tfor (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {\n\t\t\tlet res: Response;\n\t\t\ttry {\n\t\t\t\tres = await fetch(url, { headers });\n\t\t\t} catch (err) {\n\t\t\t\t// An explicit abort/cancel is intentional — surface it immediately\n\t\t\t\t// rather than burning the retry budget masking it as transient.\n\t\t\t\tif (err instanceof Error && err.name === \"AbortError\") throw err;\n\t\t\t\t// Otherwise a transport-level failure (connection refused/reset) —\n\t\t\t\t// e.g. an api replica mid-recreate. Retry; the next attempt\n\t\t\t\t// round-robins to a healthy replica.\n\t\t\t\tlastErr = err;\n\t\t\t\tif (attempt >= MAX_ATTEMPTS) break;\n\t\t\t\tawait delay(RETRY_BASE_MS * 2 ** (attempt - 1));\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (!res.ok) {\n\t\t\t\tif (RETRYABLE_STATUS.has(res.status) && attempt < MAX_ATTEMPTS) {\n\t\t\t\t\t// Drain the body so the connection can be reused, then back off.\n\t\t\t\t\tawait res.text().catch(() => {});\n\t\t\t\t\tawait delay(RETRY_BASE_MS * 2 ** (attempt - 1));\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tconst body = await res.text();\n\t\t\t\tconst message = `GET ${url} → ${res.status} ${body}`;\n\t\t\t\tthrow (\n\t\t\t\t\tbillingPausedFrom(res.status, body, message) ??\n\t\t\t\t\tnew IndexHttpStatusError(res.status, message)\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn (await res.json()) as T;\n\t\t}\n\t\tthrow (\n\t\t\tlastErr ?? new Error(`GET ${url} failed after ${MAX_ATTEMPTS} attempts`)\n\t\t);\n\t}\n\n\t/** Fetch a single cursor page of an Index collection. */\n\tprivate async getPage<K extends string, T>(\n\t\tpath: string,\n\t\tkey: K,\n\t\tparams: URLSearchParams,\n\t): Promise<{ items: T[]; next_cursor: string | null }> {\n\t\tconst env: Envelope<K, T> = await this.get(\n\t\t\t`${this.indexBaseUrl}${path}?${params}`,\n\t\t\tthis.indexApiKey,\n\t\t);\n\t\treturn { items: env[key], next_cursor: env.next_cursor };\n\t}\n\n\t/** Drain a cursor-paginated Index collection over [fromHeight, toHeight]. */\n\tprivate async walk<K extends string, T>(\n\t\tpath: string,\n\t\tkey: K,\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t\textraParams: Record<string, string> = {},\n\t): Promise<T[]> {\n\t\tconst out: T[] = [];\n\t\tlet cursor: string | null = null;\n\t\tdo {\n\t\t\tconst params = new URLSearchParams({\n\t\t\t\tto_height: String(toHeight),\n\t\t\t\tlimit: String(PAGE_LIMIT),\n\t\t\t\t...extraParams,\n\t\t\t});\n\t\t\t// from_height and cursor are mutually exclusive — anchor the first page\n\t\t\t// by height, then page forward by cursor only.\n\t\t\tif (cursor) params.set(\"cursor\", cursor);\n\t\t\telse params.set(\"from_height\", String(fromHeight));\n\t\t\tconst { items, next_cursor } = await this.getPage<K, T>(\n\t\t\t\tpath,\n\t\t\t\tkey,\n\t\t\t\tparams,\n\t\t\t);\n\t\t\tout.push(...items);\n\t\t\tcursor = next_cursor;\n\t\t} while (cursor);\n\t\treturn out;\n\t}\n\n\t/**\n\t * Fetch ONE page of contract-call transactions filtered to `contractId`.\n\t * Unlike walk(), this does NOT drain — the caller pages by feeding back\n\t * next_cursor — so a sparse high-volume filter (e.g. a single contract over\n\t * all history) costs one request per batch, not O(all-history) per tick.\n\t * `cursor` is exclusive (rows strictly after it); on the first call pass\n\t * `fromHeight` to anchor the backfill start instead.\n\t */\n\tasync fetchContractCalls(\n\t\tcontractId: string,\n\t\topts: {\n\t\t\ttoHeight: number;\n\t\t\tcursor?: string | null;\n\t\t\tfromHeight?: number;\n\t\t\tlimit?: number;\n\t\t},\n\t): Promise<{\n\t\ttransactions: IndexTransactionRow[];\n\t\tnext_cursor: string | null;\n\t}> {\n\t\tconst params = new URLSearchParams({\n\t\t\tto_height: String(opts.toHeight),\n\t\t\tlimit: String(opts.limit ?? PAGE_LIMIT),\n\t\t\tcontract_id: contractId,\n\t\t});\n\t\tif (opts.cursor) params.set(\"cursor\", opts.cursor);\n\t\telse params.set(\"from_height\", String(opts.fromHeight ?? 0));\n\t\tconst { items, next_cursor } = await this.getPage<\n\t\t\t\"transactions\",\n\t\t\tIndexTransactionRow\n\t\t>(\"/v1/index/transactions\", \"transactions\", params);\n\t\treturn { transactions: items, next_cursor };\n\t}\n\n\twalkBlocks(fromHeight: number, toHeight: number): Promise<IndexBlockRow[]> {\n\t\treturn this.walk<\"blocks\", IndexBlockRow>(\n\t\t\t\"/v1/index/blocks\",\n\t\t\t\"blocks\",\n\t\t\tfromHeight,\n\t\t\ttoHeight,\n\t\t);\n\t}\n\n\t/** Lowest block height in [fromHeight, toHeight] with a matching event, or\n\t * null. One page, limit 1 — built for sparse-scan probes. */\n\tasync firstEventHeight(\n\t\teventType: string,\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t\tcontractId?: string,\n\t): Promise<number | null> {\n\t\tconst params = new URLSearchParams({\n\t\t\tevent_type: eventType,\n\t\t\tfrom_height: String(fromHeight),\n\t\t\tto_height: String(toHeight),\n\t\t\tlimit: \"1\",\n\t\t\t...(contractId ? { contract_id: contractId } : {}),\n\t\t});\n\t\tconst { items } = await this.getPage<\"events\", IndexEventRow>(\n\t\t\t\"/v1/index/events\",\n\t\t\t\"events\",\n\t\t\tparams,\n\t\t);\n\t\treturn items[0]?.block_height ?? null;\n\t}\n\n\twalkEvents(\n\t\teventType: string,\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t\t/** Join the submitting tx so each row carries `tx_*` — lets an event-only\n\t\t * subgraph skip walkTransactions and build `ctx.tx` from the event. */\n\t\twithTx = false,\n\t\t/** Scope the walk to one contract (what a filter pins), so the Index\n\t\t * returns, and meters, only that contract's rows. */\n\t\tcontractId?: string,\n\t): Promise<IndexEventRow[]> {\n\t\treturn this.walk<\"events\", IndexEventRow>(\n\t\t\t\"/v1/index/events\",\n\t\t\t\"events\",\n\t\t\tfromHeight,\n\t\t\ttoHeight,\n\t\t\t{\n\t\t\t\tevent_type: eventType,\n\t\t\t\t...(withTx ? { tx_context: \"true\" } : {}),\n\t\t\t\t...(contractId ? { contract_id: contractId } : {}),\n\t\t\t},\n\t\t);\n\t}\n\n\t/** Every node storage write over the range, each with its writing tx\n\t * (`tx_context=true`); scoped to one contract's keys when given. */\n\twalkStateWrites(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t\tcontractId?: string,\n\t): Promise<IndexStateWriteRow[]> {\n\t\treturn this.walk<\"state_writes\", IndexStateWriteRow>(\n\t\t\t\"/v1/index/state-writes\",\n\t\t\t\"state_writes\",\n\t\t\tfromHeight,\n\t\t\ttoHeight,\n\t\t\t{\n\t\t\t\ttx_context: \"true\",\n\t\t\t\t...(contractId ? { contract_id: contractId } : {}),\n\t\t\t},\n\t\t);\n\t}\n\n\t/** Lowest height the Index serves `state_writes` for, or null when it has\n\t * none (its node does not deliver them). One row. */\n\tasync firstStateWriteHeight(): Promise<number | null> {\n\t\tconst { items } = await this.getPage<\"state_writes\", IndexStateWriteRow>(\n\t\t\t\"/v1/index/state-writes\",\n\t\t\t\"state_writes\",\n\t\t\tnew URLSearchParams({ from_height: \"0\", limit: \"1\" }),\n\t\t);\n\t\treturn items[0]?.block_height ?? null;\n\t}\n\n\t/**\n\t * Canonical sBTC protocol events over [fromHeight, toHeight] for `topics`,\n\t * ordered by (block_height, event_index). One drained walk per topic (the\n\t * endpoint filters on a single topic); `requestId` narrows each walk.\n\t */\n\tasync fetchSbtcEvents(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t\ttopics: readonly string[],\n\t\topts?: { requestId?: number },\n\t): Promise<IndexSbtcEventRow[]> {\n\t\tconst out: IndexSbtcEventRow[] = [];\n\t\tfor (const topic of topics) {\n\t\t\tout.push(\n\t\t\t\t...(await this.walk<\"events\", IndexSbtcEventRow>(\n\t\t\t\t\t\"/v1/index/sbtc/events\",\n\t\t\t\t\t\"events\",\n\t\t\t\t\tfromHeight,\n\t\t\t\t\ttoHeight,\n\t\t\t\t\t{\n\t\t\t\t\t\ttopic,\n\t\t\t\t\t\t...(opts?.requestId !== undefined\n\t\t\t\t\t\t\t? { request_id: String(opts.requestId) }\n\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t},\n\t\t\t\t)),\n\t\t\t);\n\t\t}\n\t\treturn out.sort(\n\t\t\t(a, b) =>\n\t\t\t\ta.block_height - b.block_height || a.event_index - b.event_index,\n\t\t);\n\t}\n\n\t/** Every canonical contract conforming to `trait` (declared or inferred),\n\t * draining `/v1/contracts`'s `next_cursor`. `classifiedAfter` limits it to\n\t * contracts classified at or after that time. */\n\tasync listContractsByTrait(\n\t\ttrait: string,\n\t\topts: { classifiedAfter?: Date } = {},\n\t): Promise<IndexContractRow[]> {\n\t\tconst out: IndexContractRow[] = [];\n\t\tlet cursor: string | null = null;\n\t\tdo {\n\t\t\tconst params = new URLSearchParams({\n\t\t\t\ttrait,\n\t\t\t\tlimit: String(CONTRACTS_PAGE_LIMIT),\n\t\t\t});\n\t\t\tif (opts.classifiedAfter) {\n\t\t\t\tparams.set(\"classified_after\", opts.classifiedAfter.toISOString());\n\t\t\t}\n\t\t\tif (cursor) params.set(\"cursor\", cursor);\n\t\t\tconst { items, next_cursor } = await this.getPage<\n\t\t\t\t\"contracts\",\n\t\t\t\tIndexContractRow\n\t\t\t>(\"/v1/contracts\", \"contracts\", params);\n\t\t\tout.push(...items);\n\t\t\tcursor = next_cursor;\n\t\t} while (cursor);\n\t\treturn out;\n\t}\n\n\t/** Withdrawals whose BTC sweep crossed the confirmation threshold strictly\n\t * after `confirmedAfter`, across all history. */\n\tfetchSettledWithdrawals(\n\t\tconfirmedAfter: Date,\n\t): Promise<IndexSbtcWithdrawalRow[]> {\n\t\treturn this.walk<\"withdrawals\", IndexSbtcWithdrawalRow>(\n\t\t\t\"/v1/index/sbtc/withdrawals\",\n\t\t\t\"withdrawals\",\n\t\t\t0,\n\t\t\tTHROUGH_TIP,\n\t\t\t{\n\t\t\t\tsettlement_confirmed: \"true\",\n\t\t\t\tconfirmed_after: confirmedAfter.toISOString(),\n\t\t\t},\n\t\t);\n\t}\n\n\twalkTransactions(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t): Promise<IndexTransactionRow[]> {\n\t\treturn this.walk<\"transactions\", IndexTransactionRow>(\n\t\t\t\"/v1/index/transactions\",\n\t\t\t\"transactions\",\n\t\t\tfromHeight,\n\t\t\ttoHeight,\n\t\t);\n\t}\n\n\t/** Canonical tip height from the Streams clock. */\n\tasync getStreamsTip(): Promise<number> {\n\t\tconst tip = await this.get<{ block_height: number }>(\n\t\t\t`${this.streamsBaseUrl}/v1/streams/tip`,\n\t\t\tthis.streamsApiKey,\n\t\t);\n\t\treturn Number(tip.block_height) || 0;\n\t}\n\n\t/**\n\t * Highest block height the Index data plane can serve (tip is inline in every\n\t * envelope). This is the data-availability bound — a consumer must not\n\t * process past it, even if the Streams clock is ahead.\n\t *\n\t * `opts.wait` (seconds, clamped to `MAX_INDEX_WAIT_SECONDS`) long-polls:\n\t * the server holds the request until a fresher tip than `opts.knownHeight`\n\t * is available or `wait` elapses. Pass `knownHeight` —\n\t * the last tip THIS caller observed — or `wait` is a no-op (nothing to\n\t * compare against). A caller that only wants the plain tip omits both.\n\t */\n\tasync getIndexTip(opts?: {\n\t\twait?: number;\n\t\tknownHeight?: number;\n\t\t/**\n\t\t * Event types this caller actually reads (e.g. a chain evaluator's\n\t\t * `referencedEventTypes`). Narrows the returned tip — and, together with\n\t\t * `wait`, what counts as \"nothing new\" — to the MIN committed height\n\t\t * over just these types, instead of the global floor over EVERY classic\n\t\t * decoder. Without it, an unrelated decoder committing (any of ~15,\n\t\t * several times a block) moves the global floor and makes an unrelated\n\t\t * `wait` return early — the exact busy-idle pattern this fixes. Omit\n\t\t * when there's no meaningful subset (falls back to the global floor,\n\t\t * unchanged from before this option existed).\n\t\t */\n\t\teventTypes?: readonly string[];\n\t}): Promise<number> {\n\t\t// `tip_only`: this method never reads `blocks[]`, only `tip`, so the\n\t\t// server can skip the row query. It ALSO fixes what `tip_only` exists\n\t\t// for: `blocks.length` is governed by the SOURCE tip (`/blocks` windows\n\t\t// to it), but `knownHeight` here is the DECODED tip this method itself\n\t\t// returns — comparing against raw rows made an idle `wait` return\n\t\t// almost instantly whenever ingest ran even slightly ahead of decode\n\t\t// (routes/index.ts's /blocks handler judges emptiness against\n\t\t// `tip.block_height` directly when `tip_only` is set, sidestepping the\n\t\t// mismatch). Do NOT set this on `getIndexSourceTip` below — its\n\t\t// `knownHeight` baseline is the source tip already, so the row-based\n\t\t// check is the CORRECT one there, not a bug to route around.\n\t\tconst env = await this.getIndexTipEnvelope({\n\t\t\t...opts,\n\t\t\ttipOnly: true,\n\t\t\teventTypes: opts?.eventTypes,\n\t\t});\n\t\treturn Number(env.tip?.block_height) || 0;\n\t}\n\n\t/** Ingest tip. VM rows land with the block; decoded `block_height` can lag.\n\t * Same `wait`/`knownHeight` long-poll contract as {@link getIndexTip}. */\n\tasync getIndexSourceTip(opts?: {\n\t\twait?: number;\n\t\tknownHeight?: number;\n\t}): Promise<number> {\n\t\tconst env = await this.getIndexTipEnvelope(opts);\n\t\treturn Number(env.tip?.source_block_height ?? env.tip?.block_height) || 0;\n\t}\n\n\t/**\n\t * Per-decoder committed heights from the last tip envelope fetched by\n\t * `getIndexTip()`/`getIndexSourceTip()` — reads the cached response, no\n\t * extra request. `undefined` before either has been called, or against an\n\t * older server that doesn't send the field. A caller (the chain evaluator)\n\t * uses this to bound itself by the decoders it actually reads instead of\n\t * the conservative cross-decoder floor `block_height` itself carries.\n\t */\n\tgetDecodedHeights(): Record<string, number | null> | undefined {\n\t\treturn this.lastTipEnvelope?.tip.decoded_heights;\n\t}\n\n\t/** False once a `wait`/`tip_only` request has 400'd on THIS client instance\n\t * (see the catch in `getIndexTipEnvelope`) — an older server that doesn't\n\t * recognize these params. A caller that reuses one client across many\n\t * calls (the chain evaluator) uses this to stop scheduling\n\t * itself as if `wait` actually blocked, instead of re-discovering the 400\n\t * on every single call. */\n\twaitIsSupported(): boolean {\n\t\treturn this.waitSupported;\n\t}\n\n\tprivate async getIndexTipEnvelope(opts?: {\n\t\twait?: number;\n\t\tknownHeight?: number;\n\t\ttipOnly?: boolean;\n\t\teventTypes?: readonly string[];\n\t}): Promise<IndexTipEnvelope> {\n\t\tconst wantsWait = this.waitSupported && (opts?.wait ?? 0) > 0;\n\t\tconst wantsTipOnly = this.waitSupported && Boolean(opts?.tipOnly);\n\t\tconst wantsEventTypes =\n\t\t\tthis.waitSupported && wantsTipOnly && Boolean(opts?.eventTypes?.length);\n\t\tconst params = new URLSearchParams({ limit: \"1\" });\n\t\tif (wantsTipOnly) params.set(\"tip_only\", \"true\");\n\t\t// Only meaningful alongside tip_only — it narrows what the returned\n\t\t// (and wait-compared) tip means, and tip_only is what makes the server\n\t\t// return a tip at all instead of a page of rows.\n\t\tif (wantsEventTypes && opts?.eventTypes) {\n\t\t\tparams.set(\"event_types\", opts.eventTypes.join(\",\"));\n\t\t}\n\t\tif (wantsWait) {\n\t\t\tparams.set(\n\t\t\t\t\"wait\",\n\t\t\t\tString(Math.min(MAX_INDEX_WAIT_SECONDS, Math.max(0, opts?.wait ?? 0))),\n\t\t\t);\n\t\t\t// `from_height` is what makes the wait meaningful here: with no cursor,\n\t\t\t// `/v1/index/blocks?limit=1` always returns a row (the default window's\n\t\t\t// oldest block), so the server would never see \"nothing new\" to wait\n\t\t\t// on. Anchoring one past the last height THIS caller saw makes the page\n\t\t\t// empty until a fresher block lands — the same signal the endpoint\n\t\t\t// already uses for a normal forward walk.\n\t\t\tif (opts?.knownHeight !== undefined) {\n\t\t\t\tparams.set(\"from_height\", String(opts.knownHeight + 1));\n\t\t\t}\n\t\t}\n\t\ttry {\n\t\t\tconst env = await this.get<IndexTipEnvelope>(\n\t\t\t\t`${this.indexBaseUrl}/v1/index/blocks?${params}`,\n\t\t\t\tthis.indexApiKey,\n\t\t\t);\n\t\t\tthis.lastTipEnvelope = env;\n\t\t\treturn env;\n\t\t} catch (err) {\n\t\t\t// An older server's `validateQueryParams` 400s on `wait`/`from_height`/\n\t\t\t// `tip_only`/`event_types` it doesn't recognize. Treat that ONE status\n\t\t\t// as \"this server predates long-poll\", not an outage: disable every\n\t\t\t// extension for the rest of this client's life (event_types is only\n\t\t\t// ever sent alongside tip_only, so gating on `wantsTipOnly` already\n\t\t\t// covers a server that knows `wait`/`tip_only` but not `event_types`\n\t\t\t// yet) and retry plainly, so `FallbackBlockSource` never mistakes a\n\t\t\t// version-skew 400 for the api being down and routes to the DB tap.\n\t\t\t// Gating on `wantsWait || wantsTipOnly` (not `wantsWait` alone)\n\t\t\t// matters mid-rollout: a server already upgraded for `wait` but not\n\t\t\t// yet for `tip_only` still 400s on the one it doesn't know, even on a\n\t\t\t// plain, non-waiting `getIndexTip()` call.\n\t\t\tif (\n\t\t\t\t(wantsWait || wantsTipOnly) &&\n\t\t\t\terr instanceof IndexHttpStatusError &&\n\t\t\t\terr.status === 400\n\t\t\t) {\n\t\t\t\tthis.waitSupported = false;\n\t\t\t\treturn this.getIndexTipEnvelope(opts);\n\t\t\t}\n\t\t\tthrow err;\n\t\t}\n\t}\n\n\t/** Reorgs since a resume token (wall-clock `detected_at`-keyed). `chain`\n\t * defaults to `\"stacks\"` (unchanged); `\"bitcoin\"` reads Runes reorgs\n\t * (`btc_reorgs`, plan 060) instead — a separate feed, own resume tokens. */\n\tasync listReorgs(\n\t\tsince: string,\n\t\tchain: \"stacks\" | \"bitcoin\" = \"stacks\",\n\t): Promise<{ reorgs: StreamsReorgRow[]; next_since: string | null }> {\n\t\tconst params = new URLSearchParams({ since });\n\t\tif (chain === \"bitcoin\") params.set(\"chain\", \"bitcoin\");\n\t\treturn this.get(\n\t\t\t`${this.streamsBaseUrl}/v1/streams/reorgs?${params}`,\n\t\t\tthis.streamsApiKey,\n\t\t);\n\t}\n\n\t/** Bitcoin (Runes) Streams tip height — `chain=bitcoin`'s own clock,\n\t * unrelated to the Stacks tip (plan 060). */\n\tasync getBitcoinStreamsTip(): Promise<number> {\n\t\tconst tip = await this.get<{ block_height: number }>(\n\t\t\t`${this.streamsBaseUrl}/v1/streams/tip?chain=bitcoin`,\n\t\t\tthis.streamsApiKey,\n\t\t);\n\t\treturn Number(tip.block_height) || 0;\n\t}\n\n\t/**\n\t * One page of `chain=bitcoin` Streams events (Runes activity, plan 060's\n\t * evaluator source). Unlike `walkEvents`, this does NOT drain — the caller\n\t * pages by feeding back `next_cursor` — so a tick bounds its own work\n\t * instead of draining an unbounded backlog in one call. `afterCursor`\n\t * (exclusive) takes precedence over `fromHeight` when both are given,\n\t * mirroring the REST endpoint's own `cursor`/`from_height` exclusivity.\n\t */\n\tasync getBitcoinStreamsEventsPage(opts: {\n\t\ttypes: readonly string[];\n\t\ttoHeight: number;\n\t\tafterCursor?: string;\n\t\tfromHeight?: number;\n\t\tlimit?: number;\n\t}): Promise<{ events: RuneStreamsEvent[]; next_cursor: string | null }> {\n\t\tconst params = new URLSearchParams({\n\t\t\tchain: \"bitcoin\",\n\t\t\tto_height: String(opts.toHeight),\n\t\t\tlimit: String(opts.limit ?? PAGE_LIMIT),\n\t\t\ttypes: opts.types.join(\",\"),\n\t\t});\n\t\tif (opts.afterCursor) params.set(\"from_cursor\", opts.afterCursor);\n\t\telse params.set(\"from_height\", String(opts.fromHeight ?? 0));\n\t\treturn this.get(\n\t\t\t`${this.streamsBaseUrl}/v1/streams/events?${params}`,\n\t\t\tthis.streamsApiKey,\n\t\t);\n\t}\n}\n\n/** Decoder + subgraph processor client. One env contract; omits bearer when no key. */\nexport function createInternalIndexHttpClient(): IndexHttpClient {\n\tconst baseUrl = defaultInternalIndexBaseUrl();\n\treturn new IndexHttpClient({\n\t\tindexBaseUrl: baseUrl,\n\t\tstreamsBaseUrl: baseUrl,\n\t\tindexApiKey: defaultInternalIndexApiKey(),\n\t\tstreamsApiKey: defaultInternalStreamsApiKey(),\n\t});\n}\n"
7
7
  ],
8
- "mappings": "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",
9
- "debugId": "E54A5375EB1771BD64756E2164756E21",
8
+ "mappings": "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",
9
+ "debugId": "53AE43019E152E4064756E2164756E21",
10
10
  "names": []
11
11
  }
@@ -502,7 +502,7 @@ interface ApiKeysTable {
502
502
  rate_limit: Generated<number>;
503
503
  ip_address: string;
504
504
  account_id: string;
505
- product: Generated<"account" | "streams" | "index" | "sentinel" | "docs">;
505
+ product: Generated<"account" | "streams" | "index" | "sentinel" | "docs" | "tenant">;
506
506
  /** Sentinel permission areas; set only on `product = 'sentinel'` keys. */
507
507
  areas: unknown | null;
508
508
  tier: "free" | "build" | "scale" | "enterprise" | "internal" | null;
@@ -284,7 +284,7 @@ interface ApiKeysTable {
284
284
  rate_limit: Generated<number>;
285
285
  ip_address: string;
286
286
  account_id: string;
287
- product: Generated<"account" | "streams" | "index" | "sentinel" | "docs">;
287
+ product: Generated<"account" | "streams" | "index" | "sentinel" | "docs" | "tenant">;
288
288
  /** Sentinel permission areas; set only on `product = 'sentinel'` keys. */
289
289
  areas: unknown | null;
290
290
  tier: "free" | "build" | "scale" | "enterprise" | "internal" | null;
@@ -0,0 +1,49 @@
1
+ import { type Kysely, sql } from "kysely";
2
+ import { onControlPlane } from "../src/db/migration-role.ts";
3
+
4
+ /**
5
+ * Tenant keys: `api_keys.product = 'tenant'`, the keys the workload host mints
6
+ * for a hosted tenant stack (`POST /internal/keys/tenant`). They read Streams
7
+ * and Index like an account key, and they are the only keys the hosted
8
+ * chain-read route (`/v1/chain-read`) accepts. They never open account routes.
9
+ *
10
+ * Existing tenant keys move over by provenance, never by name (a customer can
11
+ * name a key anything): the internal route is the only writer of
12
+ * `ip_address = 'workload-host'`; a customer key carries the client IP the
13
+ * trusted proxy saw. The workload host's tenant rows store no key id, so
14
+ * provenance is the strongest signal this database holds.
15
+ */
16
+ export async function up(db: Kysely<unknown>): Promise<void> {
17
+ await onControlPlane(async () => {
18
+ await sql`SET lock_timeout = '30s'`.execute(db);
19
+ await sql`
20
+ ALTER TABLE api_keys DROP CONSTRAINT IF EXISTS api_keys_product_check
21
+ `.execute(db);
22
+ await sql`
23
+ ALTER TABLE api_keys
24
+ ADD CONSTRAINT api_keys_product_check
25
+ CHECK (product IN ('account', 'streams', 'index', 'sentinel', 'docs', 'tenant'))
26
+ `.execute(db);
27
+ await sql`
28
+ UPDATE api_keys SET product = 'tenant'
29
+ WHERE ip_address = 'workload-host' AND product = 'account'
30
+ `.execute(db);
31
+ });
32
+ }
33
+
34
+ export async function down(db: Kysely<unknown>): Promise<void> {
35
+ await onControlPlane(async () => {
36
+ await sql`SET lock_timeout = '30s'`.execute(db);
37
+ await sql`
38
+ UPDATE api_keys SET product = 'account' WHERE product = 'tenant'
39
+ `.execute(db);
40
+ await sql`
41
+ ALTER TABLE api_keys DROP CONSTRAINT IF EXISTS api_keys_product_check
42
+ `.execute(db);
43
+ await sql`
44
+ ALTER TABLE api_keys
45
+ ADD CONSTRAINT api_keys_product_check
46
+ CHECK (product IN ('account', 'streams', 'index', 'sentinel', 'docs'))
47
+ `.execute(db);
48
+ });
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@secondlayer/shared",
3
- "version": "13.1.0",
3
+ "version": "13.2.1",
4
4
  "description": "Internal building blocks shared across Secondlayer packages.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -101,6 +101,10 @@
101
101
  "types": "./dist/src/error-envelope.d.ts",
102
102
  "import": "./dist/src/error-envelope.js"
103
103
  },
104
+ "./chain-read": {
105
+ "types": "./dist/src/chain-read.d.ts",
106
+ "import": "./dist/src/chain-read.js"
107
+ },
104
108
  "./index-internal-auth": {
105
109
  "types": "./dist/src/index-internal-auth.d.ts",
106
110
  "import": "./dist/src/index-internal-auth.js"
@@ -249,7 +253,7 @@
249
253
  "prepublishOnly": "bun run build"
250
254
  },
251
255
  "dependencies": {
252
- "@secondlayer/stacks": "^6.4.0",
256
+ "@secondlayer/stacks": "^6.4.1",
253
257
  "kysely": "0.28.17",
254
258
  "kysely-postgres-js": "3.0.0",
255
259
  "postgres": "^3.4.6",