@secondlayer/cli 9.7.0 → 9.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2785,8 +2785,8 @@ async function getSubgraphAgentSchema(name, options) {
2785
2785
  async function getSubgraphMarkdown(name, options) {
2786
2786
  return (await getPlatformClient()).subgraphs.markdown(name, options);
2787
2787
  }
2788
- async function reindexSubgraphApi(name, options) {
2789
- return (await getPlatformClient()).subgraphs.reindex(name, options);
2788
+ async function reindexSubgraphApi(name) {
2789
+ return (await getPlatformClient()).subgraphs.reindex(name);
2790
2790
  }
2791
2791
  async function backfillSubgraphApi(name, options) {
2792
2792
  return (await getPlatformClient()).subgraphs.backfill(name, options);
@@ -34817,7 +34817,7 @@ var {
34817
34817
  // package.json
34818
34818
  var package_default = {
34819
34819
  name: "@secondlayer/cli",
34820
- version: "9.7.0",
34820
+ version: "9.8.0",
34821
34821
  repository: {
34822
34822
  type: "git",
34823
34823
  url: "git+https://github.com/ryanwaits/secondlayer.git",
@@ -34854,15 +34854,16 @@ var package_default = {
34854
34854
  author: "",
34855
34855
  license: "MIT",
34856
34856
  dependencies: {
34857
+ "@biomejs/js-api": "^0.7.0",
34858
+ "@biomejs/wasm-nodejs": "^1.9.0",
34859
+ "@dsnp/parquetjs": "^1.8.8",
34857
34860
  "@inquirer/prompts": "^8.2.0",
34858
34861
  "@secondlayer/bundler": "^0.4.1",
34859
34862
  "@secondlayer/scaffold": "^1.5.1",
34860
34863
  "@secondlayer/sdk": "^6.51.0",
34861
- "@secondlayer/shared": "^7.4.0",
34864
+ "@secondlayer/shared": "^7.7.0",
34862
34865
  "@secondlayer/stacks": "^3.2.0",
34863
- "@secondlayer/subgraphs": "^3.26.0",
34864
- "@biomejs/js-api": "^0.7.0",
34865
- "@biomejs/wasm-nodejs": "^1.9.0",
34866
+ "@secondlayer/subgraphs": "^3.26.4",
34866
34867
  esbuild: "^0.27.4",
34867
34868
  "smol-toml": "^1.6.1",
34868
34869
  zod: "^4.3.6"
@@ -38626,6 +38627,696 @@ function handleProjectError(err) {
38626
38627
  error(err instanceof Error ? err.message : String(err));
38627
38628
  process.exit(1);
38628
38629
  }
38630
+ // src/commands/verify.ts
38631
+ import {
38632
+ compareRangeDigests,
38633
+ computeRangeDigest
38634
+ } from "@secondlayer/shared/archive/range-digest";
38635
+ import { getDb as getDb2 } from "@secondlayer/shared/db";
38636
+
38637
+ // src/lib/archive-reference.ts
38638
+ import { createHash as createHash2 } from "node:crypto";
38639
+ import { readFile as readFile4 } from "node:fs/promises";
38640
+ import { dirname as dirname16, join as join13, resolve as resolve12 } from "node:path";
38641
+ import { verifyStreamsBulkManifestSignature } from "@secondlayer/shared/streams-bulk-manifest";
38642
+ function isLatestPointer(value) {
38643
+ const pointer = value;
38644
+ return typeof pointer.snapshot_path === "string" && (value.partitions === undefined || value.partitions.length === 0);
38645
+ }
38646
+ async function fetchJson(url) {
38647
+ const response2 = await fetch(url, { signal: AbortSignal.timeout(30000) });
38648
+ if (!response2.ok) {
38649
+ throw new Error(`could not fetch manifest (${response2.status} ${response2.statusText})`);
38650
+ }
38651
+ return response2.json();
38652
+ }
38653
+ async function loadReference(source) {
38654
+ const isRemote = /^https?:\/\//.test(source);
38655
+ let manifest;
38656
+ let root;
38657
+ if (isRemote) {
38658
+ manifest = await fetchJson(source);
38659
+ const url = new URL(source);
38660
+ url.pathname = url.pathname.replace(/\/snapshots\/[^/]+$/, "").replace(/\/[^/]+\.json$/, "");
38661
+ root = url.toString();
38662
+ } else {
38663
+ const path11 = resolve12(source);
38664
+ manifest = JSON.parse(await readFile4(path11, "utf8"));
38665
+ root = isLatestPointer(manifest) ? dirname16(path11) : dirname16(dirname16(path11));
38666
+ }
38667
+ if (!isLatestPointer(manifest)) {
38668
+ return { manifest, origin: source, root, isRemote };
38669
+ }
38670
+ const pointer = manifest;
38671
+ const snapshotPath = pointer.snapshot_path;
38672
+ const resolved = isRemote ? await fetchJson(`${root.replace(/\/$/, "")}/${snapshotPath}`) : JSON.parse(await readFile4(join13(root, snapshotPath), "utf8"));
38673
+ if (pointer.snapshot_digest) {
38674
+ const digest = createHash2("sha256").update(JSON.stringify((({ signature: _s, key_id: _k, ...rest }) => rest)(resolved))).digest("hex");
38675
+ if (digest !== pointer.snapshot_digest) {
38676
+ throw new Error(`pointer/snapshot mismatch: latest.json names ${pointer.snapshot_digest}, resolved manifest hashes to ${digest}`);
38677
+ }
38678
+ }
38679
+ return { manifest: resolved, origin: source, root, isRemote };
38680
+ }
38681
+ function checkSignature(manifest, publicKeyPem, insecure) {
38682
+ if (insecure)
38683
+ return { verified: false, reason: "signature check skipped" };
38684
+ if (!manifest.signature) {
38685
+ return { verified: false, reason: "manifest carries no signature" };
38686
+ }
38687
+ if (!publicKeyPem) {
38688
+ return {
38689
+ verified: false,
38690
+ reason: "no public key available to verify with"
38691
+ };
38692
+ }
38693
+ try {
38694
+ const verified = verifyStreamsBulkManifestSignature(manifest, publicKeyPem);
38695
+ return {
38696
+ verified,
38697
+ reason: verified ? undefined : "signature did not verify"
38698
+ };
38699
+ } catch (err) {
38700
+ return {
38701
+ verified: false,
38702
+ reason: err instanceof Error ? err.message : "signature check failed"
38703
+ };
38704
+ }
38705
+ }
38706
+ async function resolvePublicKey(explicitPem, apiUrl) {
38707
+ if (explicitPem)
38708
+ return explicitPem;
38709
+ try {
38710
+ const response2 = await fetch(`${apiUrl.replace(/\/$/, "")}/public/streams/signing-key`, { signal: AbortSignal.timeout(1e4) });
38711
+ if (!response2.ok)
38712
+ return;
38713
+ const body = await response2.json();
38714
+ return body.public_key_pem;
38715
+ } catch {
38716
+ return;
38717
+ }
38718
+ }
38719
+ async function fetchVerifiedPartition(reference, partition) {
38720
+ let bytes;
38721
+ if (reference.isRemote) {
38722
+ const url = `${reference.root.replace(/\/$/, "")}/${partition.path}`;
38723
+ const response2 = await fetch(url, { signal: AbortSignal.timeout(300000) });
38724
+ if (!response2.ok) {
38725
+ throw new Error(`could not fetch ${partition.path} (${response2.status} ${response2.statusText})`);
38726
+ }
38727
+ bytes = Buffer.from(await response2.arrayBuffer());
38728
+ } else {
38729
+ bytes = await readFile4(join13(reference.root, partition.path));
38730
+ }
38731
+ const digest = createHash2("sha256").update(bytes).digest("hex");
38732
+ if (digest !== partition.sha256) {
38733
+ throw new Error(`${partition.path} failed verification: expected ${partition.sha256}, got ${digest}`);
38734
+ }
38735
+ return bytes;
38736
+ }
38737
+
38738
+ // src/commands/verify.ts
38739
+ init_output();
38740
+ var VERIFY_EXIT = {
38741
+ CLEAN: 0,
38742
+ DIVERGED: 1,
38743
+ UNANCHORED: 2
38744
+ };
38745
+ function parseHeight2(value, flag) {
38746
+ const parsed = Number(value);
38747
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
38748
+ throw new Error(`${flag} must be a non-negative integer, got "${value}"`);
38749
+ }
38750
+ return parsed;
38751
+ }
38752
+ function statusLabel(status2) {
38753
+ if (status2 === "match")
38754
+ return green("ok");
38755
+ if (status2 === "count-mismatch")
38756
+ return red("rows differ");
38757
+ if (status2 === "digest-mismatch")
38758
+ return red("data differs");
38759
+ return yellow("missing");
38760
+ }
38761
+ function registerVerifyCommand(program2) {
38762
+ program2.command("verify").description("Compare local chain data against a signed archive (read-only; nothing is uploaded)").requiredOption("--against <manifest>", "archive manifest: an https URL or a local file path").option("--from-block <n>", "first height to check").option("--to-block <n>", "last height to check").option("--counts", "also compare transaction/event row counts (slower)").option("--public-key <pem>", "pin the signing key instead of fetching it").option("--insecure", "skip the manifest signature check (not recommended)").option("--json", "Output as JSON").addHelpText("after", `
38763
+ Examples:
38764
+ $ sl verify --against https://archive.secondlayer.tools/.../snapshots/<digest>.json
38765
+ $ sl verify --against ./snapshot.json --from-block 8000000 --to-block 8499999
38766
+ $ sl verify --against ./snapshot.json --counts --json
38767
+
38768
+ Exit codes:
38769
+ 0 local data matches the archive
38770
+ 1 divergence found (divergent ranges are listed)
38771
+ 2 unanchored — reference unavailable or unverifiable`).action(async (opts) => {
38772
+ try {
38773
+ const fromBlock = opts.fromBlock === undefined ? undefined : parseHeight2(opts.fromBlock, "--from-block");
38774
+ const toBlock = opts.toBlock === undefined ? undefined : parseHeight2(opts.toBlock, "--to-block");
38775
+ if (fromBlock !== undefined && toBlock !== undefined && toBlock < fromBlock) {
38776
+ throw new Error("--to-block must be >= --from-block");
38777
+ }
38778
+ const { manifest, origin } = await loadReference(opts.against);
38779
+ const publicKey = await resolvePublicKey(opts.publicKey, process.env.SL_API_URL ?? "https://api.secondlayer.tools");
38780
+ const signature = checkSignature(manifest, publicKey, !!opts.insecure);
38781
+ if (!signature.verified && !opts.insecure) {
38782
+ printError(`Cannot trust the reference: ${signature.reason}.`, {
38783
+ hint: "Pass --public-key <pem> to pin a key, or --insecure to compare anyway (result is unverified)."
38784
+ });
38785
+ output({
38786
+ json: opts.json,
38787
+ data: {
38788
+ status: "unanchored",
38789
+ reference: origin,
38790
+ reason: signature.reason
38791
+ },
38792
+ human: () => {}
38793
+ });
38794
+ process.exit(VERIFY_EXIT.UNANCHORED);
38795
+ }
38796
+ const reference = (manifest.range_digests ?? []).filter((d2) => {
38797
+ if (fromBlock !== undefined && d2.to_block < fromBlock)
38798
+ return false;
38799
+ if (toBlock !== undefined && d2.from_block > toBlock)
38800
+ return false;
38801
+ return true;
38802
+ });
38803
+ if (reference.length === 0) {
38804
+ printError("The reference publishes no digests for that range.", {
38805
+ hint: "Check --from-block/--to-block against the manifest's coverage."
38806
+ });
38807
+ process.exit(VERIFY_EXIT.UNANCHORED);
38808
+ }
38809
+ const db = getDb2();
38810
+ const local = [];
38811
+ let checked = 0;
38812
+ for (const range of reference) {
38813
+ local.push(await computeRangeDigest(db, range.dataset, range.from_block, range.to_block));
38814
+ checked++;
38815
+ if (checked % 25 === 0 || checked === reference.length) {
38816
+ note(` checked ${checked}/${reference.length} ranges`);
38817
+ }
38818
+ }
38819
+ const comparisons = compareRangeDigests(local, reference);
38820
+ const countChecks = [];
38821
+ if (opts.counts && manifest.partitions) {
38822
+ for (const partition of manifest.partitions) {
38823
+ if (partition.dataset === "blocks")
38824
+ continue;
38825
+ if (fromBlock !== undefined && partition.to_block < fromBlock)
38826
+ continue;
38827
+ if (toBlock !== undefined && partition.from_block > toBlock)
38828
+ continue;
38829
+ const actual = await computeRangeDigest(db, partition.dataset, partition.from_block, partition.to_block);
38830
+ countChecks.push({
38831
+ dataset: partition.dataset,
38832
+ from_block: partition.from_block,
38833
+ to_block: partition.to_block,
38834
+ status: actual.row_count === partition.row_count ? "match" : "count-mismatch",
38835
+ expected_digest: null,
38836
+ actual_digest: null,
38837
+ expected_rows: partition.row_count,
38838
+ actual_rows: actual.row_count
38839
+ });
38840
+ }
38841
+ }
38842
+ const all2 = [...comparisons, ...countChecks];
38843
+ const diverged = all2.filter((c4) => c4.status !== "match");
38844
+ const report = {
38845
+ status: diverged.length === 0 ? "clean" : "diverged",
38846
+ reference: origin,
38847
+ signature_verified: signature.verified,
38848
+ ranges_checked: all2.length,
38849
+ divergent_ranges: diverged
38850
+ };
38851
+ output({
38852
+ json: opts.json,
38853
+ data: report,
38854
+ human: () => {
38855
+ if (diverged.length === 0) {
38856
+ success(`Local data matches the archive across ${all2.length} ranges.`);
38857
+ note(signature.verified ? " reference signature verified" : " reference signature NOT verified (--insecure)");
38858
+ return;
38859
+ }
38860
+ warn(`${diverged.length} of ${all2.length} ranges diverge from the archive.`);
38861
+ console.error("");
38862
+ console.error(formatTable(["RANGE", "DATASET", "STATUS", "EXPECTED", "LOCAL"], diverged.map((d2) => [
38863
+ `${d2.from_block}-${d2.to_block}`,
38864
+ d2.dataset,
38865
+ statusLabel(d2.status),
38866
+ String(d2.expected_rows),
38867
+ String(d2.actual_rows)
38868
+ ])));
38869
+ console.error("");
38870
+ console.error(dim("These ranges hold different data than the signed archive. Repair with:"));
38871
+ const first = diverged[0];
38872
+ if (first) {
38873
+ console.error(dim(` sl repair --from-block ${first.from_block} --to-block ${first.to_block}`));
38874
+ }
38875
+ }
38876
+ });
38877
+ process.exit(diverged.length === 0 ? VERIFY_EXIT.CLEAN : VERIFY_EXIT.DIVERGED);
38878
+ } catch (err) {
38879
+ printError(err instanceof Error ? err.message : String(err), {
38880
+ hint: "Set DATABASE_URL to the instance you want to verify."
38881
+ });
38882
+ process.exit(VERIFY_EXIT.UNANCHORED);
38883
+ }
38884
+ });
38885
+ }
38886
+ // src/commands/repair.ts
38887
+ import { unlink as unlink2, writeFile as writeFile5 } from "node:fs/promises";
38888
+ import { tmpdir as tmpdir2 } from "node:os";
38889
+ import { join as join14 } from "node:path";
38890
+ import { ParquetReader } from "@dsnp/parquetjs";
38891
+ import {
38892
+ compareRangeDigests as compareRangeDigests2,
38893
+ computeRangeDigest as computeRangeDigest2
38894
+ } from "@secondlayer/shared/archive/range-digest";
38895
+ import { getDb as getDb3, sql as sql2 } from "@secondlayer/shared/db";
38896
+ init_output();
38897
+ var REPAIR_EXIT = {
38898
+ OK: 0,
38899
+ DIVERGENCE_REMAINS: 1,
38900
+ UNANCHORED: 2
38901
+ };
38902
+ function asNumber(value) {
38903
+ return typeof value === "bigint" ? Number(value) : Number(value);
38904
+ }
38905
+ function asText(value) {
38906
+ return Buffer.isBuffer(value) ? value.toString("utf8") : String(value);
38907
+ }
38908
+ function asOptionalText(value) {
38909
+ return value === null || value === undefined ? null : asText(value);
38910
+ }
38911
+ async function readBlocksPartition(bytes, label) {
38912
+ const path11 = join14(tmpdir2(), `sl-repair-${label}-${process.pid}.parquet`);
38913
+ await writeFile5(path11, bytes);
38914
+ try {
38915
+ const reader = await ParquetReader.openFile(path11);
38916
+ const rows = [];
38917
+ try {
38918
+ const cursor = reader.getCursor();
38919
+ for (let row = await cursor.next();row; row = await cursor.next()) {
38920
+ rows.push({
38921
+ height: asNumber(row.height),
38922
+ hash: asText(row.hash),
38923
+ parent_hash: asText(row.parent_hash),
38924
+ burn_block_height: asNumber(row.burn_block_height),
38925
+ burn_block_hash: asOptionalText(row.burn_block_hash),
38926
+ index_block_hash: asOptionalText(row.index_block_hash),
38927
+ timestamp: asNumber(row.timestamp)
38928
+ });
38929
+ }
38930
+ } finally {
38931
+ await reader.close();
38932
+ }
38933
+ return rows;
38934
+ } finally {
38935
+ await unlink2(path11).catch(() => {});
38936
+ }
38937
+ }
38938
+ function parseHeight3(value, flag) {
38939
+ const parsed = Number(value);
38940
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
38941
+ throw new Error(`${flag} must be a non-negative integer, got "${value}"`);
38942
+ }
38943
+ return parsed;
38944
+ }
38945
+ async function planRange(db, reference, partition) {
38946
+ const bytes = await fetchVerifiedPartition(reference, partition);
38947
+ const archiveBlocks = await readBlocksPartition(bytes, `${partition.from_block}-${partition.to_block}`);
38948
+ const localRows = await db.selectFrom("blocks").select(["height", "hash"]).where("canonical", "=", true).where("height", ">=", partition.from_block).where("height", "<=", partition.to_block).execute();
38949
+ const localByHeight = new Map(localRows.map((r2) => [Number(r2.height), r2.hash]));
38950
+ const fixes = [];
38951
+ for (const block of archiveBlocks) {
38952
+ const localHash = localByHeight.get(block.height);
38953
+ if (localHash === undefined) {
38954
+ fixes.push({
38955
+ height: block.height,
38956
+ kind: "insert",
38957
+ local_hash: null,
38958
+ archive_hash: block.hash
38959
+ });
38960
+ } else if (localHash !== block.hash) {
38961
+ fixes.push({
38962
+ height: block.height,
38963
+ kind: "replace",
38964
+ local_hash: localHash,
38965
+ archive_hash: block.hash
38966
+ });
38967
+ }
38968
+ localByHeight.delete(block.height);
38969
+ }
38970
+ const extraHeights = [...localByHeight.keys()].sort((a4, b3) => a4 - b3);
38971
+ return { fixes, extraHeights };
38972
+ }
38973
+ async function applyFixes(db, reference, partition, fixes) {
38974
+ if (fixes.length === 0)
38975
+ return 0;
38976
+ const bytes = await fetchVerifiedPartition(reference, partition);
38977
+ const archiveBlocks = await readBlocksPartition(bytes, `apply-${partition.from_block}`);
38978
+ const byHeight = new Map(archiveBlocks.map((b3) => [b3.height, b3]));
38979
+ const targets = fixes.map((fix) => byHeight.get(fix.height)).filter((b3) => b3 !== undefined);
38980
+ await db.transaction().execute(async (tx) => {
38981
+ for (const block of targets) {
38982
+ await sql2`
38983
+ INSERT INTO blocks (
38984
+ height, hash, parent_hash, burn_block_height,
38985
+ burn_block_hash, index_block_hash, timestamp, canonical
38986
+ ) VALUES (
38987
+ ${block.height}, ${block.hash}, ${block.parent_hash},
38988
+ ${block.burn_block_height}, ${block.burn_block_hash},
38989
+ ${block.index_block_hash}, ${block.timestamp}, true
38990
+ )
38991
+ ON CONFLICT (height) DO UPDATE SET
38992
+ hash = EXCLUDED.hash,
38993
+ parent_hash = EXCLUDED.parent_hash,
38994
+ burn_block_height = EXCLUDED.burn_block_height,
38995
+ burn_block_hash = EXCLUDED.burn_block_hash,
38996
+ index_block_hash = EXCLUDED.index_block_hash,
38997
+ timestamp = EXCLUDED.timestamp,
38998
+ canonical = true
38999
+ `.execute(tx);
39000
+ }
39001
+ });
39002
+ return targets.length;
39003
+ }
39004
+ function registerRepairCommand(program2) {
39005
+ program2.command("repair").description("Replace local chain data that diverges from a signed archive (dry-run by default)").requiredOption("--against <manifest>", "archive manifest: an https URL or a local file path").option("--from-block <n>", "first height to consider").option("--to-block <n>", "last height to consider").option("--apply", "write the repair (default is a dry-run plan)").option("--public-key <pem>", "pin the signing key instead of fetching it").option("--json", "Output as JSON").addHelpText("after", `
39006
+ Examples:
39007
+ $ sl repair --against ./snapshot.json # plan only
39008
+ $ sl repair --against ./snapshot.json --apply # write the fix
39009
+ $ sl repair --against ./snapshot.json --from-block 8500000 --to-block 8549999
39010
+
39011
+ Exit codes:
39012
+ 0 nothing to repair, or the repair completed and re-verified clean
39013
+ 1 divergence remains (dry-run, or repair did not fully resolve it)
39014
+ 2 unanchored — reference unavailable or unverifiable`).action(async (opts) => {
39015
+ try {
39016
+ const fromBlock = opts.fromBlock === undefined ? undefined : parseHeight3(opts.fromBlock, "--from-block");
39017
+ const toBlock = opts.toBlock === undefined ? undefined : parseHeight3(opts.toBlock, "--to-block");
39018
+ const reference = await loadReference(opts.against);
39019
+ const publicKey = await resolvePublicKey(opts.publicKey, process.env.SL_API_URL ?? "https://api.secondlayer.tools");
39020
+ const signature = checkSignature(reference.manifest, publicKey, false);
39021
+ if (!signature.verified) {
39022
+ printError(`Refusing to repair from an untrusted reference: ${signature.reason}.`, {
39023
+ hint: "Pass --public-key <pem> to pin the archive's signing key."
39024
+ });
39025
+ process.exit(REPAIR_EXIT.UNANCHORED);
39026
+ }
39027
+ const db = getDb3();
39028
+ const inRange = (from, to) => !(fromBlock !== undefined && to < fromBlock || toBlock !== undefined && from > toBlock);
39029
+ const referenceDigests = (reference.manifest.range_digests ?? []).filter((d2) => d2.dataset === "blocks" && inRange(d2.from_block, d2.to_block));
39030
+ if (referenceDigests.length === 0) {
39031
+ printError("The reference publishes no block digests for that range.", {
39032
+ hint: "Check --from-block/--to-block against the manifest's coverage."
39033
+ });
39034
+ process.exit(REPAIR_EXIT.UNANCHORED);
39035
+ }
39036
+ const local = [];
39037
+ for (const range of referenceDigests) {
39038
+ local.push(await computeRangeDigest2(db, "blocks", range.from_block, range.to_block));
39039
+ }
39040
+ const divergent = compareRangeDigests2(local, referenceDigests).filter((c4) => c4.status !== "match");
39041
+ if (divergent.length === 0) {
39042
+ success("Nothing to repair — local blocks match the archive.");
39043
+ output({
39044
+ json: opts.json,
39045
+ data: { status: "clean", fixes: [] },
39046
+ human: () => {}
39047
+ });
39048
+ process.exit(REPAIR_EXIT.OK);
39049
+ }
39050
+ const partitions = (reference.manifest.partitions ?? []).filter((p2) => p2.dataset === "blocks" && divergent.some((d2) => d2.from_block === p2.from_block && d2.to_block === p2.to_block));
39051
+ const allFixes = [];
39052
+ const allExtra = [];
39053
+ const planned = [];
39054
+ for (const partition of partitions) {
39055
+ note(` reading ${partition.dataset} ${partition.from_block}-${partition.to_block}`);
39056
+ const { fixes, extraHeights } = await planRange(db, reference, partition);
39057
+ planned.push({ partition, fixes });
39058
+ allFixes.push(...fixes);
39059
+ allExtra.push(...extraHeights);
39060
+ }
39061
+ let applied = 0;
39062
+ if (opts.apply) {
39063
+ for (const { partition, fixes } of planned) {
39064
+ applied += await applyFixes(db, reference, partition, fixes);
39065
+ }
39066
+ }
39067
+ let remaining = divergent.length;
39068
+ if (opts.apply) {
39069
+ const after = [];
39070
+ for (const range of referenceDigests) {
39071
+ after.push(await computeRangeDigest2(db, "blocks", range.from_block, range.to_block));
39072
+ }
39073
+ remaining = compareRangeDigests2(after, referenceDigests).filter((c4) => c4.status !== "match").length;
39074
+ }
39075
+ const report = {
39076
+ status: opts.apply ? remaining === 0 ? "repaired" : "incomplete" : "plan",
39077
+ reference: reference.origin,
39078
+ divergent_ranges: divergent.length,
39079
+ blocks_to_replace: allFixes.filter((f2) => f2.kind === "replace").length,
39080
+ blocks_to_insert: allFixes.filter((f2) => f2.kind === "insert").length,
39081
+ local_only_heights: allExtra,
39082
+ applied,
39083
+ fixes: allFixes
39084
+ };
39085
+ output({
39086
+ json: opts.json,
39087
+ data: report,
39088
+ human: () => {
39089
+ const replace = report.blocks_to_replace;
39090
+ const insert = report.blocks_to_insert;
39091
+ if (opts.apply) {
39092
+ if (remaining === 0) {
39093
+ success(`Repaired ${applied} blocks across ${divergent.length} ranges; re-verified clean.`);
39094
+ } else {
39095
+ warn(`Applied ${applied} blocks but ${remaining} ranges still diverge.`);
39096
+ }
39097
+ } else {
39098
+ warn(`${divergent.length} ranges diverge — ${replace} blocks to replace, ${insert} to insert.`);
39099
+ }
39100
+ if (allFixes.length > 0) {
39101
+ console.error("");
39102
+ console.error(formatTable(["HEIGHT", "ACTION", "LOCAL", "ARCHIVE"], allFixes.slice(0, 20).map((f2) => [
39103
+ String(f2.height),
39104
+ f2.kind,
39105
+ (f2.local_hash ?? "—").slice(0, 18),
39106
+ f2.archive_hash.slice(0, 18)
39107
+ ])));
39108
+ if (allFixes.length > 20) {
39109
+ console.error(dim(` … and ${allFixes.length - 20} more`));
39110
+ }
39111
+ }
39112
+ if (allExtra.length > 0) {
39113
+ console.error("");
39114
+ warn(`${allExtra.length} local heights are absent from the archive (e.g. ${allExtra.slice(0, 5).join(", ")}).`);
39115
+ console.error(dim(" These are NOT removed — transactions and events reference them."));
39116
+ }
39117
+ if (!opts.apply) {
39118
+ console.error("");
39119
+ console.error(dim("(dry-run — pass --apply to write the repair)"));
39120
+ } else {
39121
+ console.error("");
39122
+ console.error(dim("Decoded rows for repaired heights are not rebuilt here — re-run your decoders for these ranges."));
39123
+ }
39124
+ }
39125
+ });
39126
+ process.exit(opts.apply && remaining === 0 ? REPAIR_EXIT.OK : REPAIR_EXIT.DIVERGENCE_REMAINS);
39127
+ } catch (err) {
39128
+ const message = err instanceof Error ? err.message : String(err);
39129
+ const hint = /failed verification/.test(message) ? "The archive object does not match its signed digest — re-download it, and report this if it persists." : /could not fetch/.test(message) ? "Check the archive URL and your network connection." : "Set DATABASE_URL to the instance you want to repair.";
39130
+ printError(message, { hint });
39131
+ process.exit(REPAIR_EXIT.UNANCHORED);
39132
+ }
39133
+ });
39134
+ }
39135
+ // src/commands/bootstrap.ts
39136
+ import { unlink as unlink3, writeFile as writeFile6 } from "node:fs/promises";
39137
+ import { tmpdir as tmpdir3 } from "node:os";
39138
+ import { join as join15 } from "node:path";
39139
+ import { ParquetReader as ParquetReader2 } from "@dsnp/parquetjs";
39140
+ import { confirm as confirm7 } from "@inquirer/prompts";
39141
+ import {
39142
+ copyStatement,
39143
+ writeRowsToCopyStream
39144
+ } from "@secondlayer/shared/archive/copy-loader";
39145
+ import { computeRangeDigest as computeRangeDigest3 } from "@secondlayer/shared/archive/range-digest";
39146
+ import { getDb as getDb4, getRawClient } from "@secondlayer/shared/db";
39147
+ init_output();
39148
+ var BOOTSTRAP_EXIT = {
39149
+ OK: 0,
39150
+ INCOMPLETE: 1,
39151
+ REFUSED: 2
39152
+ };
39153
+ var BLOCKS_PER_DAY_ESTIMATE = 5400;
39154
+ var RESTORE_ROWS_PER_SECOND = 30000;
39155
+ function formatDuration2(seconds2) {
39156
+ if (seconds2 < 90)
39157
+ return `${Math.round(seconds2)}s`;
39158
+ if (seconds2 < 5400)
39159
+ return `${Math.round(seconds2 / 60)} min`;
39160
+ if (seconds2 < 172800)
39161
+ return `${(seconds2 / 3600).toFixed(1)} hours`;
39162
+ return `${(seconds2 / 86400).toFixed(1)} days`;
39163
+ }
39164
+ async function* readPartitionRows(bytes, label) {
39165
+ const path11 = join15(tmpdir3(), `sl-bootstrap-${label}-${process.pid}.parquet`);
39166
+ await writeFile6(path11, bytes);
39167
+ try {
39168
+ const reader = await ParquetReader2.openFile(path11);
39169
+ try {
39170
+ const cursor = reader.getCursor();
39171
+ for (let row = await cursor.next();row; row = await cursor.next()) {
39172
+ yield row;
39173
+ }
39174
+ } finally {
39175
+ await reader.close();
39176
+ }
39177
+ } finally {
39178
+ await unlink3(path11).catch(() => {});
39179
+ }
39180
+ }
39181
+ async function loadPartition(reference, partition, rawClient) {
39182
+ const bytes = await fetchVerifiedPartition(reference, partition);
39183
+ const writable2 = await rawClient.unsafe(copyStatement(partition.dataset)).writable();
39184
+ const written = await writeRowsToCopyStream({
39185
+ writable: writable2,
39186
+ dataset: partition.dataset,
39187
+ rows: readPartitionRows(bytes, `${partition.dataset}-${partition.from_block}`)
39188
+ });
39189
+ if (written !== partition.row_count) {
39190
+ throw new Error(`${partition.path}: loaded ${written} rows, manifest declares ${partition.row_count}`);
39191
+ }
39192
+ return written;
39193
+ }
39194
+ function registerBootstrapCommand(program2) {
39195
+ program2.command("bootstrap").description("Restore chain history from a verified archive instead of syncing from genesis").requiredOption("--against <manifest>", "archive manifest: an https URL or a local file path").option("--to-block <n>", "stop at this height instead of the archive tip").option("--public-key <pem>", "pin the signing key instead of fetching it").option("-y, --yes", "skip the confirmation prompt").option("--json", "Output as JSON").addHelpText("after", `
39196
+ Examples:
39197
+ $ sl bootstrap --against https://archive.secondlayer.tools/.../snapshots/<digest>.json
39198
+ $ sl bootstrap --against ./snapshot.json --to-block 4000000 --yes
39199
+
39200
+ Exit codes:
39201
+ 0 restored and verified
39202
+ 1 restore completed but verification found divergence
39203
+ 2 refused (non-empty target, or an untrusted reference)`).action(async (opts) => {
39204
+ try {
39205
+ const reference = await loadReference(opts.against);
39206
+ const publicKey = await resolvePublicKey(opts.publicKey, process.env.SL_API_URL ?? "https://api.secondlayer.tools");
39207
+ const signature = checkSignature(reference.manifest, publicKey, false);
39208
+ if (!signature.verified) {
39209
+ printError(`Refusing to bootstrap from an untrusted archive: ${signature.reason}.`, {
39210
+ hint: "Pass --public-key <pem> to pin the archive's signing key."
39211
+ });
39212
+ process.exit(BOOTSTRAP_EXIT.REFUSED);
39213
+ }
39214
+ const db = getDb4();
39215
+ const existing = await db.selectFrom("blocks").select(({ fn }) => fn.countAll().as("count")).executeTakeFirst();
39216
+ if (Number(existing?.count ?? 0) > 0) {
39217
+ printError("This database already holds blocks.", {
39218
+ hint: "Bootstrap is for a fresh instance. To fix an existing one, use `sl repair`."
39219
+ });
39220
+ process.exit(BOOTSTRAP_EXIT.REFUSED);
39221
+ }
39222
+ const toBlock = opts.toBlock === undefined ? undefined : Number(opts.toBlock);
39223
+ const partitions = (reference.manifest.partitions ?? []).filter((p2) => toBlock === undefined || p2.to_block <= toBlock);
39224
+ if (partitions.length === 0) {
39225
+ printError("The archive has no partitions for that range.", {
39226
+ hint: "Check --to-block against the manifest's coverage."
39227
+ });
39228
+ process.exit(BOOTSTRAP_EXIT.REFUSED);
39229
+ }
39230
+ const totalRows = partitions.reduce((sum, p2) => sum + p2.row_count, 0);
39231
+ const totalBytes = partitions.reduce((sum, p2) => sum + p2.byte_size, 0);
39232
+ const tipHeight = Math.max(...partitions.map((p2) => p2.to_block));
39233
+ const restoreSeconds = totalRows / RESTORE_ROWS_PER_SECOND;
39234
+ const genesisSeconds = tipHeight / BLOCKS_PER_DAY_ESTIMATE * 86400;
39235
+ if (!opts.json) {
39236
+ console.error("");
39237
+ console.error(formatKeyValue([
39238
+ ["archive", reference.origin],
39239
+ ["coverage", `genesis → ${tipHeight.toLocaleString()}`],
39240
+ ["rows", totalRows.toLocaleString()],
39241
+ ["download", `${(totalBytes / 1e9).toFixed(1)} GB`],
39242
+ ["signature", "verified"]
39243
+ ]));
39244
+ console.error("");
39245
+ console.error(` sync from genesis ${bold(`~${formatDuration2(genesisSeconds)}`)} ${dim("(estimate)")}`);
39246
+ console.error(` restore from archive ${bold(`~${formatDuration2(restoreSeconds)}`)}`);
39247
+ console.error("");
39248
+ }
39249
+ if (!opts.yes && !opts.json) {
39250
+ const proceed = await confirm7({
39251
+ message: "Restore from the verified archive?",
39252
+ default: true
39253
+ });
39254
+ if (!proceed) {
39255
+ note("Nothing was written.");
39256
+ process.exit(BOOTSTRAP_EXIT.REFUSED);
39257
+ }
39258
+ }
39259
+ const order = ["blocks", "transactions", "events"];
39260
+ const rawClient = getRawClient("source");
39261
+ const loaded = { blocks: 0, transactions: 0, events: 0 };
39262
+ const startedAt = Date.now();
39263
+ for (const dataset of order) {
39264
+ const datasetPartitions = partitions.filter((p2) => p2.dataset === dataset).sort((a4, b3) => a4.from_block - b3.from_block);
39265
+ for (const [index, partition] of datasetPartitions.entries()) {
39266
+ loaded[dataset] += await loadPartition(reference, partition, rawClient);
39267
+ const done = index + 1;
39268
+ if (done % 10 === 0 || done === datasetPartitions.length) {
39269
+ const elapsed2 = (Date.now() - startedAt) / 1000;
39270
+ note(` ${dataset}: ${done}/${datasetPartitions.length} partitions · ${loaded[dataset].toLocaleString()} rows · ${formatDuration2(elapsed2)} elapsed`);
39271
+ }
39272
+ }
39273
+ }
39274
+ const referenceDigests = (reference.manifest.range_digests ?? []).filter((d2) => d2.dataset === "blocks" && d2.to_block <= tipHeight);
39275
+ let divergent = 0;
39276
+ for (const range of referenceDigests) {
39277
+ const actual = await computeRangeDigest3(db, "blocks", range.from_block, range.to_block);
39278
+ if (actual.digest !== range.digest || actual.row_count !== range.row_count) {
39279
+ divergent++;
39280
+ }
39281
+ }
39282
+ const elapsed = (Date.now() - startedAt) / 1000;
39283
+ const report = {
39284
+ status: divergent === 0 ? "restored" : "divergent",
39285
+ archive: reference.origin,
39286
+ tip_height: tipHeight,
39287
+ rows: loaded,
39288
+ ranges_verified: referenceDigests.length,
39289
+ divergent_ranges: divergent,
39290
+ elapsed_seconds: Math.round(elapsed)
39291
+ };
39292
+ output({
39293
+ json: opts.json,
39294
+ data: report,
39295
+ human: () => {
39296
+ if (divergent === 0) {
39297
+ success(`Restored ${loaded.blocks.toLocaleString()} blocks, ${loaded.transactions.toLocaleString()} transactions, ${loaded.events.toLocaleString()} events in ${formatDuration2(elapsed)}.`);
39298
+ if (referenceDigests.length > 0) {
39299
+ note(` verified ${referenceDigests.length} ranges against the archive`);
39300
+ } else {
39301
+ warn(" archive published no block digests — restore is unverified");
39302
+ }
39303
+ console.error("");
39304
+ console.error(dim(`Your instance holds history through ${tipHeight.toLocaleString()}. Start the indexer to continue from there.`));
39305
+ } else {
39306
+ warn(`Restored ${loaded.blocks.toLocaleString()} blocks but ${divergent} ranges do not match the archive.`);
39307
+ console.error(dim(" Investigate with: sl verify --against <manifest>"));
39308
+ }
39309
+ }
39310
+ });
39311
+ process.exit(divergent === 0 ? BOOTSTRAP_EXIT.OK : BOOTSTRAP_EXIT.INCOMPLETE);
39312
+ } catch (err) {
39313
+ const message = err instanceof Error ? err.message : String(err);
39314
+ const hint = /failed verification/.test(message) ? "An archive object does not match its signed digest — re-download and retry." : /could not fetch/.test(message) ? "Check the archive URL and your network connection." : "Set DATABASE_URL to the (empty) instance you want to bootstrap.";
39315
+ printError(message, { hint });
39316
+ process.exit(BOOTSTRAP_EXIT.REFUSED);
39317
+ }
39318
+ });
39319
+ }
38629
39320
  // src/cli.ts
38630
39321
  var { version } = package_default;
38631
39322
  program.name("secondlayer").alias("sl").description("SecondLayer CLI — dedicated Stacks indexing + real-time subgraphs").version(version).option("--network <network>", "Override network (local, testnet, mainnet)").option("--api-key <key>", "API credential (overrides SL_API_KEY)").option("--api-url <url>", "API endpoint (overrides SL_API_URL)").showSuggestionAfterError(true).showHelpAfterError("(run `sl --help` to see available commands)");
@@ -38682,10 +39373,13 @@ registerStatusCommand(program);
38682
39373
  registerDoctorCommand(program);
38683
39374
  registerConfigCommand(program);
38684
39375
  registerContextCommand(program);
39376
+ registerBootstrapCommand(program);
39377
+ registerVerifyCommand(program);
39378
+ registerRepairCommand(program);
38685
39379
  program.commandsGroup("Account:");
38686
39380
  registerAccountCommand(program);
38687
39381
  registerKeysCommand(program);
38688
39382
  program.parse();
38689
39383
 
38690
- //# debugId=C3BB2E9E464137CA64756E2164756E21
39384
+ //# debugId=C0418C213027DA8564756E2164756E21
38691
39385
  //# sourceMappingURL=cli.js.map