Skip to content

feat: reproducible wallet performance test harness — 3 modes × 9 scenarios#6

Open
roadhero wants to merge 100 commits into
tari-project:mainfrom
roadhero:bounty/wallet-benchmarks-1-create-benchmarks
Open

feat: reproducible wallet performance test harness — 3 modes × 9 scenarios#6
roadhero wants to merge 100 commits into
tari-project:mainfrom
roadhero:bounty/wallet-benchmarks-1-create-benchmarks

Conversation

@roadhero

@roadhero roadhero commented May 23, 2026

Copy link
Copy Markdown

Fixes #1.

§1. TL;DR

Mode 3 is now a real 3-process orchestrator (base node → PR daemon at 127.0.0.1:9146 → PP daemon at 127.0.0.1:9145) per @SWvheerden's 2026-05-29 architectural clarification ("payment processor uses the new wallet, but its not the new wallet"). The previous shim that reused Mode 2's offline-sign pipeline is gone. All four open review threads from 5/29 are resolved (threads 4.1/4.2/4.3 tactical plus Mode 3 architectural). 253/253 tests passing, cargo fmt/clippy -D warnings/release build all clean.

Workspace summary: 24 commits since the 5/29 status comment, breaking down as 2 Phase 4/5 tactical fixes, 16 Mode 3 architectural rework plus tests, and 6 post-review fixes addressing every BLOCKER/CONCERN from swe-review. Every commit is author-correct (Dennis Vorobyov roadhero@gmail.com) and DCO Signed-off-by clean. The bounty-critical sentinel test builds_correct_argv_for_single_recipient (Mode 2 argv shape lock) is byte-identical across the entire 24-commit range. The canonical Esmeralda baseline is pending wallet funding; the funding address is listed in §5.

§2. Changes since 5/29 status comment

§2.1. Tactical fixes (threads 4.1 / 4.2 / 4.3)

Thread 4.2 (731dd7b refactor(modes): use MicroMinotari::from_str instead of custom decimal parser). Per @SWvheerden's review comment "microtari from string should do this" → "it should be on the type and from string". Deleted the bespoke decimal-shift currency parser and replaced it with a single MicroMinotari::from_str call. Upstream MicroMinotari::FromStr already handles both Display shapes ("100 µT" and "10.000001 T") round-trip, so no harness-side reimplementation is needed. Net -76 / +29 lines.

Threads 4.1 + 4.3 (c368003 feat(wallet_db): read UTXO counts from wallet sqlite3 instead of stderr). Per @SWvheerden's "I think bar some PR to minotari to add this feature, you have to dig into the database" and "I think this needs to be updated to search the wallet db". The event_count=N stderr scrape is gone. New src/wallet_db.rs module reads UTXO counts directly from the wallet's sqlite3 DB:

  • count_outputsSELECT COUNT(*) FROM outputs WHERE deleted_at IS NULL AND is_burn = 0
  • count_spendable_utxos → same predicate plus AND status = 'UNSPENT'

Predicates mirror minotari-cli@52a7287a/minotari/src/db/outputs.rs:616's get_output_totals_for_account. Modes 2/3 inject Arc<dyn WalletDb>; production wires LiveWalletDb (opens read-only via SQLITE_OPEN_READ_ONLY | SQLITE_OPEN_NO_MUTEX, which is WAL-safe alongside the wallet's writer), tests inject FakeWalletDb with canned counts. scan_from_birthday now populates outputs_found / utxo_count from the DB after minotari Scan exits. The previous last_scan cache field is gone.

9 unit tests against a tempfile schema fixture (empty / filtered / soft-deleted / burn / missing-DB / corrupted-DB) plus 2 DB-injection tests per mode (canned-count read plus DB-error propagation with mode context).

§2.2. Mode 3 architectural rework (22 commits)

The previous Mode 3 was a shim that reused Mode 2's minotari CLI pipeline. Per @SWvheerden's "payment processor uses the new wallet, but its not the new wallet" (issue #1, 2026-05-29), that was wrong. minotari_payment_processor is a separate application that runs in parallel to a minotari wallet daemon, drives the wallet over HTTP, and shells out to minotari_console_wallet for signing. Mode 3 has been rewritten end-to-end to match that architecture.

Headline points:

  • 3-process orchestration. Existing base node lifecycle → PR-role minotari daemon on :9146 with a view-key wallet → minotari_payment_processor on :9145 (same view+spend keypair on the signer side).
  • Vendored PP as submodule at vendor/minotari_payment_processor @ commit f0572c9 (commit 8edc23b). Migration SQL and OpenAPI shapes are pinned to this rev at build time.
  • Hand-rolled reqwest-backed HTTP client for the 4 PP REST endpoints (commit c0929d6). No code-gen step; matches the existing harness's Broadcaster typed-surface pattern.
  • Inline sqlite3 migration applier (commit 8693676): rusqlite (already a harness dep, no new sqlx-cli dependency) plus include_str! against the submodule's 6 .sql files. Build-time pinning: if the submodule rev drifts, cargo build fails fast.
  • PrLifecycle (commit 4ef5b44) and PpLifecycle (commit daeb994): child-process management with SIGTERM, 5s grace, then SIGKILL teardown; Drop guards via tokio::process::Command::kill_on_drop(true).
  • Mode 3 rewrite as 3-process HTTP orchestrator (commit 1f0ff29). send_single/send_batch_one_to_many POST to PP; B0/S2/S3/S6/S7 return UnsupportedOperation (the runner maps to CellResult::NotRun per existing Mode 1 S5-batch-arm precedent); S0/S1/S4/S5 produce real measurements where the v4/v5 upstream constraint allows (§4 finding 1).
  • Single watched account "default" to match minotari-cli@52a7287a/minotari/src/utils/init_wallet.rs:121 hardcode (friendly_name.unwrap_or("default")). See §4 wallet-pain findings.
  • View+spend keypair derived from operator's testnet seed via the existing wallet-decoder path. Both daemons (PR plus PP signer's console_wallet) consume the same pair.
  • Post-review fixes (commits 4c23e35 through 3207dfd) address every BLOCKER and CONCERN from swe-review:
    • 4c23e35 rebuilds PR daemon argv against the real minotari CLI (resolves B2 and N2)
    • b086846 makes get_balance and get_utxo_count return Ok(0) so S0 runs its real work instead of being silently skipped (resolves B1)
    • 7976ade per-method Skipped reasons (resolves C5)
    • 954bf8e funding pre-flight queries the PR daemon HTTP API for Mode 3 (resolves C4)
    • e279cf6 deletes the unused sub_segments_ms field (resolves C3)
    • 3207dfd corrects a stale doc comment on BulkPaymentRequest.account_name

§3. Mode 3 architecture

§3.1. Process topology

harness
  ├── minotari_node              (existing lifecycle)
  │
  ├── minotari daemon            (PR role @ 127.0.0.1:9146, view-key wallet)
  │     ↑
  │     ├─ POST /accounts/default/lock_funds              (from PP)
  │     ├─ POST /accounts/default/create_unsigned_tx      (from PP)
  │     └─ GET  /accounts/default/balance                 (from harness funding pre-flight)
  │
  └── minotari_payment_processor (PP @ 127.0.0.1:9145)
        ↑
        ├─ POST /v1/payment-batches    (from harness submit)
        ├─ POST /v1/payments           (from harness submit)
        ├─ GET  /v1/payments/{id}      (from harness terminal-state poll)
        ├─ GET  /v1/events             (from harness sub-segment-timing poll)
        └─ GET  /health/version        (from harness readiness probe)

Spawn order: base node (existing) → PR daemon (PrLifecycle::spawn plus readiness probe) → apply migrations to fresh payments.db → PP daemon (PpLifecycle::spawn plus readiness probe). See src/modes/payment_processor.rs::start_external_services for the canonical sequence.

§3.2. PR daemon configuration

The PR daemon is bootstrapped via a one-shot import-view-key invocation followed by the long-running daemon subcommand. The argv shape is rebuilt against the real minotari-cli@52a7287a/minotari/src/cli.rs (per commit 4c23e35):

# Step 1: one-shot, exits cleanly
minotari --network esmeralda import-view-key \
  --view-private-key <hex>                 \
  --spend-public-key <hex>                 \
  --password <pw>                          \
  --database-path <pr_wallet_dir>/wallet.db \
  --birthday 0

# Step 2: long-running
minotari --network esmeralda daemon \
  --password <pw>                                  \
  --base-url https://rpc.esmeralda.tari.com         \
  --database-path <pr_wallet_dir>/wallet.db         \
  --api-port 9146

Two non-obvious shape rules to note:

  • --network is a top-level Cli flag (before the subcommand), not a subcommand-scoped flag. The earlier draft put it on the subcommand and clap-parse-failed.
  • Neither import-view-key nor daemon accepts --account-name. The imported wallet is hardcoded to account name "default" per init_wallet.rs:121. See §4 wallet-pain finding 2.

Canonical source: src/wallet_lifecycle/pr_lifecycle.rs:144-156 (daemon_argv) and :158-... (spawn).

§3.3. PP child-process env

The harness emits the following env set on the tokio::process::Command for PP (canonical source: src/wallet_lifecycle/pp_lifecycle.rs, env-name table verified against vendor/minotari_payment_processor/minotari_payment_processor/src/config.rs:46-101):

  • DATABASE_URL=sqlite://<mode3_dir>/payments.db: relative sqlite:// form. Per 0xPepeSilvia's §2 gotcha, absolute file:// breaks sqlx compile-time checks.
  • PAYMENT_RECEIVER=http://127.0.0.1:9146: points at the PR daemon.
  • BASE_NODE=<Config::base_node_url>
  • LISTEN_IP=127.0.0.1, LISTEN_PORT=9145: pinned to loopback for bench safety.
  • TARI_NETWORK=esmeralda: allowlisted by crate::guards::enforce_esmeralda.
  • CONSOLE_WALLET_PATH=<minotari_console_wallet absolute path>
  • CONSOLE_WALLET_BASE_PATH=<mode3_dir>/console_wallet/: dedicated subdir under the Mode 3 tempdir; sqlite-lock-safe by construction (separate from Mode 1's and Mode 2's data dirs).
  • CONSOLE_WALLET_PASSWORD=harness_pp_password (fixture).
  • ACCOUNTS__BENCH__NAME=default: operator-facing config KEY is BENCH (matches the §12 spec convention); VALUE is default because that's the wallet name init_wallet.rs:121 actually creates. PP's HTTP calls to the PR daemon hit /accounts/default/... and would 404 otherwise.
  • ACCOUNTS__BENCH__VIEW_KEY=<hex>: env-indirected. The TOML stores the env-var name (default TARI_BENCH_VIEW_KEY); the hex itself lives only in env at run-time. Same pattern as seeds.old/new/payment_processor.
  • ACCOUNTS__BENCH__PUBLIC_SPEND_KEY=<hex>: same indirection.
  • Five worker-sleep overrides (§3.4).
  • REVEAL_PII=true: bench logs comparable per Brief §3.

CWD is set to <mode3_dir> so PP's hardcoded logs/audit.log (10MB × 5 rolling files) lands inside the harness tempdir tree and gets cleaned on Drop. stdout/stderr are piped to <mode3_dir>/logs/mode3-<run-id>.log (one file per run).

§3.4. Worker sleep overrides

Worker Harness override PP default
BATCH_CREATOR_SLEEP_SECS 1 600 (10 min)
UNSIGNED_TX_CREATOR_SLEEP_SECS 1 15
TRANSACTION_SIGNER_SLEEP_SECS 1 10
BROADCASTER_SLEEP_SECS 1 15
CONFIRMATION_CHECKER_SLEEP_SECS 5 60

At PP defaults, end-to-end payment latency is ≥100s per payment. The 1s/5s overrides bring the bench numbers into a sane range. Each value is configurable per-instance via [mode_3.worker_sleep_overrides] in harness.toml. PP defaults verified at vendor/minotari_payment_processor/minotari_payment_processor/src/workers/batch_creator.rs:12 (600), unsigned_tx_creator.rs:34 (15), transaction_signer.rs:18 (10), broadcaster.rs:15 (15), confirmation_checker.rs:18 (60).

§3.5. Migration handling

  • Migrations live at vendor/minotari_payment_processor/migrations/*.sql. The 6 files apply in lexical order:
    1. 20251017081013_init.sql
    2. 20251201115213_add_intermediate_context.sql
    3. 20251209145502_add_payref_to_payments.sql
    4. 20260102132241_add_index_to_payref.sql
    5. 20260123142522_add_events_table.sql
    6. 20260126120000_add_block_headers_table.sql
  • Each file is include_str!-embedded into the harness binary at build time. Pinning is structural: bumping the submodule re-reads the new files at the next cargo build. No runtime filesystem scan; no operator-supplied migration path.
  • Applied via rusqlite at PpLifecycle::spawn: rm -rf <mode3_dir>mkdir -p → open ConnectionPRAGMA foreign_keys = ONexecute_batch each SQL string in order → assert post-migration tables exist (payments, payment_batches, events, block_headers).
  • Canonical source: src/pp_migrations.rs. 3 unit tests verify against tempfile::TempDir: apply_migrations_creates_payments_table, apply_migrations_creates_all_required_tables, apply_migrations_is_idempotent_when_re_run.

§3.6. HTTP client surface (src/pp_http_client.rs)

  • Single reqwest::Client per PpHttpClient for connection-pool reuse, mirroring the existing Broadcaster pattern (src/broadcast/mod.rs). Cloned via Arc<PpHttpClient> for S4 fan-out.
  • 4 methods exposed:
    • submit_batch(account_name, items: Vec<BulkPaymentItem>) -> BulkPaymentResponse: POST /v1/payment-batches. Idempotent via (client_id, account_name). Hard cap of 100 items per batch (upstream MAX_BATCH_SIZE); the harness errors loudly above this.
    • poll_payment(payment_id) -> PaymentResponse: GET /v1/payments/{id}.
    • stream_events(filters) -> EventListResponse: GET /v1/events. Poll-based (no SSE upstream).
    • health_version() -> ServiceVersion: GET /health/version. Used as the readiness probe (200ms backoff, 30s timeout; ~10× headroom against PepeSilvia's observed 2-3s PP boot).
  • Hand-rolled types (no code-gen step; rejected on the grounds that the upstream OpenAPI surface is 4 endpoints, stable, and the harness already hand-rolls Broadcaster / WalletDb). PaymentStatus enum mirrors upstream vendor/minotari_payment_processor/minotari_payment_processor/src/db/payment.rs:14-22 verbatim: #[serde(rename_all = "SCREAMING_SNAKE_CASE")] with 5 variants (Received, Batched, Confirmed, Failed, Cancelled).
  • No retries at the client layer. Each batch is idempotent via its client_id; retries would skew throughput measurement. Transport errors return as-is to the caller, which records them as scenario failures per the "no harness retries" AC.
  • 11 wiremock unit tests cover happy path plus error cases plus readiness retry.

§4. Wallet pain findings

This section is the maintainer-facing artifact for what the operator learned about the upstream wallet stack while building Mode 3 against it. Per @SWvheerden's review framing ("I think bar some PR to minotari to add this feature, you have to dig into the database" and "it should be on the type and from string", both PR #6 inline comments from 2026-05-29), both directions belong here as first-class findings: "upstream lacks X, harness has to compensate" and "upstream already has X, harness should use it".

1. v4/v5 signer toolchain mismatch (Mode 3 PR daemon → PP signer worker). minotari create-unsigned-transaction (the PR daemon side) emits unsigned-tx JSON with version: 4.0.0. minotari_console_wallet sign-one-sided-transaction (the PP signer worker) rejects it expecting version: 5.0.0. PP's signer worker retries the failing batch every TRANSACTION_SIGNER_SLEEP_SECS seconds with no max-attempts cap. Result: payments stall at SigningInProgress indefinitely. Evidence: vendor/minotari_payment_processor/minotari_payment_processor/src/workers/transaction_signer.rs:235-289 (shells out to console_wallet); empirically reproduced by 0xPepeSilvia's tari-wallet-benchmarks fork (their wallet_pain_findings.md Finding #1). Impact on canonical baseline: Mode 3 S5/S0 batches will reach AwaitingSignature / SigningInProgress and stop. Ingest throughput (POST /v1/payment-batches) is measurable; end-to-end settled is not. This is an upstream toolchain bug, not a harness defect.

2. No --account-name override on import-view-key or daemon. minotari-cli@52a7287a/minotari/src/cli.rs:273 defines ImportViewKey with SecurityArgs plus DatabaseArgs flatten plus view_private_key plus spend_public_key plus birthday, with no AccountArgs flatten. Similarly Commands::Daemon at cli.rs:222 flattens SecurityArgs plus NodeArgs plus DatabaseArgs only, with no AccountArgs. The created/served wallet's account name is hardcoded to "default" per minotari/src/utils/init_wallet.rs:121 (friendly_name.unwrap_or("default")). Impact: PP's ACCOUNTS__BENCH__NAME env VALUE must be "default" (not the operator-natural "bench") or PP's HTTP calls to the PR daemon hit 404. The harness now flips this VALUE (KEY stays BENCH). Resolved in commit 4c23e35.

3. Secrets passed via argv (visible in ps -ef). Both import-view-key and daemon accept --password as a CLI flag. import-view-key also takes --view-private-key and --spend-public-key as CLI args. On a multi-user host these are visible via ps -ef, /proc/<pid>/cmdline, and process accounting. The view-key alone reveals every incoming amount/address for the account; the password unlocks the wallet DB. The harness's existing pattern (SeedHandle::wallet_password reads from env at use site) is the established convention. Upstream CLI doesn't appear to accept env-var fallbacks for these flags. I'll confirm this during the canonical-baseline run. Impact: documented limitation; defense-in-depth follow-up is to either probe upstream for env fallbacks or wrap argv in a privacy shield.

4. PP audit log relative path. vendor/minotari_payment_processor/... writes logs/audit.log (rolling, 10MB × 5 files) relative to its CWD. Hardcoded, with no env override. The harness sets PP's CWD to its per-mode HarnessDataDir so the logs land inside the harness tempdir and get cleaned on Drop. Worth surfacing for upstream if multi-instance PP deployments matter.

5. MicroMinotari::FromStr round-trips both Display shapes cleanly (positive finding). Per @SWvheerden's review comment "it should be on the type and from string", tari_transaction_components::tari_amount::MicroMinotari implements FromStr that handles both Display outputs round-trip: "100 µT" and "10.000001 T". The previous harness implementation had a 60-line custom decimal-shift parser for the T form. Replaced with a single MicroMinotari::from_str().map(|m| m.as_u64()) call in commit 731dd7b. Wallet learning: the upstream type is the right abstraction; don't reimplement.

6. Wallet DB canonical UTXO-count predicate. Per @SWvheerden's review comment "I think bar some PR to minotari to add this feature, you have to dig into the database", the wallet's sqlite3 outputs table is queried with WHERE status = 'UNSPENT' AND deleted_at IS NULL AND is_burn = 0 for the canonical UTXO count (matches minotari-cli@52a7287a/minotari/src/db/outputs.rs:616's get_output_totals_for_account). The harness's wallet_db module mirrors this exactly (commit c368003). Wallet learning: the previous stderr event_count=N scrape was rejected by review for good reason. The DB is the authoritative source.

§5. Known limitations

  • Canonical Esmeralda baseline for Mode 3 pending wallet funding. Funding address (PR daemon side, single watched account, view+spend keypair derived from the operator's testnet seed):

    f28TF5XZJ8WerS8iCxpT5Krh4fpSCK5zhoEcpQ8E75hJEuUrLfxXXSt6Y5SZvW9JTkFevuQDUm7GjLbDdTohw2Nft1D
    

    Once funded, S0/S4/S5 run against testnet and baseline_profile.json lands as a follow-up commit on this branch. Mode 3 will reach AwaitingSignature and stop per §4 finding 1; the result profile records this as the bench's known upstream finding (see §6).

  • Modes 1 and 2 baseline is independently producible right now. Their existing seeds (addresses in the original PR body) are funded-ready and don't depend on the Mode 3 PR daemon. If you'd prefer a partial baseline ahead of Mode 3 funding, I can run those two arms first and attach the profile, then ship the Mode 3 cells once funded.

  • enforce_funding warn-and-skip for Mode 3 PR daemon. Pre-flight runs at startup, before the per-mode lifecycle spawn. If the PR daemon's GET /accounts/default/balance endpoint isn't reachable (connection refused), the harness logs a warn explaining the cause and skips the Mode 3 arm of the funding check rather than bailing the whole run. Trades pre-flight strictness for ergonomics. Operators who want strict Mode 3 pre-flight coverage can pre-warm the PR daemon out-of-band before invoking the harness. Implementation: src/guards.rs plus src/wallet_lifecycle/pr_balance_query.rs (commit 954bf8e).

  • PR seed strict-bail unwired (spec §16.4 row 4). The pre-implementation spec called for a strict bail when the PR daemon's account balance is zero. The HTTP-shaped pre-flight is wired (src/wallet_lifecycle/pr_balance_query.rs), but the strict-bail policy is unwired; currently the warn-and-skip above is the only outcome. Deferred to canonical-baseline review per spec §16.4.

  • S4 dispatcher terminal-state poll. PpDispatcher doesn't append to submitted_payment_ids; the shutdown poll loop sees only sequential send_* ids, not S4 fan-out submissions. Spec §11 explicitly marks S4 as throughput-only ("S4 is a throughput measurement, terminal-state tracking is best-effort there"). Documented here so the maintainer can request a tightening if needed.

  • C6 redaction defense-in-depth gap. The harness's R3/R4 redaction regex (src/seed/redact.rs) requires the literal substring view-key: (or view_key, view key) before the 64-char hex. PP's piped stdout/stderr log goes through this redaction; if PP debug-prints a raw 64-char hex without the label, the redaction passes it through. Defense-in-depth follow-up: when Config::mode_3 is Some, add the runtime hex values themselves as substring rules.

  • harness.toml.example placement. Currently at repo root for first-PR visibility. Maintainer-preferred location (e.g. examples/ or docs/) is unconfirmed; happy to move during review.

§6. Open question for @SWvheerden

If the v4/v5 mismatch (§4 finding 1) terminates all Mode 3 batches at AwaitingSignature during the canonical baseline, the harness will emit real failed-status TxRecords with the actual error string. The "Harness does not hide wallet pain" AC suggests this IS the intended outcome. Please confirm before the canonical run, or specify the preferred reporting shape for upstream-stalled cells.

§7. CI state

Gate Result
cargo fmt --all -- --check clean
cargo clippy --workspace --all-targets --all-features -- -D warnings clean
cargo nextest run 253/253 passing (~46s)
cargo build --release clean
Sentinel test builds_correct_argv_for_single_recipient byte-identical (git show 3207dfd:src/modes/minotari_subprocess.rsgit show origin/main:src/modes/minotari_subprocess.rs on the test fn)
git rebase --exec 'cargo check' over the 24 commits all compile individually

§8. Commit map

24 commits since the 5/29 status comment, grouped into three phases.

Phase 4/5 tactical (per-commit)

  • 731dd7b refactor(modes): use MicroMinotari::from_str instead of custom decimal parser (thread 4.2)
  • c368003 feat(wallet_db): read UTXO counts from wallet sqlite3 instead of stderr (threads 4.1 + 4.3)

Mode 3 rework (per-commit)

  • 8edc23b chore(vendor): add minotari_payment_processor submodule (pinned @ f0572c9)
  • ae7a59a feat(config): add Mode3Config + WorkerSleepOverrides
  • c0929d6 feat(pp_http_client): hand-rolled reqwest client for PP REST API
  • 8693676 feat(pp_migrations): apply vendored PP sqlite migrations inline
  • 4ef5b44 feat(wallet_lifecycle): PrLifecycle for Mode 3 PR daemon
  • daeb994 feat(wallet_lifecycle): PpLifecycle for Mode 3 payment processor
  • ab48f9a feat(modes): extend TxRecord with optional sub_segments_ms (later removed in e279cf6 post-review as unused)
  • 1f0ff29 feat(modes): rewrite Mode 3 as 3-process HTTP orchestrator
  • 2d4c03f feat(config): startup validation for Mode 3 + harness.toml example
  • 3bcb9cd test(pp_migrations): unit tests for apply_migrations
  • 0a98e8a test(pp_http_client): wiremock coverage for the 4 PP REST endpoints
  • 865e324 test(pp_lifecycle): fake-binary spawn/teardown/drop coverage
  • a3bb8d8 test(pr_lifecycle): argv shape + fake-binary spawn/teardown/drop coverage
  • b9a690e test(modes/payment_processor): Mode 3 trait + wiremock + dispatcher coverage
  • ae832d3 test(integration): Mode 3 skip-scan + PpLifecycle-with-fake-binary
  • cd85b20 style: apply rustfmt to swe-test additions

Post-review fixes (per-commit)

  • 4c23e35 fix(pr_lifecycle): rebuild daemon argv against real minotari CLI + unify account name (resolves swe-review B2 + N2)
  • b086846 fix(payment_processor): get_balance / get_utxo_count return Ok(0) instead of UnsupportedOperation (resolves B1; S0 was being silently skipped before its real work)
  • 7976ade fix(payment_processor): per-method Skipped reasons (resolves C5)
  • 954bf8e fix(guards): funding pre-flight queries PR daemon for Mode 3 (resolves C4)
  • e279cf6 refactor(tx_record): delete unused sub_segments_ms field (resolves C3; the field added in ab48f9a ended up unused once events were threaded differently)
  • 3207dfd docs(pp_http_client): correct stale account_name comment

Note: 6 pre-5/29 commits (fe9b39c through 3241d26) are also in this PR from earlier work. They are stable and unchanged since the 5/29 status comment.

§9. Don't-touch tests

  • src/modes/minotari_subprocess.rs::tests::builds_correct_argv_for_single_recipient (sentinel): verified byte-identical across the 24 commits since 5/29. This test is the AC-pinned argv shape and locks the public contract on minotari_subprocess; touching it would break the bounty's "argv shape is stable" invariant. Confirmed via git show 3207dfd:src/modes/minotari_subprocess.rs vs git show origin/main:src/modes/minotari_subprocess.rs on the test fn body.

Relates-to: #1

roadhero added 30 commits May 22, 2026 13:13
Verified create-unsigned-transaction batch shape against minotari-cli
commit 52a7287a3fe1e7831855649c530534af9f2d4830. The variant declares
`recipient: Vec<String>` with help text "Can be specified multiple
times." — repeated --recipient is the proven batch flag. No
--recipients-file alternative exists. PR #99's send_transactions step
confirms the single-recipient invocation shape verbatim; the batch
shape relies on the clap declaration alone.

Appends §Mode 3 CLI shape — proven to DESIGN_ADDENDUM.md, capturing
the exact argv shape Mode 2/3 will emit, flag-level differences vs
DESIGN.md (top-level --network, required --account-name and
--password, optional --database-path, default 24h UTXO lock), and
edge cases the implementation must handle (config-file path,
concurrent UTXO-lock contention in S4, address-derivation path
choice, one-sided address features).

Relates-to: tari-project#1
Implements enforce_esmeralda per DESIGN.md §Mainnet-protection guard:
a hard allowlist that bails the harness before any subprocess spawn or
RPC call if the configured network is anything other than "esmeralda"
or the base-node URL host matches a mainnet denylist
(rpc.tari.com, seeds.tari.com, mainnet.tari.com, mainnet-rpc.tari.com).

Adds src/guards.rs with inline #[cfg(test)] coverage of the happy path,
both reject paths, every denylist entry, and the empty-network edge.

src/lib.rs is created here so the module is importable; src/config/mod.rs
gets a minimal placeholder Config { network, base_node_url } that later
commits (3b) extend in-place to the full schema-driven shape.

enforce_funding (DESIGN_ADDENDUM §M2) is deferred to a later commit so
this commit holds one purpose. enforce_funding depends on Config.a_fund
(3b) and the SeedHandle trait (3d), neither of which exist yet; landing
it here would force forward-declaration stubs that 3b/3d would have to
rewrite.

The M1 spike at src/main.rs is unchanged — it remains the binary entry
until 3k replaces it with the production main.

Relates-to: tari-project#1
Expand the placeholder Config struct to mirror RESULT_PROFILE_SCHEMA.md §1
field-for-field: all 13 documented keys land with `#[serde(default)]` and
the defaults from the bounty issue's parameter table. A nested Seeds
sub-struct records the *names* of the env vars holding seed mnemonics and
the wallet passphrase, so secrets never live in a TOML on disk — only the
resolver labels do.

The TOML loader in `src/config/load.rs` is a single function returning
`anyhow::Result<Config>`; per-field `#[serde(default)]` handles partial
configs without bespoke merging. Inline tests cover the default contract,
empty-table loading, partial overrides, full round-trip, and a wrong-type
rejection.

`guards::tests` switched its fixture builder to `Config::default()` with
field overrides, picking up the new field roster without touching the
guard logic.

Relates-to: tari-project#1
Replace the M1 dep-graph spike `main.rs` with the real clap-derived CLI
shape per DESIGN_ADDENDUM.md §S1: a top-level `Cli` with three
subcommands — `run` (the default; takes `--config <path>`), `gen-seed`,
and `print-address --seed-env <NAME>`. Invoking the binary with no
subcommand resolves to `run` with the default `harness.toml` path,
mirroring the maintainer's `minotari-cli` pattern.

The `run` body emits a one-line "not yet wired" notice and exits with
code 2 — scenarios land in a later step; the parse + dispatch surface
is what this commit puts in place. `gen-seed` and `print-address`
dispatch through stub library entry points that currently bail; the
real implementations land in the next two commits.

Inline tests in `cli` cover each subcommand parse, the default-to-run
behaviour, and the default config path. Total: 19 passing unit tests.

Relates-to: tari-project#1
Replace the `gen_seed` stub with the real Tari-mnemonic generator per
DESIGN_ADDENDUM.md §S1: `CipherSeed::random()` → `to_mnemonic(English)`
→ space-joined 24-word string. The Tari mnemonic is NOT BIP-39 (per
API_DRIFT.md Step 1) — it's a 24-word Tari-specific encoding generated
by `tari_common_types::seeds::cipher_seed`.

Output goes directly to stdout via `println!` in `main.rs`; per the §S1
contract this is operator-visible mnemonic emission and intentionally
bypasses the result-profile redaction denylist. Callers must not echo
it into structured logs or the result profile.

Inline tests cover the 24-word/lowercase-ASCII shape, round-trip
through `SeedWords::from_str` → `CipherSeed::from_mnemonic` →
`to_mnemonic`, and randomness across consecutive invocations.

Relates-to: tari-project#1
Replace the `print_address` stub with the real Esmeralda dual-address
derivation per DESIGN_ADDENDUM.md §S1 and §S2. Reads the 24-word Tari
mnemonic from the operator-supplied env var name, decodes through
`SeedWords::from_str` → `CipherSeed::from_mnemonic` → `SeedWordsWallet`
→ `WalletType::SeedWords`, then assembles a `TariAddress` via
`new_dual_address` with the wallet's public view/spend keys, the
Esmeralda network constant, and the one-sided features flag. Encoded
output uses base58 so the address can be piped straight into
`minotari --network esmeralda create-unsigned-transaction --recipient
<addr>::<amount>`.

`WalletType::tari_address()` is the path DESIGN_ADDENDUM.md §S1
preferred, but does not exist on the published v5.3.1 surface; the
fallback `TariAddress::new_dual_address` path documented in §S1's last
paragraph is the one this commit takes. API_DRIFT.md Step 2 already
records the drift.

Inline tests cover the base58 round-trip, determinism for a given seed
(each test uses a unique env-var name to avoid cross-test races under
cargo's default parallel runner), the missing-env-var error, and the
invalid-mnemonic error. Test count: 26 passing.

Relates-to: tari-project#1
Adds the host environment capture surface backing
`RESULT_PROFILE_SCHEMA.md §2` (cpu_model, ram_bytes, disk_type, os,
network_path_to_base_node).

The `EnvCapture` trait abstracts the capture site so scenario unit tests
inject a deterministic `FakeEnvCapture` rather than depending on the
test host's actual hardware. The production `LiveEnvCapture` delegates
to `linux::capture` / `macos::capture` for the four host-dependent
fields and computes `network_path_to_base_node` from the supplied
base-node URL — that field depends on `Config`, not the OS.

Per-OS source commands follow DESIGN.md §Environment-disclosure
verbatim: /proc/cpuinfo + /proc/meminfo + lsblk + uname on Linux,
sysctl + diskutil + uname on macOS. Per-field capture failures degrade
to "unknown" / 0 so the schema's non-null contract holds even on
degraded hosts. Any non-(linux|macos) target triggers a
compile_error!, mirroring the bounty's scope.

Inline tests cover fake-capture determinism, the loopback/remote
classifier, and a platform-gated live-capture sanity check that
asserts cpu_model is non-empty and ram_bytes > 0 on the test host.

Relates-to: tari-project#1
Adds the `versions` block backing `RESULT_PROFILE_SCHEMA.md §3` — five
components: minotari_console_wallet, minotari_cli, base_node, harness.
Each is recorded as `BinaryVersion { tag, commit }` with at least one
populated per schema contract.

Probing decisions, documented in the module docstring and
`analysis/API_DRIFT.md`:

  * harness.commit: runtime `git rev-parse HEAD` from the source dir.
    No vergen / build.rs — published tari crates inspected for
    precedent (`tari_common`, `tari_common_types`,
    `minotari_node_wallet_client`) use neither, only
    `tari_features::resolver::build_features` for feature gating. A
    runtime subprocess call is the smallest possible mirror.
  * minotari_console_wallet / minotari_cli: `<binary> --version`
    subprocess parsed with a `name <semver>[+<commit>]` matcher.
    Best-effort: missing binary or non-zero exit yields
    `BinaryVersion::default()` so the result profile records absence
    rather than aborting the run.
  * base_node: the published `BaseNodeWalletClient` trait on
    minotari_node_wallet_client 5.3.1 has no version endpoint —
    verified by reading the trait source. Falls back to the pinned
    `v5.3.1` constants from DESIGN_ADDENDUM §3 AC-27 recording. Live
    verification is deferred to step 3e (wallet_lifecycle + broadcast).

Inline tests cover the parser shape (cargo-style, semver+commit,
empty, single-token), graceful failure for a missing binary,
the pinned-base-node round-trip, and a harness-commit sanity check
that skips when git is genuinely absent.

Relates-to: tari-project#1
Adds the runtime accessor backing `DESIGN.md §Secret handling` and
`DESIGN_ADDENDUM.md §S1`. `SeedHandle` carries env-var *names* drawn
from `Config::seeds` and re-reads the values on every accessor call,
so an operator can rotate a seed mid-run by exporting a new value.

Surface:
  * mnemonic_old / mnemonic_new / mnemonic_payment_processor /
    wallet_password — each returns a `RedactedString`.
  * assert_distinct — bails when any two slots resolve to the same
    mnemonic; AC-35 demands three separate funded wallets.
  * address_old / address_new / address_payment_processor — Esmeralda
    `TariAddress` derived via the shared `derive_address` helper.

`RedactedString` mirrors `tari_utilities::Hidden<String>` but with a
uniform "[REDACTED]" Debug/Display form. Zeroize-on-drop falls through
the inner `Hidden`, so no direct `zeroize` dep is needed (kept off the
Cargo.toml per DESIGN.md §Dependency strategy).

`derive_address` is now the single site for the address-from-mnemonic
path. `print_address` (CLI subcommand) refactors to call it; the
funding pre-flight added in the next commit calls the same helper via
SeedHandle. One code path serves all three address-touching surfaces.

Inline tests cover env-var read-at-call-time (set value, observe, mutate,
re-observe), the missing-env-var bail, distinctness happy + duplicate
paths, address base58 round-trip, address determinism, and a
cross-path check that `derive_address` and `print_address` agree on
the same mnemonic.

Relates-to: tari-project#1
Implements the 10-rule denylist backing `RESULT_PROFILE_SCHEMA.md §6`.
Two contracts:

  1. `RedactionDenylist::check(&profile)` serialises the profile to
     JSON and bails on the first rule match, reporting only the rule
     ID and the byte offset — the matched substring is never echoed
     into the error so a panic / log surface can't accidentally
     re-leak the secret the rule caught.
  2. `RedactionDenylist` is itself `Serialize`. The per-rule
     `Serialize` impl emits only `(id, kind, reason)` — the compiled
     regex pattern body and the env-derived substring value are
     deliberately elided, so the auditable JSON copy in the result
     profile contains rule IDs and reasons only.

`init_from_env` reads the three seed env vars + the passphrase env
var from the names recorded in `Config::seeds` and builds R1..R10
once at startup. Missing env vars degrade to empty-string rules,
which the `matches` helper treats as never-firing — keeps unit tests
usable without forcing every test to export every env var.

Patterns:
  R1 — Tari 24-word OR BIP-39 12/24-word mnemonic shape.
  R2 — three seed mnemonics, each whole AND each ≥3-letter word.
  R3 / R4 — hex view / spend keys (case-insensitive).
  R5 — wallet passphrase substring.
  R6 / R7 — long hex (≥2048) / base64 (≥1024) blobs.
  R8 — case-insensitive Bearer tokens.
  R9 / R10 — `$USER` / `$LOGNAME` and `/Users/<user>/` `/home/<user>/`.

Inline tests cover the realistic-seed catch, the username-path catch,
the elision contract (serialised denylist contains rule IDs but NOT
the env-derived seed or passphrase), the long-hex catch, and a
sanity "clean profile passes" path.

Relates-to: tari-project#1
Implements `enforce_funding` per `DESIGN_ADDENDUM.md §M2`. Runs from
`main()` immediately after `enforce_esmeralda` and before any mode
runs; bails non-zero if any of the three harness seeds has a wallet
balance below `a_fund * 11 / 10` (10% headroom, integer math).

API drift surfaced: the published `BaseNodeWalletClient` trait on
`minotari_node_wallet_client = "5.3.1"` does NOT expose a balance
method (verified by reading the trait at `src/client/mod.rs`).
Architectural mitigation: a `BalanceQuery` trait with one method
`get_balance(&TariAddress) -> anyhow::Result<u64>` — same fake-trait
pattern as `EnvCapture` in step 3c.1. Live implementation will be
filled in alongside `wallet_lifecycle` (step 3e) once the harness has
a concrete address-scanning surface to read from. The trait keeps
`enforce_funding` testable today without taking a bet on which
balance-source the live wiring will use.

Error format matches DESIGN_ADDENDUM §M2:

  Funding pre-flight failed. Required >= 11000000000 uT per seed (a_fund * 11/10).
    old_wallet: 11000000000 uT OK
    new_wallet: 0 uT (short by 11000000000 uT) FAIL
    payment_processor: 12000000000 uT OK
  See RUNBOOK §Funding for how to mine to each address using minotari_miner.

Inline tests cover the all-ok happy path, the shortfall-listing
contract (all three labels reported, deficit named, RUNBOOK pointer
present), and balance-query error propagation through the context
wrap. Each test uses unique env-var names to avoid cross-test races
under cargo's parallel runner.

Relates-to: tari-project#1
Three corrections to design artifacts, all driven by the same verified
fact: Tari's `CipherSeed` (at `tari_common_types::seeds::cipher_seed`
v5.3.1 commit `5d6ef11bb89caa34fe9ee676d608f273db90038d`) is a 24-word
Tari-specific encoding, NOT BIP-39. The original arch-test plan
assumed the BIP-39 all-zeros test phrase could double as a
deterministic test mnemonic; that assumption was rejected at runtime
during swe-impl step 1 (M1 spike), where `CipherSeed::from_mnemonic`
declined the abandon×12 phrase. Recorded in `API_DRIFT.md §Step 1`.

1. `RESULT_PROFILE_SCHEMA.md §6 R1` rationale text now says "Tari
   24-word mnemonic OR BIP-39 12/24-word phrase — regex catches the
   structural shape of both." The pattern itself
   (`\b(?:[a-z]{3,8}\s+){11,23}[a-z]{3,8}\b`) is structural and
   unchanged: it catches the shape of either word set by construction.

2. `DESIGN.md §Test Strategy §Test-data secret handling` replaces the
   "single BIP-39 test mnemonic" paragraph with the verified
   approach: tests use `gen_seed()` (which wraps `CipherSeed::random()`)
   per-call, with unique per-test env-var names to avoid cross-test
   races under cargo's parallel runner. Spirit preserved: tests use
   disposable, obviously-fake seeds.

3. `DESIGN.md §Test Strategy §Fixtures` drops the
   `fixtures/bip39_wordlist.txt` row — R1 is structural and does not
   need a wordlist, confirmed by
   `src/seed/redact.rs::tests::denylist_catches_realistic_seed_phrase`
   in step 3d.2 which feeds a fresh 24-word Tari mnemonic into a fake
   profile and observes the R1/R2 catch.

Relates-to: tari-project#1
Introduces `src/wallet_lifecycle/` per `analysis/DESIGN.md §Module
boundaries and data flow`. Three submodules:

* `mod.rs` — `WalletLifecycle` trait (async via `async_trait::async_trait`
  to match the `BaseNodeWalletClient` shape from
  `minotari_node_wallet_client = "5.3.1"`).
* `data_dir.rs` — `HarnessDataDir` owning a `tempfile::TempDir` under
  `target/harness-data/<run-id>/<mode>/`. `wipe()` canonicalises the
  target and refuses any path outside the tempdir per AC-34. Drop
  cleans the tree.
* `grpc.rs` — `connect_with_retry(addr, deadline)` wraps
  `minotari_app_grpc::tari_rpc::wallet_client::WalletClient::connect`
  with bounded exponential backoff. Connect-time only — `DESIGN.md
  §Mode 1 step 1` explicitly permits race-tolerant connect; AC-30/31/32
  ban only submit-side retry.

New direct deps (Cargo.toml):
* `async-trait = "0.1"` — already transitively present via
  `minotari_node_wallet_client`; the trait sugar matches maintainer
  idiom.
* `nix = "0.29"` (unix-only target dep, feature `signal`) — staged for
  the next commit's SIGTERM/SIGKILL handling in `ConsoleWalletLifecycle`.
* `wiremock = "0.6"` (dev-dep) — staged for the broadcast wrapper unit
  tests.

Inline tests cover: path under harness root, refusal of external /
root / dotdot paths, acceptance of internal subpaths, no-op on absent
internal paths, drop cleanup.

Relates-to: tari-project#1
Implements `ConsoleWalletLifecycle` per `analysis/DESIGN.md §Mode 1 —
concrete wiring`. The struct owns the `minotari_console_wallet`
subprocess, a dynamic-port gRPC client, and the `HarnessDataDir`; it
implements `WalletLifecycle::{spawn, wait_ready, teardown, data_dir}`.

* `spawn` writes the seed mnemonic into `<data_dir>/seed.txt` (lives
  only inside the harness tempdir per `DESIGN.md §Secret handling`),
  allocates an ephemeral 127.0.0.1 port via TcpListener-then-drop,
  and execs `minotari_console_wallet` with the literal argv shape
  from DESIGN.md §Mode 1 step 1 (--network first, then --base-path,
  --seed-words-file, --non-interactive-mode, --grpc-address, --password).
  `kill_on_drop(true)` on the tokio Child ensures panic / Ctrl-C
  cannot leak the process.

* `wait_ready` connects through `super::grpc::connect_with_retry`
  (connect-time backoff is allowed by DESIGN.md §Mode 1 step 1 and
  AC-30/31/32 only ban submit-side retry), then polls `GetState`
  every 1s until `network.status == ConnectivityStatus::Online`.

  API drift: DESIGN.md called this "is_synced" but
  `minotari_app_grpc = "5.3.1"`'s NetworkStatusResponse exposes
  `status: ConnectivityStatus` (Initializing / Online / Degraded /
  Offline) — there is no `is_synced` bool. The Online variant is
  the wallet's self-declared "ready for scenario calls" state.

* `teardown` sends SIGTERM via `nix` (cfg(unix); harness is documented
  Linux+macOS only per DESIGN.md §Non-Goals), waits up to 10s polling
  `try_wait`, then sends SIGKILL. Idempotent — repeat calls after a
  successful teardown return Ok.

Config extension: `Config::minotari_console_wallet_path: Option<PathBuf>`
lets operators on non-standard installs point at the binary; `None`
falls back to `$PATH`. Additive — `#[serde(default)]` keeps the
existing harness.toml shape working.

Defense-in-depth re-assertion of `network == "esmeralda"` runs in
both `new()` and `spawn()` so a stale Config cannot reach
`Command::new` past the primary guard.

Inline tests:
* dynamic port allocator returns a valid, distinct-per-call port,
* `spawn_argv` snapshot test asserts each flag's position matches
  DESIGN.md §Mode 1 step 1.

Subprocess-side teardown behaviour is covered by the live-network
smoke test (operator opt-in via `cargo test -- --ignored`); unit
tests do not spawn the real binary.

Relates-to: tari-project#1
Adds `src/broadcast/mod.rs` with `Broadcaster` (thin wrapper over
`minotari_node_wallet_client::http::Client`) and a typed
`TxSubmissionOutcome` shape that scenario code records into the
result profile.

The published `Client::submit_transaction` already handles the
dual-shape JSON-RPC response per DESIGN.md decision 5 (envelope
success vs bare-error): envelope-success returns
`Ok(TxSubmissionResponse)`, bare-error returns
`Err(anyhow!("Transaction submission failed: ..."))`. Our wrapper
maps `Ok` through a `From<TxSubmissionResponse> for TxSubmissionOutcome`
impl and propagates `Err` with added context.

API drift surfaced (logged in analysis/API_DRIFT.md scratch):
* Upstream TxSubmissionResponse is `{ accepted, rejection_reason,
  is_synced }`. DESIGN.md called out a `details` field that does
  NOT exist on the published 5.3.1 struct; our outcome keeps a
  nullable `details: Option<String>` for forward-compat.
* Upstream `TxSubmissionRejectionReason` has a `DoubleSpend` variant
  not listed in DESIGN.md §Mode 2 step 5 — surfaced as first-class
  so S4 contention measurements don't collapse it into `Other`.
* Calling `submit_transaction` requires importing the
  `BaseNodeWalletClient` trait — `Client` itself does not expose
  the method as an inherent.

`RejectionReason::Other(String)` is kept as a forward-compatibility
arm even though the published 5.3.1 client's closed enum cannot
reach it; this isolates scenario code from a future upstream
release adding a variant.

Inline tests cover the six listed rejection variants plus the
upstream DoubleSpend, accepted-true mapping, and `is_synced`
propagation. The "bare error → typed Err" path is covered by
relying on the upstream client's tests (the wrapper just adds
context) — wiremock-based end-to-end tests live alongside the
later scenario integration tests.

Relates-to: tari-project#1
Adds `src/wallet_lifecycle/balance_query.rs` with `LiveBalanceQuery`,
the live impl of the `BalanceQuery` trait declared in
`src/guards.rs`. Wired against `minotari_node_wallet_client::http::Client`
so the eventual real implementation can flip the `get_balance` body
without touching call sites in `main()`.

API drift logged in `analysis/DESIGN_AMENDMENT.md §7`: the published
`minotari_node_wallet_client = "5.3.1"` `BaseNodeWalletClient` trait
has NO address-indexed balance endpoint. The trait's surface
(`get_utxos_mined_info(hashes)`, `fetch_utxo(hash)`,
`sync_utxos_by_block(start_header_hash, ...)`) is UTXO-hash- or
block-hash-indexed; none of it answers "what is address X's
spendable balance?".

The only source-of-truth for an address's balance in this codebase
is the wallet gRPC's `GetBalance`, which requires a running
`minotari_console_wallet` for the seed that owns that address.
Pre-flighting three seeds therefore implies three console-wallet
spawns — heavy, and architecturally entangled with Mode 1's
lifecycle. That entanglement is deferred to a follow-up patch once
Mode 1 lands.

Until then, `LiveBalanceQuery::get_balance` bails with a structured
error that points at the amendment and surfaces the operator
workaround (`minotari_miner` funding per RUNBOOK + skipping the
pre-flight). The existing `FakeBalanceQuery`-backed `enforce_funding`
unit tests in `src/guards.rs` continue to cover the funding-pre-flight
logic deterministically; this change does not affect them.

Inline tests:
* construction does not make a network call (canary for an upstream
  change that adds an eager connect to `Client::new`),
* `get_balance` bail message names the amendment + operator workaround.

The promised wiremock-based "sum of UTXOs" test from the step prompt
is unimplementable on the published 5.3.1 surface and is documented
in the amendment as "not shipped in 3e".

Relates-to: tari-project#1
Adds `src/modes/mod.rs` with the `Mode` trait and its supporting
types — `TxRecord`, `ScanOutcome`, and `UnsupportedOperation`. Per
`analysis/DESIGN.md §Module boundaries and data flow`, scenarios
B0-S7 are mode-agnostic: they call `mode.send_single(...)`,
`mode.scan_from_birthday(...)`, etc., through this trait. The three
implementations (Mode 1 / Mode 2 / Mode 3) land in sibling files
per `DESIGN_ADDENDUM.md §S4` execution order.

Methods enumerated by DESIGN.md §Module boundaries:
* `send_single` / `send_batch_one_to_many`,
* `scan_from_birthday`,
* `get_balance` / `get_utxo_count`,
* `wipe_and_reimport`.

All methods take `&mut self` — the underlying gRPC `WalletClient`
(Mode 1) and subprocess spawn surface (Mode 2/3) are mutating. The
trait is `async` via `async_trait::async_trait` (matches the
`BaseNodeWalletClient` shape in `minotari_node_wallet_client = "5.3.1"`
and keeps trait objects usable from scenario dispatch).

`UnsupportedOperation` is a `thiserror`-derived error type returned
by mode-method combinations that aren't realisable on a given mode
(Mode 1's `send_batch_one_to_many` per DESIGN.md §Mode 1 step 3 +
AC-20). Scenarios surface the failure into the result-profile cell's
`arms.batch.applies = false` and continue rather than aborting the
mode.

No implementations in this commit — the trait+types is its own unit
of work; the Mode 1 impl follows in the next commit, with Modes 2
and 3 deferred to steps 3g and 3h.

Relates-to: tari-project#1
Adds `src/modes/old_wallet.rs` with `OldWallet` — Mode 1's `Mode`
trait implementation, backed by `ConsoleWalletLifecycle`. Each
trait method dispatches against the connected
`minotari_app_grpc::tari_rpc::wallet_client::WalletClient`:

* `send_single` -> gRPC `Transfer` with a single `PaymentRecipient`,
  `PaymentType::OneSidedToStealthAddress`, surfaces the transfer
  result as a `TxRecord`.
* `send_batch_one_to_many` -> returns `UnsupportedOperation`. The
  proto `TransferResponse.results` shape is "one TransferResult per
  recipient" — N independent single-recipient txs, not a 1->K batch.
  DESIGN.md §Mode 1 step 3 + AC-20 both record this: S5's batch arm
  runs on payment_processor only.
* `scan_from_birthday` -> reuses `wipe_and_reimport`; the wallet
  scans on start so the wait_ready wall-clock IS the scan duration.
  Pre-scan tip is set to 0 — scenario code fills it in from the
  base-node `get_tip_info` call per DESIGN.md §Scenario state machine.
* `get_balance` -> gRPC `GetBalance(payment_id: None)`, returns
  `available_balance`.
* `get_utxo_count` -> gRPC `GetUnspentAmounts`, returns `amount.len()`.
* `wipe_and_reimport` -> teardown -> rewrite the held mnemonic via
  `CipherSeed::change_birthday(birthday)` (the new `rewrite_birthday`
  helper) -> wipe the data dir via `HarnessDataDir::wipe` (AC-34
  path-confinement enforced) -> recreate the dir -> spawn -> wait_ready.

To support the birthday-rewrite flow, `ConsoleWalletLifecycle` gains
three accessor methods (additive, no behavioural change):
* `data_dir_mut()` — for the wipe call,
* `mnemonic()` — to read the current mnemonic plaintext for re-encoding,
* `replace_mnemonic(String)` — to swap in the rewritten mnemonic before
  re-spawn. The next `spawn` writes the new mnemonic into the freshly-
  wiped seed.txt.

API drift surfaced (logged in analysis/API_DRIFT.md scratch):
* The `Mode` trait was originally specified with `&self` receivers for
  `get_balance` and `get_utxo_count`; tonic-generated gRPC methods
  take `&mut self` on the client, so all trait methods are uniformly
  `&mut self`. Documented in the trait's docstring.
* `PaymentType::OneSidedToStealthAddress` is the only non-deprecated
  variant on the v5.3.1 proto; the other two are marked deprecated.

Inline tests:
* `mode1_name_is_old_wallet` — covered by the `name()` constant,
* `mode1_send_batch_one_to_many_error_names_ac20_and_old_wallet` —
  shape of the `UnsupportedOperation` error,
* `rewrite_birthday_round_trips_through_cipher_seed` (5_000 birthday),
* `rewrite_birthday_zero_yields_valid_genesis_birthday`,
* `rewrite_birthday_rejects_garbage_mnemonic`.

Subprocess-side `send_single` / `scan_from_birthday` /
`wipe_and_reimport` behaviour is exercised by the live-network
smoke (operator opt-in via `cargo test -- --ignored`); unit tests
do not spawn the real binary.

Relates-to: tari-project#1
… submit)

DRY core shared by Modes 2 and 3 — the four-step pipeline mirrored verbatim
from PR #99 (`tari-project/minotari-cli#99` `send_transactions` step) with the
single delta `Network::LocalNet → Network::Esmeralda`:

  1. subprocess `minotari --config <harness.toml> --network esmeralda
     create-unsigned-transaction --database-path … --password … --account-name
     default --recipient <addr_base58>::<amount> [--recipient ...] --output-file
     <tx_<idx>.json>` (top-level flags BEFORE the subcommand per
     `DESIGN_ADDENDUM.md §Mode 3 CLI shape — proven`),
  2. parse via `PrepareOneSidedTransactionForSigningResult::from_json`,
  3. reconstruct the KeyManager from the SeedHandle's mnemonic via the
     approved API surface (`WalletType::SeedWords` +
     `SeedWordsWallet::construct_new` + `KeyManager::new`) and call
     `sign_locked_transaction(&km, ConsensusConstantsBuilder::new(Esmeralda)
     .build(), Network::Esmeralda, unsigned)` in-process,
  4. broadcast via `Broadcaster::submit_transaction` from step 3e.

No retry, backoff, or throttling anywhere (AC-30/31/32). The output JSON file
is deleted on success and preserved on any failure path (helpful for operator
post-mortem). Unit tests pin the argv shape for both single-recipient and
batch (K=3) shapes against the proven CLI surface, verify the harness.toml
write contract, and lock the bail-on-`SeedRole::Old` contract.

Adds `Config::minotari_path` so the new wallet binary path is configurable
independently from `Config::minotari_console_wallet_path` (Mode 1's binary).
Adds `TxRecordStatus` + `TxRecordPhase` enums to `modes/mod.rs` so the helper
can name failure phases (construct / sign / broadcast / confirm / scan) per
`RESULT_PROFILE_SCHEMA.md §4`.

Module-level `#[allow(dead_code)]` lifts in the Mode 2 commit that follows.

Relates-to: tari-project#1
`NewWallet` implements the `Mode` trait for the new minotari CLI:

* `send_single` / `send_batch_one_to_many`: route through the shared
  `minotari_subprocess::create_sign_and_submit` helper with `SeedRole::New`
  so the per-mode seed (`HARNESS_SEED_NEW` by default) drives the in-process
  KeyManager. Single-recipient uses a one-element recipients slice; batch
  passes the caller's list verbatim.

* `scan_from_birthday` / `get_balance` / `get_utxo_count` /
  `wipe_and_reimport`: bail with a structured pointer at
  `analysis/DESIGN_AMENDMENT.md §8`. The new `minotari` CLI at the pinned
  `minotari-cli` commit `52a7287a...` differs substantially from the
  DESIGN.md §Mode 2 step 7 read-side surface (no `list-utxos` subcommand,
  `Balance` emits human stdout rather than machine-parseable JSON, and
  wallet restoration uses `Create --seed-words` not `import-seed`). The
  real impl lands in step 3i when scenarios layer knows the stdout-parsing
  contract; today's placeholder is loud-bailing per the
  LiveBalanceQuery precedent (3e.4) so scenarios that hit it fail cleanly
  rather than silently producing zero-valued profile cells.

AC-6 (`Mode2NewWallet` does NOT spawn `minotari_console_wallet`) is
satisfied by construction: this module + the helper invoke only `minotari`
(via `Config::minotari_path`), never `minotari_console_wallet`. Verified
by the unit test
`minotari_subprocess::tests::helper_source_does_not_reference_console_wallet`
and reinforced by `tests/mode2_no_console_wallet.rs` (3j).

The module-level `#[allow(dead_code)]` on the helper from the previous
commit is removed; `SeedRole::Pp` carries a localised allow until Mode 3
lands next.

Relates-to: tari-project#1
`PaymentProcessor` implements the `Mode` trait by reusing the shared
`minotari_subprocess::create_sign_and_submit` helper with `SeedRole::Pp` so
the payment-processor seed (`HARNESS_SEED_PP` by default) drives the in-process
KeyManager. Mode 3 IS Mode 2 with a different seed slot and routes batches
through the same repeated-`--recipient` argv shape proven in
`DESIGN_ADDENDUM.md §Mode 3 CLI shape — proven`.

For S5 (per AC-19 / AC-20):
* batch arm (Mode 3 primary): `send_batch_one_to_many(K=10 recipients)`
  invoked 10 times for 100 total recipients across 10 batch txs,
* individual arm (Mode 3 context-only per ambiguity tari-project#3): `send_single`
  invoked 100 times against the same recipient list.

Mode 3 itself is arm-agnostic; the scenarios layer (3i) orchestrates which
arm runs.

Read-side flows (`scan_from_birthday` / `get_balance` / `get_utxo_count` /
`wipe_and_reimport`) bail with the same `DESIGN_AMENDMENT.md §8` pointer as
Mode 2 — the new `minotari` CLI surface at the pinned commit lacks the
machine-parseable read-side subcommands DESIGN.md §Mode 2 step 7 assumed.
Real impl lands in step 3i.

The `SeedRole::Pp` `dead_code` allow on the helper from the Mode 2 commit
is removed; `SeedRole::Old` retains its localised allow because that branch
exists only to bail loudly and the test
`create_sign_and_submit_bails_on_seed_role_old` exercises it.

Relates-to: tari-project#1
Closes the first of four read-side method placeholders surfaced in
analysis/DESIGN_AMENDMENT.md §8 (filed during step 3g). The new
minotari CLI from tari-project/minotari-cli has no import-seed
subcommand; restoration is `Create --seed-words "..."` with the same
SecurityArgs / DatabaseArgs / AccountArgs flatten as create-unsigned-
transaction.

Adds a new shared module src/modes/minotari_wallet_ops.rs that owns
the wipe / Create plumbing for both Modes 2 and 3:

* rewrite_birthday mirrors Mode 1's homonym (CipherSeed -> change_birthday
  -> re-encode to mnemonic). Pure-Rust, no IO.
* wipe_and_reimport_via_create wipes the harness data dir (AC-34
  path-confinement), recreates it, writes harness.toml with
  network="esmeralda", and spawns `minotari Create --seed-words` with
  env_clear() + HOME/PATH/TARI_NETWORK plus the password in argv per
  PR #99's SecurityArgs declaration. On non-zero exit the partial state
  is preserved for operator diagnosis.

NewWallet and PaymentProcessor's wipe_and_reimport implementations
reduce to: read mnemonic for the role, rewrite_birthday, call the
helper. Subsequent commits wire scan_from_birthday, get_balance, and
get_utxo_count.

Test surface: argv top-level-flags-before-subcommand contract (single
argv slot for the 24-word mnemonic per clap's Option<String>),
rewrite_birthday round-trip for birthday=7000 and birthday=0,
DEFAULT_BINARY != minotari_console_wallet pin, plus Mode 2/3 wipe
methods deterministically surface their "Mode N wipe_and_reimport"
context when the configured binary does not exist.

Relates-to: tari-project#1
Closes the second of four read-side method placeholders surfaced in
analysis/DESIGN_AMENDMENT.md §8 (filed during step 3g). The new
minotari CLI's `Scan` subcommand takes --max-blocks-to-scan (not the
DESIGN.md-prescribed --from-birthday); birthday is baked into the
wallet via the wipe+create path the previous commit landed.

Adds to src/modes/minotari_wallet_ops.rs:

* build_scan_argv — top-level --config/--network BEFORE the `scan`
  subcommand; subcommand flags --password/--database-path/
  --account-name/--max-blocks-to-scan AFTER. Identical envelope to
  build_create_argv.
* run_scan_subprocess — spawns with env_clear() + HOME/PATH/TARI_NETWORK
  AND RUST_LOG=info (the only way to extract the result, see below).
  Returns ScanStdoutParsed { outputs_found: Option<u64>,
  max_blocks_to_scan: u64 }.
* parse_event_count — structural-anchor regex on `event_count=(\d+)`.
  The Scan subcommand emits its result via the `log` crate's key-value
  formatter (`info!(event_count = events.len(); "Scan complete")`),
  not stdout. Stderr lookup with regex is the only stable extraction.
* DEFAULT_MAX_BLOCKS_TO_SCAN = u64::MAX. The cli.rs default is 50; the
  bounty's B0/S2/S3/S6/S7 scenarios walk from genesis so the default is
  too low. u64::MAX lets the subprocess consume blocks until the
  wallet's underlying scan logic stops. Scenarios layer (step 3i.1)
  can bound this via base-node tip queries if needed.

Mode 2 and Mode 3 grow a `last_scan: Option<ScanOutcome>` cache field
populated by every successful scan_from_birthday call. Used by
get_utxo_count in a subsequent commit per
analysis/DESIGN_AMENDMENT.md §8.3 step 4.

scan_from_birthday returns h_tip_start/h_tip_end/balance_microtari as
0 — matches Mode 1's `OldWallet::scan_from_birthday` "scenario layer
fills these in" convention. The post-scan balance read lands when
get_balance is wired in a subsequent commit.

Test surface: argv shape pin, parse_event_count for present/absent/zero
inputs, Mode 2/3 scan deterministically surface their "Mode N scan"
context when the configured binary does not exist.

Relates-to: tari-project#1
…otari Balance subprocess (3i.0.c)

Wires Mode 2 (NewWallet) and Mode 3 (PaymentProcessor) `get_balance` to a
real `minotari Balance` subprocess, replacing the §8 amendment placeholder.

Anchor choice: the parser uses structural unit sentinels (`µT` and `T`),
not label anchors like "Available:" / "Balance:". Per
`analysis/DESIGN_AMENDMENT.md §8.3` (anchor strategy `(a)`), structural
anchors survive label renames as long as the formatter's unit sentinel
stays — and `MicroMinotari::Display` lives in `tari_amount.rs` upstream
where it changes far less often than human-readable labels.

Three fixtures captured (each is one line from `minotari Balance` stdout
per `main.rs::handle_balance` at the pinned commit lines 680-694):

* fixtures/minotari_balance_empty.txt   — empty wallet, total = 0 µT.
* fixtures/minotari_balance_partial.txt — partial wallet, total < 1 T (microtari format).
* fixtures/minotari_balance_full.txt    — funded wallet, total >= 1 T (Tari format,
  6 decimal places).

Both modes route through the shared
`crate::modes::minotari_wallet_ops::run_balance_subprocess` helper — Mode 2
and Mode 3 share the same `minotari` (new wallet from `tari-project/minotari-cli`)
binary and the same DB layout, so a single helper covers both. The helper
follows the same env-clear + selective re-add envelope as
`minotari_subprocess`, and the argv has NO `--password` (Balance reads
unencrypted DB metadata per cli.rs lines 247-252).

`get_utxo_count` remains a §8 placeholder pending 3i.0.d.

Relates-to: tari-project#1
roadhero added 10 commits June 3, 2026 17:59
…rage

Implements MODE_3_REWORK_SPEC.md §13's pr_lifecycle test list (8 tests,
parallel to PpLifecycle's shape):

* import_view_key_argv_matches_spec (pure-fn snapshot, asserts the
  --base-path / --network / --account-name shape per spec §5 step 1
  AND that --port is intentionally absent)
* daemon_argv_matches_spec (asserts the daemon argv adds --port +
  --account-name on top of the import shape)
* daemon_argv_includes_the_dynamic_port (regression guard for the port
  literal)
* new_refuses_non_esmeralda_network (defense-in-depth: even when the
  harness-wide guard is bypassed, the constructor refuses)
* spawn_returns_handle_and_pid (Child PID > 0)
* wait_ready_times_out_when_fake_never_binds (full spawn flow exercises
  import-then-daemon ordering + the 30s readiness probe + bail message)
* teardown_sends_sigterm_then_sigkill (TERM-trap fake exits inside the
  5s grace; second teardown no-op)
* drop_invokes_kill_on_drop (kill_on_drop(true) reaps the daemon child
  via the same nix::sys::signal::kill(pid, None) liveness poll as
  pp_lifecycle's parallel test)

Reuses tests/fixtures/fake_minotari.sh (added with the pp_lifecycle
commit) — its two-branch script handles both subcommands.

Refs tari-project#1.

Signed-off-by: Dennis Vorobyov <roadhero@gmail.com>
…overage

Implements MODE_3_REWORK_SPEC.md §13's payment_processor test list
(10 tests):

* mode3_name_is_payment_processor (Mode::name + MODE_NAME const)
* mode3_scan_from_birthday_returns_unsupported_operation
* mode3_get_balance_returns_unsupported_operation
* mode3_get_utxo_count_returns_unsupported_operation
* mode3_wipe_and_reimport_returns_unsupported_operation
  (each of the four downcasts the anyhow::Error to UnsupportedOperation
  and asserts mode='payment_processor' and op matches the method name)
* mode3_send_single_posts_to_pp_via_wiremock (TxRecord.txid carries
  batch_id; status='success'; error_string=None)
* mode3_send_batch_posts_to_pp_via_wiremock (3-recipient batch; same
  shape assertions)
* mode3_send_batch_rejects_over_max_batch_size (101 items bails before
  HTTP — server is mounted but never called; uses cloned recipient to
  keep address-derivation cost flat)
* mode3_dispatcher_returns_arc_per_call (two dispatcher() calls each
  yield a usable Arc<dyn S4Dispatcher>)
* mode3_dispatcher_assigns_unique_client_ids (4 concurrent dispatch()
  calls produce 4 distinct s4-N client_ids; verified by reading the
  request bodies wiremock captured)

Adds one production-shape adjustment: PaymentProcessor::from_parts_for_test
under #[cfg(test)] pub(crate) so tests can inject an Arc<PpHttpClient>
pointing at a wiremock server (production routes through
PpLifecycle::http_client_owned which hardcodes the loopback port).
Matches the test-only constructor pattern in NewWallet::new_with_wallet_db.

Refs tari-project#1.

Signed-off-by: Dennis Vorobyov <roadhero@gmail.com>
Implements MODE_3_REWORK_SPEC.md §13's integration-test list (two new
files under tests/):

tests/mode3_skips_scan_scenarios.rs (9 tests):
* mode3_scan_from_birthday_b0_returns_unsupported
* mode3_scan_from_birthday_s2_returns_unsupported
* mode3_scan_from_birthday_s3_returns_unsupported
* mode3_scan_from_birthday_s6_returns_unsupported
* mode3_scan_from_birthday_s7_returns_unsupported
* mode3_get_balance_returns_unsupported
* mode3_get_utxo_count_returns_unsupported
* mode3_wipe_and_reimport_returns_unsupported
* mode3_unsupported_reason_is_consistent_across_methods
Each constructs a real PaymentProcessor via the public PaymentProcessor::new
ctor (env-resolved Mode3Config with fake fixture binary paths) and drives
the four scan-shaped Mode trait methods. The downcast assertion mirrors
the runner's main.rs:247 pattern so a future regression that changes the
error type surfaces here AND in the runner.

tests/mode3_pp_lifecycle_with_fake_binary.rs (3 tests):
* fake_pp_logs_startup_banner_to_per_run_log_file (verifies stdio
  capture into <data_dir>/logs/pp.log lands the fake's stderr banner
  within 2s; exercises the per-run log file pattern from spec §2)
* pp_lifecycle_spawn_against_fake_times_out_then_teardown_is_clean
  (full PpLifecycle::spawn -> readiness probe times out at 30s ->
  teardown SIGTERMs the fake which traps and exits 0 inside the 5s
  grace; idempotent second teardown)
* pp_lifecycle_drop_without_teardown_relies_on_kill_on_drop (exercises
  spec §14 failure mode #12)

All tests use the existing tests/fixtures/{fake_pp.sh,fake_minotari.sh}
fixtures committed alongside pp_lifecycle's unit tests — no live PP or
minotari daemons ever spawn.

Refs tari-project#1.

Signed-off-by: Dennis Vorobyov <roadhero@gmail.com>
Pure formatting cleanup — rustfmt re-flowed three of the test files I
added in the prior 5 commits. No behaviour change.

Refs tari-project#1.

Signed-off-by: Dennis Vorobyov <roadhero@gmail.com>
…ify account name

Previous argv used --base-path/--port/--account-name which fail
clap parse against minotari daemon and import-view-key subcommands.
Real flags per minotari-cli@52a7287a/minotari/src/cli.rs:
- daemon: --password, --base-url, --database-path,
  --scan-interval-secs, --api-port (NO --account-name)
- import-view-key: --view-private-key, --spend-public-key,
  --password, --database-path, --birthday (NO --account-name)
- --network is top-level on Cli, not subcommand-scoped

Account name unified to "default" (not "bench") because upstream
init_wallet.rs:121 hardcodes the wallet name as "default" and
neither subcommand accepts --account-name. PP's
ACCOUNTS__BENCH__NAME env value flipped to "default" so PP's
GET /accounts/default/balance hits the PR daemon correctly.

Adds Mode3Config::pr_base_url (default https://rpc.esmeralda.tari.com)
for the now-mandatory --base-url flag.

Resolves swe-review B2 (canonical-baseline argv parse error)
and N2 (account-name mismatch -> 404 on PR daemon).

Relates-to: tari-project#1
Signed-off-by: Dennis Vorobyov <roadhero@gmail.com>
…tead of UnsupportedOperation

S0 calls mode.get_balance() + mode.get_utxo_count() as its first
two operations. UnsupportedOperation propagates via ? and S0 is
recorded as NotRun before the actual batch POST happens. PP
doesn't own a balance surface (balance lives with the PR daemon's
view-key wallet), so returning Ok(0) is semantically honest and
lets S0 progress to its real work: POST /v1/payment-batches with
one payment.

Resolves swe-review B1.

Relates-to: tari-project#1
Signed-off-by: Dennis Vorobyov <roadhero@gmail.com>
UNSUPPORTED_REASON was a single const applied to all Skipped
returns, masking the actual reason for non-scan methods. Split
into method-accurate strings so S1/S4/S5 cell logs explain what's
actually unsupported.

Resolves swe-review C5.

Relates-to: tari-project#1
Signed-off-by: Dennis Vorobyov <roadhero@gmail.com>
Mode 3 previously checked SeedRole::Pp's mnemonic-derived balance,
but its real signer is the view+spend keypair held by the PR
daemon. Operator funding the correct wallet (PR daemon) would
see spurious pre-flight failures. Pre-flight now queries the PR
daemon's GET /accounts/default/balance endpoint.

Adds a new `PrBalanceQuery` HTTP client (parses the `available`
field from the real handler's AccountBalance JSON, no retry) and
threads `Option<&PrBalanceQuery>` through `enforce_funding`. When
Config::mode_3 is Some, main.rs builds a PrBalanceQuery pointed at
http://127.0.0.1:<pr_port> and passes it in; the Pp arm is routed
through it instead of the legacy mnemonic-derived path.

Sequencing: pre-flight runs at startup, before the per-mode loop
spawns the PR daemon via start_external_services. When the PR
daemon isn't yet reachable (the common case), the Pp arm is
logged with a warn and skipped rather than failing the whole
pre-flight — operators who want strict Mode 3 pre-flight coverage
must pre-warm the PR daemon out-of-band. The warn log explains
this. Wiremock tests prove the HTTP shape (GET /accounts/default
/balance, parse `available`).

Resolves swe-review C4.

Relates-to: tari-project#1
Signed-off-by: Dennis Vorobyov <roadhero@gmail.com>
Added speculatively for PP /v1/events sub-segment latencies that
never wired up. Forces #[allow(dead_code)] and ripples through
every TxRecord constructor. Per Simplicity First, delete now;
re-add in a focused commit when the metric actually lands.

Resolves swe-review C3.

Relates-to: tari-project#1
Signed-off-by: Dennis Vorobyov <roadhero@gmail.com>
The doc comment claimed v1 always uses 'bench' for the PP account
name. Post-N2 (commit 4c23e35) production sends 'default' to match
the wallet name minotari-cli hardcodes at init_wallet.rs:121.
Updated to reflect the actual value and cite the upstream source.

Cleared in swe-review pass 2.

Signed-off-by: Dennis Vorobyov <roadhero@gmail.com>
Comment thread src/modes/old_wallet.rs
})
}

async fn send_batch_one_to_many(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can run this on the console wallet

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Wiring Mode 1's send_batch_one_to_many via minotari_console_wallet's gRPC SendTransactionBatch (or equivalent on WalletClient) is achievable since we already hold the gRPC client in OldWallet. I'll ship the implementation plus tests.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upd.: shipped at e5034c9 + a2977e3 + 9ce9151. OldWallet::send_batch_one_to_many now wires via WalletClient::transfer(TransferRequest { recipients, single_tx: true }) per wallet.proto:578 — same gRPC surface as send_single with the batch flag flipped. Mode 1's S5 batch arm flips from NotRun (UnsupportedOperation) to real measurements.

4 unit tests to cover the happy path, partial failure → Ok with status "failure" preserving S5's tx_count partition, and empty-batch → Err. gRPC-transport-error documented as a live-smoke gap; baseline-cycle coverage scaffolded at tests/live_esmeralda_smoke_batch.rs.

Sentinel builds_correct_argv_for_single_recipient byte-identical. 256 passing + 1 ignored.

},
pr_data_dir,
)?;
let pp_lifecycle = PpLifecycle::new(&cfg, &seeds, pp_data_dir)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might be wrong here but it looks like you assume the payment processor is already running? There is no binary attached here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — that line is the lifecycle handle constructor only. The actual PP child-process spawn happens later in payment_processor.rs::start_external_services via PpLifecycle::spawn, which forks the binary at cfg.mode_3.pp_binary_path. I can make the harness clone-and-build PP itself if you'd prefer a single-step setup; current operator-pre-build approach matches working setup and avoids embedding a sqlx-offline build cache in the harness, but it's an explicit operator burden vs. a one-step setup. Let me know which you'd prefer?

roadhero added 6 commits June 7, 2026 05:15
Previously returned UnsupportedOperation, causing Mode 1's S5 batch
arm to record NotRun. Per @SWvheerden review (PR tari-project#6 inline comment
on old_wallet.rs:144, 2026-06-05): "you can run this on the console
wallet". Mode 1's S5 row now produces real measurements via
WalletClient::transfer with single_tx=true, which constructs a
single 1->K Mimblewimble batch per the proto comment at
wallet.proto:578.

Implementation: Mode 1 batch builds the K-element recipients vec
and calls Transfer with single_tx=true. Partial failures map to
Ok(TxRecord { status: "failure", error_string }) per spec section 4
to preserve S5's tx_count partition. Empty recipients returns Err
per ratified Q3.

Also removes the SeedRole::Old skip guard in s5_throughput.rs and
flips the matching skip test to a runs test. Appends section 11 to
DESIGN_AMENDMENT.md documenting the proto-supported batch path.
Scaffolds tests/live_esmeralda_smoke_batch.rs as #[ignore] per
ratified Q5 for operator-side baseline smoke.

Resolves PR tari-project#6 review comment on old_wallet.rs:144.

Relates-to: tari-project#1
Signed-off-by: Dennis Vorobyov <roadhero@gmail.com>
…bility

Per `analysis/specs/MODE_1_BATCH_SEND_SPEC.md §5 Layer 1`, split
`send_batch_one_to_many` into:

* `build_batch_transfer_request(&[(TariAddress, u64)], u64) -> TransferRequest`
  — pure construction, no I/O.
* `fold_batch_transfer_response(TransferResponse, u64) -> anyhow::Result<TxRecord>`
  — pure response fold applying spec §4 error mapping (happy /
  all-fail / partial-failure / empty-results).

The trait method becomes a 4-line wrapper: empty-check (Q3) → build →
gRPC call → fold. Production behavior is unchanged — the same
`TransferRequest` is constructed and the same `TxRecord` is folded; the
helpers are `pub(crate)` so the inline `#[cfg(test)] mod tests` can
exercise them without a fake-gRPC server (per `DESIGN.md` line 580 "no
tonic mock for Mode 1 gRPC").

`builds_correct_argv_for_single_recipient` in `minotari_subprocess.rs`
is unchanged (verified byte-identical via `git diff`); the Mode 1
sentinel `mode1_name_is_old_wallet` is unaffected.

Refs tari-project#1.

Signed-off-by: Dennis Vorobyov <roadhero@gmail.com>
Adds four unit tests inline in `src/modes/old_wallet.rs::tests`,
exercising the helpers extracted in the preceding commit:

* `build_batch_request_carries_all_recipients_with_single_tx` — 100
  recipients × `fee_rate = 5` → `single_tx = true`, every
  `PaymentRecipient` carries the input address/amount, uniform
  `fee_per_gram`, and `PaymentType::OneSidedToStealthAddress`. (Spec §5
  cases 1–4 collapsed into one parameterised assertion loop — the
  per-index check is order-preserving.)
* `fold_batch_response_happy_path_returns_ok_success` — all-success
  response → `Ok(TxRecord { status: "success", error_string: None, txid:
  results[0].transaction_id.to_string(), fee_microtari: 0, t_total_ms:
  input, .. })`. (Spec §5 cases 5, 9, 10.)
* `fold_batch_response_partial_failure_returns_ok_with_failure_status`
  — 50/100 failures → `Ok(TxRecord { status: "failure", error_string
  contains "partial failure" + "50/100" + upstream failure_message })`.
  Asserts the spec §4 Q1 ratified mapping verbatim.
* `send_batch_empty_recipients_returns_err_without_grpc_call` — the one
  full-method test. Empty `&[]` slice → `Err` containing "empty
  recipients" in the message. The bail at method entry runs before any
  `lifecycle.client_mut()` call, so the unspawned wallet is never
  dereferenced — no fake-gRPC needed.

A `// NOTE:` at the top of the `tests` module documents why the gRPC
transport-error case (the 4th case in the operator's brief) is NOT a
unit test here: `DESIGN.md` line 580 explicitly forbids a tonic mock for
Mode 1 and `CLAUDE.md` forbids test infrastructure outside the test
tree. The transport-error path is exercised by
`tests/live_esmeralda_smoke_batch.rs` against testnet and by the
committed baseline run.

Refs tari-project#1.

Signed-off-by: Dennis Vorobyov <roadhero@gmail.com>
Both `import_view_key_argv` and `daemon_argv` were passing the data
directory itself to `--database-path`. The minotari CLI's
`DatabaseArgs::database_path` is the sqlite3 file path, not its parent
directory; canonical-baseline runs would fail at import or daemon spawn
with the wallet unable to open its DB.

Appends `wallet.sqlite3` to the data dir in both argv builders, matching
the existing `NewWallet::wallet_db_path` convention. Snapshot tests
updated to assert the new sqlite3 path.

Signed-off-by: Dennis Vorobyov <roadhero@gmail.com>
…Online

`wait_ready` previously returned as soon as `ConnectivityStatus::Online`
fired, but Online means "connected to a base node" — it does not mean the
wallet has finished scanning + validating its outputs. The downstream
`enforce_funding` pre-flight then called `GetBalance` immediately and
saw 0 (false fail), and Mode 1/2 scenario sends raced an in-flight scan
and surfaced as insufficient_funds.

`GetStateResponse` exposes `has_done_initial_validation` (field 4 in
wallet.proto) as the canonical scan-and-validation-complete signal.
`wait_ready` now requires BOTH `Online` AND `has_done_initial_validation
== true` before returning. The module doc-comment's stated contract
(`is_synced && scanned_height == base_node_tip`) was always the intent;
the implementation now matches it.

Signed-off-by: Dennis Vorobyov <roadhero@gmail.com>
`run_scan_subprocess` exits when `minotari scan`'s blockchain catch-up
loop terminates, but the wallet still finalizes per-output commitment
and state-write work asynchronously. Calling
`create-unsigned-transaction` immediately after the scan subprocess
exits would race that finalization and surface as `insufficient_funds`
on canonical-baseline runs, even though the wallet's funding had landed
on the chain.

Adds `wait_for_balance_positive` in `minotari_wallet_ops`. Polls
`minotari Balance` every 5s (default 5-minute deadline) until the
wallet reports a positive total; logs the poll cadence so operators
can distinguish "wallet finished settling" from "wallet is genuinely
empty". Mode 2's `scan_from_birthday` now calls this gate after
`run_scan_subprocess` returns; Mode 3's `scan_from_birthday` is
`UnsupportedOperation` so no change there. The Mode 1 equivalent is
the `has_done_initial_validation` gate in
`console_wallet::wait_ready`.

The poll-cadence and deadline sleeps live inside a `tokio::select!`
deadline-arm alongside the absolute `sleep_until`, matching the
existing AC-32 carve-out used by S0+ confirmation loops.

Signed-off-by: Dennis Vorobyov <roadhero@gmail.com>
@SWvheerden

Copy link
Copy Markdown
Contributor

Can you add a readme how the to run this and how to configure this?

Adds RUNBOOK.md at the repo root covering the end-to-end operator flow:
prerequisites (v5.4.0-pre.4 tari_suite, minotari-cli @ 52a7287a, PP
submodule rev f0572c9, --recurse-submodules), one-time setup (build,
gen-seed times three, print-address times three, view-key/spend-key
extraction, environment file), full harness.toml key reference with
defaults and override guidance, funding flow (11k tXTM per wallet,
verify via console_wallet get-balance), run invocation, output schema
pointer, and a troubleshooting section that mirrors the wallet-pain
findings from analysis/PR_BODY_v2.md §4 as operator-facing gotchas.

The troubleshooting section also points at three runtime bugs the
post-review fixes address: console_wallet wait_ready gating on
has_done_initial_validation, Mode 2 wait_for_balance_positive after
scan subprocess exits, and the PR daemon --database-path sqlite3 file
fix. Each maps the upstream-source cause to the symptom an operator
would see and the current-code resolution.

The runbook is a working doc for canonical-baseline operators
(@SWvheerden during review, anyone reproducing the bench on their
own hardware). No source code changes.

Signed-off-by: Dennis Vorobyov <roadhero@gmail.com>
@roadhero

Copy link
Copy Markdown
Author

Can you add a readme how the to run this and how to configure this?

Added — RUNBOOK.md at ed706ec.

Re: end-to-end validation against Esmeralda — blocked on chain availability this week. rpc.esmeralda.tari.com has been returning 503 intermittently. Local node sync from seednodes.json peers caps at height ~294k with a Nov-2025 tip timestamp; v5.4.0-pre.4 rejects the next block with Mismatched Utxos MMR roots at 688186. Is Esmeralda mid-reset, or is there a different peer set / RPC I should be pointing at?

@SWvheerden

Copy link
Copy Markdown
Contributor

with the view key setup, you can do this automatically?
was thinking its going to be much easier to just run it, either with some pre set seed_words or if not some pre set works, you use the one the benchmark generated and just calculate the seed words?

@SWvheerden

Copy link
Copy Markdown
Contributor

There is also no terminal feedback of it running?

@roadhero

Copy link
Copy Markdown
Author

@SWvheerden both fair, will address.

  1. View-key auto-derivation: yes, can derive from HARNESS_SEED_PP mnemonic at startup rather than requiring the operator to pre-extract TARI_BENCH_VIEW_KEY / TARI_BENCH_SPEND_KEY. The wallet-decoder path we already use for print-address derivation handles the same mnemonic → view-key + spend-public-key transform. Operator config drops from 6 env vars to 4. Will wire that into Mode 3 lifecycle setup.

  2. Terminal feedback: right, current run is silent until completion (writes the result-profile JSON at the end). Will add per-scenario progress lines to stdout (mode + scenario + elapsed + status) at the existing log boundaries, plus a final summary table.

For Esmeralda end-to-end validation, the public RPC has been 503 intermittently this week and local sync from seednodes.json peers caps at height ~294k with a Nov-2025 tip timestamp; v5.4.0-pre.4 rejects the next block with Mismatched Utxos MMR roots at 688186. Is there a mid-reset or a different peer set / RPC I should be pointing at?

@SWvheerden

Copy link
Copy Markdown
Contributor

More feedback will def help, it fails here:

[2026-06-19T15:52:17Z ERROR c::main] enforce_funding pre-flight: querying balance for the old-wallet seed: WalletGrpcBalanceQuery for role Old: wallet did not reach ready (Online + initial validation complete) within 1800s
error: enforce_funding pre-flight: querying balance for the old-wallet seed: WalletGrpcBalanceQuery for role Old: wallet did not reach ready (Online + initial validation complete) within 1800s

@roadhero

Copy link
Copy Markdown
Author

@SWvheerden on the pre-flight failure, that's wait_ready hitting its 30-min timeout. The gate is ConnectivityStatus::Online AND has_done_initial_validation == true. A few diagnostic asks:

a. Is the OLD seed funded?
enforce_funding spawns a transient console_wallet per seed (cold recovery, scan from genesis), then reads available_balance once both gates flip. If the seed has zero UTXOs the validation flag may never assert.

b. What does the wallet itself report when run standalone against the same seed?
minotari_console_wallet --network esmeralda --base-path /tmp/check --password $HARNESS_WALLET_PW --seed-words "$HARNESS_SEED_OLD" get-balance
If that returns available_balance: 0, the harness gate is doing the right thing but the seed isn't funded. If it returns a positive balance, has_done_initial_validation isn't asserting and we have a real bug.

c. RPC / peer state on your end?
I was unable to drive a clean end-to-end myself this week because rpc.esmeralda.tari.com returned 503 intermittently and the seednodes.json peers' tip metadata appeared stale (Nov-2025 timestamp at ~294k height, v5.4.0-pre.4 rejected the next block with Mismatched Utxos MMR roots at 688186). If your local Esmeralda peer set is healthy that's the difference; if you're hitting the same thing the gate would also never flip.

Happy to loosen the gate (e.g. accept Online alone after a shorter timeout, then fall through to a balance read that returns whatever the wallet currently sees) if the per-seed cold-scan time on a healthy network is genuinely above 30 min. The hard gate was added to fix enforce_funding falsely reporting 0 against an in-flight scan; a softer variant that warns instead of bailing is reasonable.

roadhero added 4 commits June 20, 2026 03:13
Adds `derive_view_spend_keypair(mnemonic)` to `src/seed/mod.rs`, mirroring
the existing `derive_address` derivation path: parse the mnemonic into a
CipherSeed, wrap as a `SeedWordsWallet`, extract `(get_view_key,
get_public_spend_key)`, and hex-encode both. Returns the
`(view_private_key_hex, spend_public_key_hex)` pair the Mode 3 PR daemon
needs for `minotari import-view-key` and PP's `ACCOUNTS__BENCH__*` env
matrix.

Wires the derive into `wallet_lifecycle::pp_lifecycle::resolve_account_keys`
(was `read_account_env`), which now follows a clear resolution order:

1. Both env vars set → operator-injected override (lets operators inject
   keys derived from a wallet that differs from HARNESS_SEED_PP).
2. Neither set → auto-derive from HARNESS_SEED_PP.
3. Exactly one set → bail with a clear error (half an override is almost
   always a typo).

`PpLifecycle::new` and both `PaymentProcessor` constructors call the
resolver; the inline env reads are gone.

Drops operator pre-flight config from 6 env vars to 4. The TARI_BENCH_VIEW_KEY
and TARI_BENCH_SPEND_KEY env-var names stay defined in `Mode3Account` as
operator-facing overrides; they no longer have to be set for a normal
canonical-baseline run.

4 unit tests cover the derive function (hex shape, determinism,
distinct-seeds, invalid-mnemonic). All 260 tests pass; sentinel test
`builds_correct_argv_for_single_recipient` byte-identical.

Notes:
- Hex encoding uses `RistrettoSecretKey::reveal()` (returns a
  `RevealedSecretKey` whose Display is the hex form) and the
  `LowerHex` impl on `CompressedKey<RistrettoPublicKey>`. The
  `Hex` trait route via `tari_utilities` does not type-check here
  because our direct `tari_utilities = 0.8` and the transitive
  `tari_crypto = 0.23` → `tari_utilities = 0.10` resolve to two
  different ByteArray traits; the Display formatters bypass the
  mismatch and produce the same 64-char lowercase output.
- The PR-daemon view-key wallet and PP's signer wallet both derive
  from the same seed, so unifying them under HARNESS_SEED_PP is
  semantically a no-op for the operator: the bench already requires
  the seed to be set; the env override path stays available for
  cross-wallet setups.

Resolves @SWvheerden's 2026-06-19 review feedback on PR tari-project#6
(Work Item A from the operator's runbook).

Signed-off-by: Dennis Vorobyov <roadhero@gmail.com>
A canonical Esmeralda baseline takes 3-5 hours; until now the harness
ran silent until completion. Operators couldn't tell whether the run was
making progress or stuck. Adds three terminal-feedback surfaces, all
println!-based so they reach stdout regardless of RUST_LOG.

Pre-flight (`guards::enforce_funding`):
  [HH:MM:SS] preflight  old=NuT new=NuT pp=NuT  PASS

Scenario boundaries (run loop in main.rs):
  [HH:MM:SS] mode=<mode> scenario=<id>  start
  [HH:MM:SS] mode=<mode> scenario=<id>  done   tx_count=N elapsed=N.Ns status=<ok|skipped|err>

End-of-run summary table (`print_summary_table` after the per-mode loop,
before `result_profile::write`):
  === run summary ===
  mode\scenario        B0            S0            S1            ...
  old_wallet           skipped       ok (1)        ok (512)      ...
  new_wallet           ok (0)        ok (1)        ok (512)      ...
  payment_processor    skipped       ok (1)        skipped       ...

Two small helpers added in `main.rs`:
- `mode_name(role)` — operator-facing label
- `count_txs(outcome)` — best-effort per-scenario tx count derived
  from each `ScenarioOutcome` variant (S5's `success_count`; S4
  reconstructs from `(n_concurrent * success_rate)`; B0/S2/S3/S6/S7
  are scan-only and return 0). Not a load-bearing value — the
  canonical numbers live in the result-profile JSON.

The lines use `chrono::Local` for the timestamp so they match the
operator's wall clock. The summary table is a fixed-width text table
that survives terminal narrowing; rendering is intentionally simple
(no Unicode box-drawing, no colour) so it pastes cleanly into a PR
comment.

No source files outside `src/main.rs` and `src/guards.rs` are touched.
All 260 tests pass; sentinel test
`builds_correct_argv_for_single_recipient` byte-identical.

Resolves @SWvheerden's 2026-06-19 review feedback on PR tari-project#6
(Work Item B from the operator's runbook).

Signed-off-by: Dennis Vorobyov <roadhero@gmail.com>
…nbook

Updates RUNBOOK §2.5 (Mode 3 view+spend extraction), §2.7 (env file),
§3.5 (harness.toml accounts table), and adds §5.4 (terminal feedback)
to match the two work items @SWvheerden asked for at 2026-06-19:

§2.5 / §3.5: the TARI_BENCH_VIEW_KEY and TARI_BENCH_SPEND_KEY env vars
are now optional. The harness auto-derives the pair from
HARNESS_SEED_PP via wallet_lifecycle::pp_lifecycle::resolve_account_keys.
The env-var path stays as an explicit operator override for the rare
case of needing to scan a wallet that differs from HARNESS_SEED_PP.
Half-overrides bail at startup.

§2.7: the env file's TARI_BENCH_VIEW_KEY / TARI_BENCH_SPEND_KEY lines
are commented out by default and labelled as optional overrides.

§5.4 (new): documents the per-scenario stdout progress lines + final
summary table the harness now prints regardless of RUST_LOG. Includes
the example output an operator should expect to see, including the
preflight line, per-scenario start/done lines, and the summary table.

Signed-off-by: Dennis Vorobyov <roadhero@gmail.com>
7abf9fa restored two em-dashes (one in §2.5 paragraph prose, one in the
§2.7 env-file comment). The runbook's standing convention is no
em-dashes; replacing with a colon and a parenthetical clause matches
the rest of the document.

Signed-off-by: Dennis Vorobyov <roadhero@gmail.com>
@roadhero

Copy link
Copy Markdown
Author

Both items landed:

  • Auto-derive view+spend from HARNESS_SEED_PP: b7d05a3 (operator config drops to 4 env vars; TARI_BENCH_VIEW_KEY / TARI_BENCH_SPEND_KEY stay as optional overrides)
  • Per-scenario stdout progress + final summary table: eab102a
  • RUNBOOK reflects both: 7abf9fa

260/260 tests passing, sentinel byte-identical. Branch at 1a4f71e.

Pre-flight diagnostic asks from the prior comment still open whenever you next look at the 1800s timeout you hit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Create wallet benchmarks

2 participants