From 7cccf15ac84ad1441fbbfc3a7b8af5d92316775c Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Sat, 29 Aug 2026 08:25:24 +0200 Subject: [PATCH] test(cli): end-to-end coverage for witness exchange The multi-signature flow had unit tests for the blob format but nothing ran the commands themselves. The submit path in particular was migrated to the LEZ v0.2.4 WalletCore API compile-checked only. Add scripts/multisig-e2e-test.sh, which drives the full round trip against a live sequencer. Wallet A exports a partial transaction with --export and --co-signer, wallet B signs it with spel sign and submits it with spel submit, and the transaction must confirm. Both wallets are created from scratch in the work directory, so the flow proves the co-signer needs no key of the exporter. Two failing paths are asserted along the way: submitting while a witness is still missing, and submitting a blob whose message was tampered with after signing. The script starts its own sequencer on port 3044 and never touches a running stack. Add spel-cli/tests/exchange_flow.rs with four binary-level tests for sign and submit failure paths. All four reject before the wallet is touched, so they run without a wallet home or sequencer and stay deterministic. Wire the script into CI as multisig-e2e-test, mirroring the init E2E job with the LEZ tag pinned to the framework's pinned revision. Drop an unused import from exchange.rs. --- .github/workflows/ci.yml | 75 ++++++ scripts/multisig-e2e-test.sh | 402 ++++++++++++++++++++++++++++++++ spel-cli/src/exchange.rs | 1 - spel-cli/tests/exchange_flow.rs | 157 +++++++++++++ 4 files changed, 634 insertions(+), 1 deletion(-) create mode 100755 scripts/multisig-e2e-test.sh create mode 100644 spel-cli/tests/exchange_flow.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bd198b2..d56a720b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -334,6 +334,81 @@ jobs: export PATH="/tmp/lssa/target/release:$PATH" scripts/ffi-call-test.sh /tmp/ffi-call-test + multisig-e2e-test: + name: Multisig E2E (witness exchange) + needs: [sequencer-setup] + runs-on: ubuntu-latest + timeout-minutes: 35 + env: + RISC0_VERSION: "3.0.5" + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: dtolnay/rust-toolchain@stable + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev + + - name: Clone LEZ at correct revision + run: | + rm -rf /tmp/lssa + git clone https://github.com/logos-blockchain/logos-execution-zone.git /tmp/lssa + cd /tmp/lssa + git fetch --depth 1 origin ${{ needs.sequencer-setup.outputs.lez-rev }} + git checkout ${{ needs.sequencer-setup.outputs.lez-rev }} + + - name: Restore sequencer cache + uses: actions/cache@v4 + with: + path: /tmp/lssa/target/release/sequencer_service + key: sequencer-${{ needs.sequencer-setup.outputs.lez-rev }} + + - name: Restore wallet cache + uses: actions/cache@v4 + with: + path: /tmp/lssa/target/release/wallet + key: wallet-${{ needs.sequencer-setup.outputs.lez-rev }} + + - name: Install logos-blockchain-circuits + run: | + curl -sSL https://raw.githubusercontent.com/logos-blockchain/logos-blockchain/1da154c74b911318fb853d37261f8a05ffe513b4/scripts/setup-logos-blockchain-circuits.sh | bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Restore spel CLI cache (PR-specific) + uses: actions/cache@v4 + with: + path: /tmp/lssa/target/release/spel + key: spel-cli-${{ needs.sequencer-setup.outputs.lez-rev }}-pr${{ github.event.pull_request.number || 'main' }}-${{ github.run_id }} + + - name: Determine SPEL ref for testing + id: spel-ref + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + SPEL_REF="refs/pull/${{ github.event.pull_request.number }}/head" + else + SPEL_REF="${{ github.sha }}" + fi + echo "SPEL_REF=$SPEL_REF" >> $GITHUB_OUTPUT + + - name: Install risc0 toolchain + run: | + curl -L https://risc0.com/install | bash + echo "$HOME/.risc0/bin" >> $GITHUB_PATH + export PATH="$HOME/.risc0/bin:$PATH" + rzup install cargo-risczero ${{ env.RISC0_VERSION }} + rzup install rust + + - name: Run multisig E2E test + env: + LSSA_DIR: /tmp/lssa + LEZ_TAG: ${{ needs.sequencer-setup.outputs.lez-tag }} + SPEL_TAG: ${{ steps.spel-ref.outputs.SPEL_REF }} + SPEL_GIT: ${{ github.server_url }}/${{ github.repository }}.git + run: | + export PATH="/tmp/lssa/target/release:$PATH" + scripts/multisig-e2e-test.sh /tmp/multisig-e2e-test + init-e2e-test: name: Init E2E (default flags) needs: [sequencer-setup] diff --git a/scripts/multisig-e2e-test.sh b/scripts/multisig-e2e-test.sh new file mode 100755 index 00000000..2ab42d05 --- /dev/null +++ b/scripts/multisig-e2e-test.sh @@ -0,0 +1,402 @@ +#!/usr/bin/env bash +# Multisig E2E Test (witness exchange) +# Exercises the export → sign → submit round trip against a live sequencer, +# with two freshly created wallets standing in for the two signers: +# - wallet A builds a partial transaction with --export and --co-signer +# - wallet B signs it with `spel sign` +# - wallet B submits it with `spel submit` and the TX must confirm +# +# Failing paths asserted along the way: +# - `spel submit` while a witness is still missing is rejected +# - `spel submit` of a blob tampered with after signing is rejected +# +# The sequencer is started by this script on its own port; it never touches +# an already-running stack. Both wallets are created from scratch in WORK_DIR +# so the flow proves key isolation: wallet B holds no key of wallet A. +# +# Usage: ./multisig-e2e-test.sh [WORK_DIR] +# +# Required Environment Variables: +# LEZ_TAG - LEZ revision/tag to test against +# LSSA_DIR - Path to logos-execution-zone directory with sequencer built +# Optional Environment Variables: +# SPEL_TAG - SPEL revision for init (e.g. refs/pull/XXX/head) +# SPEL_GIT - SPEL git URL for init (fork testing) +# SPEL_BIN - Path to the spel binary (default /tmp/lssa/target/release/spel) + +set -euo pipefail + +export RISC0_DEV_MODE=1 + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +WORK_DIR="${1:-${WORK_DIR:-/tmp/spel-multisig-e2e}}" +SEQUENCER_PORT="${SEQUENCER_PORT:-3044}" +SEQUENCER_URL="http://127.0.0.1:${SEQUENCER_PORT}" +PROJECT_NAME="multisig_e2e_test" + +if [ -z "${LEZ_TAG:-}" ]; then + echo "ERROR: LEZ_TAG environment variable is required" + exit 1 +fi + +if [ -z "${LSSA_DIR:-}" ]; then + echo "ERROR: LSSA_DIR environment variable is required" + exit 1 +fi + +LSSA_DIR="$(cd "$LSSA_DIR" && pwd)" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log() { echo -e "${GREEN}[MULTISIG-E2E]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +fail() { echo -e "${RED}[FAIL]${NC} $*"; exit 1; } + +cleanup() { + if [ -n "${SEQ_PID:-}" ] && kill -0 "$SEQ_PID" 2>/dev/null; then + kill "$SEQ_PID" 2>/dev/null || true + wait "$SEQ_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +# ─── Prerequisites ───────────────────────────────────────────────────────── + +command -v cargo >/dev/null 2>&1 || fail "cargo not found" + +SEQUENCER_BIN="${LSSA_DIR}/target/release/sequencer_service" +[ -x "$SEQUENCER_BIN" ] || fail "sequencer_service not found at $SEQUENCER_BIN" + +WALLET_BIN="${LSSA_DIR}/target/release/wallet" +[ -x "$WALLET_BIN" ] || fail "wallet not found at $WALLET_BIN" + +SPEL_BIN="${SPEL_BIN:-/tmp/lssa/target/release/spel}" +[ -x "$SPEL_BIN" ] || fail "spel binary not found at $SPEL_BIN" + +# Wallet config template. LEZ v0.2.0 moved the debug configs under a lez/ +# subdirectory; prefer the new location, fall back to the pre-rc6 path. +if [ -f "${LSSA_DIR}/lez/wallet/configs/debug/wallet_config.json" ]; then + WALLET_CONFIG_TEMPLATE="${LSSA_DIR}/lez/wallet/configs/debug/wallet_config.json" +else + WALLET_CONFIG_TEMPLATE="${LSSA_DIR}/wallet/configs/debug/wallet_config.json" +fi +[ -f "$WALLET_CONFIG_TEMPLATE" ] || fail "wallet_config.json not found under $LSSA_DIR" + +WALLET_PASSWORD="${WALLET_PASSWORD:-test}" +SPEL_TAG="${SPEL_TAG:-local}" + +# ─── Setup ───────────────────────────────────────────────────────────────── + +log "Setting up in ${WORK_DIR}..." +rm -rf "$WORK_DIR" +mkdir -p "$WORK_DIR" +cd "$WORK_DIR" + +# ─── Step 1: Scaffold project ────────────────────────────────────────────── + +log "Step 1: Creating SPEL project (LEZ=${LEZ_TAG}, SPEL=${SPEL_TAG})..." +SPEL_GIT_ARGS=() +if [ -n "${SPEL_GIT:-}" ]; then + SPEL_GIT_ARGS=(--spel-git "$SPEL_GIT") +fi +"$SPEL_BIN" init --lez-tag "$LEZ_TAG" --spel-rev "$SPEL_TAG" "${SPEL_GIT_ARGS[@]}" "$PROJECT_NAME" \ + > "$WORK_DIR/init.log" 2>&1 || { cat "$WORK_DIR/init.log"; fail "spel init failed"; } +cd "$PROJECT_NAME" + +# Regenerate lockfiles so the pinned revisions take effect +(cd methods/guest && cargo generate-lockfile > "$WORK_DIR/guest-lockfile.log" 2>&1) \ + || warn "Guest lockfile regeneration failed" +cargo generate-lockfile > "$WORK_DIR/root-lockfile.log" 2>&1 \ + || warn "Root lockfile regeneration failed" + +# Re-pin the enum-ordinalize crates to LEZ's version (4.3.2) in the guest lockfile. +# generate-lockfile above re-resolves educe's `^4` to 4.4.1, which requires +# rustc 1.89, but the guest builds with the risc0 toolchain (rustc 1.88). +# spel init already pins these, but the regeneration above undoes it, so redo it. +# Both enum-ordinalize and enum-ordinalize-derive resolve independently. +(cd methods/guest \ + && cargo update -p enum-ordinalize --precise 4.3.2 >> "$WORK_DIR/guest-lockfile.log" 2>&1 \ + && cargo update -p enum-ordinalize-derive --precise 4.3.2 >> "$WORK_DIR/guest-lockfile.log" 2>&1) \ + || warn "enum-ordinalize pin failed (guest build may hit rustc 1.89)" + +log " ✓ Project scaffolded" + +# ─── Step 2: Build guest binary ─────────────────────────────────────────── + +log "Step 2: Building guest binary..." +RISC0_SKIP_BUILD= make build > "$WORK_DIR/build.log" 2>&1 || { cat "$WORK_DIR/build.log"; fail "Build failed"; } +GUEST_BIN=$(find . -name "*.bin" -path "*/riscv32im*" | head -1) +[ -n "$GUEST_BIN" ] || fail "No guest binary found" +GUEST_BIN_ABS="$(realpath "$GUEST_BIN")" +log " ✓ Built: $(basename "$GUEST_BIN") ($(stat -c%s "$GUEST_BIN") bytes)" + +# ─── Step 3: Generate IDL ───────────────────────────────────────────────── + +log "Step 3: Generating IDL..." +make idl > "$WORK_DIR/idl.log" 2>&1 || fail "IDL generation failed (see $WORK_DIR/idl.log)" +IDL_FILE=$(find . -name "*-idl.json" | head -1) +[ -n "$IDL_FILE" ] || fail "No IDL file found" +IDL_ABS="$(realpath "$IDL_FILE")" +log " ✓ IDL generated" + +# ─── Step 4: Start sequencer ────────────────────────────────────────────── + +log "Step 4: Starting sequencer on port ${SEQUENCER_PORT}..." +# Only kill a leftover sequencer from a previous run of THIS script (matched +# by port), never a developer's live stack on another port. +pgrep -f "sequencer_service --port ${SEQUENCER_PORT}" | xargs -r kill 2>/dev/null || true +sleep 1 +rm -rf "${LSSA_DIR}/rocksdb-${SEQUENCER_PORT}" + +# LEZ v0.2.0 moved this under lez/; prefer it, fall back to the old path. +SEQ_CONFIGS="${LSSA_DIR}/lez/sequencer/service/configs/debug/sequencer_config.json" +if [ ! -f "$SEQ_CONFIGS" ]; then + SEQ_CONFIGS="${LSSA_DIR}/sequencer/service/configs/debug/sequencer_config.json" +fi +if [ ! -f "$SEQ_CONFIGS" ]; then + SEQ_CONFIGS=$(find "$LSSA_DIR" -name "sequencer_config.json" 2>/dev/null | head -1) +fi +[ -n "$SEQ_CONFIGS" ] && [ -f "$SEQ_CONFIGS" ] || fail "Sequencer config not found" + +# LEZ v0.2.0 writes a bedrock_signing_key (and rocksdb) under config.home, +# which defaults to "." — i.e. the sequencer's cwd. The LEZ checkout is +# read-only in CI, so launching from there fails with "Permission denied". +# Copy the config into the writable work dir with home rewritten to it. +SEQ_HOME="$WORK_DIR/seq-home" +mkdir -p "$SEQ_HOME" +SEQ_CONFIG_PATCHED="$WORK_DIR/sequencer_config.json" +# Paths are passed as argv (not interpolated into the source) so a path +# containing quotes or other special characters can't break the script. +python3 -c ' +import json, sys +src, home, dst = sys.argv[1], sys.argv[2], sys.argv[3] +cfg = json.load(open(src)) +cfg["home"] = home +json.dump(cfg, open(dst, "w")) +' "$SEQ_CONFIGS" "$SEQ_HOME" "$SEQ_CONFIG_PATCHED" || fail "Failed to patch sequencer config home" +SEQ_CONFIGS="$SEQ_CONFIG_PATCHED" + +cd "$SEQ_HOME" +RUST_LOG=info $SEQUENCER_BIN --port "$SEQUENCER_PORT" "$SEQ_CONFIGS" \ + > "$WORK_DIR/sequencer.log" 2>&1 & +SEQ_PID=$! +sleep 2 +if ! kill -0 $SEQ_PID 2>/dev/null; then + echo "❌ Sequencer failed to start. Logs:" + cat "$WORK_DIR/sequencer.log" | tail -30 + exit 1 +fi +cd "$WORK_DIR/$PROJECT_NAME" + +log " Waiting for sequencer..." +for i in $(seq 1 90); do + if curl -sf -o /dev/null -w '%{http_code}' "$SEQUENCER_URL" 2>/dev/null | grep -qE '200|405'; then + log " ✓ Sequencer up"; break + fi + kill -0 "$SEQ_PID" 2>/dev/null || fail "Sequencer died" + echo -n "." + sleep 2 +done + +log " Waiting for first block..." +for i in $(seq 1 60); do + if curl -sf -X POST "$SEQUENCER_URL" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","method":"getLastBlockId","params":[],"id":1}' 2>/dev/null; then + log " ✓ Sequencer producing blocks"; break + fi + sleep 3 + echo -n "." +done + +# ─── Step 5: Create two isolated wallets ────────────────────────────────── +# Each wallet home is a fresh directory holding only a wallet_config.json +# pointed at our sequencer; `wallet account new` bootstraps its storage.json. +# Wallet A is the exporter, wallet B the co-signer. Neither holds the +# other's keys, which is the whole point of the exercise. + +log "Step 5: Creating two wallets..." +WALLET_A="$WORK_DIR/wallet-a" +WALLET_B="$WORK_DIR/wallet-b" + +for home in "$WALLET_A" "$WALLET_B"; do + mkdir -p "$home" + # The sequencer address schema changed in LEZ v0.2.1 (flat sequencer_addr + # became a sequencers array). Patch whichever schema the template uses — + # writing the wrong one is silently ignored by serde and the wallet then + # talks to the default port (spel/co #256). + python3 -c ' +import json, sys +src, dst, url = sys.argv[1], sys.argv[2], sys.argv[3] +with open(src) as f: + config = json.load(f) +if isinstance(config.get("sequencers"), list): + entry = config["sequencers"][0] if config["sequencers"] else {} + entry["sequencer_addr"] = url + config["sequencers"] = [entry] + config.pop("sequencer_addr", None) +else: + config["sequencer_addr"] = url +with open(dst, "w") as f: + json.dump(config, f, indent=4) +' "$WALLET_CONFIG_TEMPLATE" "$home/wallet_config.json" "$SEQUENCER_URL" \ + || fail "Failed to write wallet config for $home" +done + +SIGNER_A=$(printf '%s\n' "$WALLET_PASSWORD" \ + | NSSA_WALLET_HOME_DIR="$WALLET_A" LEE_WALLET_HOME_DIR="$WALLET_A" \ + "$WALLET_BIN" account new public 2>&1 \ + | sed -n 's/.*Public\/\([A-Za-z0-9]*\).*/\1/p' | tail -1) +[ -n "$SIGNER_A" ] || fail "Could not create account in wallet A" +log " Wallet A signer: ${SIGNER_A:0:20}..." + +SIGNER_B=$(printf '%s\n' "$WALLET_PASSWORD" \ + | NSSA_WALLET_HOME_DIR="$WALLET_B" LEE_WALLET_HOME_DIR="$WALLET_B" \ + "$WALLET_BIN" account new public 2>&1 \ + | sed -n 's/.*Public\/\([A-Za-z0-9]*\).*/\1/p' | tail -1) +[ -n "$SIGNER_B" ] || fail "Could not create account in wallet B" +log " Wallet B signer: ${SIGNER_B:0:20}..." + +[ "$SIGNER_A" != "$SIGNER_B" ] || fail "Wallet A and B produced the same account id" + +# ─── Step 6: Deploy program (wallet A) ──────────────────────────────────── + +log "Step 6: Deploying program..." +printf '%s\n' "$WALLET_PASSWORD" \ + | NSSA_WALLET_HOME_DIR="$WALLET_A" LEE_WALLET_HOME_DIR="$WALLET_A" \ + "$WALLET_BIN" deploy-program "$GUEST_BIN_ABS" \ + > "$WORK_DIR/deploy.log" 2>&1 || { cat "$WORK_DIR/deploy.log"; fail "Deploy failed"; } +log " ✓ Program deployed" + +# ─── Step 7: Initialize (single-signer, wallet A) ───────────────────────── + +log "Step 7: Sending initialize transaction..." +SEQUENCER_URL="$SEQUENCER_URL" \ +NSSA_WALLET_HOME_DIR="$WALLET_A" LEE_WALLET_HOME_DIR="$WALLET_A" \ + "$SPEL_BIN" --idl "$IDL_ABS" -p "$GUEST_BIN_ABS" \ + initialize \ + --owner "$SIGNER_A" \ + > "$WORK_DIR/initialize-tx.log" 2>&1 || { cat "$WORK_DIR/initialize-tx.log"; fail "Initialize TX failed"; } +log " ✓ Initialize TX submitted and confirmed" + +# ─── Step 8: Export partial transaction (wallet A + co-signer B) ────────── + +log "Step 8: Exporting partial transaction (--export / --co-signer)..." +BLOB="$WORK_DIR/multisig-tx.json" +SEQUENCER_URL="$SEQUENCER_URL" \ +NSSA_WALLET_HOME_DIR="$WALLET_A" LEE_WALLET_HOME_DIR="$WALLET_A" \ + "$SPEL_BIN" --idl "$IDL_ABS" -p "$GUEST_BIN_ABS" \ + --export "$BLOB" --co-signer "$SIGNER_B" \ + do_something \ + --owner "$SIGNER_A" \ + --amount 42 \ + > "$WORK_DIR/export.log" 2>&1 || { cat "$WORK_DIR/export.log"; fail "Export failed"; } + +grep -q "Partial transaction written" "$WORK_DIR/export.log" \ + || { cat "$WORK_DIR/export.log"; fail "Export did not report writing the blob"; } +[ -f "$BLOB" ] || fail "Blob file not written: $BLOB" + +# Exporter wallet holds only signer A's key, so the blob must carry exactly +# one witness and still list two required signers. +python3 -c ' +import json, sys +blob = json.load(open(sys.argv[1])) +version = blob["version"] +signers = blob["signers"] +witnesses = blob["witnesses"] +assert version == 1, f"unexpected version {version}" +assert len(signers) == 2, f"expected 2 signers, got {len(signers)}" +assert len(witnesses) == 1, f"expected 1 witness, got {len(witnesses)}" +missing = [s for s in signers if s not in witnesses] +assert len(missing) == 1, f"expected 1 missing signer, got {missing}" +print(" ✓ Blob: 2 signers, 1 witness, co-signer still missing") +' "$BLOB" || fail "Blob content check failed" +log " ✓ Partial transaction exported" + +# ─── Step 9: Premature submit must be rejected ──────────────────────────── + +log "Step 9: Submitting with a witness still missing (must fail)..." +if NSSA_WALLET_HOME_DIR="$WALLET_B" LEE_WALLET_HOME_DIR="$WALLET_B" \ + "$SPEL_BIN" submit "$BLOB" > "$WORK_DIR/premature-submit.log" 2>&1; then + cat "$WORK_DIR/premature-submit.log" + fail "submit succeeded although a witness is missing" +fi +grep -q "missing signers" "$WORK_DIR/premature-submit.log" \ + || { cat "$WORK_DIR/premature-submit.log"; fail "submit failed for the wrong reason"; } +log " ✓ Premature submit rejected (missing signers)" + +# ─── Step 10: Co-sign from wallet B ─────────────────────────────────────── + +log "Step 10: Signing from wallet B (spel sign)..." +printf 'y\n' | \ +NSSA_WALLET_HOME_DIR="$WALLET_B" LEE_WALLET_HOME_DIR="$WALLET_B" \ + "$SPEL_BIN" sign "$BLOB" \ + > "$WORK_DIR/sign.log" 2>&1 || { cat "$WORK_DIR/sign.log"; fail "spel sign failed"; } + +grep -q "Witnesses added" "$WORK_DIR/sign.log" \ + || { cat "$WORK_DIR/sign.log"; fail "sign did not report adding a witness"; } +grep -q "All witnesses collected" "$WORK_DIR/sign.log" \ + || { cat "$WORK_DIR/sign.log"; fail "sign did not report the blob complete"; } + +python3 -c ' +import json, sys +blob = json.load(open(sys.argv[1])) +witnesses = blob["witnesses"] +assert len(witnesses) == 2, f"expected 2 witnesses, got {len(witnesses)}" +print(" ✓ Blob now fully signed (2 of 2 witnesses)") +' "$BLOB" || fail "Blob witness count check failed" +log " ✓ Co-signer witness appended" + +# ─── Step 11: Tampered blob must be rejected ────────────────────────────── +# Flip one bit in the last byte of message_hex — the tail of the borsh +# message is instruction data, so the blob stays decodable but its hash no +# longer matches what either signer signed. + +log "Step 11: Submitting a tampered blob (must fail)..." +TAMPERED="$WORK_DIR/multisig-tx-tampered.json" +python3 -c ' +import json, sys +src, dst = sys.argv[1], sys.argv[2] +blob = json.load(open(src)) +raw = bytearray.fromhex(blob["message_hex"]) +raw[-1] ^= 0xFF +blob["message_hex"] = raw.hex() +json.dump(blob, open(dst, "w"), indent=2) +' "$BLOB" "$TAMPERED" || fail "Failed to write tampered blob" + +if NSSA_WALLET_HOME_DIR="$WALLET_B" LEE_WALLET_HOME_DIR="$WALLET_B" \ + "$SPEL_BIN" submit "$TAMPERED" > "$WORK_DIR/tampered-submit.log" 2>&1; then + cat "$WORK_DIR/tampered-submit.log" + fail "submit accepted a tampered blob" +fi +grep -q "does not verify against the message" "$WORK_DIR/tampered-submit.log" \ + || { cat "$WORK_DIR/tampered-submit.log"; fail "tampered submit failed for the wrong reason"; } +log " ✓ Tampered blob rejected" + +# ─── Step 12: Submit the real blob (wallet B) ───────────────────────────── + +log "Step 12: Submitting the fully signed blob..." +NSSA_WALLET_HOME_DIR="$WALLET_B" LEE_WALLET_HOME_DIR="$WALLET_B" \ + "$SPEL_BIN" submit "$BLOB" \ + > "$WORK_DIR/submit.log" 2>&1 || { cat "$WORK_DIR/submit.log"; fail "spel submit failed"; } + +grep -q "Transaction confirmed" "$WORK_DIR/submit.log" \ + || { cat "$WORK_DIR/submit.log"; fail "submit did not confirm the transaction"; } +log " ✓ Multisig transaction submitted and confirmed" + +# ─── Done ───────────────────────────────────────────────────────────────── + +log "" +log "🎉 Multisig E2E test PASSED!" +log " All steps completed successfully:" +log " ✅ spel init + build + IDL — project ready" +log " ✅ two isolated wallets created (A = exporter, B = co-signer)" +log " ✅ deploy + initialize — program live" +log " ✅ --export / --co-signer — partial TX written, 1 of 2 witnesses" +log " ✅ premature submit rejected — missing signers" +log " ✅ spel sign — co-signer witness appended, blob complete" +log " ✅ tampered submit rejected — signature no longer verifies" +log " ✅ spel submit — multisig TX confirmed in a block" diff --git a/spel-cli/src/exchange.rs b/spel-cli/src/exchange.rs index 50b43925..75270f85 100644 --- a/spel-cli/src/exchange.rs +++ b/spel-cli/src/exchange.rs @@ -4,7 +4,6 @@ use std::io::Write; use std::{io, process}; use common::transaction::LeeTransaction; -use nssa::public_transaction::WitnessSet; use nssa::{AccountId, PublicKey, PublicTransaction, Signature}; use sequencer_service_rpc::RpcClient as _; use wallet::WalletCore; diff --git a/spel-cli/tests/exchange_flow.rs b/spel-cli/tests/exchange_flow.rs new file mode 100644 index 00000000..8ef5b3af --- /dev/null +++ b/spel-cli/tests/exchange_flow.rs @@ -0,0 +1,157 @@ +//! `spel sign` and `spel submit` failure paths, exercised through the real +//! binary. Every case here is rejected before the wallet is touched, so no +//! wallet home or sequencer is needed and the output is deterministic. The +//! happy path (export → sign → submit against a live sequencer) lives in +//! scripts/multisig-e2e-test.sh. + +use std::collections::BTreeMap; +use std::path::Path; +use std::process::{Command, Output}; + +use nssa::public_transaction::Message; +use nssa::{AccountId, PrivateKey, PublicKey, Signature}; +use spel::blob::{TxBlob, WitnessEntry}; +use spel::hex::hex_encode; + +/// A valid blob signed by `key`, listing its account as the only signer. +fn signed_blob() -> (TxBlob, PrivateKey) { + let key = PrivateKey::try_new([1; 32]).unwrap(); + let pubkey = PublicKey::new_from_private_key(&key); + let account_id = AccountId::from(&pubkey); + let message = Message::try_new( + [0; 8], + vec![account_id], + vec![1_u128.into()], + vec![1, 2, 3, 4], + ) + .unwrap(); + let bytes = borsh::to_vec(&message).unwrap(); + + let id = format!("0x{}", hex_encode(account_id.value())); + let signature = Signature::new(&key, &message.hash()); + let mut witnesses = BTreeMap::new(); + witnesses.insert( + id.clone(), + WitnessEntry { + pubkey, + signature: signature.to_string(), + }, + ); + let blob = TxBlob { + version: 1, + summary: "test".to_string(), + message_hex: hex_encode(&bytes), + signers: vec![id], + witnesses, + }; + (blob, key) +} + +fn run_spel(subcommand: &str, path: &Path) -> Output { + Command::new(env!("CARGO_BIN_EXE_spel")) + .arg(subcommand) + .arg(path) + .output() + .expect("run spel") +} + +#[test] +fn submit_rejects_blob_with_missing_witness() { + // A second required signer with no witness. The present witness still + // verifies, so the rejection is specifically about completeness. + let (mut blob, _key) = signed_blob(); + blob.signers.push(format!("0x{}", "bb".repeat(32))); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("blob.json"); + blob.save(path.to_str().unwrap()).unwrap(); + + let output = run_spel("submit", &path); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!(!output.status.success(), "submit must fail: {stderr}"); + assert!( + stderr.contains("missing signers"), + "rejection must name the cause: {stderr}" + ); +} + +#[test] +fn submit_rejects_tampered_message() { + // Swap in a different (still-decodable) message after signing. Its hash + // no longer matches what the witness signed. + let (mut blob, key) = signed_blob(); + let pubkey = PublicKey::new_from_private_key(&key); + let account_id = AccountId::from(&pubkey); + let tampered = Message::try_new( + [0; 8], + vec![account_id], + vec![1_u128.into()], + vec![9, 9, 9, 9], + ) + .unwrap(); + blob.message_hex = hex_encode(&borsh::to_vec(&tampered).unwrap()); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("blob.json"); + blob.save(path.to_str().unwrap()).unwrap(); + + let output = run_spel("submit", &path); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!(!output.status.success(), "submit must fail: {stderr}"); + assert!( + stderr.contains("does not verify against the message"), + "rejection must name the cause: {stderr}" + ); +} + +#[test] +fn sign_rejects_tampered_message() { + // The same tampering must stop `spel sign` before it shows the prompt, + // so a co-signer can never be asked to sign bytes nobody vouched for. + let (mut blob, key) = signed_blob(); + let pubkey = PublicKey::new_from_private_key(&key); + let account_id = AccountId::from(&pubkey); + let tampered = Message::try_new( + [0; 8], + vec![account_id], + vec![1_u128.into()], + vec![9, 9, 9, 9], + ) + .unwrap(); + blob.message_hex = hex_encode(&borsh::to_vec(&tampered).unwrap()); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("blob.json"); + blob.save(path.to_str().unwrap()).unwrap(); + + let output = run_spel("sign", &path); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!(!output.status.success(), "sign must fail: {stderr}"); + assert!( + stderr.contains("does not verify against the message"), + "rejection must name the cause: {stderr}" + ); + assert!( + !stdout.contains("Sign and update the file?"), + "prompt must not be reached on a tampered blob:\n{stdout}" + ); +} + +#[test] +fn submit_rejects_unreadable_blob() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("does-not-exist.json"); + + let output = run_spel("submit", &path); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!(!output.status.success(), "submit must fail: {stderr}"); + assert!( + stderr.contains("cannot read blob file"), + "rejection must name the cause: {stderr}" + ); +}