@secondlayer/cli 9.7.0 → 9.9.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 +880 -9
- package/dist/cli.js.map +11 -6
- package/package.json +7 -6
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
|
|
2789
|
-
return (await getPlatformClient()).subgraphs.reindex(name
|
|
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.
|
|
34820
|
+
version: "9.9.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.
|
|
34862
|
-
"@secondlayer/stacks": "^3.
|
|
34863
|
-
"@secondlayer/subgraphs": "^3.26.
|
|
34864
|
-
"@biomejs/js-api": "^0.7.0",
|
|
34865
|
-
"@biomejs/wasm-nodejs": "^1.9.0",
|
|
34864
|
+
"@secondlayer/shared": "^7.8.0",
|
|
34865
|
+
"@secondlayer/stacks": "^3.3.1",
|
|
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,872 @@ 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 {
|
|
38642
|
+
checkKeyTrust,
|
|
38643
|
+
publicKeyFor,
|
|
38644
|
+
verifyRegistry
|
|
38645
|
+
} from "@secondlayer/shared/archive/key-registry";
|
|
38646
|
+
import { verifyStreamsBulkManifestSignature } from "@secondlayer/shared/streams-bulk-manifest";
|
|
38647
|
+
function isLatestPointer(value) {
|
|
38648
|
+
const pointer = value;
|
|
38649
|
+
return typeof pointer.snapshot_path === "string" && (value.partitions === undefined || value.partitions.length === 0);
|
|
38650
|
+
}
|
|
38651
|
+
async function fetchJson(url) {
|
|
38652
|
+
const response2 = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
|
38653
|
+
if (!response2.ok) {
|
|
38654
|
+
throw new Error(`could not fetch manifest (${response2.status} ${response2.statusText})`);
|
|
38655
|
+
}
|
|
38656
|
+
return response2.json();
|
|
38657
|
+
}
|
|
38658
|
+
async function loadReference(source) {
|
|
38659
|
+
const isRemote = /^https?:\/\//.test(source);
|
|
38660
|
+
let manifest;
|
|
38661
|
+
let root;
|
|
38662
|
+
if (isRemote) {
|
|
38663
|
+
manifest = await fetchJson(source);
|
|
38664
|
+
const url = new URL(source);
|
|
38665
|
+
url.pathname = url.pathname.replace(/\/snapshots\/[^/]+$/, "").replace(/\/[^/]+\.json$/, "");
|
|
38666
|
+
root = url.toString();
|
|
38667
|
+
} else {
|
|
38668
|
+
const path11 = resolve12(source);
|
|
38669
|
+
manifest = JSON.parse(await readFile4(path11, "utf8"));
|
|
38670
|
+
root = isLatestPointer(manifest) ? dirname16(path11) : dirname16(dirname16(path11));
|
|
38671
|
+
}
|
|
38672
|
+
if (!isLatestPointer(manifest)) {
|
|
38673
|
+
return { manifest, origin: source, root, isRemote };
|
|
38674
|
+
}
|
|
38675
|
+
const pointer = manifest;
|
|
38676
|
+
const snapshotPath = pointer.snapshot_path;
|
|
38677
|
+
const resolved = isRemote ? await fetchJson(`${root.replace(/\/$/, "")}/${snapshotPath}`) : JSON.parse(await readFile4(join13(root, snapshotPath), "utf8"));
|
|
38678
|
+
if (pointer.snapshot_digest) {
|
|
38679
|
+
const digest = createHash2("sha256").update(JSON.stringify((({ signature: _s, key_id: _k, ...rest }) => rest)(resolved))).digest("hex");
|
|
38680
|
+
if (digest !== pointer.snapshot_digest) {
|
|
38681
|
+
throw new Error(`pointer/snapshot mismatch: latest.json names ${pointer.snapshot_digest}, resolved manifest hashes to ${digest}`);
|
|
38682
|
+
}
|
|
38683
|
+
}
|
|
38684
|
+
return { manifest: resolved, origin: source, root, isRemote };
|
|
38685
|
+
}
|
|
38686
|
+
function checkSignature(manifest, publicKeyPem, insecure) {
|
|
38687
|
+
if (insecure)
|
|
38688
|
+
return { verified: false, reason: "signature check skipped" };
|
|
38689
|
+
if (!manifest.signature) {
|
|
38690
|
+
return { verified: false, reason: "manifest carries no signature" };
|
|
38691
|
+
}
|
|
38692
|
+
if (!publicKeyPem) {
|
|
38693
|
+
return {
|
|
38694
|
+
verified: false,
|
|
38695
|
+
reason: "no public key available to verify with"
|
|
38696
|
+
};
|
|
38697
|
+
}
|
|
38698
|
+
try {
|
|
38699
|
+
const verified = verifyStreamsBulkManifestSignature(manifest, publicKeyPem);
|
|
38700
|
+
return {
|
|
38701
|
+
verified,
|
|
38702
|
+
reason: verified ? undefined : "signature did not verify"
|
|
38703
|
+
};
|
|
38704
|
+
} catch (err) {
|
|
38705
|
+
return {
|
|
38706
|
+
verified: false,
|
|
38707
|
+
reason: err instanceof Error ? err.message : "signature check failed"
|
|
38708
|
+
};
|
|
38709
|
+
}
|
|
38710
|
+
}
|
|
38711
|
+
async function resolvePublicKey(explicitPem, apiUrl) {
|
|
38712
|
+
if (explicitPem)
|
|
38713
|
+
return explicitPem;
|
|
38714
|
+
try {
|
|
38715
|
+
const response2 = await fetch(`${apiUrl.replace(/\/$/, "")}/public/streams/signing-key`, { signal: AbortSignal.timeout(1e4) });
|
|
38716
|
+
if (!response2.ok)
|
|
38717
|
+
return;
|
|
38718
|
+
const body = await response2.json();
|
|
38719
|
+
return body.public_key_pem;
|
|
38720
|
+
} catch {
|
|
38721
|
+
return;
|
|
38722
|
+
}
|
|
38723
|
+
}
|
|
38724
|
+
async function fetchVerifiedPartition(reference, partition) {
|
|
38725
|
+
let bytes;
|
|
38726
|
+
if (reference.isRemote) {
|
|
38727
|
+
const url = `${reference.root.replace(/\/$/, "")}/${partition.path}`;
|
|
38728
|
+
const response2 = await fetch(url, { signal: AbortSignal.timeout(300000) });
|
|
38729
|
+
if (!response2.ok) {
|
|
38730
|
+
throw new Error(`could not fetch ${partition.path} (${response2.status} ${response2.statusText})`);
|
|
38731
|
+
}
|
|
38732
|
+
bytes = Buffer.from(await response2.arrayBuffer());
|
|
38733
|
+
} else {
|
|
38734
|
+
bytes = await readFile4(join13(reference.root, partition.path));
|
|
38735
|
+
}
|
|
38736
|
+
const digest = createHash2("sha256").update(bytes).digest("hex");
|
|
38737
|
+
if (digest !== partition.sha256) {
|
|
38738
|
+
throw new Error(`${partition.path} failed verification: expected ${partition.sha256}, got ${digest}`);
|
|
38739
|
+
}
|
|
38740
|
+
return bytes;
|
|
38741
|
+
}
|
|
38742
|
+
|
|
38743
|
+
// src/commands/verify.ts
|
|
38744
|
+
init_output();
|
|
38745
|
+
var VERIFY_EXIT = {
|
|
38746
|
+
CLEAN: 0,
|
|
38747
|
+
DIVERGED: 1,
|
|
38748
|
+
UNANCHORED: 2
|
|
38749
|
+
};
|
|
38750
|
+
function parseHeight2(value, flag) {
|
|
38751
|
+
const parsed = Number(value);
|
|
38752
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
|
38753
|
+
throw new Error(`${flag} must be a non-negative integer, got "${value}"`);
|
|
38754
|
+
}
|
|
38755
|
+
return parsed;
|
|
38756
|
+
}
|
|
38757
|
+
function statusLabel(status2) {
|
|
38758
|
+
if (status2 === "match")
|
|
38759
|
+
return green("ok");
|
|
38760
|
+
if (status2 === "count-mismatch")
|
|
38761
|
+
return red("rows differ");
|
|
38762
|
+
if (status2 === "digest-mismatch")
|
|
38763
|
+
return red("data differs");
|
|
38764
|
+
return yellow("missing");
|
|
38765
|
+
}
|
|
38766
|
+
function registerVerifyCommand(program2) {
|
|
38767
|
+
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", `
|
|
38768
|
+
Examples:
|
|
38769
|
+
$ sl verify --against https://archive.secondlayer.tools/.../snapshots/<digest>.json
|
|
38770
|
+
$ sl verify --against ./snapshot.json --from-block 8000000 --to-block 8499999
|
|
38771
|
+
$ sl verify --against ./snapshot.json --counts --json
|
|
38772
|
+
|
|
38773
|
+
Exit codes:
|
|
38774
|
+
0 local data matches the archive
|
|
38775
|
+
1 divergence found (divergent ranges are listed)
|
|
38776
|
+
2 unanchored — reference unavailable or unverifiable`).action(async (opts) => {
|
|
38777
|
+
try {
|
|
38778
|
+
const fromBlock = opts.fromBlock === undefined ? undefined : parseHeight2(opts.fromBlock, "--from-block");
|
|
38779
|
+
const toBlock = opts.toBlock === undefined ? undefined : parseHeight2(opts.toBlock, "--to-block");
|
|
38780
|
+
if (fromBlock !== undefined && toBlock !== undefined && toBlock < fromBlock) {
|
|
38781
|
+
throw new Error("--to-block must be >= --from-block");
|
|
38782
|
+
}
|
|
38783
|
+
const { manifest, origin } = await loadReference(opts.against);
|
|
38784
|
+
const publicKey = await resolvePublicKey(opts.publicKey, process.env.SL_API_URL ?? "https://api.secondlayer.tools");
|
|
38785
|
+
const signature = checkSignature(manifest, publicKey, !!opts.insecure);
|
|
38786
|
+
if (!signature.verified && !opts.insecure) {
|
|
38787
|
+
printError(`Cannot trust the reference: ${signature.reason}.`, {
|
|
38788
|
+
hint: "Pass --public-key <pem> to pin a key, or --insecure to compare anyway (result is unverified)."
|
|
38789
|
+
});
|
|
38790
|
+
output({
|
|
38791
|
+
json: opts.json,
|
|
38792
|
+
data: {
|
|
38793
|
+
status: "unanchored",
|
|
38794
|
+
reference: origin,
|
|
38795
|
+
reason: signature.reason
|
|
38796
|
+
},
|
|
38797
|
+
human: () => {}
|
|
38798
|
+
});
|
|
38799
|
+
process.exit(VERIFY_EXIT.UNANCHORED);
|
|
38800
|
+
}
|
|
38801
|
+
const reference = (manifest.range_digests ?? []).filter((d2) => {
|
|
38802
|
+
if (fromBlock !== undefined && d2.to_block < fromBlock)
|
|
38803
|
+
return false;
|
|
38804
|
+
if (toBlock !== undefined && d2.from_block > toBlock)
|
|
38805
|
+
return false;
|
|
38806
|
+
return true;
|
|
38807
|
+
});
|
|
38808
|
+
if (reference.length === 0) {
|
|
38809
|
+
printError("The reference publishes no digests for that range.", {
|
|
38810
|
+
hint: "Check --from-block/--to-block against the manifest's coverage."
|
|
38811
|
+
});
|
|
38812
|
+
process.exit(VERIFY_EXIT.UNANCHORED);
|
|
38813
|
+
}
|
|
38814
|
+
const db = getDb2();
|
|
38815
|
+
const local = [];
|
|
38816
|
+
let checked = 0;
|
|
38817
|
+
for (const range of reference) {
|
|
38818
|
+
local.push(await computeRangeDigest(db, range.dataset, range.from_block, range.to_block));
|
|
38819
|
+
checked++;
|
|
38820
|
+
if (checked % 25 === 0 || checked === reference.length) {
|
|
38821
|
+
note(` checked ${checked}/${reference.length} ranges`);
|
|
38822
|
+
}
|
|
38823
|
+
}
|
|
38824
|
+
const comparisons = compareRangeDigests(local, reference);
|
|
38825
|
+
const countChecks = [];
|
|
38826
|
+
if (opts.counts && manifest.partitions) {
|
|
38827
|
+
for (const partition of manifest.partitions) {
|
|
38828
|
+
if (partition.dataset === "blocks")
|
|
38829
|
+
continue;
|
|
38830
|
+
if (fromBlock !== undefined && partition.to_block < fromBlock)
|
|
38831
|
+
continue;
|
|
38832
|
+
if (toBlock !== undefined && partition.from_block > toBlock)
|
|
38833
|
+
continue;
|
|
38834
|
+
const actual = await computeRangeDigest(db, partition.dataset, partition.from_block, partition.to_block);
|
|
38835
|
+
countChecks.push({
|
|
38836
|
+
dataset: partition.dataset,
|
|
38837
|
+
from_block: partition.from_block,
|
|
38838
|
+
to_block: partition.to_block,
|
|
38839
|
+
status: actual.row_count === partition.row_count ? "match" : "count-mismatch",
|
|
38840
|
+
expected_digest: null,
|
|
38841
|
+
actual_digest: null,
|
|
38842
|
+
expected_rows: partition.row_count,
|
|
38843
|
+
actual_rows: actual.row_count
|
|
38844
|
+
});
|
|
38845
|
+
}
|
|
38846
|
+
}
|
|
38847
|
+
const all2 = [...comparisons, ...countChecks];
|
|
38848
|
+
const diverged = all2.filter((c4) => c4.status !== "match");
|
|
38849
|
+
const report = {
|
|
38850
|
+
status: diverged.length === 0 ? "clean" : "diverged",
|
|
38851
|
+
reference: origin,
|
|
38852
|
+
signature_verified: signature.verified,
|
|
38853
|
+
ranges_checked: all2.length,
|
|
38854
|
+
divergent_ranges: diverged
|
|
38855
|
+
};
|
|
38856
|
+
output({
|
|
38857
|
+
json: opts.json,
|
|
38858
|
+
data: report,
|
|
38859
|
+
human: () => {
|
|
38860
|
+
if (diverged.length === 0) {
|
|
38861
|
+
success(`Local data matches the archive across ${all2.length} ranges.`);
|
|
38862
|
+
note(signature.verified ? " reference signature verified" : " reference signature NOT verified (--insecure)");
|
|
38863
|
+
return;
|
|
38864
|
+
}
|
|
38865
|
+
warn(`${diverged.length} of ${all2.length} ranges diverge from the archive.`);
|
|
38866
|
+
console.error("");
|
|
38867
|
+
console.error(formatTable(["RANGE", "DATASET", "STATUS", "EXPECTED", "LOCAL"], diverged.map((d2) => [
|
|
38868
|
+
`${d2.from_block}-${d2.to_block}`,
|
|
38869
|
+
d2.dataset,
|
|
38870
|
+
statusLabel(d2.status),
|
|
38871
|
+
String(d2.expected_rows),
|
|
38872
|
+
String(d2.actual_rows)
|
|
38873
|
+
])));
|
|
38874
|
+
console.error("");
|
|
38875
|
+
console.error(dim("These ranges hold different data than the signed archive. Repair with:"));
|
|
38876
|
+
const first = diverged[0];
|
|
38877
|
+
if (first) {
|
|
38878
|
+
console.error(dim(` sl repair --from-block ${first.from_block} --to-block ${first.to_block}`));
|
|
38879
|
+
}
|
|
38880
|
+
}
|
|
38881
|
+
});
|
|
38882
|
+
process.exit(diverged.length === 0 ? VERIFY_EXIT.CLEAN : VERIFY_EXIT.DIVERGED);
|
|
38883
|
+
} catch (err) {
|
|
38884
|
+
printError(err instanceof Error ? err.message : String(err), {
|
|
38885
|
+
hint: "Set DATABASE_URL to the instance you want to verify."
|
|
38886
|
+
});
|
|
38887
|
+
process.exit(VERIFY_EXIT.UNANCHORED);
|
|
38888
|
+
}
|
|
38889
|
+
});
|
|
38890
|
+
}
|
|
38891
|
+
// src/commands/repair.ts
|
|
38892
|
+
import { unlink as unlink2, writeFile as writeFile5 } from "node:fs/promises";
|
|
38893
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
38894
|
+
import { join as join14 } from "node:path";
|
|
38895
|
+
import { ParquetReader } from "@dsnp/parquetjs";
|
|
38896
|
+
import {
|
|
38897
|
+
compareRangeDigests as compareRangeDigests2,
|
|
38898
|
+
computeRangeDigest as computeRangeDigest2
|
|
38899
|
+
} from "@secondlayer/shared/archive/range-digest";
|
|
38900
|
+
import { getDb as getDb3, sql as sql2 } from "@secondlayer/shared/db";
|
|
38901
|
+
init_output();
|
|
38902
|
+
var REPAIR_EXIT = {
|
|
38903
|
+
OK: 0,
|
|
38904
|
+
DIVERGENCE_REMAINS: 1,
|
|
38905
|
+
UNANCHORED: 2
|
|
38906
|
+
};
|
|
38907
|
+
function asNumber(value) {
|
|
38908
|
+
return typeof value === "bigint" ? Number(value) : Number(value);
|
|
38909
|
+
}
|
|
38910
|
+
function asText(value) {
|
|
38911
|
+
return Buffer.isBuffer(value) ? value.toString("utf8") : String(value);
|
|
38912
|
+
}
|
|
38913
|
+
function asOptionalText(value) {
|
|
38914
|
+
return value === null || value === undefined ? null : asText(value);
|
|
38915
|
+
}
|
|
38916
|
+
async function readBlocksPartition(bytes, label) {
|
|
38917
|
+
const path11 = join14(tmpdir2(), `sl-repair-${label}-${process.pid}.parquet`);
|
|
38918
|
+
await writeFile5(path11, bytes);
|
|
38919
|
+
try {
|
|
38920
|
+
const reader = await ParquetReader.openFile(path11);
|
|
38921
|
+
const rows = [];
|
|
38922
|
+
try {
|
|
38923
|
+
const cursor = reader.getCursor();
|
|
38924
|
+
for (let row = await cursor.next();row; row = await cursor.next()) {
|
|
38925
|
+
rows.push({
|
|
38926
|
+
height: asNumber(row.height),
|
|
38927
|
+
hash: asText(row.hash),
|
|
38928
|
+
parent_hash: asText(row.parent_hash),
|
|
38929
|
+
burn_block_height: asNumber(row.burn_block_height),
|
|
38930
|
+
burn_block_hash: asOptionalText(row.burn_block_hash),
|
|
38931
|
+
index_block_hash: asOptionalText(row.index_block_hash),
|
|
38932
|
+
timestamp: asNumber(row.timestamp)
|
|
38933
|
+
});
|
|
38934
|
+
}
|
|
38935
|
+
} finally {
|
|
38936
|
+
await reader.close();
|
|
38937
|
+
}
|
|
38938
|
+
return rows;
|
|
38939
|
+
} finally {
|
|
38940
|
+
await unlink2(path11).catch(() => {});
|
|
38941
|
+
}
|
|
38942
|
+
}
|
|
38943
|
+
function parseHeight3(value, flag) {
|
|
38944
|
+
const parsed = Number(value);
|
|
38945
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
|
38946
|
+
throw new Error(`${flag} must be a non-negative integer, got "${value}"`);
|
|
38947
|
+
}
|
|
38948
|
+
return parsed;
|
|
38949
|
+
}
|
|
38950
|
+
async function planRange(db, reference, partition) {
|
|
38951
|
+
const bytes = await fetchVerifiedPartition(reference, partition);
|
|
38952
|
+
const archiveBlocks = await readBlocksPartition(bytes, `${partition.from_block}-${partition.to_block}`);
|
|
38953
|
+
const localRows = await db.selectFrom("blocks").select(["height", "hash"]).where("canonical", "=", true).where("height", ">=", partition.from_block).where("height", "<=", partition.to_block).execute();
|
|
38954
|
+
const localByHeight = new Map(localRows.map((r2) => [Number(r2.height), r2.hash]));
|
|
38955
|
+
const fixes = [];
|
|
38956
|
+
for (const block of archiveBlocks) {
|
|
38957
|
+
const localHash = localByHeight.get(block.height);
|
|
38958
|
+
if (localHash === undefined) {
|
|
38959
|
+
fixes.push({
|
|
38960
|
+
height: block.height,
|
|
38961
|
+
kind: "insert",
|
|
38962
|
+
local_hash: null,
|
|
38963
|
+
archive_hash: block.hash
|
|
38964
|
+
});
|
|
38965
|
+
} else if (localHash !== block.hash) {
|
|
38966
|
+
fixes.push({
|
|
38967
|
+
height: block.height,
|
|
38968
|
+
kind: "replace",
|
|
38969
|
+
local_hash: localHash,
|
|
38970
|
+
archive_hash: block.hash
|
|
38971
|
+
});
|
|
38972
|
+
}
|
|
38973
|
+
localByHeight.delete(block.height);
|
|
38974
|
+
}
|
|
38975
|
+
const extraHeights = [...localByHeight.keys()].sort((a4, b3) => a4 - b3);
|
|
38976
|
+
return { fixes, extraHeights };
|
|
38977
|
+
}
|
|
38978
|
+
async function applyFixes(db, reference, partition, fixes) {
|
|
38979
|
+
if (fixes.length === 0)
|
|
38980
|
+
return 0;
|
|
38981
|
+
const bytes = await fetchVerifiedPartition(reference, partition);
|
|
38982
|
+
const archiveBlocks = await readBlocksPartition(bytes, `apply-${partition.from_block}`);
|
|
38983
|
+
const byHeight = new Map(archiveBlocks.map((b3) => [b3.height, b3]));
|
|
38984
|
+
const targets = fixes.map((fix) => byHeight.get(fix.height)).filter((b3) => b3 !== undefined);
|
|
38985
|
+
await db.transaction().execute(async (tx) => {
|
|
38986
|
+
for (const block of targets) {
|
|
38987
|
+
await sql2`
|
|
38988
|
+
INSERT INTO blocks (
|
|
38989
|
+
height, hash, parent_hash, burn_block_height,
|
|
38990
|
+
burn_block_hash, index_block_hash, timestamp, canonical
|
|
38991
|
+
) VALUES (
|
|
38992
|
+
${block.height}, ${block.hash}, ${block.parent_hash},
|
|
38993
|
+
${block.burn_block_height}, ${block.burn_block_hash},
|
|
38994
|
+
${block.index_block_hash}, ${block.timestamp}, true
|
|
38995
|
+
)
|
|
38996
|
+
ON CONFLICT (height) DO UPDATE SET
|
|
38997
|
+
hash = EXCLUDED.hash,
|
|
38998
|
+
parent_hash = EXCLUDED.parent_hash,
|
|
38999
|
+
burn_block_height = EXCLUDED.burn_block_height,
|
|
39000
|
+
burn_block_hash = EXCLUDED.burn_block_hash,
|
|
39001
|
+
index_block_hash = EXCLUDED.index_block_hash,
|
|
39002
|
+
timestamp = EXCLUDED.timestamp,
|
|
39003
|
+
canonical = true
|
|
39004
|
+
`.execute(tx);
|
|
39005
|
+
}
|
|
39006
|
+
});
|
|
39007
|
+
return targets.length;
|
|
39008
|
+
}
|
|
39009
|
+
function registerRepairCommand(program2) {
|
|
39010
|
+
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", `
|
|
39011
|
+
Examples:
|
|
39012
|
+
$ sl repair --against ./snapshot.json # plan only
|
|
39013
|
+
$ sl repair --against ./snapshot.json --apply # write the fix
|
|
39014
|
+
$ sl repair --against ./snapshot.json --from-block 8500000 --to-block 8549999
|
|
39015
|
+
|
|
39016
|
+
Exit codes:
|
|
39017
|
+
0 nothing to repair, or the repair completed and re-verified clean
|
|
39018
|
+
1 divergence remains (dry-run, or repair did not fully resolve it)
|
|
39019
|
+
2 unanchored — reference unavailable or unverifiable`).action(async (opts) => {
|
|
39020
|
+
try {
|
|
39021
|
+
const fromBlock = opts.fromBlock === undefined ? undefined : parseHeight3(opts.fromBlock, "--from-block");
|
|
39022
|
+
const toBlock = opts.toBlock === undefined ? undefined : parseHeight3(opts.toBlock, "--to-block");
|
|
39023
|
+
const reference = await loadReference(opts.against);
|
|
39024
|
+
const publicKey = await resolvePublicKey(opts.publicKey, process.env.SL_API_URL ?? "https://api.secondlayer.tools");
|
|
39025
|
+
const signature = checkSignature(reference.manifest, publicKey, false);
|
|
39026
|
+
if (!signature.verified) {
|
|
39027
|
+
printError(`Refusing to repair from an untrusted reference: ${signature.reason}.`, {
|
|
39028
|
+
hint: "Pass --public-key <pem> to pin the archive's signing key."
|
|
39029
|
+
});
|
|
39030
|
+
process.exit(REPAIR_EXIT.UNANCHORED);
|
|
39031
|
+
}
|
|
39032
|
+
const db = getDb3();
|
|
39033
|
+
const inRange = (from, to) => !(fromBlock !== undefined && to < fromBlock || toBlock !== undefined && from > toBlock);
|
|
39034
|
+
const referenceDigests = (reference.manifest.range_digests ?? []).filter((d2) => d2.dataset === "blocks" && inRange(d2.from_block, d2.to_block));
|
|
39035
|
+
if (referenceDigests.length === 0) {
|
|
39036
|
+
printError("The reference publishes no block digests for that range.", {
|
|
39037
|
+
hint: "Check --from-block/--to-block against the manifest's coverage."
|
|
39038
|
+
});
|
|
39039
|
+
process.exit(REPAIR_EXIT.UNANCHORED);
|
|
39040
|
+
}
|
|
39041
|
+
const local = [];
|
|
39042
|
+
for (const range of referenceDigests) {
|
|
39043
|
+
local.push(await computeRangeDigest2(db, "blocks", range.from_block, range.to_block));
|
|
39044
|
+
}
|
|
39045
|
+
const divergent = compareRangeDigests2(local, referenceDigests).filter((c4) => c4.status !== "match");
|
|
39046
|
+
if (divergent.length === 0) {
|
|
39047
|
+
success("Nothing to repair — local blocks match the archive.");
|
|
39048
|
+
output({
|
|
39049
|
+
json: opts.json,
|
|
39050
|
+
data: { status: "clean", fixes: [] },
|
|
39051
|
+
human: () => {}
|
|
39052
|
+
});
|
|
39053
|
+
process.exit(REPAIR_EXIT.OK);
|
|
39054
|
+
}
|
|
39055
|
+
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));
|
|
39056
|
+
const allFixes = [];
|
|
39057
|
+
const allExtra = [];
|
|
39058
|
+
const planned = [];
|
|
39059
|
+
for (const partition of partitions) {
|
|
39060
|
+
note(` reading ${partition.dataset} ${partition.from_block}-${partition.to_block}`);
|
|
39061
|
+
const { fixes, extraHeights } = await planRange(db, reference, partition);
|
|
39062
|
+
planned.push({ partition, fixes });
|
|
39063
|
+
allFixes.push(...fixes);
|
|
39064
|
+
allExtra.push(...extraHeights);
|
|
39065
|
+
}
|
|
39066
|
+
let applied = 0;
|
|
39067
|
+
if (opts.apply) {
|
|
39068
|
+
for (const { partition, fixes } of planned) {
|
|
39069
|
+
applied += await applyFixes(db, reference, partition, fixes);
|
|
39070
|
+
}
|
|
39071
|
+
}
|
|
39072
|
+
let remaining = divergent.length;
|
|
39073
|
+
if (opts.apply) {
|
|
39074
|
+
const after = [];
|
|
39075
|
+
for (const range of referenceDigests) {
|
|
39076
|
+
after.push(await computeRangeDigest2(db, "blocks", range.from_block, range.to_block));
|
|
39077
|
+
}
|
|
39078
|
+
remaining = compareRangeDigests2(after, referenceDigests).filter((c4) => c4.status !== "match").length;
|
|
39079
|
+
}
|
|
39080
|
+
const report = {
|
|
39081
|
+
status: opts.apply ? remaining === 0 ? "repaired" : "incomplete" : "plan",
|
|
39082
|
+
reference: reference.origin,
|
|
39083
|
+
divergent_ranges: divergent.length,
|
|
39084
|
+
blocks_to_replace: allFixes.filter((f2) => f2.kind === "replace").length,
|
|
39085
|
+
blocks_to_insert: allFixes.filter((f2) => f2.kind === "insert").length,
|
|
39086
|
+
local_only_heights: allExtra,
|
|
39087
|
+
applied,
|
|
39088
|
+
fixes: allFixes
|
|
39089
|
+
};
|
|
39090
|
+
output({
|
|
39091
|
+
json: opts.json,
|
|
39092
|
+
data: report,
|
|
39093
|
+
human: () => {
|
|
39094
|
+
const replace = report.blocks_to_replace;
|
|
39095
|
+
const insert = report.blocks_to_insert;
|
|
39096
|
+
if (opts.apply) {
|
|
39097
|
+
if (remaining === 0) {
|
|
39098
|
+
success(`Repaired ${applied} blocks across ${divergent.length} ranges; re-verified clean.`);
|
|
39099
|
+
} else {
|
|
39100
|
+
warn(`Applied ${applied} blocks but ${remaining} ranges still diverge.`);
|
|
39101
|
+
}
|
|
39102
|
+
} else {
|
|
39103
|
+
warn(`${divergent.length} ranges diverge — ${replace} blocks to replace, ${insert} to insert.`);
|
|
39104
|
+
}
|
|
39105
|
+
if (allFixes.length > 0) {
|
|
39106
|
+
console.error("");
|
|
39107
|
+
console.error(formatTable(["HEIGHT", "ACTION", "LOCAL", "ARCHIVE"], allFixes.slice(0, 20).map((f2) => [
|
|
39108
|
+
String(f2.height),
|
|
39109
|
+
f2.kind,
|
|
39110
|
+
(f2.local_hash ?? "—").slice(0, 18),
|
|
39111
|
+
f2.archive_hash.slice(0, 18)
|
|
39112
|
+
])));
|
|
39113
|
+
if (allFixes.length > 20) {
|
|
39114
|
+
console.error(dim(` … and ${allFixes.length - 20} more`));
|
|
39115
|
+
}
|
|
39116
|
+
}
|
|
39117
|
+
if (allExtra.length > 0) {
|
|
39118
|
+
console.error("");
|
|
39119
|
+
warn(`${allExtra.length} local heights are absent from the archive (e.g. ${allExtra.slice(0, 5).join(", ")}).`);
|
|
39120
|
+
console.error(dim(" These are NOT removed — transactions and events reference them."));
|
|
39121
|
+
}
|
|
39122
|
+
if (!opts.apply) {
|
|
39123
|
+
console.error("");
|
|
39124
|
+
console.error(dim("(dry-run — pass --apply to write the repair)"));
|
|
39125
|
+
} else {
|
|
39126
|
+
console.error("");
|
|
39127
|
+
console.error(dim("Decoded rows for repaired heights are not rebuilt here — re-run your decoders for these ranges."));
|
|
39128
|
+
}
|
|
39129
|
+
}
|
|
39130
|
+
});
|
|
39131
|
+
process.exit(opts.apply && remaining === 0 ? REPAIR_EXIT.OK : REPAIR_EXIT.DIVERGENCE_REMAINS);
|
|
39132
|
+
} catch (err) {
|
|
39133
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
39134
|
+
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.";
|
|
39135
|
+
printError(message, { hint });
|
|
39136
|
+
process.exit(REPAIR_EXIT.UNANCHORED);
|
|
39137
|
+
}
|
|
39138
|
+
});
|
|
39139
|
+
}
|
|
39140
|
+
// src/commands/bootstrap.ts
|
|
39141
|
+
import { unlink as unlink3, writeFile as writeFile6 } from "node:fs/promises";
|
|
39142
|
+
import { tmpdir as tmpdir3 } from "node:os";
|
|
39143
|
+
import { join as join15 } from "node:path";
|
|
39144
|
+
import { ParquetReader as ParquetReader2 } from "@dsnp/parquetjs";
|
|
39145
|
+
import { confirm as confirm7 } from "@inquirer/prompts";
|
|
39146
|
+
import {
|
|
39147
|
+
copyStatement,
|
|
39148
|
+
writeRowsToCopyStream
|
|
39149
|
+
} from "@secondlayer/shared/archive/copy-loader";
|
|
39150
|
+
import { computeRangeDigest as computeRangeDigest3 } from "@secondlayer/shared/archive/range-digest";
|
|
39151
|
+
import { getDb as getDb4, getRawClient, sql as sql3 } from "@secondlayer/shared/db";
|
|
39152
|
+
init_output();
|
|
39153
|
+
var BOOTSTRAP_EXIT = {
|
|
39154
|
+
OK: 0,
|
|
39155
|
+
INCOMPLETE: 1,
|
|
39156
|
+
REFUSED: 2
|
|
39157
|
+
};
|
|
39158
|
+
var BLOCKS_PER_DAY_ESTIMATE = 5400;
|
|
39159
|
+
var RESTORE_ROWS_PER_SECOND = 30000;
|
|
39160
|
+
async function readNodeTip() {
|
|
39161
|
+
const url = process.env.STACKS_NODE_RPC_URL ?? "http://localhost:20443";
|
|
39162
|
+
try {
|
|
39163
|
+
const response2 = await fetch(`${url.replace(/\/$/, "")}/v2/info`, {
|
|
39164
|
+
signal: AbortSignal.timeout(5000)
|
|
39165
|
+
});
|
|
39166
|
+
if (!response2.ok)
|
|
39167
|
+
return null;
|
|
39168
|
+
const info2 = await response2.json();
|
|
39169
|
+
return typeof info2.stacks_tip_height === "number" ? info2.stacks_tip_height : null;
|
|
39170
|
+
} catch {
|
|
39171
|
+
return null;
|
|
39172
|
+
}
|
|
39173
|
+
}
|
|
39174
|
+
function formatDuration2(seconds2) {
|
|
39175
|
+
if (seconds2 < 90)
|
|
39176
|
+
return `${Math.round(seconds2)}s`;
|
|
39177
|
+
if (seconds2 < 5400)
|
|
39178
|
+
return `${Math.round(seconds2 / 60)} min`;
|
|
39179
|
+
if (seconds2 < 172800)
|
|
39180
|
+
return `${(seconds2 / 3600).toFixed(1)} hours`;
|
|
39181
|
+
return `${(seconds2 / 86400).toFixed(1)} days`;
|
|
39182
|
+
}
|
|
39183
|
+
async function* readPartitionRows(bytes, label) {
|
|
39184
|
+
const path11 = join15(tmpdir3(), `sl-bootstrap-${label}-${process.pid}.parquet`);
|
|
39185
|
+
await writeFile6(path11, bytes);
|
|
39186
|
+
try {
|
|
39187
|
+
const reader = await ParquetReader2.openFile(path11);
|
|
39188
|
+
try {
|
|
39189
|
+
const cursor = reader.getCursor();
|
|
39190
|
+
for (let row = await cursor.next();row; row = await cursor.next()) {
|
|
39191
|
+
yield row;
|
|
39192
|
+
}
|
|
39193
|
+
} finally {
|
|
39194
|
+
await reader.close();
|
|
39195
|
+
}
|
|
39196
|
+
} finally {
|
|
39197
|
+
await unlink3(path11).catch(() => {});
|
|
39198
|
+
}
|
|
39199
|
+
}
|
|
39200
|
+
async function loadPartition(reference, partition, rawClient) {
|
|
39201
|
+
const bytes = await fetchVerifiedPartition(reference, partition);
|
|
39202
|
+
const writable2 = await rawClient.unsafe(copyStatement(partition.dataset)).writable();
|
|
39203
|
+
const written = await writeRowsToCopyStream({
|
|
39204
|
+
writable: writable2,
|
|
39205
|
+
dataset: partition.dataset,
|
|
39206
|
+
rows: readPartitionRows(bytes, `${partition.dataset}-${partition.from_block}`)
|
|
39207
|
+
});
|
|
39208
|
+
if (written !== partition.row_count) {
|
|
39209
|
+
throw new Error(`${partition.path}: loaded ${written} rows, manifest declares ${partition.row_count}`);
|
|
39210
|
+
}
|
|
39211
|
+
return written;
|
|
39212
|
+
}
|
|
39213
|
+
function registerBootstrapCommand(program2) {
|
|
39214
|
+
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", `
|
|
39215
|
+
Examples:
|
|
39216
|
+
$ sl bootstrap --against https://archive.secondlayer.tools/.../snapshots/<digest>.json
|
|
39217
|
+
$ sl bootstrap --against ./snapshot.json --to-block 4000000 --yes
|
|
39218
|
+
|
|
39219
|
+
Exit codes:
|
|
39220
|
+
0 restored and verified
|
|
39221
|
+
1 restore completed but verification found divergence
|
|
39222
|
+
2 refused (non-empty target, or an untrusted reference)`).action(async (opts) => {
|
|
39223
|
+
try {
|
|
39224
|
+
const reference = await loadReference(opts.against);
|
|
39225
|
+
const publicKey = await resolvePublicKey(opts.publicKey, process.env.SL_API_URL ?? "https://api.secondlayer.tools");
|
|
39226
|
+
const signature = checkSignature(reference.manifest, publicKey, false);
|
|
39227
|
+
if (!signature.verified) {
|
|
39228
|
+
printError(`Refusing to bootstrap from an untrusted archive: ${signature.reason}.`, {
|
|
39229
|
+
hint: "Pass --public-key <pem> to pin the archive's signing key."
|
|
39230
|
+
});
|
|
39231
|
+
process.exit(BOOTSTRAP_EXIT.REFUSED);
|
|
39232
|
+
}
|
|
39233
|
+
const db = getDb4();
|
|
39234
|
+
const existing = await db.selectFrom("blocks").select(({ fn }) => fn.countAll().as("count")).executeTakeFirst();
|
|
39235
|
+
if (Number(existing?.count ?? 0) > 0) {
|
|
39236
|
+
printError("This database already holds blocks.", {
|
|
39237
|
+
hint: "Bootstrap is for a fresh instance. To fix an existing one, use `sl repair`."
|
|
39238
|
+
});
|
|
39239
|
+
process.exit(BOOTSTRAP_EXIT.REFUSED);
|
|
39240
|
+
}
|
|
39241
|
+
const toBlock = opts.toBlock === undefined ? undefined : Number(opts.toBlock);
|
|
39242
|
+
const partitions = (reference.manifest.partitions ?? []).filter((p2) => toBlock === undefined || p2.to_block <= toBlock);
|
|
39243
|
+
if (partitions.length === 0) {
|
|
39244
|
+
printError("The archive has no partitions for that range.", {
|
|
39245
|
+
hint: "Check --to-block against the manifest's coverage."
|
|
39246
|
+
});
|
|
39247
|
+
process.exit(BOOTSTRAP_EXIT.REFUSED);
|
|
39248
|
+
}
|
|
39249
|
+
const totalRows = partitions.reduce((sum, p2) => sum + p2.row_count, 0);
|
|
39250
|
+
const totalBytes = partitions.reduce((sum, p2) => sum + p2.byte_size, 0);
|
|
39251
|
+
const tipHeight = Math.max(...partitions.map((p2) => p2.to_block));
|
|
39252
|
+
const restoreSeconds = totalRows / RESTORE_ROWS_PER_SECOND;
|
|
39253
|
+
const genesisSeconds = tipHeight / BLOCKS_PER_DAY_ESTIMATE * 86400;
|
|
39254
|
+
if (!opts.json) {
|
|
39255
|
+
console.error("");
|
|
39256
|
+
console.error(formatKeyValue([
|
|
39257
|
+
["archive", reference.origin],
|
|
39258
|
+
["coverage", `genesis → ${tipHeight.toLocaleString()}`],
|
|
39259
|
+
["rows", totalRows.toLocaleString()],
|
|
39260
|
+
["download", `${(totalBytes / 1e9).toFixed(1)} GB`],
|
|
39261
|
+
["signature", "verified"]
|
|
39262
|
+
]));
|
|
39263
|
+
console.error("");
|
|
39264
|
+
console.error(` sync from genesis ${bold(`~${formatDuration2(genesisSeconds)}`)} ${dim("(estimate)")}`);
|
|
39265
|
+
console.error(` restore from archive ${bold(`~${formatDuration2(restoreSeconds)}`)}`);
|
|
39266
|
+
console.error("");
|
|
39267
|
+
}
|
|
39268
|
+
if (!opts.yes && !opts.json) {
|
|
39269
|
+
const proceed = await confirm7({
|
|
39270
|
+
message: "Restore from the verified archive?",
|
|
39271
|
+
default: true
|
|
39272
|
+
});
|
|
39273
|
+
if (!proceed) {
|
|
39274
|
+
note("Nothing was written.");
|
|
39275
|
+
process.exit(BOOTSTRAP_EXIT.REFUSED);
|
|
39276
|
+
}
|
|
39277
|
+
}
|
|
39278
|
+
const nodeTipAtStart = await readNodeTip();
|
|
39279
|
+
const order = ["blocks", "transactions", "events"];
|
|
39280
|
+
const rawClient = getRawClient("source");
|
|
39281
|
+
const loaded = { blocks: 0, transactions: 0, events: 0 };
|
|
39282
|
+
const startedAt = Date.now();
|
|
39283
|
+
for (const dataset of order) {
|
|
39284
|
+
const datasetPartitions = partitions.filter((p2) => p2.dataset === dataset).sort((a4, b3) => a4.from_block - b3.from_block);
|
|
39285
|
+
for (const [index, partition] of datasetPartitions.entries()) {
|
|
39286
|
+
loaded[dataset] += await loadPartition(reference, partition, rawClient);
|
|
39287
|
+
const done = index + 1;
|
|
39288
|
+
if (done % 10 === 0 || done === datasetPartitions.length) {
|
|
39289
|
+
const elapsed2 = (Date.now() - startedAt) / 1000;
|
|
39290
|
+
note(` ${dataset}: ${done}/${datasetPartitions.length} partitions · ${loaded[dataset].toLocaleString()} rows · ${formatDuration2(elapsed2)} elapsed`);
|
|
39291
|
+
}
|
|
39292
|
+
}
|
|
39293
|
+
}
|
|
39294
|
+
const referenceDigests = (reference.manifest.range_digests ?? []).filter((d2) => d2.dataset === "blocks" && d2.to_block <= tipHeight);
|
|
39295
|
+
let divergent = 0;
|
|
39296
|
+
for (const range of referenceDigests) {
|
|
39297
|
+
const actual = await computeRangeDigest3(db, "blocks", range.from_block, range.to_block);
|
|
39298
|
+
if (actual.digest !== range.digest || actual.row_count !== range.row_count) {
|
|
39299
|
+
divergent++;
|
|
39300
|
+
}
|
|
39301
|
+
}
|
|
39302
|
+
const network = process.env.STACKS_NETWORK ?? "mainnet";
|
|
39303
|
+
await sql3`
|
|
39304
|
+
INSERT INTO index_progress (
|
|
39305
|
+
network, last_indexed_block, last_contiguous_block,
|
|
39306
|
+
highest_seen_block, updated_at
|
|
39307
|
+
) VALUES (
|
|
39308
|
+
${network}, ${tipHeight}, ${tipHeight}, ${tipHeight}, NOW()
|
|
39309
|
+
)
|
|
39310
|
+
ON CONFLICT (network) DO UPDATE SET
|
|
39311
|
+
last_indexed_block = GREATEST(index_progress.last_indexed_block, EXCLUDED.last_indexed_block),
|
|
39312
|
+
last_contiguous_block = GREATEST(index_progress.last_contiguous_block, EXCLUDED.last_contiguous_block),
|
|
39313
|
+
highest_seen_block = GREATEST(index_progress.highest_seen_block, EXCLUDED.highest_seen_block),
|
|
39314
|
+
updated_at = NOW()
|
|
39315
|
+
`.execute(db);
|
|
39316
|
+
const seam = nodeTipAtStart !== null && nodeTipAtStart > tipHeight ? {
|
|
39317
|
+
nodeTip: nodeTipAtStart,
|
|
39318
|
+
gap: nodeTipAtStart - tipHeight
|
|
39319
|
+
} : null;
|
|
39320
|
+
const elapsed = (Date.now() - startedAt) / 1000;
|
|
39321
|
+
const report = {
|
|
39322
|
+
status: divergent === 0 ? "restored" : "divergent",
|
|
39323
|
+
archive: reference.origin,
|
|
39324
|
+
tip_height: tipHeight,
|
|
39325
|
+
rows: loaded,
|
|
39326
|
+
ranges_verified: referenceDigests.length,
|
|
39327
|
+
divergent_ranges: divergent,
|
|
39328
|
+
elapsed_seconds: Math.round(elapsed),
|
|
39329
|
+
resume_from: tipHeight + 1,
|
|
39330
|
+
node_tip_at_start: nodeTipAtStart,
|
|
39331
|
+
catch_up_blocks: seam?.gap ?? null
|
|
39332
|
+
};
|
|
39333
|
+
output({
|
|
39334
|
+
json: opts.json,
|
|
39335
|
+
data: report,
|
|
39336
|
+
human: () => {
|
|
39337
|
+
if (divergent === 0) {
|
|
39338
|
+
success(`Restored ${loaded.blocks.toLocaleString()} blocks, ${loaded.transactions.toLocaleString()} transactions, ${loaded.events.toLocaleString()} events in ${formatDuration2(elapsed)}.`);
|
|
39339
|
+
if (referenceDigests.length > 0) {
|
|
39340
|
+
note(` verified ${referenceDigests.length} ranges against the archive`);
|
|
39341
|
+
} else {
|
|
39342
|
+
warn(" archive published no block digests — restore is unverified");
|
|
39343
|
+
}
|
|
39344
|
+
console.error("");
|
|
39345
|
+
console.error(dim(`Your instance holds history through ${tipHeight.toLocaleString()} and will resume at ${(tipHeight + 1).toLocaleString()}.`));
|
|
39346
|
+
if (seam) {
|
|
39347
|
+
console.error(dim(`The chain advanced to ${seam.nodeTip.toLocaleString()} while restoring — ${seam.gap.toLocaleString()} blocks to catch up.`));
|
|
39348
|
+
console.error(dim(" Start the indexer; it backfills that range before following the tip."));
|
|
39349
|
+
} else {
|
|
39350
|
+
console.error(dim(" Start the indexer to continue from there. (Node unreachable — catch-up range unknown.)"));
|
|
39351
|
+
}
|
|
39352
|
+
} else {
|
|
39353
|
+
warn(`Restored ${loaded.blocks.toLocaleString()} blocks but ${divergent} ranges do not match the archive.`);
|
|
39354
|
+
console.error(dim(" Investigate with: sl verify --against <manifest>"));
|
|
39355
|
+
}
|
|
39356
|
+
}
|
|
39357
|
+
});
|
|
39358
|
+
process.exit(divergent === 0 ? BOOTSTRAP_EXIT.OK : BOOTSTRAP_EXIT.INCOMPLETE);
|
|
39359
|
+
} catch (err) {
|
|
39360
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
39361
|
+
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.";
|
|
39362
|
+
printError(message, { hint });
|
|
39363
|
+
process.exit(BOOTSTRAP_EXIT.REFUSED);
|
|
39364
|
+
}
|
|
39365
|
+
});
|
|
39366
|
+
}
|
|
39367
|
+
// src/commands/faucet.ts
|
|
39368
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
39369
|
+
import { readFile as readFile5 } from "node:fs/promises";
|
|
39370
|
+
import { join as join16 } from "node:path";
|
|
39371
|
+
import { http as http3, createWalletClient } from "@secondlayer/stacks";
|
|
39372
|
+
import { mnemonicToAccount } from "@secondlayer/stacks/accounts";
|
|
39373
|
+
import { devnet } from "@secondlayer/stacks/chains";
|
|
39374
|
+
import { Cl } from "@secondlayer/stacks/clarity";
|
|
39375
|
+
import { parse as parseToml2 } from "smol-toml";
|
|
39376
|
+
init_output();
|
|
39377
|
+
var DUST_LIMIT_SATS = 546n;
|
|
39378
|
+
var DEFAULT_AMOUNT_SATS = 1000000n;
|
|
39379
|
+
async function readDeployerMnemonic(projectDir) {
|
|
39380
|
+
const path11 = join16(projectDir, "settings", "Devnet.toml");
|
|
39381
|
+
let raw;
|
|
39382
|
+
try {
|
|
39383
|
+
raw = await readFile5(path11, "utf8");
|
|
39384
|
+
} catch {
|
|
39385
|
+
throw new Error(`could not read ${path11}`);
|
|
39386
|
+
}
|
|
39387
|
+
const parsed = parseToml2(raw);
|
|
39388
|
+
const mnemonic = parsed.accounts?.deployer?.mnemonic;
|
|
39389
|
+
if (!mnemonic) {
|
|
39390
|
+
throw new Error(`no accounts.deployer.mnemonic in ${path11}`);
|
|
39391
|
+
}
|
|
39392
|
+
return mnemonic;
|
|
39393
|
+
}
|
|
39394
|
+
async function readBurnAnchor(rpcUrl) {
|
|
39395
|
+
const info2 = await (await fetch(`${rpcUrl.replace(/\/$/, "")}/v2/info`, {
|
|
39396
|
+
signal: AbortSignal.timeout(1e4)
|
|
39397
|
+
})).json();
|
|
39398
|
+
const height = info2.burn_block_height;
|
|
39399
|
+
if (typeof height !== "number") {
|
|
39400
|
+
throw new Error("node did not report a burn block height");
|
|
39401
|
+
}
|
|
39402
|
+
const burn = await (await fetch(`${rpcUrl.replace(/\/$/, "")}/extended/v2/burn-blocks/${height}`, { signal: AbortSignal.timeout(1e4) })).json();
|
|
39403
|
+
if (!burn.burn_block_hash) {
|
|
39404
|
+
throw new Error(`node did not report a hash for burn block ${height}`);
|
|
39405
|
+
}
|
|
39406
|
+
return { height, hash: burn.burn_block_hash.replace(/^0x/, "") };
|
|
39407
|
+
}
|
|
39408
|
+
function parseAmount(value) {
|
|
39409
|
+
if (value === undefined)
|
|
39410
|
+
return DEFAULT_AMOUNT_SATS;
|
|
39411
|
+
const parsed = BigInt(value);
|
|
39412
|
+
if (parsed < DUST_LIMIT_SATS) {
|
|
39413
|
+
throw new Error(`--amount must be at least ${DUST_LIMIT_SATS} sats (the protocol dust limit), got ${parsed}`);
|
|
39414
|
+
}
|
|
39415
|
+
return parsed;
|
|
39416
|
+
}
|
|
39417
|
+
function registerFaucetCommand(program2) {
|
|
39418
|
+
const faucet = program2.command("faucet").description("Mint test assets on a local devnet");
|
|
39419
|
+
faucet.command("sbtc").description("Mint mock sBTC to an address on your local devnet").requiredOption("--to <address>", "recipient Stacks address").option("--amount <sats>", `amount in sats (default ${DEFAULT_AMOUNT_SATS}, minimum ${DUST_LIMIT_SATS})`).option("--contract <id>", "sBTC deposit contract (default: <deployer>.sbtc-deposit)").option("--rpc-url <url>", "devnet node RPC (default http://localhost:3999)").option("--json", "Output as JSON").addHelpText("after", `
|
|
39420
|
+
Examples:
|
|
39421
|
+
$ sl faucet sbtc --to ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM
|
|
39422
|
+
$ sl faucet sbtc --to ST2... --amount 5000000
|
|
39423
|
+
|
|
39424
|
+
Requires a devnet running with the sBTC contract set deployed by your
|
|
39425
|
+
Clarinet deployer — that deployer is the signer set on your own chain, which
|
|
39426
|
+
is what makes minting possible without Bitcoin.`).action(async (opts) => {
|
|
39427
|
+
try {
|
|
39428
|
+
const network = process.env.STACKS_NETWORK ?? "devnet";
|
|
39429
|
+
if (network !== "devnet" && network !== "local") {
|
|
39430
|
+
printError(`sl faucet only works on devnet, not ${network}.`, {
|
|
39431
|
+
hint: "Mock sBTC is mintable because you deployed the contracts. On testnet or mainnet the signer set is not yours."
|
|
39432
|
+
});
|
|
39433
|
+
process.exit(2);
|
|
39434
|
+
}
|
|
39435
|
+
const rpcUrl = opts.rpcUrl ?? "http://localhost:3999";
|
|
39436
|
+
const amount = parseAmount(opts.amount);
|
|
39437
|
+
const projectDir = findClarinetProject(process.cwd());
|
|
39438
|
+
if (!projectDir) {
|
|
39439
|
+
printError("No Clarinet project found.", {
|
|
39440
|
+
hint: "Run this from a project containing Clarinet.toml, or pass --contract explicitly."
|
|
39441
|
+
});
|
|
39442
|
+
process.exit(2);
|
|
39443
|
+
}
|
|
39444
|
+
const mnemonic = await readDeployerMnemonic(projectDir);
|
|
39445
|
+
const account = mnemonicToAccount(mnemonic, {
|
|
39446
|
+
addressVersion: devnet.addressVersion.singleSig
|
|
39447
|
+
});
|
|
39448
|
+
const contract = opts.contract ?? `${account.address}.sbtc-deposit`;
|
|
39449
|
+
const anchor = await readBurnAnchor(rpcUrl);
|
|
39450
|
+
const client3 = createWalletClient({
|
|
39451
|
+
account,
|
|
39452
|
+
chain: devnet,
|
|
39453
|
+
transport: http3(rpcUrl)
|
|
39454
|
+
});
|
|
39455
|
+
const depositTxid = randomBytes2(32).toString("hex");
|
|
39456
|
+
const sweepTxid = randomBytes2(32).toString("hex");
|
|
39457
|
+
note(` minting ${amount} sats to ${opts.to}`);
|
|
39458
|
+
note(` via ${contract} as signer ${account.address}`);
|
|
39459
|
+
const txid = await client3.callContract({
|
|
39460
|
+
contract,
|
|
39461
|
+
functionName: "complete-deposit-wrapper",
|
|
39462
|
+
functionArgs: [
|
|
39463
|
+
Cl.bufferFromHex(depositTxid),
|
|
39464
|
+
Cl.uint(0),
|
|
39465
|
+
Cl.uint(amount),
|
|
39466
|
+
Cl.principal(opts.to),
|
|
39467
|
+
Cl.bufferFromHex(anchor.hash),
|
|
39468
|
+
Cl.uint(anchor.height),
|
|
39469
|
+
Cl.bufferFromHex(sweepTxid)
|
|
39470
|
+
],
|
|
39471
|
+
postConditionMode: "allow"
|
|
39472
|
+
});
|
|
39473
|
+
output({
|
|
39474
|
+
json: opts.json,
|
|
39475
|
+
data: {
|
|
39476
|
+
txid,
|
|
39477
|
+
recipient: opts.to,
|
|
39478
|
+
amount_sats: amount.toString(),
|
|
39479
|
+
contract,
|
|
39480
|
+
burn_height: anchor.height
|
|
39481
|
+
},
|
|
39482
|
+
human: () => {
|
|
39483
|
+
success(`Minted ${amount} sats of mock sBTC to ${opts.to}.`);
|
|
39484
|
+
note(` tx ${txid}`);
|
|
39485
|
+
console.error(dim(" Mock sBTC on your devnet — not the canonical asset."));
|
|
39486
|
+
}
|
|
39487
|
+
});
|
|
39488
|
+
} catch (err) {
|
|
39489
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
39490
|
+
const hint = /ECONNREFUSED|fetch failed/.test(message) ? "Is your devnet running? Start it with `clarinet devnet start`." : /NoSuchContract|does not exist/.test(message) ? "Deploy the sBTC contract set to your devnet first, or pass --contract." : "Check that your Clarinet deployer matches the sBTC contract deployer.";
|
|
39491
|
+
printError(message, { hint });
|
|
39492
|
+
process.exit(1);
|
|
39493
|
+
}
|
|
39494
|
+
});
|
|
39495
|
+
}
|
|
38629
39496
|
// src/cli.ts
|
|
38630
39497
|
var { version } = package_default;
|
|
38631
39498
|
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)");
|
|
@@ -38677,15 +39544,19 @@ Examples:
|
|
|
38677
39544
|
program.commandsGroup("Local development:");
|
|
38678
39545
|
registerLocalCommand(program);
|
|
38679
39546
|
registerDevnetCommand(program);
|
|
39547
|
+
registerFaucetCommand(program);
|
|
38680
39548
|
program.commandsGroup("Diagnostics:");
|
|
38681
39549
|
registerStatusCommand(program);
|
|
38682
39550
|
registerDoctorCommand(program);
|
|
38683
39551
|
registerConfigCommand(program);
|
|
38684
39552
|
registerContextCommand(program);
|
|
39553
|
+
registerBootstrapCommand(program);
|
|
39554
|
+
registerVerifyCommand(program);
|
|
39555
|
+
registerRepairCommand(program);
|
|
38685
39556
|
program.commandsGroup("Account:");
|
|
38686
39557
|
registerAccountCommand(program);
|
|
38687
39558
|
registerKeysCommand(program);
|
|
38688
39559
|
program.parse();
|
|
38689
39560
|
|
|
38690
|
-
//# debugId=
|
|
39561
|
+
//# debugId=F913417C10273C0264756E2164756E21
|
|
38691
39562
|
//# sourceMappingURL=cli.js.map
|