diff --git a/.github/workflows/ci-smoke-preprod.yml b/.github/workflows/ci-smoke-preprod.yml new file mode 100644 index 00000000..548202d6 --- /dev/null +++ b/.github/workflows/ci-smoke-preprod.yml @@ -0,0 +1,55 @@ +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: 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() && steps.check-secrets.outputs.configured == 'true' + 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..883b37ed --- /dev/null +++ b/scripts/ci-smoke/create-wallets.ts @@ -0,0 +1,128 @@ +#!/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 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..."); + 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,