Skip to content

Commit 64ec031

Browse files
authored
Merge pull request #76 from mattglory/security-lead/mainnet-plan-guard
test: pin what `clarinet deployments apply --mainnet` would publish
2 parents 4af27a6 + 5a020e0 commit 64ec031

1 file changed

Lines changed: 166 additions & 0 deletions

File tree

‎tests/mainnet-plan-guard.test.ts‎

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
2+
import { spawnSync } from "node:child_process";
3+
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
7+
/**
8+
* What `clarinet deployments apply --mainnet` would publish from this repo.
9+
*
10+
* On any machine with a `settings/Mainnet.toml`, `apply --mainnet` publishes the
11+
* plan clarinet computes from Clarinet.toml: after two Enters by default, or with
12+
* no prompt at all under `-d`. Verified 2026-09-27 against clarinet 3.23.2 in a
13+
* network-isolated container with a throwaway key and a mock node (see the #75
14+
* review). Clarinet.toml registers the funds-bearing contracts under their real
15+
* names but pointed at their `contracts/test/` localized copies, so that plan
16+
* publishes those copies, whose sBTC calls resolve to the in-plan mock
17+
* `contracts/sbtc-token.clar`, plus test fixtures such as `malicious-token`.
18+
*
19+
* The target is that no `contracts/test/` path is in the mainnet plan. That needs
20+
* D6's structural fix (docs/security/CONTRACT_INVENTORY.md §7.3), so it cannot
21+
* pass yet and is recorded below with `it.fails`. Until then, the known set is
22+
* pinned so it cannot silently grow: registering one more `contracts/test/` file
23+
* in Clarinet.toml puts it in what a mainnet deploy run publishes, and must fail
24+
* here. When D6 lands, both tests will fail: empty KNOWN_TEST_PATHS and turn the
25+
* `it.fails` into `it`.
26+
*
27+
* Nothing is signed or sent. `deployments generate` only writes a plan file, in a
28+
* temp copy of the project. The dummy Mainnet.toml uses the public Clarinet
29+
* devnet deployer mnemonic already committed in settings/Devnet.toml, points at
30+
* an RPC address nothing listens on, and `--manual-cost` skips fee estimation.
31+
* Requirements resolve from the vendored `.cache`, so no network is needed
32+
* (verified in a `--network none` container).
33+
*
34+
* Needs the same clarinet version as CI (read from .github/workflows/test.yml).
35+
* Required in CI; skipped locally if missing or mismatched. Set CLARINET_BIN to
36+
* point at a matching binary.
37+
*/
38+
const KNOWN_TEST_PATHS = [
39+
"contracts/test/flashstack-pool-oracle-v2.clar",
40+
"contracts/test/flashstack-pool-oracle.clar",
41+
"contracts/test/flashstack-pool-v3.clar",
42+
"contracts/test/flashstack-sbtc-core-v2.clar",
43+
"contracts/test/flashstack-sbtc-core.clar",
44+
"contracts/test/flashstack-sbtc-pool-v2.clar",
45+
"contracts/test/flashstack-sbtc-pool-v3.clar",
46+
"contracts/test/flashstack-sbtc-pool.clar",
47+
"contracts/test/flashstack-stx-core-v2.clar",
48+
"contracts/test/flashstack-stx-core.clar",
49+
"contracts/test/flashstack-stx-pool-v2.clar",
50+
"contracts/test/flashstack-stx-pool-v3.clar",
51+
"contracts/test/flashstack-stx-pool.clar",
52+
"contracts/test/flashstack-v3-receiver-trait.clar",
53+
"contracts/test/malicious-token.clar",
54+
"contracts/test/mock-usdcx.clar",
55+
"contracts/test/sip-010-trait-ft-standard.clar",
56+
"contracts/test/test-pool-receiver-good.clar",
57+
"contracts/test/test-pool-v3-receiver-bad.clar",
58+
"contracts/test/test-pool-v3-receiver-good.clar",
59+
"contracts/test/test-pool-v3-receiver-reentrant.clar",
60+
"contracts/test/test-receiver-bad.clar",
61+
"contracts/test/test-receiver-good.clar",
62+
"contracts/test/test-sbtc-pool-receiver-good.clar",
63+
"contracts/test/test-sbtc-receiver-bad.clar",
64+
"contracts/test/test-sbtc-receiver-good.clar",
65+
];
66+
67+
const CLARINET = process.env.CLARINET_BIN || "clarinet";
68+
const CI_VERSION = readFileSync(".github/workflows/test.yml", "utf-8").match(
69+
/clarinet\/releases\/download\/v(\d+\.\d+\.\d+)\//,
70+
)?.[1];
71+
const probe = spawnSync(CLARINET, ["--version"], { encoding: "utf-8" });
72+
const localVersion = probe.stdout?.match(/clarinet (\d+\.\d+\.\d+)/)?.[1];
73+
const usable = CI_VERSION !== undefined && localVersion === CI_VERSION;
74+
75+
if (process.env.CI && !usable) {
76+
throw new Error(
77+
`mainnet-plan-guard needs clarinet ${CI_VERSION} in CI, found ${localVersion ?? "none"} (${CLARINET})`,
78+
);
79+
}
80+
81+
type Publish = { name: string; path: string };
82+
83+
// Line-based on purpose: clarinet writes one `key: value` per line, and this
84+
// avoids depending on a YAML parser that is only a transitive dependency.
85+
function publishesOf(planYaml: string): Publish[] {
86+
const out: Publish[] = [];
87+
let cur: Partial<Publish> & { type?: string } = {};
88+
const flush = () => {
89+
if (cur.type === "contract-publish" && cur.name && cur.path) {
90+
out.push({ name: cur.name, path: cur.path });
91+
}
92+
};
93+
for (const line of planYaml.split("\n")) {
94+
const tx = line.match(/^\s*- transaction-type: (\S+)/);
95+
if (tx) {
96+
flush();
97+
cur = { type: tx[1] };
98+
continue;
99+
}
100+
const kv = line.match(/^\s*(contract-name|path): (\S+)/);
101+
if (kv) cur[kv[1] === "contract-name" ? "name" : "path"] = kv[2];
102+
}
103+
flush();
104+
return out;
105+
}
106+
107+
describe.skipIf(!usable)("clarinet's computed mainnet plan (apply --mainnet)", () => {
108+
let dir: string;
109+
let publishes: Publish[];
110+
111+
beforeAll(() => {
112+
dir = mkdtempSync(join(tmpdir(), "mainnet-plan-guard-"));
113+
for (const p of ["Clarinet.toml", "contracts", ".cache"]) {
114+
cpSync(p, join(dir, p), { recursive: true });
115+
}
116+
mkdirSync(join(dir, "settings"));
117+
cpSync("settings/Devnet.toml", join(dir, "settings/Devnet.toml"));
118+
const devnet = readFileSync("settings/Devnet.toml", "utf-8");
119+
const mnemonic = devnet.match(/\[accounts\.deployer\]\s*\nmnemonic = "([^"]+)"/)?.[1];
120+
if (!mnemonic) throw new Error("public devnet deployer mnemonic not found in settings/Devnet.toml");
121+
writeFileSync(
122+
join(dir, "settings/Mainnet.toml"),
123+
`[network]\nname = "mainnet"\nstacks_node_rpc_address = "http://127.0.0.1:1"\n\n` +
124+
`[accounts.deployer]\nmnemonic = "${mnemonic}"\n`,
125+
);
126+
127+
const gen = spawnSync(CLARINET, ["deployments", "generate", "--mainnet", "--manual-cost"], {
128+
cwd: dir,
129+
encoding: "utf-8",
130+
stdio: ["ignore", "pipe", "pipe"],
131+
timeout: 300_000,
132+
});
133+
if (gen.status !== 0) {
134+
throw new Error(`clarinet deployments generate --mainnet failed:\n${gen.stdout}\n${gen.stderr}`);
135+
}
136+
publishes = publishesOf(readFileSync(join(dir, "deployments/default.mainnet-plan.yaml"), "utf-8"));
137+
}, 300_000);
138+
139+
afterAll(() => {
140+
if (dir) rmSync(dir, { recursive: true, force: true });
141+
});
142+
143+
const testPaths = () =>
144+
publishes
145+
.map((p) => p.path)
146+
.filter((p) => p.startsWith("contracts/test/"))
147+
.sort();
148+
149+
it("parses a real plan (guards against a vacuous pass)", () => {
150+
const paths = publishes.map((p) => p.path);
151+
expect(paths).toContain("contracts/flashstack-core.clar");
152+
expect(paths.length).toBeGreaterThan(KNOWN_TEST_PATHS.length);
153+
});
154+
155+
it("publishes no contracts/test/ file beyond the known set", () => {
156+
expect(
157+
testPaths(),
158+
"the contracts/test/ files `apply --mainnet` would publish changed. An added entry is a test build headed " +
159+
"for mainnet; update KNOWN_TEST_PATHS only for a reviewed removal",
160+
).toEqual(KNOWN_TEST_PATHS);
161+
});
162+
163+
it.fails("TARGET (needs D6): publishes no contracts/test/ file at all", () => {
164+
expect(testPaths()).toEqual([]);
165+
});
166+
});

0 commit comments

Comments
 (0)