Skip to content

Commit b9ea7b8

Browse files
authored
Merge pull request #217 from MeshJS/fix/invalid-witnesses-and-ci-smoke
fix: filter extraneous VKey witnesses + add CI smoke test system
2 parents 0d5ee84 + dc49af2 commit b9ea7b8

21 files changed

Lines changed: 1084 additions & 7 deletions
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
name: CI Smoke (Preprod)
2+
3+
on:
4+
pull_request:
5+
branches: [main, preprod]
6+
workflow_dispatch:
7+
8+
env:
9+
API_BASE_URL: ${{ secrets.SMOKE_API_BASE_URL }}
10+
SIGNER_MNEMONIC_1: ${{ secrets.SMOKE_SIGNER_MNEMONIC_1 }}
11+
SIGNER_MNEMONIC_2: ${{ secrets.SMOKE_SIGNER_MNEMONIC_2 }}
12+
BOT_MNEMONIC: ${{ secrets.SMOKE_BOT_MNEMONIC }}
13+
BOT_KEY_ID: ${{ secrets.SMOKE_BOT_KEY_ID }}
14+
BOT_SECRET: ${{ secrets.SMOKE_BOT_SECRET }}
15+
16+
jobs:
17+
smoke:
18+
runs-on: ubuntu-latest
19+
timeout-minutes: 15
20+
steps:
21+
- uses: actions/checkout@v4
22+
23+
- uses: actions/setup-node@v4
24+
with:
25+
node-version: 20
26+
27+
- name: Check secrets configured
28+
id: check-secrets
29+
run: |
30+
if [ -z "$API_BASE_URL" ]; then
31+
echo "configured=false" >> "$GITHUB_OUTPUT"
32+
echo "Smoke test skipped: SMOKE_* secrets not configured."
33+
else
34+
echo "configured=true" >> "$GITHUB_OUTPUT"
35+
fi
36+
37+
- name: Install dependencies
38+
if: steps.check-secrets.outputs.configured == 'true'
39+
run: npm ci
40+
41+
- name: Stage 1 - Bootstrap wallets
42+
if: steps.check-secrets.outputs.configured == 'true'
43+
run: npx tsx scripts/ci-smoke/create-wallets.ts
44+
45+
- name: Stage 2 - Run route chain
46+
if: steps.check-secrets.outputs.configured == 'true'
47+
run: npx tsx scripts/ci-smoke/run-route-chain.ts
48+
49+
- name: Upload smoke artifacts
50+
uses: actions/upload-artifact@v4
51+
if: always() && steps.check-secrets.outputs.configured == 'true'
52+
with:
53+
name: smoke-report
54+
path: ci-artifacts/
55+
retention-days: 14

scripts/ci-smoke/create-wallets.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#!/usr/bin/env npx tsx
2+
/**
3+
* Stage 1: Bootstrap CI smoke-test wallets.
4+
*
5+
* Reads mnemonic secrets from env vars, derives payment addresses,
6+
* authenticates the bot, creates 3 wallet variants (legacy, hierarchical,
7+
* SDK-based), and writes versioned context JSON for downstream stages.
8+
*
9+
* Required env vars:
10+
* API_BASE_URL - Base URL of the multisig API
11+
* SIGNER_MNEMONIC_1 - Space-separated mnemonic for signer 1
12+
* SIGNER_MNEMONIC_2 - Space-separated mnemonic for signer 2
13+
* BOT_KEY_ID - Bot key ID for authentication
14+
* BOT_SECRET - Bot secret for authentication
15+
* BOT_MNEMONIC - Space-separated mnemonic for bot wallet
16+
*
17+
* Usage:
18+
* npx tsx scripts/ci-smoke/create-wallets.ts
19+
*/
20+
import { derivePaymentAddress, mnemonicFromEnv, requireEnv } from "./lib/keys";
21+
import { writeContext } from "./lib/context";
22+
import { botAuth, createWallet } from "../bot-ref/bot-client";
23+
import type { Context } from "./scenarios/types";
24+
25+
const REQUIRED_ENV_VARS = [
26+
"API_BASE_URL",
27+
"SIGNER_MNEMONIC_1",
28+
"SIGNER_MNEMONIC_2",
29+
"BOT_MNEMONIC",
30+
"BOT_KEY_ID",
31+
"BOT_SECRET",
32+
];
33+
34+
const NETWORK_ID = 0; // preprod
35+
36+
async function main() {
37+
const missing = REQUIRED_ENV_VARS.filter((v) => !process.env[v]);
38+
if (missing.length > 0) {
39+
console.error("Smoke test skipped: missing env vars:", missing.join(", "));
40+
console.error("Configure the SMOKE_* secrets in GitHub repository settings.");
41+
process.exit(0);
42+
}
43+
44+
const baseUrl = requireEnv("API_BASE_URL");
45+
46+
console.log("Deriving signer addresses...");
47+
const signer1Mnemonic = mnemonicFromEnv("SIGNER_MNEMONIC_1");
48+
const signer2Mnemonic = mnemonicFromEnv("SIGNER_MNEMONIC_2");
49+
const botMnemonic = mnemonicFromEnv("BOT_MNEMONIC");
50+
51+
const [signer1Addr, signer2Addr, botAddr] = await Promise.all([
52+
derivePaymentAddress(signer1Mnemonic, NETWORK_ID),
53+
derivePaymentAddress(signer2Mnemonic, NETWORK_ID),
54+
derivePaymentAddress(botMnemonic, NETWORK_ID),
55+
]);
56+
57+
console.log(`Signer 1: ${signer1Addr}`);
58+
console.log(`Signer 2: ${signer2Addr}`);
59+
console.log(`Bot: ${botAddr}`);
60+
61+
console.log("Authenticating bot...");
62+
const botKeyId = requireEnv("BOT_KEY_ID");
63+
const botSecret = requireEnv("BOT_SECRET");
64+
const { token, botId } = await botAuth({
65+
baseUrl,
66+
botKeyId,
67+
secret: botSecret,
68+
paymentAddress: botAddr,
69+
});
70+
71+
console.log("Creating legacy wallet (2 signers, atLeast 1)...");
72+
const legacy = await createWallet(baseUrl, token, {
73+
name: `CI-Legacy-${Date.now()}`,
74+
description: "CI smoke: legacy 2-of-1",
75+
signersAddresses: [signer1Addr, botAddr],
76+
signersDescriptions: ["Signer1", "Bot"],
77+
numRequiredSigners: 1,
78+
scriptType: "atLeast",
79+
network: NETWORK_ID,
80+
});
81+
console.log(` Legacy wallet: ${legacy.walletId} (${legacy.address})`);
82+
83+
console.log("Creating hierarchical wallet (2 signers + stake/DRep, atLeast 2)...");
84+
const hierarchical = await createWallet(baseUrl, token, {
85+
name: `CI-Hierarchical-${Date.now()}`,
86+
description: "CI smoke: hierarchical 2-of-2 with stake+DRep",
87+
signersAddresses: [signer1Addr, signer2Addr],
88+
signersDescriptions: ["Signer1", "Signer2"],
89+
numRequiredSigners: 2,
90+
scriptType: "atLeast",
91+
network: NETWORK_ID,
92+
});
93+
console.log(` Hierarchical wallet: ${hierarchical.walletId} (${hierarchical.address})`);
94+
95+
console.log("Creating SDK wallet (3 signers, atLeast 2)...");
96+
const sdk = await createWallet(baseUrl, token, {
97+
name: `CI-SDK-${Date.now()}`,
98+
description: "CI smoke: SDK 3-of-2",
99+
signersAddresses: [signer1Addr, signer2Addr, botAddr],
100+
signersDescriptions: ["Signer1", "Signer2", "Bot"],
101+
numRequiredSigners: 2,
102+
scriptType: "atLeast",
103+
network: NETWORK_ID,
104+
});
105+
console.log(` SDK wallet: ${sdk.walletId} (${sdk.address})`);
106+
107+
const ctx: Context = {
108+
version: "1",
109+
baseUrl,
110+
botToken: token,
111+
botId,
112+
botAddress: botAddr,
113+
signerAddresses: [signer1Addr, signer2Addr],
114+
wallets: {
115+
legacy: { id: legacy.walletId, address: legacy.address },
116+
hierarchical: { id: hierarchical.walletId, address: hierarchical.address },
117+
sdk: { id: sdk.walletId, address: sdk.address },
118+
},
119+
};
120+
121+
writeContext(ctx);
122+
console.log("\nBootstrap complete. Context written to ci-artifacts/bootstrap-context.json");
123+
}
124+
125+
main().catch((e) => {
126+
console.error("Bootstrap failed:", e);
127+
process.exit(1);
128+
});

scripts/ci-smoke/lib/context.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Read / write the versioned bootstrap context JSON used between CI stages.
3+
*/
4+
import { readFileSync, writeFileSync, mkdirSync } from "fs";
5+
import { dirname, join } from "path";
6+
import type { Context } from "../scenarios/types";
7+
8+
const ARTIFACTS_DIR = join(process.cwd(), "ci-artifacts");
9+
const CONTEXT_FILE = join(ARTIFACTS_DIR, "bootstrap-context.json");
10+
11+
const CONTEXT_VERSION = "1";
12+
13+
export function writeContext(ctx: Context): void {
14+
mkdirSync(dirname(CONTEXT_FILE), { recursive: true });
15+
writeFileSync(CONTEXT_FILE, JSON.stringify({ ...ctx, version: CONTEXT_VERSION }, null, 2) + "\n");
16+
}
17+
18+
export function readContext(): Context {
19+
const raw = readFileSync(CONTEXT_FILE, "utf8");
20+
const ctx = JSON.parse(raw) as Context;
21+
if (ctx.version !== CONTEXT_VERSION) {
22+
throw new Error(
23+
`Context version mismatch: expected ${CONTEXT_VERSION}, got ${ctx.version}`,
24+
);
25+
}
26+
return ctx;
27+
}

scripts/ci-smoke/lib/keys.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* Derive signer payment addresses from mnemonics using MeshWallet.
3+
*/
4+
5+
export async function derivePaymentAddress(
6+
mnemonic: string[],
7+
networkId: 0 | 1,
8+
): Promise<string> {
9+
const { MeshWallet } = await import("@meshsdk/core");
10+
const wallet = new MeshWallet({
11+
networkId,
12+
key: { type: "mnemonic", words: mnemonic },
13+
});
14+
await wallet.init();
15+
return wallet.getChangeAddress();
16+
}
17+
18+
export function requireEnv(name: string): string {
19+
const value = process.env[name];
20+
if (!value) {
21+
throw new Error(`Missing required env var: ${name}`);
22+
}
23+
return value;
24+
}
25+
26+
export function mnemonicFromEnv(envName: string): string[] {
27+
const raw = requireEnv(envName);
28+
const words = raw.trim().split(/\s+/);
29+
if (words.length < 12) {
30+
throw new Error(`${envName} must contain at least 12 mnemonic words`);
31+
}
32+
return words;
33+
}

scripts/ci-smoke/lib/report.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* Generate human-readable + JSON reports for the CI smoke run.
3+
*/
4+
import { writeFileSync, mkdirSync } from "fs";
5+
import { dirname, join } from "path";
6+
import type { ScenarioResult } from "../scenarios/types";
7+
8+
const ARTIFACTS_DIR = join(process.cwd(), "ci-artifacts");
9+
const REPORT_FILE = join(ARTIFACTS_DIR, "ci-route-chain-report.json");
10+
11+
export interface RunReport {
12+
timestamp: string;
13+
totalScenarios: number;
14+
passed: number;
15+
failed: number;
16+
aborted: boolean;
17+
durationMs: number;
18+
results: ScenarioResult[];
19+
}
20+
21+
export function writeReport(report: RunReport): void {
22+
mkdirSync(dirname(REPORT_FILE), { recursive: true });
23+
writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2) + "\n");
24+
}
25+
26+
export function printSummary(report: RunReport): void {
27+
console.log("\n=== CI Smoke Test Report ===");
28+
console.log(`Timestamp: ${report.timestamp}`);
29+
console.log(`Total: ${report.totalScenarios}`);
30+
console.log(`Passed: ${report.passed}`);
31+
console.log(`Failed: ${report.failed}`);
32+
console.log(`Aborted: ${report.aborted}`);
33+
console.log(`Duration: ${report.durationMs}ms`);
34+
console.log("");
35+
36+
for (const r of report.results) {
37+
const icon = r.passed ? "PASS" : "FAIL";
38+
const crit = r.critical ? " [CRITICAL]" : "";
39+
console.log(` ${icon} ${r.name}${crit} (${r.durationMs}ms) - ${r.message}`);
40+
}
41+
42+
console.log("");
43+
if (report.failed > 0) {
44+
console.log("SMOKE TEST FAILED");
45+
} else {
46+
console.log("SMOKE TEST PASSED");
47+
}
48+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#!/usr/bin/env npx tsx
2+
/**
3+
* Stage 2: Execute the scenario chain against the bootstrapped wallets.
4+
*
5+
* Reads context from ci-artifacts/bootstrap-context.json, runs each scenario
6+
* sequentially, and produces a report at ci-artifacts/ci-route-chain-report.json.
7+
*
8+
* Critical scenario failures abort the chain. Non-critical failures are logged
9+
* but execution continues.
10+
*
11+
* Usage:
12+
* npx tsx scripts/ci-smoke/run-route-chain.ts
13+
*/
14+
import { readContext } from "./lib/context";
15+
import { writeReport, printSummary, type RunReport } from "./lib/report";
16+
import { scenarios } from "./scenarios/manifest";
17+
import type { ScenarioResult } from "./scenarios/types";
18+
19+
async function main() {
20+
const ctx = readContext();
21+
console.log(`Loaded context v${ctx.version} with ${Object.keys(ctx.wallets).length} wallets`);
22+
23+
const results: ScenarioResult[] = [];
24+
let aborted = false;
25+
const startTime = Date.now();
26+
27+
for (const scenario of scenarios) {
28+
let result: ScenarioResult;
29+
try {
30+
result = await scenario(ctx);
31+
} catch (err) {
32+
result = {
33+
name: scenario.name || "unknown",
34+
passed: false,
35+
critical: true,
36+
message: `Unhandled error: ${err instanceof Error ? err.message : String(err)}`,
37+
durationMs: 0,
38+
};
39+
}
40+
41+
results.push(result);
42+
43+
const icon = result.passed ? "PASS" : "FAIL";
44+
const crit = result.critical ? " [CRITICAL]" : "";
45+
console.log(`${icon} ${result.name}${crit} (${result.durationMs}ms)`);
46+
47+
if (!result.passed && result.critical) {
48+
console.error(`Critical failure in "${result.name}": ${result.message}`);
49+
console.error("Aborting scenario chain.");
50+
aborted = true;
51+
break;
52+
}
53+
}
54+
55+
const report: RunReport = {
56+
timestamp: new Date().toISOString(),
57+
totalScenarios: scenarios.length,
58+
passed: results.filter((r) => r.passed).length,
59+
failed: results.filter((r) => !r.passed).length,
60+
aborted,
61+
durationMs: Date.now() - startTime,
62+
results,
63+
};
64+
65+
writeReport(report);
66+
printSummary(report);
67+
68+
if (report.failed > 0) {
69+
process.exit(1);
70+
}
71+
}
72+
73+
main().catch((e) => {
74+
console.error("Route chain failed:", e);
75+
process.exit(1);
76+
});

0 commit comments

Comments
 (0)