From 11d99586259a96b67245331c3a8db8de1b5658a3 Mon Sep 17 00:00:00 2001 From: QSchlegel Date: Sat, 4 Apr 2026 22:23:22 +0100 Subject: [PATCH 1/2] fix: filter extraneous VKey witnesses + add CI smoke test system Fix InvalidWitnessesUTXOW error on ballot vote submission by filtering witnesses whose key hash is not required by any native script in the transaction. Also adds defense-in-depth recovery in submitTxWithScriptRecovery. Create real-chain CI smoke system (issue #213) with 3 stages: - Stage 1: Bootstrap wallets from mnemonic secrets - Stage 2: Run 9 scenario tests against v1 API routes - Stage 3: Produce JSON report artifacts Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci-smoke-preprod.yml | 42 +++++++ scripts/ci-smoke/create-wallets.ts | 112 ++++++++++++++++++ scripts/ci-smoke/lib/context.ts | 27 +++++ scripts/ci-smoke/lib/keys.ts | 33 ++++++ scripts/ci-smoke/lib/report.ts | 48 ++++++++ scripts/ci-smoke/run-route-chain.ts | 76 ++++++++++++ scripts/ci-smoke/scenarios/add-transaction.ts | 68 +++++++++++ scripts/ci-smoke/scenarios/bot-auth.ts | 48 ++++++++ scripts/ci-smoke/scenarios/create-wallet.ts | 66 +++++++++++ scripts/ci-smoke/scenarios/free-utxos.ts | 40 +++++++ scripts/ci-smoke/scenarios/governance.ts | 54 +++++++++ scripts/ci-smoke/scenarios/manifest.ts | 28 +++++ scripts/ci-smoke/scenarios/native-script.ts | 50 ++++++++ scripts/ci-smoke/scenarios/pending-txns.ts | 56 +++++++++ .../ci-smoke/scenarios/sign-transaction.ts | 79 ++++++++++++ scripts/ci-smoke/scenarios/types.ts | 33 ++++++ scripts/ci-smoke/scenarios/wallet-ids.ts | 54 +++++++++ .../wallet/transactions/transaction-card.tsx | 6 +- src/hooks/useTransaction.ts | 7 +- src/utils/txScriptRecovery.ts | 63 ++++++++++ src/utils/txSignUtils.ts | 72 +++++++++++ 21 files changed, 1055 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/ci-smoke-preprod.yml create mode 100644 scripts/ci-smoke/create-wallets.ts create mode 100644 scripts/ci-smoke/lib/context.ts create mode 100644 scripts/ci-smoke/lib/keys.ts create mode 100644 scripts/ci-smoke/lib/report.ts create mode 100644 scripts/ci-smoke/run-route-chain.ts create mode 100644 scripts/ci-smoke/scenarios/add-transaction.ts create mode 100644 scripts/ci-smoke/scenarios/bot-auth.ts create mode 100644 scripts/ci-smoke/scenarios/create-wallet.ts create mode 100644 scripts/ci-smoke/scenarios/free-utxos.ts create mode 100644 scripts/ci-smoke/scenarios/governance.ts create mode 100644 scripts/ci-smoke/scenarios/manifest.ts create mode 100644 scripts/ci-smoke/scenarios/native-script.ts create mode 100644 scripts/ci-smoke/scenarios/pending-txns.ts create mode 100644 scripts/ci-smoke/scenarios/sign-transaction.ts create mode 100644 scripts/ci-smoke/scenarios/types.ts create mode 100644 scripts/ci-smoke/scenarios/wallet-ids.ts diff --git a/.github/workflows/ci-smoke-preprod.yml b/.github/workflows/ci-smoke-preprod.yml new file mode 100644 index 00000000..cfc03e3e --- /dev/null +++ b/.github/workflows/ci-smoke-preprod.yml @@ -0,0 +1,42 @@ +name: CI Smoke (Preprod) + +on: + pull_request: + branches: [main, preprod] + workflow_dispatch: + +env: + API_BASE_URL: ${{ secrets.SMOKE_API_BASE_URL }} + SIGNER_MNEMONIC_1: ${{ secrets.SMOKE_SIGNER_MNEMONIC_1 }} + SIGNER_MNEMONIC_2: ${{ secrets.SMOKE_SIGNER_MNEMONIC_2 }} + BOT_MNEMONIC: ${{ secrets.SMOKE_BOT_MNEMONIC }} + BOT_KEY_ID: ${{ secrets.SMOKE_BOT_KEY_ID }} + BOT_SECRET: ${{ secrets.SMOKE_BOT_SECRET }} + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm ci + + - name: Stage 1 - Bootstrap wallets + run: npx tsx scripts/ci-smoke/create-wallets.ts + + - name: Stage 2 - Run route chain + run: npx tsx scripts/ci-smoke/run-route-chain.ts + + - name: Upload smoke artifacts + uses: actions/upload-artifact@v4 + if: always() + with: + name: smoke-report + path: ci-artifacts/ + retention-days: 14 diff --git a/scripts/ci-smoke/create-wallets.ts b/scripts/ci-smoke/create-wallets.ts new file mode 100644 index 00000000..6f443452 --- /dev/null +++ b/scripts/ci-smoke/create-wallets.ts @@ -0,0 +1,112 @@ +#!/usr/bin/env npx tsx +/** + * Stage 1: Bootstrap CI smoke-test wallets. + * + * Reads mnemonic secrets from env vars, derives payment addresses, + * authenticates the bot, creates 3 wallet variants (legacy, hierarchical, + * SDK-based), and writes versioned context JSON for downstream stages. + * + * Required env vars: + * API_BASE_URL - Base URL of the multisig API + * SIGNER_MNEMONIC_1 - Space-separated mnemonic for signer 1 + * SIGNER_MNEMONIC_2 - Space-separated mnemonic for signer 2 + * BOT_KEY_ID - Bot key ID for authentication + * BOT_SECRET - Bot secret for authentication + * BOT_MNEMONIC - Space-separated mnemonic for bot wallet + * + * Usage: + * npx tsx scripts/ci-smoke/create-wallets.ts + */ +import { derivePaymentAddress, mnemonicFromEnv, requireEnv } from "./lib/keys"; +import { writeContext } from "./lib/context"; +import { botAuth, createWallet } from "../bot-ref/bot-client"; +import type { Context } from "./scenarios/types"; + +const NETWORK_ID = 0; // preprod + +async function main() { + const baseUrl = requireEnv("API_BASE_URL"); + + console.log("Deriving signer addresses..."); + const signer1Mnemonic = mnemonicFromEnv("SIGNER_MNEMONIC_1"); + const signer2Mnemonic = mnemonicFromEnv("SIGNER_MNEMONIC_2"); + const botMnemonic = mnemonicFromEnv("BOT_MNEMONIC"); + + const [signer1Addr, signer2Addr, botAddr] = await Promise.all([ + derivePaymentAddress(signer1Mnemonic, NETWORK_ID), + derivePaymentAddress(signer2Mnemonic, NETWORK_ID), + derivePaymentAddress(botMnemonic, NETWORK_ID), + ]); + + console.log(`Signer 1: ${signer1Addr}`); + console.log(`Signer 2: ${signer2Addr}`); + console.log(`Bot: ${botAddr}`); + + console.log("Authenticating bot..."); + const botKeyId = requireEnv("BOT_KEY_ID"); + const botSecret = requireEnv("BOT_SECRET"); + const { token, botId } = await botAuth({ + baseUrl, + botKeyId, + secret: botSecret, + paymentAddress: botAddr, + }); + + console.log("Creating legacy wallet (2 signers, atLeast 1)..."); + const legacy = await createWallet(baseUrl, token, { + name: `CI-Legacy-${Date.now()}`, + description: "CI smoke: legacy 2-of-1", + signersAddresses: [signer1Addr, botAddr], + signersDescriptions: ["Signer1", "Bot"], + numRequiredSigners: 1, + scriptType: "atLeast", + network: NETWORK_ID, + }); + console.log(` Legacy wallet: ${legacy.walletId} (${legacy.address})`); + + console.log("Creating hierarchical wallet (2 signers + stake/DRep, atLeast 2)..."); + const hierarchical = await createWallet(baseUrl, token, { + name: `CI-Hierarchical-${Date.now()}`, + description: "CI smoke: hierarchical 2-of-2 with stake+DRep", + signersAddresses: [signer1Addr, signer2Addr], + signersDescriptions: ["Signer1", "Signer2"], + numRequiredSigners: 2, + scriptType: "atLeast", + network: NETWORK_ID, + }); + console.log(` Hierarchical wallet: ${hierarchical.walletId} (${hierarchical.address})`); + + console.log("Creating SDK wallet (3 signers, atLeast 2)..."); + const sdk = await createWallet(baseUrl, token, { + name: `CI-SDK-${Date.now()}`, + description: "CI smoke: SDK 3-of-2", + signersAddresses: [signer1Addr, signer2Addr, botAddr], + signersDescriptions: ["Signer1", "Signer2", "Bot"], + numRequiredSigners: 2, + scriptType: "atLeast", + network: NETWORK_ID, + }); + console.log(` SDK wallet: ${sdk.walletId} (${sdk.address})`); + + const ctx: Context = { + version: "1", + baseUrl, + botToken: token, + botId, + botAddress: botAddr, + signerAddresses: [signer1Addr, signer2Addr], + wallets: { + legacy: { id: legacy.walletId, address: legacy.address }, + hierarchical: { id: hierarchical.walletId, address: hierarchical.address }, + sdk: { id: sdk.walletId, address: sdk.address }, + }, + }; + + writeContext(ctx); + console.log("\nBootstrap complete. Context written to ci-artifacts/bootstrap-context.json"); +} + +main().catch((e) => { + console.error("Bootstrap failed:", e); + process.exit(1); +}); diff --git a/scripts/ci-smoke/lib/context.ts b/scripts/ci-smoke/lib/context.ts new file mode 100644 index 00000000..300ffbe3 --- /dev/null +++ b/scripts/ci-smoke/lib/context.ts @@ -0,0 +1,27 @@ +/** + * Read / write the versioned bootstrap context JSON used between CI stages. + */ +import { readFileSync, writeFileSync, mkdirSync } from "fs"; +import { dirname, join } from "path"; +import type { Context } from "../scenarios/types"; + +const ARTIFACTS_DIR = join(process.cwd(), "ci-artifacts"); +const CONTEXT_FILE = join(ARTIFACTS_DIR, "bootstrap-context.json"); + +const CONTEXT_VERSION = "1"; + +export function writeContext(ctx: Context): void { + mkdirSync(dirname(CONTEXT_FILE), { recursive: true }); + writeFileSync(CONTEXT_FILE, JSON.stringify({ ...ctx, version: CONTEXT_VERSION }, null, 2) + "\n"); +} + +export function readContext(): Context { + const raw = readFileSync(CONTEXT_FILE, "utf8"); + const ctx = JSON.parse(raw) as Context; + if (ctx.version !== CONTEXT_VERSION) { + throw new Error( + `Context version mismatch: expected ${CONTEXT_VERSION}, got ${ctx.version}`, + ); + } + return ctx; +} diff --git a/scripts/ci-smoke/lib/keys.ts b/scripts/ci-smoke/lib/keys.ts new file mode 100644 index 00000000..8b8e7ce2 --- /dev/null +++ b/scripts/ci-smoke/lib/keys.ts @@ -0,0 +1,33 @@ +/** + * Derive signer payment addresses from mnemonics using MeshWallet. + */ + +export async function derivePaymentAddress( + mnemonic: string[], + networkId: 0 | 1, +): Promise { + const { MeshWallet } = await import("@meshsdk/core"); + const wallet = new MeshWallet({ + networkId, + key: { type: "mnemonic", words: mnemonic }, + }); + await wallet.init(); + return wallet.getChangeAddress(); +} + +export function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required env var: ${name}`); + } + return value; +} + +export function mnemonicFromEnv(envName: string): string[] { + const raw = requireEnv(envName); + const words = raw.trim().split(/\s+/); + if (words.length < 12) { + throw new Error(`${envName} must contain at least 12 mnemonic words`); + } + return words; +} diff --git a/scripts/ci-smoke/lib/report.ts b/scripts/ci-smoke/lib/report.ts new file mode 100644 index 00000000..e9a309ff --- /dev/null +++ b/scripts/ci-smoke/lib/report.ts @@ -0,0 +1,48 @@ +/** + * Generate human-readable + JSON reports for the CI smoke run. + */ +import { writeFileSync, mkdirSync } from "fs"; +import { dirname, join } from "path"; +import type { ScenarioResult } from "../scenarios/types"; + +const ARTIFACTS_DIR = join(process.cwd(), "ci-artifacts"); +const REPORT_FILE = join(ARTIFACTS_DIR, "ci-route-chain-report.json"); + +export interface RunReport { + timestamp: string; + totalScenarios: number; + passed: number; + failed: number; + aborted: boolean; + durationMs: number; + results: ScenarioResult[]; +} + +export function writeReport(report: RunReport): void { + mkdirSync(dirname(REPORT_FILE), { recursive: true }); + writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2) + "\n"); +} + +export function printSummary(report: RunReport): void { + console.log("\n=== CI Smoke Test Report ==="); + console.log(`Timestamp: ${report.timestamp}`); + console.log(`Total: ${report.totalScenarios}`); + console.log(`Passed: ${report.passed}`); + console.log(`Failed: ${report.failed}`); + console.log(`Aborted: ${report.aborted}`); + console.log(`Duration: ${report.durationMs}ms`); + console.log(""); + + for (const r of report.results) { + const icon = r.passed ? "PASS" : "FAIL"; + const crit = r.critical ? " [CRITICAL]" : ""; + console.log(` ${icon} ${r.name}${crit} (${r.durationMs}ms) - ${r.message}`); + } + + console.log(""); + if (report.failed > 0) { + console.log("SMOKE TEST FAILED"); + } else { + console.log("SMOKE TEST PASSED"); + } +} diff --git a/scripts/ci-smoke/run-route-chain.ts b/scripts/ci-smoke/run-route-chain.ts new file mode 100644 index 00000000..7a90164d --- /dev/null +++ b/scripts/ci-smoke/run-route-chain.ts @@ -0,0 +1,76 @@ +#!/usr/bin/env npx tsx +/** + * Stage 2: Execute the scenario chain against the bootstrapped wallets. + * + * Reads context from ci-artifacts/bootstrap-context.json, runs each scenario + * sequentially, and produces a report at ci-artifacts/ci-route-chain-report.json. + * + * Critical scenario failures abort the chain. Non-critical failures are logged + * but execution continues. + * + * Usage: + * npx tsx scripts/ci-smoke/run-route-chain.ts + */ +import { readContext } from "./lib/context"; +import { writeReport, printSummary, type RunReport } from "./lib/report"; +import { scenarios } from "./scenarios/manifest"; +import type { ScenarioResult } from "./scenarios/types"; + +async function main() { + const ctx = readContext(); + console.log(`Loaded context v${ctx.version} with ${Object.keys(ctx.wallets).length} wallets`); + + const results: ScenarioResult[] = []; + let aborted = false; + const startTime = Date.now(); + + for (const scenario of scenarios) { + let result: ScenarioResult; + try { + result = await scenario(ctx); + } catch (err) { + result = { + name: scenario.name || "unknown", + passed: false, + critical: true, + message: `Unhandled error: ${err instanceof Error ? err.message : String(err)}`, + durationMs: 0, + }; + } + + results.push(result); + + const icon = result.passed ? "PASS" : "FAIL"; + const crit = result.critical ? " [CRITICAL]" : ""; + console.log(`${icon} ${result.name}${crit} (${result.durationMs}ms)`); + + if (!result.passed && result.critical) { + console.error(`Critical failure in "${result.name}": ${result.message}`); + console.error("Aborting scenario chain."); + aborted = true; + break; + } + } + + const report: RunReport = { + timestamp: new Date().toISOString(), + totalScenarios: scenarios.length, + passed: results.filter((r) => r.passed).length, + failed: results.filter((r) => !r.passed).length, + aborted, + durationMs: Date.now() - startTime, + results, + }; + + writeReport(report); + printSummary(report); + + if (report.failed > 0) { + process.exit(1); + } +} + +main().catch((e) => { + console.error("Route chain failed:", e); + process.exit(1); +}); diff --git a/scripts/ci-smoke/scenarios/add-transaction.ts b/scripts/ci-smoke/scenarios/add-transaction.ts new file mode 100644 index 00000000..27761bc3 --- /dev/null +++ b/scripts/ci-smoke/scenarios/add-transaction.ts @@ -0,0 +1,68 @@ +import type { Context, ScenarioResult } from "./types"; + +/** + * Submits a minimal placeholder transaction via the addTransaction API. + * This validates the route accepts and stores the transaction, not that it is + * blockchain-valid (we don't have funded UTxOs in CI by default). + */ +export async function addTransactionScenario(ctx: Context): Promise { + const start = Date.now(); + try { + const base = ctx.baseUrl.replace(/\/$/, ""); + + // Minimal tx CBOR placeholder (empty body). The API stores whatever CBOR + // is given — real chain validation happens only at submission. + const placeholderTxCbor = "84a400800180020000a0f5f6"; + const placeholderTxJson = JSON.stringify({ + type: "Tx ConwayEra", + description: "CI smoke test placeholder", + }); + + const res = await fetch(`${base}/api/v1/addTransaction`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${ctx.botToken}`, + }, + body: JSON.stringify({ + walletId: ctx.wallets.sdk.id, + address: ctx.botAddress, + txCbor: placeholderTxCbor, + txJson: placeholderTxJson, + description: "CI smoke add-transaction test", + }), + }); + + if (!res.ok) { + const text = await res.text(); + return { + name: "add-transaction", + passed: false, + critical: true, + message: `addTransaction returned ${res.status}: ${text}`, + durationMs: Date.now() - start, + }; + } + + const data = (await res.json()) as { id?: string }; + if (data.id) { + ctx.pendingTxId = data.id; + } + + return { + name: "add-transaction", + passed: true, + critical: true, + message: `Transaction created: ${data.id ?? "ok"}`, + durationMs: Date.now() - start, + }; + } catch (err) { + return { + name: "add-transaction", + passed: false, + critical: true, + message: err instanceof Error ? err.message : String(err), + durationMs: Date.now() - start, + }; + } +} diff --git a/scripts/ci-smoke/scenarios/bot-auth.ts b/scripts/ci-smoke/scenarios/bot-auth.ts new file mode 100644 index 00000000..e5909d14 --- /dev/null +++ b/scripts/ci-smoke/scenarios/bot-auth.ts @@ -0,0 +1,48 @@ +import type { Context, ScenarioResult } from "./types"; +import { botAuth } from "../../bot-ref/bot-client"; +import { requireEnv } from "../lib/keys"; + +export async function botAuthScenario(ctx: Context): Promise { + const start = Date.now(); + try { + const botKeyId = requireEnv("BOT_KEY_ID"); + const botSecret = requireEnv("BOT_SECRET"); + + const { token, botId } = await botAuth({ + baseUrl: ctx.baseUrl, + botKeyId, + secret: botSecret, + paymentAddress: ctx.botAddress, + }); + + if (!token || typeof token !== "string") { + return { + name: "bot-auth", + passed: false, + critical: true, + message: "botAuth returned no token", + durationMs: Date.now() - start, + }; + } + + // Refresh token in context for downstream scenarios + ctx.botToken = token; + ctx.botId = botId; + + return { + name: "bot-auth", + passed: true, + critical: true, + message: `Authenticated as bot ${botId}`, + durationMs: Date.now() - start, + }; + } catch (err) { + return { + name: "bot-auth", + passed: false, + critical: true, + message: err instanceof Error ? err.message : String(err), + durationMs: Date.now() - start, + }; + } +} diff --git a/scripts/ci-smoke/scenarios/create-wallet.ts b/scripts/ci-smoke/scenarios/create-wallet.ts new file mode 100644 index 00000000..aaa52711 --- /dev/null +++ b/scripts/ci-smoke/scenarios/create-wallet.ts @@ -0,0 +1,66 @@ +import type { Context, ScenarioResult } from "./types"; +import { createWallet } from "../../bot-ref/bot-client"; + +export async function createWalletScenario(ctx: Context): Promise { + const start = Date.now(); + try { + // Verify all 3 wallet variants were bootstrapped with valid addresses + for (const [variant, wallet] of Object.entries(ctx.wallets)) { + if (!wallet.id || !wallet.address) { + return { + name: "create-wallet", + passed: false, + critical: true, + message: `Missing ${variant} wallet in context`, + durationMs: Date.now() - start, + }; + } + if (!wallet.address.startsWith("addr")) { + return { + name: "create-wallet", + passed: false, + critical: true, + message: `${variant} wallet address invalid: ${wallet.address}`, + durationMs: Date.now() - start, + }; + } + } + + // Create an additional test wallet to verify the route works in this session + const testWallet = await createWallet(ctx.baseUrl, ctx.botToken, { + name: `CI-Verify-${Date.now()}`, + description: "CI smoke: create-wallet verification", + signersAddresses: [ctx.signerAddresses[0]!, ctx.botAddress], + signersDescriptions: ["Signer1", "Bot"], + numRequiredSigners: 1, + scriptType: "atLeast", + network: 0, + }); + + if (!testWallet.walletId || !testWallet.address.startsWith("addr")) { + return { + name: "create-wallet", + passed: false, + critical: true, + message: `Created wallet has invalid data: ${JSON.stringify(testWallet)}`, + durationMs: Date.now() - start, + }; + } + + return { + name: "create-wallet", + passed: true, + critical: true, + message: `3 bootstrap wallets valid + 1 verification wallet created (${testWallet.walletId})`, + durationMs: Date.now() - start, + }; + } catch (err) { + return { + name: "create-wallet", + passed: false, + critical: true, + message: err instanceof Error ? err.message : String(err), + durationMs: Date.now() - start, + }; + } +} diff --git a/scripts/ci-smoke/scenarios/free-utxos.ts b/scripts/ci-smoke/scenarios/free-utxos.ts new file mode 100644 index 00000000..fb5de749 --- /dev/null +++ b/scripts/ci-smoke/scenarios/free-utxos.ts @@ -0,0 +1,40 @@ +import type { Context, ScenarioResult } from "./types"; +import { getFreeUtxos } from "../../bot-ref/bot-client"; + +export async function freeUtxosScenario(ctx: Context): Promise { + const start = Date.now(); + try { + const utxos = await getFreeUtxos( + ctx.baseUrl, + ctx.botToken, + ctx.wallets.legacy.id, + ctx.botAddress, + ); + + if (!Array.isArray(utxos)) { + return { + name: "free-utxos", + passed: false, + critical: false, + message: "freeUtxos did not return an array", + durationMs: Date.now() - start, + }; + } + + return { + name: "free-utxos", + passed: true, + critical: false, + message: `Found ${utxos.length} UTxOs for legacy wallet`, + durationMs: Date.now() - start, + }; + } catch (err) { + return { + name: "free-utxos", + passed: false, + critical: false, + message: err instanceof Error ? err.message : String(err), + durationMs: Date.now() - start, + }; + } +} diff --git a/scripts/ci-smoke/scenarios/governance.ts b/scripts/ci-smoke/scenarios/governance.ts new file mode 100644 index 00000000..bbce4a4e --- /dev/null +++ b/scripts/ci-smoke/scenarios/governance.ts @@ -0,0 +1,54 @@ +import type { Context, ScenarioResult } from "./types"; + +export async function governanceScenario(ctx: Context): Promise { + const start = Date.now(); + try { + const base = ctx.baseUrl.replace(/\/$/, ""); + const res = await fetch(`${base}/api/v1/governanceActiveProposals`, { + headers: { Authorization: `Bearer ${ctx.botToken}` }, + }); + + if (!res.ok) { + return { + name: "governance", + passed: false, + critical: false, + message: `governanceActiveProposals returned ${res.status}: ${await res.text()}`, + durationMs: Date.now() - start, + }; + } + + const data = (await res.json()) as { proposals?: unknown[] } | unknown[]; + const proposals = Array.isArray(data) + ? data + : Array.isArray((data as { proposals?: unknown[] }).proposals) + ? (data as { proposals: unknown[] }).proposals + : null; + + if (proposals === null) { + return { + name: "governance", + passed: false, + critical: false, + message: "governanceActiveProposals did not return proposals array", + durationMs: Date.now() - start, + }; + } + + return { + name: "governance", + passed: true, + critical: false, + message: `Found ${proposals.length} active proposals`, + durationMs: Date.now() - start, + }; + } catch (err) { + return { + name: "governance", + passed: false, + critical: false, + message: err instanceof Error ? err.message : String(err), + durationMs: Date.now() - start, + }; + } +} diff --git a/scripts/ci-smoke/scenarios/manifest.ts b/scripts/ci-smoke/scenarios/manifest.ts new file mode 100644 index 00000000..65115f95 --- /dev/null +++ b/scripts/ci-smoke/scenarios/manifest.ts @@ -0,0 +1,28 @@ +/** + * Scenario manifest: defines the execution order of smoke-test scenarios. + * + * Critical scenarios abort the chain on failure. + * Non-critical scenarios log failures but allow the chain to continue. + */ +import type { Scenario } from "./types"; +import { botAuthScenario } from "./bot-auth"; +import { createWalletScenario } from "./create-wallet"; +import { walletIdsScenario } from "./wallet-ids"; +import { nativeScriptScenario } from "./native-script"; +import { freeUtxosScenario } from "./free-utxos"; +import { addTransactionScenario } from "./add-transaction"; +import { pendingTxnsScenario } from "./pending-txns"; +import { signTransactionScenario } from "./sign-transaction"; +import { governanceScenario } from "./governance"; + +export const scenarios: Scenario[] = [ + botAuthScenario, + createWalletScenario, + walletIdsScenario, + nativeScriptScenario, + freeUtxosScenario, + addTransactionScenario, + pendingTxnsScenario, + signTransactionScenario, + governanceScenario, +]; diff --git a/scripts/ci-smoke/scenarios/native-script.ts b/scripts/ci-smoke/scenarios/native-script.ts new file mode 100644 index 00000000..33ad1ef5 --- /dev/null +++ b/scripts/ci-smoke/scenarios/native-script.ts @@ -0,0 +1,50 @@ +import type { Context, ScenarioResult } from "./types"; + +export async function nativeScriptScenario(ctx: Context): Promise { + const start = Date.now(); + try { + const walletId = ctx.wallets.legacy.id; + const base = ctx.baseUrl.replace(/\/$/, ""); + const res = await fetch( + `${base}/api/v1/nativeScript?walletId=${encodeURIComponent(walletId)}`, + { headers: { Authorization: `Bearer ${ctx.botToken}` } }, + ); + + if (!res.ok) { + return { + name: "native-script", + passed: false, + critical: false, + message: `nativeScript returned ${res.status}: ${await res.text()}`, + durationMs: Date.now() - start, + }; + } + + const data = (await res.json()) as { scriptCbor?: string; address?: string }; + if (!data.scriptCbor || typeof data.scriptCbor !== "string") { + return { + name: "native-script", + passed: false, + critical: false, + message: "nativeScript response missing scriptCbor", + durationMs: Date.now() - start, + }; + } + + return { + name: "native-script", + passed: true, + critical: false, + message: `Script CBOR length: ${data.scriptCbor.length}`, + durationMs: Date.now() - start, + }; + } catch (err) { + return { + name: "native-script", + passed: false, + critical: false, + message: err instanceof Error ? err.message : String(err), + durationMs: Date.now() - start, + }; + } +} diff --git a/scripts/ci-smoke/scenarios/pending-txns.ts b/scripts/ci-smoke/scenarios/pending-txns.ts new file mode 100644 index 00000000..c749a2fd --- /dev/null +++ b/scripts/ci-smoke/scenarios/pending-txns.ts @@ -0,0 +1,56 @@ +import type { Context, ScenarioResult } from "./types"; +import { getPendingTransactions } from "../../bot-ref/bot-client"; + +export async function pendingTxnsScenario(ctx: Context): Promise { + const start = Date.now(); + try { + const txns = await getPendingTransactions( + ctx.baseUrl, + ctx.botToken, + ctx.wallets.sdk.id, + ctx.botAddress, + ); + + if (!Array.isArray(txns)) { + return { + name: "pending-txns", + passed: false, + critical: false, + message: "pendingTransactions did not return an array", + durationMs: Date.now() - start, + }; + } + + // If add-transaction created a tx, it should appear here + if (ctx.pendingTxId) { + const found = txns.some( + (tx) => (tx as { id?: string }).id === ctx.pendingTxId, + ); + if (!found) { + return { + name: "pending-txns", + passed: false, + critical: false, + message: `Expected pending tx ${ctx.pendingTxId} not found in ${txns.length} results`, + durationMs: Date.now() - start, + }; + } + } + + return { + name: "pending-txns", + passed: true, + critical: false, + message: `Found ${txns.length} pending transactions`, + durationMs: Date.now() - start, + }; + } catch (err) { + return { + name: "pending-txns", + passed: false, + critical: false, + message: err instanceof Error ? err.message : String(err), + durationMs: Date.now() - start, + }; + } +} diff --git a/scripts/ci-smoke/scenarios/sign-transaction.ts b/scripts/ci-smoke/scenarios/sign-transaction.ts new file mode 100644 index 00000000..b0e27997 --- /dev/null +++ b/scripts/ci-smoke/scenarios/sign-transaction.ts @@ -0,0 +1,79 @@ +import type { Context, ScenarioResult } from "./types"; + +/** + * Validates the signTransaction API route by sending a co-sign request. + * This verifies the route accepts and processes witness data. + * + * Note: Without a real funded UTxO and valid key pair this will exercise + * the validation path but may not achieve a full on-chain submit. The goal + * is to validate the API route doesn't 500 and returns the expected shape. + */ +export async function signTransactionScenario(ctx: Context): Promise { + const start = Date.now(); + try { + if (!ctx.pendingTxId) { + return { + name: "sign-transaction", + passed: false, + critical: true, + message: "No pending transaction ID in context (add-transaction must run first)", + durationMs: Date.now() - start, + }; + } + + const base = ctx.baseUrl.replace(/\/$/, ""); + + // We send a dummy signature. The API will validate the key/signature + // and return an error — but the important thing is that the route + // responds correctly (doesn't 500) and returns a structured error. + const dummyKey = "a".repeat(64); + const dummySig = "b".repeat(128); + + const res = await fetch(`${base}/api/v1/signTransaction`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${ctx.botToken}`, + }, + body: JSON.stringify({ + walletId: ctx.wallets.sdk.id, + transactionId: ctx.pendingTxId, + address: ctx.botAddress, + key: dummyKey, + signature: dummySig, + broadcast: "false", + }), + }); + + // We expect a 4xx validation error (not 500), since dummy key/sig won't match + if (res.status >= 500) { + return { + name: "sign-transaction", + passed: false, + critical: true, + message: `signTransaction returned server error ${res.status}: ${await res.text()}`, + durationMs: Date.now() - start, + }; + } + + const data = await res.json(); + + // 4xx with structured error is expected (key mismatch) + // 2xx would mean the dummy worked (unlikely but fine) + return { + name: "sign-transaction", + passed: true, + critical: true, + message: `signTransaction responded ${res.status} (expected validation error with dummy key)`, + durationMs: Date.now() - start, + }; + } catch (err) { + return { + name: "sign-transaction", + passed: false, + critical: true, + message: err instanceof Error ? err.message : String(err), + durationMs: Date.now() - start, + }; + } +} diff --git a/scripts/ci-smoke/scenarios/types.ts b/scripts/ci-smoke/scenarios/types.ts new file mode 100644 index 00000000..5b001b78 --- /dev/null +++ b/scripts/ci-smoke/scenarios/types.ts @@ -0,0 +1,33 @@ +/** + * Shared types for the CI smoke-test scenario chain. + */ + +/** Mutable context that accumulates data as scenarios execute. */ +export interface Context { + version: string; + baseUrl: string; + botToken: string; + botId: string; + botAddress: string; + signerAddresses: string[]; + wallets: { + legacy: { id: string; address: string }; + hierarchical: { id: string; address: string }; + sdk: { id: string; address: string }; + }; + /** Populated by add-transaction scenario. */ + pendingTxId?: string; + /** Populated by sign-transaction scenario. */ + signedTxHash?: string; +} + +export interface ScenarioResult { + name: string; + passed: boolean; + /** If true, chain aborts on failure. */ + critical: boolean; + message: string; + durationMs: number; +} + +export type Scenario = (ctx: Context) => Promise; diff --git a/scripts/ci-smoke/scenarios/wallet-ids.ts b/scripts/ci-smoke/scenarios/wallet-ids.ts new file mode 100644 index 00000000..cf0e916b --- /dev/null +++ b/scripts/ci-smoke/scenarios/wallet-ids.ts @@ -0,0 +1,54 @@ +import type { Context, ScenarioResult } from "./types"; +import { getWalletIds } from "../../bot-ref/bot-client"; + +export async function walletIdsScenario(ctx: Context): Promise { + const start = Date.now(); + try { + const wallets = await getWalletIds( + ctx.baseUrl, + ctx.botToken, + ctx.botAddress, + ); + + if (!Array.isArray(wallets)) { + return { + name: "wallet-ids", + passed: false, + critical: true, + message: "walletIds did not return an array", + durationMs: Date.now() - start, + }; + } + + // Bot should be a signer on at least the legacy and sdk wallets + const walletIdSet = new Set(wallets.map((w) => w.walletId)); + const expectedIds = [ctx.wallets.legacy.id, ctx.wallets.sdk.id]; + const missing = expectedIds.filter((id) => !walletIdSet.has(id)); + + if (missing.length > 0) { + return { + name: "wallet-ids", + passed: false, + critical: true, + message: `Bot missing expected wallets: ${missing.join(", ")}`, + durationMs: Date.now() - start, + }; + } + + return { + name: "wallet-ids", + passed: true, + critical: true, + message: `Found ${wallets.length} wallets for bot address`, + durationMs: Date.now() - start, + }; + } catch (err) { + return { + name: "wallet-ids", + passed: false, + critical: true, + message: err instanceof Error ? err.message : String(err), + durationMs: Date.now() - start, + }; + } +} diff --git a/src/components/pages/wallet/transactions/transaction-card.tsx b/src/components/pages/wallet/transactions/transaction-card.tsx index 6a921725..051a0d2f 100644 --- a/src/components/pages/wallet/transactions/transaction-card.tsx +++ b/src/components/pages/wallet/transactions/transaction-card.tsx @@ -57,6 +57,7 @@ import { get } from "http"; import { getProvider } from "@/utils/get-provider"; import { useSiteStore } from "@/lib/zustand/site"; import { + filterWitnessesToScripts, mergeSignerWitnesses, shouldSubmitMultisigTx, submitTxWithScriptRecovery, @@ -245,9 +246,8 @@ export default function TransactionCard({ const signerWitnessPayload = await activeWallet.signTx(transaction.txCbor, true); - let signedTx = mergeSignerWitnesses( - transaction.txCbor, - signerWitnessPayload, + let signedTx = filterWitnessesToScripts( + mergeSignerWitnesses(transaction.txCbor, signerWitnessPayload), ); // sanity check diff --git a/src/hooks/useTransaction.ts b/src/hooks/useTransaction.ts index 78c12894..31091d00 100644 --- a/src/hooks/useTransaction.ts +++ b/src/hooks/useTransaction.ts @@ -7,6 +7,7 @@ import { MeshTxBuilder } from "@meshsdk/core"; import { csl } from "@meshsdk/core-csl"; import useActiveWallet from "./useActiveWallet"; import { + filterWitnessesToScripts, mergeSignerWitnesses, shouldSubmitMultisigTx, submitTxWithScriptRecovery, @@ -242,11 +243,9 @@ export default function useTransaction() { } const signerWitnessPayload = await activeWallet.signTx(unsignedTx, true); - let signedTx = mergeSignerWitnesses( - unsignedTx, - signerWitnessPayload, + let signedTx = filterWitnessesToScripts( + mergeSignerWitnesses(unsignedTx, signerWitnessPayload), ); - const signedAddresses = []; signedAddresses.push(userAddress); diff --git a/src/utils/txScriptRecovery.ts b/src/utils/txScriptRecovery.ts index 07e4c43f..d94c3e44 100644 --- a/src/utils/txScriptRecovery.ts +++ b/src/utils/txScriptRecovery.ts @@ -188,6 +188,53 @@ function hasValueNotConservedFailure(error: unknown): boolean { return extractErrorMessage(error).includes("ValueNotConservedUTxO"); } +function hasInvalidWitnessFailure(error: unknown): boolean { + return extractErrorMessage(error).includes("InvalidWitnessesUTXOW"); +} + +function extractInvalidWitnessVKeys(error: unknown): string[] { + const message = extractErrorMessage(error); + const markerIndex = message.indexOf("InvalidWitnessesUTXOW"); + if (markerIndex < 0) return []; + const tail = message.slice(markerIndex); + const matches = tail.matchAll(/VerKeyEd25519DSIGN\s+"([0-9a-fA-F]+)"/g); + return Array.from(matches, (m) => m[1]!.toLowerCase()); +} + +function removeVKeyWitnessesByPublicKey( + txHex: string, + publicKeysToRemove: Set, +): string { + const tx = csl.Transaction.from_hex(txHex); + const witnessSet = tx.witness_set(); + const existingVkeys = witnessSet.vkeys(); + if (!existingVkeys || existingVkeys.len() === 0) return txHex; + + const filteredVkeys = csl.Vkeywitnesses.new(); + for (let i = 0; i < existingVkeys.len(); i++) { + const w = existingVkeys.get(i); + const pubKeyHex = bytesToHex(w.vkey().public_key().as_bytes()).toLowerCase(); + if (!publicKeysToRemove.has(pubKeyHex)) { + filteredVkeys.add(w); + } + } + + const witnessSetClone = csl.TransactionWitnessSet.from_bytes( + witnessSet.to_bytes(), + ); + witnessSetClone.set_vkeys(filteredVkeys); + + const rebuiltTx = csl.Transaction.new( + csl.TransactionBody.from_bytes(tx.body().to_bytes()), + witnessSetClone, + tx.auxiliary_data(), + ); + if (!tx.is_valid()) { + rebuiltTx.set_is_valid(false); + } + return rebuiltTx.to_hex(); +} + function buildStaleInputError(error: unknown): Error { const original = extractErrorMessage(error); return new Error( @@ -451,6 +498,22 @@ export async function submitTxWithScriptRecovery({ } catch (submitError) { throwIfUnrecoverableSubmitError(submitError); + if (hasInvalidWitnessFailure(submitError)) { + const invalidVKeys = extractInvalidWitnessVKeys(submitError); + if (invalidVKeys.length > 0) { + const repairedTx = removeVKeyWitnessesByPublicKey( + txHex, + new Set(invalidVKeys), + ); + try { + const txHash = await submitter.submitTx(repairedTx); + return { txHash, txHex: repairedTx, repaired: true }; + } catch (retryError) { + throwIfUnrecoverableSubmitError(retryError); + } + } + } + if (!appWallet || network === undefined) { throw submitError; } diff --git a/src/utils/txSignUtils.ts b/src/utils/txSignUtils.ts index a856c7e0..7935fde4 100644 --- a/src/utils/txSignUtils.ts +++ b/src/utils/txSignUtils.ts @@ -1,4 +1,8 @@ import { csl } from "@meshsdk/core-csl"; +import { + decodeNativeScriptFromCsl, + collectSigKeyHashes, +} from "@/utils/nativeScriptUtils"; function toKeyHashHex(publicKey: csl.PublicKey): string { return Array.from(publicKey.hash().to_bytes()) @@ -157,6 +161,74 @@ export function mergeSignerWitnesses( return mergedTx.to_hex(); } +/** + * Removes VKey witnesses whose key hash is not required by any native script + * in the transaction's witness set. This prevents `InvalidWitnessesUTXOW` + * rejections from the Conway ledger when a wallet returns extraneous witnesses + * during partial signing. + * + * If the transaction contains no native scripts (non-multisig), it is returned + * unchanged. + */ +export function filterWitnessesToScripts(txHex: string): string { + const tx = csl.Transaction.from_hex(txHex); + const witnessSet = tx.witness_set(); + + const nativeScripts = witnessSet.native_scripts(); + if (!nativeScripts || nativeScripts.len() === 0) { + return txHex; + } + + const allowedKeyHashes = new Set(); + for (let i = 0; i < nativeScripts.len(); i++) { + const decoded = decodeNativeScriptFromCsl(nativeScripts.get(i)); + for (const kh of collectSigKeyHashes(decoded)) { + allowedKeyHashes.add(kh.toLowerCase()); + } + } + + if (allowedKeyHashes.size === 0) { + return txHex; + } + + const existingVkeys = witnessSet.vkeys(); + if (!existingVkeys || existingVkeys.len() === 0) { + return txHex; + } + + const filteredVkeys = csl.Vkeywitnesses.new(); + let removed = 0; + for (let i = 0; i < existingVkeys.len(); i++) { + const w = existingVkeys.get(i); + const kh = toKeyHashHex(w.vkey().public_key()); + if (allowedKeyHashes.has(kh)) { + filteredVkeys.add(w); + } else { + removed += 1; + } + } + + if (removed === 0) { + return txHex; + } + + const witnessSetClone = csl.TransactionWitnessSet.from_bytes( + witnessSet.to_bytes(), + ); + witnessSetClone.set_vkeys(filteredVkeys); + + const filteredTx = csl.Transaction.new( + csl.TransactionBody.from_bytes(tx.body().to_bytes()), + witnessSetClone, + tx.auxiliary_data(), + ); + if (!tx.is_valid()) { + filteredTx.set_is_valid(false); + } + + return filteredTx.to_hex(); +} + export { buildLegacyStylePaymentScriptCbor, buildSerializedNativeScriptCbor, From dc49af251570d4ac50cc2c357bcd24017a2a047e Mon Sep 17 00:00:00 2001 From: QSchlegel Date: Sat, 4 Apr 2026 22:33:49 +0100 Subject: [PATCH 2/2] fix: skip CI smoke gracefully when secrets are not configured The workflow now checks for SMOKE_* secrets before running and skips cleanly if they are missing. The bootstrap script also exits 0 with a message instead of failing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci-smoke-preprod.yml | 15 ++++++++++++++- scripts/ci-smoke/create-wallets.ts | 16 ++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-smoke-preprod.yml b/.github/workflows/ci-smoke-preprod.yml index cfc03e3e..548202d6 100644 --- a/.github/workflows/ci-smoke-preprod.yml +++ b/.github/workflows/ci-smoke-preprod.yml @@ -24,18 +24,31 @@ jobs: with: node-version: 20 + - name: Check secrets configured + id: check-secrets + run: | + if [ -z "$API_BASE_URL" ]; then + echo "configured=false" >> "$GITHUB_OUTPUT" + echo "Smoke test skipped: SMOKE_* secrets not configured." + else + echo "configured=true" >> "$GITHUB_OUTPUT" + fi + - name: Install dependencies + if: steps.check-secrets.outputs.configured == 'true' run: npm ci - name: Stage 1 - Bootstrap wallets + if: steps.check-secrets.outputs.configured == 'true' run: npx tsx scripts/ci-smoke/create-wallets.ts - name: Stage 2 - Run route chain + if: steps.check-secrets.outputs.configured == 'true' run: npx tsx scripts/ci-smoke/run-route-chain.ts - name: Upload smoke artifacts uses: actions/upload-artifact@v4 - if: always() + if: always() && steps.check-secrets.outputs.configured == 'true' with: name: smoke-report path: ci-artifacts/ diff --git a/scripts/ci-smoke/create-wallets.ts b/scripts/ci-smoke/create-wallets.ts index 6f443452..883b37ed 100644 --- a/scripts/ci-smoke/create-wallets.ts +++ b/scripts/ci-smoke/create-wallets.ts @@ -22,9 +22,25 @@ import { writeContext } from "./lib/context"; import { botAuth, createWallet } from "../bot-ref/bot-client"; import type { Context } from "./scenarios/types"; +const REQUIRED_ENV_VARS = [ + "API_BASE_URL", + "SIGNER_MNEMONIC_1", + "SIGNER_MNEMONIC_2", + "BOT_MNEMONIC", + "BOT_KEY_ID", + "BOT_SECRET", +]; + const NETWORK_ID = 0; // preprod async function main() { + const missing = REQUIRED_ENV_VARS.filter((v) => !process.env[v]); + if (missing.length > 0) { + console.error("Smoke test skipped: missing env vars:", missing.join(", ")); + console.error("Configure the SMOKE_* secrets in GitHub repository settings."); + process.exit(0); + } + const baseUrl = requireEnv("API_BASE_URL"); console.log("Deriving signer addresses...");