mirror of
https://github.com/luxfi/teleport.git
synced 2026-10-30 15:15:49 +00:00
BridgeV2 deploys in the TS suites now supply this chain's ZAP source identity
(networkId=96369, sourceChainId 32-byte). NOTE: the TS suite has a PRE-EXISTING
break unrelated to this change — getContractFactory("P3QVerifierStub") fails
(HH700, no such contract in the repo; renamed/removed). The messageHash
assertions still need re-baselining to the new ZAP digest D — separate test
maintenance; the digest itself is proven byte-equal to Go via the forge suite.
491 lines
19 KiB
TypeScript
491 lines
19 KiB
TypeScript
import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers";
|
|
import { expect } from "chai";
|
|
import hre, { ethers } from "hardhat";
|
|
import { execSync } from "child_process";
|
|
import path from "path";
|
|
import { promises as fs } from "fs";
|
|
|
|
/**
|
|
* BridgeV2 — strict-PQ profile gate wiring + P3Q proof slot.
|
|
*
|
|
* Three wirings under test:
|
|
* 1. `claim` calls `StrictPQProfileGate.refuseUnderStrictPQ` when an
|
|
* `activeVerifier` is configured.
|
|
* 2. `claimWithProof` calls the gate then the P3Q verifier.
|
|
* 3. The default `P3QVerifierStub` always rejects (audit gate).
|
|
*
|
|
* The verifier verifies; the gate decides admissibility. Decomplected.
|
|
*/
|
|
|
|
const PROFILE_CLASSICAL = ethers.id("PROFILE_CLASSICAL_COMPAT");
|
|
const PROFILE_STRICT_PQ = ethers.id("PROFILE_QUASAR_STRICT_PQ");
|
|
|
|
const POLICY_GROTH16_BN254 = ethers.id("ProofPolicyGroth16BN254");
|
|
const POLICY_STARK_FRI_SHA3 = ethers.id("ProofPolicySTARKFRISHA3PQ");
|
|
|
|
async function deployFixture() {
|
|
const [admin, user] = await hre.ethers.getSigners();
|
|
|
|
// Stub Warp 2.0 verifier — accepts envelopes registered with setExpected.
|
|
const Warp2Stub = await hre.ethers.getContractFactory("Warp2VerifierStub");
|
|
const warp2 = await Warp2Stub.deploy(admin.address, 0x01); // bit0=requireBeam
|
|
await warp2.waitForDeployment();
|
|
|
|
const BridgeV2 = await hre.ethers.getContractFactory("BridgeV2");
|
|
const bridge = await BridgeV2.deploy(
|
|
admin.address,
|
|
await warp2.getAddress(),
|
|
admin.address, // feeRecipient
|
|
0n, // feeRate
|
|
0x01, // minPolicyFlags
|
|
96369, // networkId — this chain's ZAP source network id
|
|
"0x0000000000000000000000000000000000000000000000000000000000017871" // sourceChainId (32-byte)
|
|
);
|
|
await bridge.waitForDeployment();
|
|
|
|
// Token
|
|
const LuxBTC = await hre.ethers.getContractFactory("LuxBTC");
|
|
const token = await LuxBTC.deploy(admin.address);
|
|
await token.waitForDeployment();
|
|
|
|
const BRIDGE_ROLE = await token.BRIDGE_ROLE();
|
|
await token.connect(admin).grantRole(BRIDGE_ROLE, await bridge.getAddress());
|
|
await bridge.connect(admin).setTokenAllowed(await token.getAddress(), true);
|
|
|
|
// P3Q stub — always rejects.
|
|
const P3QStub = await hre.ethers.getContractFactory("P3QVerifierStub");
|
|
const p3qStub = await P3QStub.deploy();
|
|
await p3qStub.waitForDeployment();
|
|
|
|
// Mock IVerifier for the ZK-claim slot exercises.
|
|
const Mock = await hre.ethers.getContractFactory("MockVerifier");
|
|
const mockGroth16 = await Mock.deploy(POLICY_GROTH16_BN254);
|
|
await mockGroth16.waitForDeployment();
|
|
const mockStark = await Mock.deploy(POLICY_STARK_FRI_SHA3);
|
|
await mockStark.waitForDeployment();
|
|
|
|
// Build a valid envelope + claim.
|
|
const network = await hre.ethers.provider.getNetwork();
|
|
const destChainId = Number(network.chainId);
|
|
const sourceChainId = 96369;
|
|
const amount = ethers.parseEther("1");
|
|
const nonce = 0n;
|
|
|
|
const c = {
|
|
sourceChainId: BigInt(sourceChainId),
|
|
destChainId: BigInt(destChainId),
|
|
token: await token.getAddress(),
|
|
amount,
|
|
recipient: user.address,
|
|
vault: false,
|
|
nonce,
|
|
};
|
|
|
|
const messageHash = ethers.keccak256(
|
|
ethers.AbiCoder.defaultAbiCoder().encode(
|
|
["uint64", "uint64", "address", "uint256", "address", "bool", "uint256"],
|
|
[
|
|
c.sourceChainId,
|
|
c.destChainId,
|
|
c.token,
|
|
c.amount,
|
|
c.recipient,
|
|
c.vault,
|
|
c.nonce,
|
|
]
|
|
)
|
|
);
|
|
|
|
const env = {
|
|
messageHash,
|
|
sourceChainId: c.sourceChainId,
|
|
destChainId: c.destChainId,
|
|
blsAggregate: "0x" + "ab".repeat(50), // non-empty
|
|
mldsaCertSet: "0x",
|
|
pulseSignature: "0x",
|
|
epoch: 1n,
|
|
};
|
|
|
|
await warp2.connect(admin).setExpected(messageHash, true);
|
|
|
|
// Section C (Red round 5): the strict-PQ slots are now also gated by
|
|
// an EXTCODEHASH allow-list controlled by DEFAULT_ADMIN_ROLE. The
|
|
// existing strict-PQ happy-path tests still need to install
|
|
// `mockStark` and `p3qStub`, so pre-allow their code hashes from the
|
|
// deployment fixture. `admin` here holds both DEFAULT_ADMIN_ROLE and
|
|
// ADMIN_ROLE (test config), mirroring a freshly-deployed bridge
|
|
// where the timelock has not yet rotated keys.
|
|
const ext = async (addr: string): Promise<string> =>
|
|
hre.ethers.provider.getCode(addr).then((code) => hre.ethers.keccak256(code));
|
|
await bridge.connect(admin).setAllowedVerifierCodeHash(
|
|
await ext(await mockStark.getAddress()),
|
|
true
|
|
);
|
|
await bridge.connect(admin).setAllowedVerifierCodeHash(
|
|
await ext(await p3qStub.getAddress()),
|
|
true
|
|
);
|
|
|
|
return {
|
|
admin,
|
|
user,
|
|
bridge,
|
|
token,
|
|
warp2,
|
|
p3qStub,
|
|
mockGroth16,
|
|
mockStark,
|
|
env,
|
|
c,
|
|
ext,
|
|
};
|
|
}
|
|
|
|
describe("BridgeV2 — strict-PQ profile gate wiring", function () {
|
|
it("(a) claim succeeds under classical-compat with any verifier in slot", async () => {
|
|
const { admin, bridge, mockGroth16, env, c } = await loadFixture(deployFixture);
|
|
|
|
// Default profile is classical-compat. Plug a Groth16 verifier into the
|
|
// ZK-claim slot — the gate must NOT fire under classical-compat.
|
|
await bridge.connect(admin).setActiveProfile(
|
|
PROFILE_CLASSICAL,
|
|
await mockGroth16.getAddress()
|
|
);
|
|
|
|
await expect(bridge.claim(env, c)).to.emit(bridge, "BridgeMintedV2");
|
|
});
|
|
|
|
it("(b) setActiveProfile refuses non-PQ verifier at install time under strict-PQ", async () => {
|
|
const { admin, bridge, mockGroth16 } = await loadFixture(deployFixture);
|
|
|
|
// Install-time refusal: a Groth16 verifier cannot be installed under
|
|
// strict-PQ at all. The previous behaviour deferred the check to
|
|
// `claim`, which let `address(0)`-no-op state slip in between install
|
|
// and claim. Allow-list gating closes that window.
|
|
await expect(
|
|
bridge.connect(admin).setActiveProfile(
|
|
PROFILE_STRICT_PQ,
|
|
await mockGroth16.getAddress()
|
|
)
|
|
)
|
|
.to.be.revertedWithCustomError(bridge, "VerifierRefusedUnderStrictPQ")
|
|
.withArgs(PROFILE_STRICT_PQ, POLICY_GROTH16_BN254);
|
|
});
|
|
|
|
it("(c) claim succeeds under strict-PQ when verifier policyId = STARK_FRI_SHA3", async () => {
|
|
const { admin, bridge, mockStark, env, c } = await loadFixture(deployFixture);
|
|
|
|
await bridge.connect(admin).setActiveProfile(
|
|
PROFILE_STRICT_PQ,
|
|
await mockStark.getAddress()
|
|
);
|
|
|
|
await expect(bridge.claim(env, c)).to.emit(bridge, "BridgeMintedV2");
|
|
});
|
|
|
|
it("setActiveProfile rejects unknown profile id", async () => {
|
|
const { admin, bridge, mockStark } = await loadFixture(deployFixture);
|
|
const bogus = ethers.id("PROFILE_BOGUS");
|
|
await expect(
|
|
bridge.connect(admin).setActiveProfile(bogus, await mockStark.getAddress())
|
|
)
|
|
.to.be.revertedWithCustomError(bridge, "UnknownProfile")
|
|
.withArgs(bogus);
|
|
});
|
|
});
|
|
|
|
describe("BridgeV2 — claimWithProof / P3Q audit gate", function () {
|
|
it("reverts when no P3Q verifier is configured", async () => {
|
|
const { bridge, env, c } = await loadFixture(deployFixture);
|
|
await expect(bridge.claimWithProof(env, c, "0x"))
|
|
.to.be.revertedWithCustomError(bridge, "P3QVerifierUnset");
|
|
});
|
|
|
|
it("(d) claimWithProof reverts because P3QVerifierStub always rejects", async () => {
|
|
const { admin, bridge, p3qStub, env, c } = await loadFixture(deployFixture);
|
|
|
|
await bridge.connect(admin).setP3QVerifier(await p3qStub.getAddress());
|
|
|
|
// The stub also emits AuditGatedReject before returning false. The
|
|
// bridge then reverts with P3QProofRejected.
|
|
await expect(bridge.claimWithProof(env, c, "0x"))
|
|
.to.be.revertedWithCustomError(bridge, "P3QProofRejected");
|
|
});
|
|
|
|
it("(e) setP3QVerifier refuses non-PQ verifier at install time", async () => {
|
|
const { admin, bridge } = await loadFixture(deployFixture);
|
|
|
|
// Defence in depth: setP3QVerifier itself enforces the STARK/FRI
|
|
// policy allow-list. A NonPQP3QStub returning POLICY_GROTH16_BN254
|
|
// cannot even be installed in the P3Q slot — the previous behaviour
|
|
// accepted the install and only refused at claim time, which let an
|
|
// admin (or compromised key) point the slot at a non-PQ backend that
|
|
// could mint without a real PQ proof check.
|
|
const NonPQ = await hre.ethers.getContractFactory("NonPQP3QStub");
|
|
const nonpq = await NonPQ.deploy();
|
|
await nonpq.waitForDeployment();
|
|
|
|
await expect(
|
|
bridge.connect(admin).setP3QVerifier(await nonpq.getAddress())
|
|
)
|
|
.to.be.revertedWithCustomError(bridge, "VerifierRefusedUnderStrictPQ")
|
|
.withArgs(PROFILE_STRICT_PQ, POLICY_GROTH16_BN254);
|
|
});
|
|
|
|
it("(f) setActiveProfile refuses address(0) verifier under strict-PQ", async () => {
|
|
const { admin, bridge } = await loadFixture(deployFixture);
|
|
|
|
await expect(
|
|
bridge.connect(admin).setActiveProfile(
|
|
PROFILE_STRICT_PQ,
|
|
ethers.ZeroAddress
|
|
)
|
|
)
|
|
.to.be.revertedWithCustomError(bridge, "VerifierUnsetUnderStrictPQ")
|
|
.withArgs(PROFILE_STRICT_PQ);
|
|
});
|
|
|
|
it("(g) setActiveProfile refuses spoofed-policy verifier under strict-PQ", async () => {
|
|
const { admin, bridge } = await loadFixture(deployFixture);
|
|
|
|
// Spoofed policy: a verifier that returns a bytes32 not in the
|
|
// deny-list AND not in the allow-list. The OLD deny-list gate would
|
|
// pass it; the NEW allow-list gate refuses it.
|
|
const Mock = await hre.ethers.getContractFactory("MockVerifier");
|
|
const spoofedPolicy = ethers.id("ProofPolicyBogusUnknown");
|
|
const spoofed = await Mock.deploy(spoofedPolicy);
|
|
await spoofed.waitForDeployment();
|
|
|
|
await expect(
|
|
bridge.connect(admin).setActiveProfile(
|
|
PROFILE_STRICT_PQ,
|
|
await spoofed.getAddress()
|
|
)
|
|
)
|
|
.to.be.revertedWithCustomError(bridge, "VerifierRefusedUnderStrictPQ")
|
|
.withArgs(PROFILE_STRICT_PQ, spoofedPolicy);
|
|
});
|
|
|
|
it("(h) setP3QVerifier refuses address(0)? — no: address(0) clears the slot", async () => {
|
|
const { admin, bridge, env, c } = await loadFixture(deployFixture);
|
|
|
|
// address(0) for the P3Q slot is permitted and explicitly clears the
|
|
// slot — subsequent claimWithProof calls revert with P3QVerifierUnset.
|
|
// This is the fail-CLOSED semantic for that path (no proof verifier
|
|
// installed ⇒ no claim possible), which is the opposite of the
|
|
// strict-PQ profile slot where address(0) is a silent no-op.
|
|
await bridge.connect(admin).setP3QVerifier(ethers.ZeroAddress);
|
|
await expect(bridge.claimWithProof(env, c, "0x"))
|
|
.to.be.revertedWithCustomError(bridge, "P3QVerifierUnset");
|
|
});
|
|
});
|
|
|
|
describe("BridgeV2 — EXTCODEHASH allow-list (Section C, Red round 5)", function () {
|
|
// Section C residual: a spoofed verifier whose `policyId()` returns
|
|
// POLICY_STARK_FRI_SHA3_PQ but whose `verify()` returns true for any
|
|
// input would pass the policy-id check alone. The code-hash gate
|
|
// refuses to install ANY verifier whose deployed bytecode is not
|
|
// pre-allowed by DEFAULT_ADMIN_ROLE.
|
|
it("(i) setActiveProfile refuses STARK-policy verifier with non-allowlisted code", async () => {
|
|
const { admin, bridge } = await loadFixture(deployFixture);
|
|
|
|
// Build a fresh MockVerifier with POLICY_STARK_FRI_SHA3 — its
|
|
// immutable field embeds the policy id into the runtime bytecode.
|
|
// Same policy ID, but the test fixture did NOT pre-allow this
|
|
// exact deployment's code (we deploy a *separate* mock here whose
|
|
// code hash matches the fixture's `mockStark` ONLY because the
|
|
// immutable bakes in identically). To assert "not-allowlisted",
|
|
// we revoke the allow first.
|
|
const Mock = await hre.ethers.getContractFactory("MockVerifier");
|
|
const m = await Mock.deploy(POLICY_STARK_FRI_SHA3);
|
|
await m.waitForDeployment();
|
|
|
|
// Compute the code hash and revoke any pre-allow.
|
|
const code = await hre.ethers.provider.getCode(await m.getAddress());
|
|
const ch = hre.ethers.keccak256(code);
|
|
await bridge.connect(admin).setAllowedVerifierCodeHash(ch, false);
|
|
|
|
await expect(
|
|
bridge.connect(admin).setActiveProfile(
|
|
PROFILE_STRICT_PQ,
|
|
await m.getAddress()
|
|
)
|
|
)
|
|
.to.be.revertedWithCustomError(bridge, "VerifierCodeHashNotAllowlisted")
|
|
.withArgs(ch);
|
|
});
|
|
|
|
it("(j) setActiveProfile accepts STARK-policy verifier with allowlisted code", async () => {
|
|
const { admin, bridge, mockStark } = await loadFixture(deployFixture);
|
|
// Fixture pre-allowed mockStark's code hash — install must succeed.
|
|
await expect(
|
|
bridge.connect(admin).setActiveProfile(
|
|
PROFILE_STRICT_PQ,
|
|
await mockStark.getAddress()
|
|
)
|
|
).to.emit(bridge, "ProfileUpdated");
|
|
});
|
|
|
|
it("(k) setP3QVerifier refuses STARK-policy verifier with non-allowlisted code", async () => {
|
|
const { admin, bridge, p3qStub } = await loadFixture(deployFixture);
|
|
|
|
const code = await hre.ethers.provider.getCode(await p3qStub.getAddress());
|
|
const ch = hre.ethers.keccak256(code);
|
|
await bridge.connect(admin).setAllowedVerifierCodeHash(ch, false);
|
|
|
|
await expect(
|
|
bridge.connect(admin).setP3QVerifier(await p3qStub.getAddress())
|
|
)
|
|
.to.be.revertedWithCustomError(bridge, "VerifierCodeHashNotAllowlisted")
|
|
.withArgs(ch);
|
|
});
|
|
|
|
it("(l) DEFAULT_ADMIN_ROLE can extend the allow-list", async () => {
|
|
const { admin, bridge } = await loadFixture(deployFixture);
|
|
|
|
const Mock = await hre.ethers.getContractFactory("MockVerifier");
|
|
const fresh = await Mock.deploy(POLICY_STARK_FRI_SHA3);
|
|
await fresh.waitForDeployment();
|
|
const code = await hre.ethers.provider.getCode(await fresh.getAddress());
|
|
const ch = hre.ethers.keccak256(code);
|
|
|
|
// Revoke (in case fixture's pre-allow collided — immutable bakes
|
|
// policy id into bytecode, so deterministic). Then re-allow and
|
|
// assert install succeeds.
|
|
await bridge.connect(admin).setAllowedVerifierCodeHash(ch, false);
|
|
await expect(
|
|
bridge.connect(admin).setActiveProfile(
|
|
PROFILE_STRICT_PQ,
|
|
await fresh.getAddress()
|
|
)
|
|
).to.be.revertedWithCustomError(bridge, "VerifierCodeHashNotAllowlisted");
|
|
|
|
// DEFAULT_ADMIN_ROLE adds the code hash.
|
|
await expect(
|
|
bridge.connect(admin).setAllowedVerifierCodeHash(ch, true)
|
|
)
|
|
.to.emit(bridge, "VerifierCodeHashAllowed")
|
|
.withArgs(ch, true);
|
|
|
|
// Now install succeeds.
|
|
await expect(
|
|
bridge.connect(admin).setActiveProfile(
|
|
PROFILE_STRICT_PQ,
|
|
await fresh.getAddress()
|
|
)
|
|
).to.emit(bridge, "ProfileUpdated");
|
|
});
|
|
|
|
it("(m) non-DEFAULT_ADMIN account cannot extend the allow-list", async () => {
|
|
const { user, bridge } = await loadFixture(deployFixture);
|
|
const ch = ethers.id("any-bytecode-hash");
|
|
// `user` is the second signer in the fixture — does NOT hold
|
|
// DEFAULT_ADMIN_ROLE. OZ AccessControl reverts with
|
|
// AccessControlUnauthorizedAccount on v5.
|
|
await expect(
|
|
bridge.connect(user).setAllowedVerifierCodeHash(ch, true)
|
|
).to.be.reverted;
|
|
});
|
|
|
|
it("(n) classical-compat profile install is NOT gated by the allow-list", async () => {
|
|
const { admin, bridge, mockGroth16 } = await loadFixture(deployFixture);
|
|
// mockGroth16's code hash is NOT pre-allowed. Under classical-compat
|
|
// the EXTCODEHASH gate must NOT fire — the test asserts the profile
|
|
// gate stays scoped to strict-PQ only (decomplected).
|
|
await expect(
|
|
bridge.connect(admin).setActiveProfile(
|
|
PROFILE_CLASSICAL,
|
|
await mockGroth16.getAddress()
|
|
)
|
|
).to.emit(bridge, "ProfileUpdated");
|
|
});
|
|
});
|
|
|
|
describe("BridgeV2 — production artifact set snapshot", function () {
|
|
// Red HIGH #8: a spoofed `MockVerifier(POLICY_STARK_FRI_SHA3_PQ)` that
|
|
// accepts any input MUST NOT ship in production deployment artifacts.
|
|
// The fix moved test-only Solidity out of `paths.sources` into
|
|
// `test-contracts/` and gated re-inclusion behind
|
|
// `HARDHAT_INCLUDE_TEST_CONTRACTS` / `npm_lifecycle_event === "test"`.
|
|
//
|
|
// This test exercises a clean production compile in an isolated
|
|
// working directory (so the main artifact tree is not clobbered) and
|
|
// asserts the set of contract names that result. Mock/Stub/Harness
|
|
// contracts must NOT appear in the production artifact set.
|
|
it("clean production compile excludes MockVerifier, NonPQP3QStub, harnesses", async function () {
|
|
this.timeout(180_000);
|
|
|
|
const cwd = path.resolve(__dirname, "..");
|
|
const stamp = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
// Config MUST live inside the project root (cwd) — Hardhat rejects
|
|
// sources outside the project, and "project" is rooted at the
|
|
// directory containing the config. Sandbox dirs (artifacts/cache)
|
|
// still live in a stamped subdir to avoid clobbering main outputs.
|
|
const sandboxRoot = path.join(cwd, ".artifact-snapshot");
|
|
const sandbox = path.join(sandboxRoot, stamp);
|
|
const artifactsDir = path.join(sandbox, "artifacts");
|
|
const cacheDir = path.join(sandbox, "cache");
|
|
await fs.mkdir(sandbox, { recursive: true });
|
|
|
|
const sandboxConfig = path.join(cwd, `.hardhat.snapshot.${stamp}.config.cjs`);
|
|
const cfgBody =
|
|
"module.exports = {\n" +
|
|
" solidity: { compilers: [\n" +
|
|
" { version: '0.8.20', settings: { optimizer: { enabled: true, runs: 200 } } },\n" +
|
|
" { version: '0.8.24', settings: { optimizer: { enabled: true, runs: 200 }, evmVersion: 'cancun' } }\n" +
|
|
" ] },\n" +
|
|
" paths: {\n" +
|
|
" sources: './contracts',\n" +
|
|
` cache: ${JSON.stringify(cacheDir)},\n` +
|
|
` artifacts: ${JSON.stringify(artifactsDir)}\n` +
|
|
" }\n" +
|
|
"};\n";
|
|
await fs.writeFile(sandboxConfig, cfgBody);
|
|
|
|
// Compile in a child process; clear env that would re-include test
|
|
// contracts so the snapshot reflects the production codepath.
|
|
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
delete env.HARDHAT_INCLUDE_TEST_CONTRACTS;
|
|
delete env.npm_lifecycle_event;
|
|
|
|
try {
|
|
execSync(`npx hardhat --config ${sandboxConfig} compile --force`, {
|
|
cwd,
|
|
env,
|
|
stdio: "pipe",
|
|
});
|
|
|
|
// Walk the production artifact tree and collect contract names.
|
|
async function collectNames(dir: string, acc: string[]): Promise<void> {
|
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
for (const e of entries) {
|
|
const p = path.join(dir, e.name);
|
|
if (e.isDirectory()) {
|
|
if (e.name === "build-info") continue;
|
|
await collectNames(p, acc);
|
|
} else if (e.name.endsWith(".json") && !e.name.endsWith(".dbg.json")) {
|
|
acc.push(e.name.replace(/\.json$/, ""));
|
|
}
|
|
}
|
|
}
|
|
const names: string[] = [];
|
|
await collectNames(artifactsDir, names);
|
|
|
|
// Hard refusals: anything test-only must not appear.
|
|
const banned = ["MockVerifier", "NonPQP3QStub", "StrictPQProfileGateHarness"];
|
|
for (const b of banned) {
|
|
expect(names, `test-only contract '${b}' leaked into production artifacts`).to.not.include(b);
|
|
}
|
|
|
|
// Sanity: real production contracts MUST still be present.
|
|
const required = ["BridgeV2", "Bridge", "ERC20B", "Warp2VerifierStub"];
|
|
for (const r of required) {
|
|
expect(names, `production contract '${r}' missing from artifact set`).to.include(r);
|
|
}
|
|
} finally {
|
|
await fs.rm(sandbox, { recursive: true, force: true });
|
|
await fs.rm(sandboxConfig, { force: true });
|
|
}
|
|
});
|
|
});
|