//! CLI tests for `cast keychain` subcommands.

use alloy_consensus::{TxEnvelope, transaction::SignerRecoverable};
use alloy_eips::{Decodable2718, Encodable2718};
use alloy_primitives::{Address, hex};
use alloy_rlp::{Header, PayloadView};
use alloy_signer::SignerSync;
use alloy_signer_local::PrivateKeySigner;
use anvil::NodeConfig;
use foundry_evm::core::tempo::PATH_USD_ADDRESS;
use foundry_test_utils::{TestCommand, util::OutputExt};
use path_slash::PathExt;
use std::{
    fs,
    io::{Read, Write},
    net::TcpListener,
    path::Path,
    thread,
    time::Duration,
};
use tempo_alloy::accounts::TempoAccountsStore;
use tempo_contracts::precompiles::TIP20_FACTORY_ADDRESS;
use tempo_primitives::TempoTxEnvelope;

/// Anvil test accounts (standard mnemonic).
mod accounts {
    pub const PK1: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
    pub const PK2: &str = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
    pub const PK3: &str = "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a";
    pub const ADDR1: &str = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
    pub const ADDR2: &str = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
    pub const ADDR3: &str = "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC";
}

fn path_usd() -> String {
    PATH_USD_ADDRESS.to_string()
}

fn write_accounts_store(tempo_home: &Path, chain_id: u64) {
    let wallet = tempo_home.join("wallet");
    fs::create_dir_all(&wallet).expect("create Tempo wallet dir");
    let store = serde_json::json!({
        "tempo-cli.store": {
            "state": {
                "activeAccount": 0,
                "chainId": chain_id,
                "accounts": [{"address": accounts::ADDR1}],
                "accessKeys": [{
                    "access": accounts::ADDR1,
                    "address": accounts::ADDR2,
                    "chainId": chain_id,
                    "keyType": "secp256k1",
                    "privateKey": accounts::PK2,
                }],
            },
        },
    });
    fs::write(
        wallet.join("store.json"),
        serde_json::to_vec(&store).expect("serialize Tempo Accounts store"),
    )
    .expect("write Tempo Accounts store");
}

fn spawn_fee_payer_service(
    fee_payer: PrivateKeySigner,
    fee_token: Address,
) -> (String, thread::JoinHandle<()>) {
    let listener = TcpListener::bind("127.0.0.1:0").expect("bind fee payer service");
    let address = listener.local_addr().expect("fee payer service address");
    let handle = thread::spawn(move || {
        let (mut stream, _) = listener.accept().expect("accept fee payer request");
        stream
            .set_read_timeout(Some(Duration::from_secs(10)))
            .expect("set fee payer service timeout");

        let mut request = Vec::new();
        let (body_offset, content_length) = loop {
            let mut chunk = [0u8; 4096];
            let read = stream.read(&mut chunk).expect("read fee payer request");
            assert_ne!(read, 0, "fee payer request ended before its body");
            request.extend_from_slice(&chunk[..read]);

            let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n")
            else {
                continue;
            };
            let headers =
                std::str::from_utf8(&request[..header_end]).expect("HTTP headers are UTF-8");
            let content_length = headers
                .lines()
                .find_map(|line| {
                    let (name, value) = line.split_once(':')?;
                    name.eq_ignore_ascii_case("content-length")
                        .then(|| value.trim().parse::<usize>().expect("valid content length"))
                })
                .expect("fee payer request has content length");
            let body_offset = header_end + 4;
            if request.len() >= body_offset + content_length {
                break (body_offset, content_length);
            }
        };

        let rpc: serde_json::Value =
            serde_json::from_slice(&request[body_offset..body_offset + content_length])
                .expect("valid fee payer JSON-RPC request");
        assert_eq!(rpc["method"], "eth_signRawTransaction");
        let raw = rpc["params"][0].as_str().expect("raw transaction parameter");
        let bytes = hex::decode(raw).expect("decode fee payer transaction");
        assert_eq!(bytes.first(), Some(&0x76), "fee payer request is a Tempo AA transaction");
        let mut encoded_fields = &bytes[1..];
        let PayloadView::List(fields) =
            Header::decode_raw(&mut encoded_fields).expect("decode fee payer transaction fields")
        else {
            panic!("fee payer request must contain an RLP list")
        };
        assert!(encoded_fields.is_empty(), "fee payer request has trailing bytes");
        assert_eq!(fields[11], [0x00], "fee payer request has the signature placeholder");

        // The fee-payer service encoding uses 0x00 as a signature marker. Normalize that
        // sponsor-owned field to an empty value so the standard envelope decoder can recover the
        // user signature before the mock sponsor fills it.
        let mut payload = Vec::new();
        for (index, field) in fields.into_iter().enumerate() {
            if index == 11 {
                payload.push(0x80);
            } else {
                payload.extend_from_slice(field);
            }
        }
        let mut normalized = vec![0x76];
        Header { list: true, payload_length: payload.len() }.encode(&mut normalized);
        normalized.extend_from_slice(&payload);
        let TempoTxEnvelope::AA(unsigned) =
            TempoTxEnvelope::decode_2718(&mut normalized.as_slice())
                .expect("decode Tempo transaction")
        else {
            panic!("fee payer request must contain a Tempo AA transaction")
        };
        let user = unsigned.recover_signer().expect("recover transaction signer");
        let (mut tx, user_signature, _) = unsigned.into_parts();
        tx.fee_token = Some(fee_token);
        tx.fee_payer_signature = Some(
            fee_payer
                .sign_hash_sync(&tx.fee_payer_signature_hash(user))
                .expect("sign fee payer authorization"),
        );

        let signed = TempoTxEnvelope::AA(tx.into_signed(user_signature));
        let mut encoded = Vec::new();
        signed.encode_2718(&mut encoded);
        let response = serde_json::json!({
            "jsonrpc": "2.0",
            "id": rpc["id"],
            "result": hex::encode_prefixed(encoded),
        })
        .to_string();
        write!(
            stream,
            "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
            response.len(),
            response,
        )
        .expect("write fee payer response");
    });
    (format!("http://{address}"), handle)
}

const ADDRESS_REGISTRY: &str = "0xFDC0000000000000000000000000000000000000";

fn tip20_factory() -> String {
    TIP20_FACTORY_ADDRESS.to_string()
}

const MISSING_SESSION_ID: &str =
    "0x5555555555555555555555555555555555555555555555555555555555555555";

fn batch_send_transfer_call(path_usd: &str) -> String {
    format!("{path_usd}::transfer(address,uint256):{},0", accounts::ADDR3)
}

const PRECOMPUTED_VADDR_SALT_FOR_ADDR1: &str =
    "0x00000000000000000000000000000000000000000000000000000000abf52baf";

fn assert_wrong_chain_error(stderr: &str) {
    assert!(stderr.contains("is for chain 31338"), "unexpected stderr:\n{stderr}");
    assert!(stderr.contains("command is using chain 31337"), "unexpected stderr:\n{stderr}");
}

fn cast_send_session_script(path_usd: &str) -> String {
    format!(
        r#"#!/bin/sh
set -eu
test -n "${{TEMPO_SESSION_ID:-}}"
tx_hash=$("${{CAST_BIN}}" send "{path_usd}" 'transfer(address,uint256)' "{recipient}" 0 --rpc-url "${{RPC_URL}}" --tempo.fee-token "{path_usd}" --async)
"${{CAST_BIN}}" receipt "$tx_hash" --rpc-url "${{RPC_URL}}" >/dev/null
printf '%s\n' "$tx_hash"
"#,
        recipient = accounts::ADDR3,
    )
}

fn create_session(cmd: &mut TestCommand, tempo_home: &Path, chain_id: &str) -> (String, String) {
    create_session_with_scope(cmd, tempo_home, chain_id, &path_usd())
}

fn create_session_with_scope(
    cmd: &mut TestCommand,
    tempo_home: &Path,
    chain_id: &str,
    scope: &str,
) -> (String, String) {
    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home);
    let create_output = cmd
        .args([
            "--json",
            "wallet",
            "session",
            "create",
            "--root",
            accounts::ADDR1,
            "--chain-id",
            chain_id,
            "--expires",
            "10m",
            "--scope",
            scope,
            "--spend-limit",
            "PathUSD=1000000",
            "--spend-limit",
            "AlphaUSD=1000000",
            "--private-key",
            accounts::PK1,
        ])
        .assert_success()
        .get_output()
        .stdout_lossy();

    let created: serde_json::Value =
        serde_json::from_str(create_output.trim()).expect("session create emits JSON");
    let session_id = created["session_id"].as_str().expect("session_id").to_string();
    let key_address = created["key_address"].as_str().expect("key_address").to_string();
    (session_id, key_address)
}

fn assert_accounts_store_key_retired(tempo_home: &Path, context: &str) {
    let store_file = tempo_home.join("wallet/store.json");
    let contents = fs::read_to_string(&store_file).expect("store.json exists");
    let store: serde_json::Value = serde_json::from_str(&contents).expect("valid store.json");
    let keys =
        store["tempo-cli.store"]["state"]["accessKeys"].as_array().expect("accessKeys array");
    assert!(
        keys.iter().all(|key| key.get("privateKey").is_none()),
        "{context} managed key must not retain private key material:\n{contents}"
    );
    assert!(!tempo_home.join("wallet/sessions.toml").exists());
}

fn assert_accounts_store_key_signable(tempo_home: &Path, context: &str) {
    let store_file = tempo_home.join("wallet/store.json");
    let contents = fs::read_to_string(&store_file).expect("store.json exists");
    let store: serde_json::Value = serde_json::from_str(&contents).expect("valid store.json");
    let keys =
        store["tempo-cli.store"]["state"]["accessKeys"].as_array().expect("accessKeys array");
    assert!(
        keys.iter().any(|key| key.get("privateKey").is_some()),
        "{context} managed key should retain private key material:\n{contents}"
    );
    assert!(!tempo_home.join("wallet/sessions.toml").exists());
}

fn assert_async_tx_hash(stdout: &str, command: &str) {
    assert!(
        stdout.trim().starts_with("0x"),
        "expected {command} --async to print a tx hash, got:\n{stdout}"
    );
}

fn assert_contains_tx_hash(stdout: &str, command: &str) {
    assert!(
        stdout.lines().any(|line| {
            let line = line.trim();
            line.len() == 66
                && line.starts_with("0x")
                && line[2..].chars().all(|c| c.is_ascii_hexdigit())
        }),
        "expected {command} to print a tx hash, got:\n{stdout}"
    );
}

fn assert_session_cleanup_failure(stderr: &str) {
    assert!(
        stderr.contains("failed to clean up Tempo session after inner command"),
        "unexpected stderr:\n{stderr}"
    );
    assert!(
        stderr.contains("session key is not provisioned on-chain yet"),
        "unexpected stderr:\n{stderr}"
    );
}

// `cast keychain rl --json` must emit `{"remaining":"<value>"}`, not a bare string.
casttest!(keychain_rl_json_is_object, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let path_usd = path_usd();

    let output = cmd
        .args([
            "keychain",
            "rl",
            accounts::ADDR1,
            accounts::ADDR2,
            &path_usd,
            "--rpc-url",
            &rpc,
            "--rpc-timeout",
            "5",
            "--json",
        ])
        .assert_success()
        .get_output()
        .stdout_lossy();

    let parsed: serde_json::Value = serde_json::from_str(output.trim())
        .expect("cast keychain rl --json should emit valid JSON");
    assert!(parsed.is_object(), "expected JSON object, got: {output}");
    assert!(
        parsed.get("remaining").is_some(),
        "expected 'remaining' key in JSON output, got: {output}"
    );
    // Must not be a bare string (old bug: `"0"`)
    assert!(!parsed.is_string(), "JSON output must not be a bare string, got: {output}");
});

// `cast keychain authorize --tempo.print-sponsor-hash --json` must emit
// `{"sponsor_hash":"0x..."}`, not a raw hex string.
casttest!(keychain_authorize_sponsor_hash_json_is_object, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();

    let output = cmd
        .args([
            "keychain",
            "authorize",
            accounts::ADDR2, // key to authorize
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
            "--tempo.print-sponsor-hash",
            "--json",
        ])
        .assert_success()
        .get_output()
        .stdout_lossy();

    let parsed: serde_json::Value = serde_json::from_str(output.trim())
        .expect("cast keychain authorize --tempo.print-sponsor-hash --json should emit valid JSON");
    assert!(parsed.is_object(), "expected JSON object, got: {output}");
    let hash = parsed
        .get("sponsor_hash")
        .and_then(|v| v.as_str())
        .unwrap_or_else(|| panic!("expected 'sponsor_hash' key in JSON output, got: {output}"));
    assert!(hash.starts_with("0x"), "sponsor_hash should be 0x-prefixed, got: {hash}");
    assert_eq!(hash.len(), 66, "sponsor_hash should be 32-byte hex (66 chars), got: {hash}");
});

casttest!(keychain_rejects_remote_sponsor_instead_of_ignoring_it, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();

    let stderr = cmd
        .cast_fuse()
        .args([
            "keychain",
            "authorize",
            accounts::ADDR2,
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
            "--sponsor-url",
            "http://localhost:1",
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert!(stderr.contains("--sponsor-url is not supported by cast keychain"), "{stderr}");
});

// TODO: remove this check once browser supports T5/T6 KeyAuthorization fields
casttest!(key_authorization_sign_rejects_browser_witness_before_browser_run, |_prj, cmd| {
    let stderr = cmd
        .args([
            "key-authorization",
            "sign",
            accounts::ADDR2,
            "--chain-id",
            "31337",
            "--witness",
            "0x5353535353535353535353535353535353535353535353535353535353535353",
            "--browser",
            "--browser-disable-open",
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert!(
        stderr.contains(
            "browser key authorization signing does not support T5/T6 fields yet: witness, admin, account"
        ),
        "unexpected stderr:\n{stderr}"
    );
    assert!(
        !stderr.contains("Waiting for browser wallet connection"),
        "browser flow should not start before rejecting --browser --witness:\n{stderr}"
    );
});

// Offline: signing an admin key authorization round-trips `is_admin`/`account` into the JSON.
casttest!(key_authorization_sign_admin_emits_admin_json, |_prj, cmd| {
    // `sign --admin` derives the bound account from the signer (it has no auth-level --account),
    // so signing with PK1 binds the authorization to ADDR1.
    let output = cmd
        .args([
            "key-authorization",
            "sign",
            accounts::ADDR2,
            "--chain-id",
            "31337",
            "--admin",
            "--private-key",
            accounts::PK1,
            "--json",
        ])
        .assert_success()
        .get_output()
        .stdout_lossy();

    let parsed: serde_json::Value = serde_json::from_str(output.trim())
        .expect("cast key-authorization sign --admin --json should emit valid JSON");
    assert_eq!(parsed["is_admin"], serde_json::Value::Bool(true), "got: {output}");
    // The bound account equals the signer (ADDR1), not the authorized key (ADDR2).
    assert_eq!(
        parsed["account"].as_str().map(str::to_lowercase),
        Some(accounts::ADDR1.to_lowercase()),
        "got: {output}"
    );
    assert!(
        parsed["signed_key_authorization"].as_str().is_some_and(|s| s.starts_with("0x")),
        "got: {output}"
    );
});

// Offline: `--admin` without `--account` is rejected before any signing happens.
casttest!(key_authorization_encode_admin_requires_account, |_prj, cmd| {
    let stderr = cmd
        .args(["key-authorization", "encode", accounts::ADDR2, "--chain-id", "31337", "--admin"])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert!(stderr.contains("--admin requires --account"), "unexpected stderr:\n{stderr}");
});

// Offline: an admin key authorization cannot carry an expiry.
casttest!(key_authorization_encode_admin_rejects_expiry, |_prj, cmd| {
    let stderr = cmd
        .args([
            "key-authorization",
            "encode",
            accounts::ADDR2,
            "--chain-id",
            "31337",
            "--admin",
            "--account",
            accounts::ADDR1,
            "--expiry",
            "1782647677",
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert!(stderr.contains("--expiry"), "unexpected stderr:\n{stderr}");
});

// Offline: `key-authorization inspect` decodes a signed admin authorization and exposes T6 fields.
casttest!(key_authorization_inspect_signed_admin_json, |_prj, cmd| {
    let signed = cmd
        .args([
            "key-authorization",
            "sign",
            accounts::ADDR2,
            "--chain-id",
            "31337",
            "--admin",
            "--private-key",
            accounts::PK1, // root account ADDR1; the bound account is derived from the signer
        ])
        .assert_success()
        .get_output()
        .stdout_lossy();
    let signed = signed.trim().to_string();

    let output = cmd
        .cast_fuse()
        .args(["key-authorization", "inspect", &signed, "--json"])
        .assert_success()
        .get_output()
        .stdout_lossy();

    let parsed: serde_json::Value = serde_json::from_str(output.trim())
        .expect("cast key-authorization inspect --json should emit valid JSON");
    assert_eq!(parsed["signed"], serde_json::Value::Bool(true), "got: {output}");
    assert_eq!(parsed["is_admin"], serde_json::Value::Bool(true), "got: {output}");
    assert_eq!(
        parsed["account"].as_str().map(str::to_lowercase),
        Some(accounts::ADDR1.to_lowercase()),
        "got: {output}"
    );
    assert_eq!(
        parsed["signer"].as_str().map(str::to_lowercase),
        Some(accounts::ADDR1.to_lowercase()),
        "got: {output}"
    );
});

// Offline: inspecting with a mismatched `--account` rejects a replayed admin authorization.
casttest!(key_authorization_inspect_account_mismatch_rejected, |_prj, cmd| {
    let signed = cmd
        .args([
            "key-authorization",
            "sign",
            accounts::ADDR2,
            "--chain-id",
            "31337",
            "--admin",
            "--private-key",
            accounts::PK1, // bound to ADDR1
        ])
        .assert_success()
        .get_output()
        .stdout_lossy();
    let signed = signed.trim().to_string();

    let stderr = cmd
        .cast_fuse()
        .args(["key-authorization", "inspect", &signed, "--account", accounts::ADDR2])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert!(stderr.contains("is bound to account"), "unexpected stderr:\n{stderr}");
});

// On-chain (T6): authorize an admin key, then confirm it via `keychain is-admin --json`.
casttest!(keychain_authorize_admin_then_is_admin, async |_prj, cmd| {
    use tempo_hardfork::TempoHardfork;
    let (_, handle) =
        anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T6.into()))).await;
    let rpc = handle.http_endpoint();

    cmd.cast_fuse()
        .args([
            "keychain",
            "authorize",
            accounts::ADDR2, // admin key to authorize
            "--admin",
            "--private-key",
            accounts::PK1, // root account ADDR1
            "--rpc-url",
            &rpc,
        ])
        .assert_success();

    let output = cmd
        .cast_fuse()
        .args([
            "keychain",
            "is-admin",
            accounts::ADDR1, // root account
            accounts::ADDR2, // admin key
            "--rpc-url",
            &rpc,
            "--json",
        ])
        .assert_success()
        .get_output()
        .stdout_lossy();

    let parsed: serde_json::Value = serde_json::from_str(output.trim())
        .expect("cast keychain is-admin --json should emit valid JSON");
    assert!(parsed.is_object(), "expected JSON object, got: {output}");
    assert_eq!(parsed["is_admin"], serde_json::Value::Bool(true), "got: {output}");
});

// On-chain: authorize a regular access key, then use the real `cast send` binary to sign,
// broadcast, and mine an account transaction with it.
casttest!(send_with_authorized_access_key_succeeds, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let path_usd = path_usd();

    cmd.cast_fuse()
        .args([
            "keychain",
            "authorize",
            accounts::ADDR2,
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
        ])
        .assert_success();

    let output = cmd
        .cast_fuse()
        .args([
            "send",
            &path_usd,
            "approve(address,uint256)",
            accounts::ADDR3,
            "0",
            "--tempo.access-key",
            accounts::PK2,
            "--tempo.root-account",
            accounts::ADDR1,
            "--tempo.fee-token",
            &path_usd,
            "--rpc-url",
            &rpc,
            "--json",
        ])
        .assert_success()
        .get_output()
        .stdout_lossy();

    let receipt: serde_json::Value =
        serde_json::from_str(output.trim()).expect("cast send emits a JSON receipt");
    assert!(receipt["transactionHash"].is_string(), "unexpected receipt: {output}");
    assert_eq!(receipt["status"], "0x1", "unexpected receipt: {output}");
});

casttest!(send_with_local_sponsor_reports_sponsor_as_fee_payer, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let path_usd = path_usd();

    let output = cmd
        .cast_fuse()
        .args([
            "send",
            &path_usd,
            "approve(address,uint256)",
            accounts::ADDR3,
            "0",
            "--private-key",
            accounts::PK1,
            "--tempo.fee-token",
            &path_usd,
            "--tempo.sponsor",
            accounts::ADDR2,
            "--tempo.sponsor-signer",
            &format!("private-key://{}", accounts::PK2),
            "--rpc-url",
            &rpc,
            "--json",
        ])
        .assert_success()
        .get_output()
        .stdout_lossy();

    let receipt: serde_json::Value =
        serde_json::from_str(output.trim()).expect("cast send emits a JSON receipt");
    let fee_payer: alloy_primitives::Address =
        receipt["feePayer"].as_str().expect("feePayer").parse().expect("valid feePayer");
    assert_eq!(
        fee_payer,
        accounts::ADDR2.parse::<alloy_primitives::Address>().unwrap(),
        "unexpected receipt: {output}"
    );
    let fee_token: alloy_primitives::Address =
        receipt["feeToken"].as_str().expect("feeToken").parse().expect("valid feeToken");
    assert_eq!(
        fee_token,
        path_usd.parse::<alloy_primitives::Address>().unwrap(),
        "unexpected receipt: {output}"
    );
});

casttest!(send_uses_access_key_from_accounts_store, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let path_usd = path_usd();

    cmd.cast_fuse()
        .args([
            "keychain",
            "authorize",
            accounts::ADDR2,
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
        ])
        .assert_success();

    let tempo_home = tempfile::tempdir().unwrap();
    write_accounts_store(tempo_home.path(), 31337);
    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    let output = cmd
        .args([
            "send",
            &path_usd,
            "approve(address,uint256)",
            accounts::ADDR3,
            "0",
            "--from",
            accounts::ADDR1,
            "--tempo.fee-token",
            &path_usd,
            "--rpc-url",
            &rpc,
            "--json",
        ])
        .assert_success()
        .get_output()
        .stdout_lossy();

    let receipt: serde_json::Value =
        serde_json::from_str(output.trim()).expect("cast send emits a JSON receipt");
    assert_eq!(receipt["status"], "0x1", "unexpected receipt: {output}");
});

casttest!(send_with_accounts_store_and_remote_sponsor_sync_succeeds, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let path_usd = path_usd();

    cmd.cast_fuse()
        .args([
            "keychain",
            "authorize",
            accounts::ADDR2,
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
        ])
        .assert_success();

    let fee_payer: PrivateKeySigner = accounts::PK3.parse().unwrap();
    let (sponsor_url, sponsor) = spawn_fee_payer_service(fee_payer.clone(), PATH_USD_ADDRESS);
    let tempo_home = tempfile::tempdir().unwrap();
    write_accounts_store(tempo_home.path(), 31337);
    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    let output = cmd
        .args([
            "send",
            &path_usd,
            "approve(address,uint256)",
            accounts::ADDR3,
            "0",
            "--from",
            accounts::ADDR1,
            "--rpc-url",
            &rpc,
            "--sponsor-url",
            &sponsor_url,
            "--sync",
            "--json",
        ])
        .assert_success()
        .get_output()
        .stdout_lossy();
    sponsor.join().expect("fee payer service completed");

    let receipt: serde_json::Value =
        serde_json::from_str(output.trim()).expect("cast send emits a JSON receipt");
    assert_eq!(receipt["status"], "0x1", "unexpected receipt: {output}");
    assert_eq!(
        receipt["feePayer"].as_str().map(str::to_lowercase),
        Some(fee_payer.address().to_string().to_lowercase()),
        "unexpected receipt: {output}"
    );
    assert_eq!(
        receipt["feeToken"].as_str().map(str::to_lowercase),
        Some(path_usd.to_lowercase()),
        "unexpected receipt: {output}"
    );
});

casttest!(tempo_unlocked_send_does_not_require_accounts_store_entry, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let path_usd = path_usd();
    let tempo_home = tempfile::tempdir().unwrap();
    write_accounts_store(tempo_home.path(), 31337);

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    let output = cmd
        .args([
            "send",
            &path_usd,
            "approve(address,uint256)",
            accounts::ADDR3,
            "0",
            "--from",
            accounts::ADDR2,
            "--unlocked",
            "--tempo.fee-token",
            &path_usd,
            "--rpc-url",
            &rpc,
            "--json",
        ])
        .assert_success()
        .get_output()
        .stdout_lossy();

    let receipt: serde_json::Value =
        serde_json::from_str(output.trim()).expect("cast send emits a JSON receipt");
    assert_eq!(receipt["status"], "0x1", "unexpected receipt: {output}");
});

casttest!(tempo_accounts_store_does_not_change_ethereum_send_or_mktx, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test()).await;
    let rpc = handle.http_endpoint();
    let tempo_home = tempfile::tempdir().unwrap();
    write_accounts_store(tempo_home.path(), 31337);

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    let output = cmd
        .args([
            "send",
            accounts::ADDR3,
            "--value",
            "1",
            "--from",
            accounts::ADDR1,
            "--unlocked",
            "--rpc-url",
            &rpc,
            "--json",
        ])
        .assert_success()
        .get_output()
        .stdout_lossy();
    let receipt: serde_json::Value =
        serde_json::from_str(output.trim()).expect("cast send emits a JSON receipt");
    assert_eq!(receipt["status"], "0x1", "unexpected receipt: {output}");

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    let output = cmd
        .args([
            "mktx",
            accounts::ADDR3,
            "--value",
            "1",
            "--from",
            accounts::ADDR1,
            "--ethsign",
            "--rpc-url",
            &rpc,
        ])
        .assert_success()
        .get_output()
        .stdout_lossy();
    let raw = alloy_primitives::hex::decode(output.trim()).expect("decode raw transaction");
    TxEnvelope::decode_2718(&mut raw.as_slice()).expect("decode Ethereum transaction");
});

casttest!(corrupt_tempo_store_does_not_break_ethereum_unlocked_send, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test()).await;
    let rpc = handle.http_endpoint();
    let tempo_home = tempfile::tempdir().unwrap();
    let wallet_dir = tempo_home.path().join("wallet");
    fs::create_dir_all(&wallet_dir).unwrap();
    fs::write(wallet_dir.join("store.json"), b"not json").unwrap();

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    cmd.args([
        "send",
        accounts::ADDR3,
        "--value",
        "1",
        "--from",
        accounts::ADDR1,
        "--unlocked",
        "--rpc-url",
        &rpc,
    ])
    .assert_success();

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    cmd.args(["call", accounts::ADDR3, "--from", accounts::ADDR1, "--rpc-url", &rpc])
        .assert_success()
        .stdout_eq("0x\n");
});

// On-chain (T6): a keychain signature from an authorized admin key passes `verify-admin`.
casttest!(keychain_verify_admin_accepts_admin_signature, async |_prj, cmd| {
    use alloy_primitives::{Address, B256, hex};
    use alloy_signer::SignerSync;
    use alloy_signer_local::PrivateKeySigner;
    use tempo_hardfork::TempoHardfork;
    use tempo_primitives::transaction::{KeychainSignature, PrimitiveSignature, TempoSignature};

    let (_, handle) =
        anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T6.into()))).await;
    let rpc = handle.http_endpoint();

    // Authorize ADDR2 as an admin key for ADDR1 (PK1).
    cmd.cast_fuse()
        .args([
            "keychain",
            "authorize",
            accounts::ADDR2,
            "--admin",
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
        ])
        .assert_success();

    // Build a keychain signature over an arbitrary hash, signed by the admin key (PK2) for ADDR1.
    let account: Address = accounts::ADDR1.parse().unwrap();
    let hash = B256::repeat_byte(0x42);
    let key: PrivateKeySigner = accounts::PK2.parse().unwrap();
    let signing_hash = KeychainSignature::signing_hash(hash, account);
    let inner = key.sign_hash_sync(&signing_hash).unwrap();
    let signature = TempoSignature::Keychain(KeychainSignature::new(
        account,
        PrimitiveSignature::Secp256k1(inner),
    ))
    .to_bytes();
    let signature_hex = hex::encode_prefixed(signature);

    let output = cmd
        .cast_fuse()
        .args([
            "keychain",
            "verify-admin",
            accounts::ADDR1,
            &hash.to_string(),
            &signature_hex,
            "--rpc-url",
            &rpc,
            "--json",
        ])
        .assert_success()
        .get_output()
        .stdout_lossy();

    let parsed: serde_json::Value = serde_json::from_str(output.trim())
        .expect("cast keychain verify-admin --json should emit valid JSON");
    assert_eq!(parsed["valid"], serde_json::Value::Bool(true), "got: {output}");
});

// On-chain (T6): `keychain authorize --admin` rejects spending limits before submitting.
casttest!(keychain_authorize_admin_rejects_limits, async |_prj, cmd| {
    use tempo_hardfork::TempoHardfork;
    let (_, handle) =
        anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T6.into()))).await;
    let rpc = handle.http_endpoint();

    let stderr = cmd
        .args([
            "keychain",
            "authorize",
            accounts::ADDR2,
            "--admin",
            "--enforce-limits",
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert!(stderr.contains("spending limits"), "unexpected stderr:\n{stderr}");
});

// An access-key signer is rejected for admin-gated keychain mutators even when it is an active
// admin key, because submitting the mutator as access-key-signed calldata reverts on-chain with
// `UnauthorizedCaller()` on the pinned Tempo build.
casttest!(keychain_access_key_cannot_submit_admin_mutator, async |_prj, cmd| {
    use tempo_hardfork::TempoHardfork;
    let (_, handle) =
        anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T6.into()))).await;
    let rpc = handle.http_endpoint();

    // Root ADDR1 (PK1) authorizes ADDR2 (PK2) as an admin key.
    cmd.cast_fuse()
        .args([
            "keychain",
            "authorize",
            accounts::ADDR2,
            "--admin",
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
        ])
        .assert_success();

    // ADDR2 is an active admin key, but the access-key mutator is still rejected locally.
    let stderr = cmd
        .cast_fuse()
        .args([
            "keychain",
            "authorize",
            accounts::ADDR3,
            "--tempo.access-key",
            accounts::PK2,
            "--tempo.root-account",
            accounts::ADDR1,
            "--rpc-url",
            &rpc,
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert!(
        stderr.contains("currently reverts on-chain with UnauthorizedCaller()"),
        "unexpected stderr:\n{stderr}"
    );
});

// Offline (T6): an admin access key signing a child authorization binds the authorization to the
// root account it manages, not to the signing admin key. This covers the delegated admin-signing
// path in `run_key_auth_sign`.
casttest!(key_authorization_sign_admin_access_key_binds_root_account, |_prj, cmd| {
    let output = cmd
        .args([
            "key-authorization",
            "sign",
            accounts::ADDR3, // the child key being authorized
            "--chain-id",
            "31337",
            "--admin",
            "--tempo.access-key",
            accounts::PK2, // admin key ADDR2 signs
            "--tempo.root-account",
            accounts::ADDR1, // on behalf of root ADDR1
            "--json",
        ])
        .assert_success()
        .get_output()
        .stdout_lossy();

    let parsed: serde_json::Value = serde_json::from_str(output.trim())
        .expect("cast key-authorization sign --json should emit valid JSON");
    assert_eq!(parsed["is_admin"], serde_json::Value::Bool(true), "got: {output}");
    // The signer is the admin key (ADDR2)...
    assert_eq!(
        parsed["signer"].as_str().map(str::to_lowercase),
        Some(accounts::ADDR2.to_lowercase()),
        "got: {output}"
    );
    // ...but the bound account is the root account being modified (ADDR1), not the signer.
    assert_eq!(
        parsed["account"].as_str().map(str::to_lowercase),
        Some(accounts::ADDR1.to_lowercase()),
        "got: {output}"
    );
});

casttest!(tempo_import_access_key_writes_accounts_store, |prj, cmd| {
    let tempo_home = prj.root().join("tempo-home");
    let authorization = cmd
        .args([
            "key-authorization",
            "sign",
            accounts::ADDR2,
            "--chain-id",
            "42431",
            "--private-key",
            accounts::PK1,
            "--bind-account",
            accounts::ADDR1,
        ])
        .assert_success()
        .get_output()
        .stdout_lossy()
        .trim()
        .to_owned();

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", &tempo_home);
    cmd.args([
        "tempo",
        "import-access-key",
        "--account",
        accounts::ADDR1,
        "--access-key",
        accounts::PK2,
        "--authorization",
        &authorization,
    ])
    .assert_success();

    let store = TempoAccountsStore::open(tempo_home.join("wallet/store.json"))
        .expect("open Accounts store");
    assert_eq!(store.active_account().unwrap().to_string(), accounts::ADDR1);
    let keys = store.access_keys().expect("read imported access key");
    assert_eq!(keys.len(), 1);
    assert_eq!(keys[0].address().to_string(), accounts::ADDR2);
    assert_eq!(keys[0].chain_id(), 42431);
    assert!(keys[0].key_authorization().is_some());
    assert!(keys[0].is_locally_signable());
});

casttest!(keychain_doctor_json_keeps_report_schema_version, async |_prj, cmd| {
    let output = cmd
        .args([
            "keychain",
            "doctor",
            accounts::ADDR2,
            "--root-account",
            accounts::ADDR1,
            "--rpc-url",
            "http://127.0.0.1:1",
            "--json",
        ])
        .assert_success()
        .get_output()
        .stdout_lossy();

    let parsed: serde_json::Value = serde_json::from_str(output.trim())
        .expect("cast keychain doctor --json should emit valid JSON");
    assert_eq!(parsed["schema_version"], 1);
});

casttest!(keychain_show_json_no_match_returns_empty_array, |prj, cmd| {
    let tempo_home = prj.root().join("tempo-home");
    fs::create_dir_all(tempo_home.join("wallet")).expect("create Tempo wallet dir");
    let store = serde_json::json!({
        "tempo-cli.store": {
            "state": {
                "activeAccount": 0,
                "chainId": 4217,
                "accounts": [{"address": accounts::ADDR2}],
                "accessKeys": [],
            },
        },
    });
    fs::write(
        tempo_home.join("wallet/store.json"),
        serde_json::to_vec(&store).expect("serialize store"),
    )
    .expect("write store.json");

    cmd.env("TEMPO_HOME", &tempo_home);

    let output = cmd
        .args(["keychain", "show", accounts::ADDR1, "--json"])
        .assert_success()
        .get_output()
        .stdout_lossy();

    let parsed: serde_json::Value = serde_json::from_str(output.trim())
        .expect("cast keychain show --json should emit valid JSON");
    assert_eq!(parsed["schema_version"], 1);
    assert_eq!(
        parsed["data"].as_array().expect("keychain show --json data should be an array").len(),
        0
    );
});

casttest!(wallet_session_revoke_revokes_provisioned_key_on_chain, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let tempo_home = tempfile::tempdir().unwrap();
    let (session_id, key_address) = create_session(&mut cmd, tempo_home.path(), "31337");

    cmd.cast_fuse()
        .args([
            "keychain",
            "authorize",
            &key_address,
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
        ])
        .assert_success();

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    cmd.args([
        "wallet",
        "session",
        "revoke",
        &session_id,
        "--private-key",
        accounts::PK1,
        "--rpc-url",
        &rpc,
    ])
    .assert_success();

    let check_output = cmd
        .cast_fuse()
        .args(["keychain", "check", accounts::ADDR1, &key_address, "--rpc-url", &rpc, "--json"])
        .assert_success()
        .get_output()
        .stdout_lossy();
    let checked: serde_json::Value =
        serde_json::from_str(check_output.trim()).expect("keychain check emits JSON");
    let checked = &checked["data"];
    assert_eq!(checked["provisioned"], false);
    assert_eq!(checked["is_revoked"], true);

    assert_accounts_store_key_retired(tempo_home.path(), "revoked");
});

casttest!(wallet_session_revoke_sponsor_hash_does_not_mark_revoked, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let tempo_home = tempfile::tempdir().unwrap();
    let (session_id, key_address) = create_session(&mut cmd, tempo_home.path(), "31337");

    cmd.cast_fuse()
        .args([
            "keychain",
            "authorize",
            &key_address,
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
        ])
        .assert_success();

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    let output = cmd
        .args([
            "wallet",
            "session",
            "revoke",
            &session_id,
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
            "--tempo.print-sponsor-hash",
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();
    assert!(
        output.contains("--tempo.print-sponsor-hash only prints a sponsor hash"),
        "unexpected stderr:\n{output}"
    );

    let check_output = cmd
        .cast_fuse()
        .args(["keychain", "check", accounts::ADDR1, &key_address, "--rpc-url", &rpc, "--json"])
        .assert_success()
        .get_output()
        .stdout_lossy();
    let checked: serde_json::Value =
        serde_json::from_str(check_output.trim()).expect("keychain check emits JSON");
    let checked = &checked["data"];
    assert_eq!(checked["provisioned"], true);
    assert_eq!(checked["is_revoked"], false);

    assert_accounts_store_key_signable(tempo_home.path(), "active");
});

casttest!(wallet_session_revoke_marks_unprovisioned_key_revoked_locally, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let tempo_home = tempfile::tempdir().unwrap();
    let (session_id, key_address) = create_session(&mut cmd, tempo_home.path(), "31337");

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    let output = cmd
        .args([
            "--json",
            "wallet",
            "session",
            "revoke",
            &session_id,
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
        ])
        .assert_success()
        .get_output()
        .stdout_lossy();
    let revoked: serde_json::Value =
        serde_json::from_str(output.trim()).expect("session revoke emits JSON");
    assert_eq!(revoked["status"], "revoked");
    assert_eq!(revoked["reason"], "not_provisioned");

    let check_output = cmd
        .cast_fuse()
        .args(["keychain", "check", accounts::ADDR1, &key_address, "--rpc-url", &rpc, "--json"])
        .assert_success()
        .get_output()
        .stdout_lossy();
    let checked: serde_json::Value =
        serde_json::from_str(check_output.trim()).expect("keychain check emits JSON");
    let checked = &checked["data"];
    assert_eq!(checked["provisioned"], false);
    assert_eq!(checked["is_revoked"], false);

    assert_accounts_store_key_retired(tempo_home.path(), "revoked");
});

casttest!(wallet_session_revoke_local_cleans_key_without_rpc, async |_prj, cmd| {
    let tempo_home = tempfile::tempdir().unwrap();
    let (session_id, _) = create_session(&mut cmd, tempo_home.path(), "31337");

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    let output = cmd
        .args(["--json", "wallet", "session", "revoke", &session_id, "--local"])
        .assert_success()
        .get_output()
        .stdout_lossy();
    let revoked: serde_json::Value =
        serde_json::from_str(output.trim()).expect("session revoke --local emits JSON");
    assert_eq!(revoked["status"], "revoked");
    assert_eq!(revoked["reason"], "local");

    assert_accounts_store_key_retired(tempo_home.path(), "revoked");
});

casttest!(wallet_session_store_preserves_existing_access_keys, async |prj, cmd| {
    let tempo_home = prj.root().join("tempo-home");
    write_accounts_store(&tempo_home, 31337);
    let before = fs::read_to_string(tempo_home.join("wallet/store.json")).unwrap();
    let before: serde_json::Value = serde_json::from_str(&before).unwrap();
    let existing = before["tempo-cli.store"]["state"]["accessKeys"][0].clone();

    let (session_id, key_address) = create_session(&mut cmd, &tempo_home, "31337");
    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", &tempo_home);
    cmd.args(["--json", "wallet", "session", "revoke", &session_id, "--local"]).assert_success();

    let after = fs::read_to_string(tempo_home.join("wallet/store.json")).unwrap();
    let after: serde_json::Value = serde_json::from_str(&after).unwrap();
    let keys = after["tempo-cli.store"]["state"]["accessKeys"].as_array().unwrap();
    assert_eq!(keys.len(), 2);
    assert_eq!(
        keys.iter()
            .find(|key| {
                key["address"]
                    .as_str()
                    .is_some_and(|address| address.eq_ignore_ascii_case(accounts::ADDR2))
            })
            .unwrap(),
        &existing
    );
    let retired = keys
        .iter()
        .find(|key| {
            key["address"]
                .as_str()
                .is_some_and(|address| address.eq_ignore_ascii_case(&key_address))
        })
        .unwrap();
    assert!(retired.get("privateKey").is_none());
    assert!(retired.get("keyAuthorization").is_some());
    assert!(!tempo_home.join("wallet/sessions.toml").exists());
});

casttest!(wallet_session_revoke_wrong_chain_preserves_local_key, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let tempo_home = tempfile::tempdir().unwrap();
    let (session_id, _) = create_session(&mut cmd, tempo_home.path(), "31338");

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    cmd.args([
        "wallet",
        "session",
        "revoke",
        &session_id,
        "--private-key",
        accounts::PK1,
        "--rpc-url",
        &rpc,
    ])
    .assert_failure();

    assert_accounts_store_key_signable(tempo_home.path(), "active");
});

casttest!(
    wallet_session_run_for_without_key_use_fails_closed_and_cleans_key_material,
    async |_prj, cmd| {
        let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
        let rpc = handle.http_endpoint();
        let tempo_home = tempfile::tempdir().unwrap();
        let path_usd = path_usd();
        let child_dir = tempfile::tempdir().unwrap();
        let child_script = child_dir.path().join("session-child.sh");
        let child_session_out = child_dir.path().join("child-session-id.txt");
        fs::write(
            &child_script,
            r#"#!/bin/sh
set -eu
test -n "${TEMPO_SESSION_ID:-}"
store_file="${TEMPO_HOME}/wallet/store.json"
test ! -e "${TEMPO_HOME}/wallet/sessions.toml"
grep -q '"privateKey":' "${store_file}"
grep -q '"keyAuthorization":' "${store_file}"
printf '%s\n' "${TEMPO_SESSION_ID}" > "$1"
"#,
        )
        .expect("write child script");

        // Git Bash (the `sh` on Windows) treats backslashes as escapes, so embed the script
        // paths with forward slashes; `to_slash_lossy` is a no-op on Unix.
        let for_command =
            format!("sh {} {}", child_script.to_slash_lossy(), child_session_out.to_slash_lossy());

        cmd.cast_fuse();
        cmd.env("TEMPO_HOME", tempo_home.path());
        let stderr = cmd
            .args([
                "wallet",
                "session",
                "--root",
                accounts::ADDR1,
                "--expires",
                "10m",
                "--scope",
                &path_usd,
                "--spend-limit",
                "PathUSD=0",
                "--for",
                &for_command,
                "--private-key",
                accounts::PK1,
                "--rpc-url",
                &rpc,
            ])
            .assert_failure()
            .get_output()
            .stderr_lossy();
        assert_session_cleanup_failure(&stderr);

        let child_session_id =
            fs::read_to_string(&child_session_out).expect("child wrote TEMPO_SESSION_ID");
        assert!(
            child_session_id.trim().starts_with("0x"),
            "unexpected child session id: {child_session_id}"
        );
        assert_accounts_store_key_retired(tempo_home.path(), "revoking");
    }
);

casttest!(wallet_session_run_for_cast_send_submits_with_session_key, async |prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let tempo_home = tempfile::tempdir().unwrap();
    let child_dir = tempfile::tempdir().unwrap();
    let child_script = child_dir.path().join("session-cast-send.sh");
    let path_usd = path_usd();
    fs::write(&child_script, cast_send_session_script(&path_usd)).expect("write child script");

    let for_command = format!("sh {}", child_script.to_slash_lossy());

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    cmd.env("CAST_BIN", prj.foundry_bin_path("cast"));
    cmd.env("RPC_URL", &rpc);
    let assertion = cmd
        .args([
            "wallet",
            "session",
            "--root",
            accounts::ADDR1,
            "--expires",
            "10m",
            "--scope",
            &path_usd,
            "--spend-limit",
            "PathUSD=1000000",
            "--for",
            &for_command,
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
        ])
        .assert_success();
    let stdout = assertion.get_output().stdout_lossy();

    assert_contains_tx_hash(&stdout, "child cast send");
    assert_accounts_store_key_retired(tempo_home.path(), "revoked");
});

casttest!(wallet_session_run_for_batch_send_submits_with_session_key, async |prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let tempo_home = tempfile::tempdir().unwrap();
    let child_dir = tempfile::tempdir().unwrap();
    let child_script = child_dir.path().join("session-batch-send.sh");
    let path_usd = path_usd();
    let call = batch_send_transfer_call(&path_usd);
    fs::write(
        &child_script,
        format!(
            r#"#!/bin/sh
set -eu
test -n "${{TEMPO_SESSION_ID:-}}"
tx_hash=$("${{CAST_BIN}}" batch-send --call "{call}" --rpc-url "${{RPC_URL}}" --tempo.fee-token "{path_usd}" --async)
"${{CAST_BIN}}" receipt "$tx_hash" --rpc-url "${{RPC_URL}}" >/dev/null
printf '%s\n' "$tx_hash"
"#,
        ),
    )
    .expect("write child script");

    let for_command = format!("sh {}", child_script.to_slash_lossy());

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    cmd.env("CAST_BIN", prj.foundry_bin_path("cast"));
    cmd.env("RPC_URL", &rpc);
    let assertion = cmd
        .args([
            "wallet",
            "session",
            "--root",
            accounts::ADDR1,
            "--expires",
            "10m",
            "--scope",
            &path_usd,
            "--spend-limit",
            "PathUSD=1000000",
            "--for",
            &for_command,
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
        ])
        .assert_success();
    let stdout = assertion.get_output().stdout_lossy();

    assert_contains_tx_hash(&stdout, "child cast batch-send");
    assert_accounts_store_key_retired(tempo_home.path(), "revoked");
});

casttest!(wallet_session_run_for_forge_script_submits_with_session_key, async |prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let tempo_home = tempfile::tempdir().unwrap();
    let path_usd = path_usd();

    foundry_test_utils::util::initialize(prj.root());
    let script = prj.add_script(
        "SessionForgeScript.s.sol",
        &format!(
            r#"
import "forge-std/Script.sol";

interface PathUsdLike {{
    function transfer(address to, uint256 amount) external returns (bool);
}}

contract SessionForgeScript is Script {{
    function run() external {{
        vm.startBroadcast();
        PathUsdLike({path_usd}).transfer({recipient}, 0);
        vm.stopBroadcast();
    }}
}}
"#,
            recipient = accounts::ADDR3,
        ),
    );

    let for_command = format!(
        "{} script {} --tc SessionForgeScript --broadcast --timeout 1 --rpc-url {} --root {}",
        prj.ensure_foundry_bin("forge").to_slash_lossy(),
        script.to_slash_lossy(),
        rpc,
        prj.root().to_slash_lossy(),
    );

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    let assertion = cmd
        .args([
            "wallet",
            "session",
            "--root",
            accounts::ADDR1,
            "--expires",
            "10m",
            "--target",
            &path_usd,
            "--selector",
            "transfer(address,uint256)",
            "--spend-limit",
            "PathUSD=1000000",
            "--spend-limit",
            "AlphaUSD=1000000",
            "--for",
            &for_command,
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
        ])
        .assert_success();
    let stdout = assertion.get_output().stdout_lossy();
    let stderr = assertion.get_output().stderr_lossy();

    assert!(
        stdout.contains("ONCHAIN EXECUTION COMPLETE & SUCCESSFUL."),
        "expected forge script broadcast completion\nstdout:\n{stdout}\nstderr:\n{stderr}"
    );

    let run_latest = foundry_common::fs::json_files(&prj.root().join("broadcast"))
        .find(|path| path.ends_with("run-latest.json"))
        .unwrap_or_else(|| {
            panic!("expected forge broadcast artifact\nstdout:\n{stdout}\nstderr:\n{stderr}")
        });
    let broadcast = fs::read_to_string(&run_latest).expect("read forge broadcast artifact");
    let broadcast: serde_json::Value =
        serde_json::from_str(&broadcast).expect("forge broadcast artifact is valid JSON");
    let tx = &broadcast["transactions"][0];

    assert_eq!(tx["transactionType"], "CALL", "unexpected forge broadcast tx: {tx}");
    assert_eq!(tx["function"], "transfer(address,uint256)", "unexpected forge broadcast tx: {tx}");
    assert_eq!(
        tx["functionAbi"], "function transfer(address to, uint256 amount) returns (bool)",
        "unexpected forge broadcast tx: {tx}"
    );
    assert_eq!(
        tx["contractAddress"].as_str().map(str::to_ascii_lowercase),
        Some(path_usd.to_ascii_lowercase()),
        "unexpected forge broadcast tx: {tx}"
    );
    assert!(
        tx["hash"].as_str().is_some_and(|hash| hash.starts_with("0x")),
        "forge broadcast tx should have a submitted hash: {tx}"
    );
    assert_accounts_store_key_retired(tempo_home.path(), "revoked");
});

casttest!(batch_send_uses_tempo_session_id_env, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let tempo_home = tempfile::tempdir().unwrap();
    let path_usd = path_usd();
    let call = batch_send_transfer_call(&path_usd);
    let (session_id, _) = create_session(&mut cmd, tempo_home.path(), "31337");

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    cmd.env("TEMPO_SESSION_ID", &session_id);
    let stdout = cmd
        .args([
            "batch-send",
            "--call",
            &call,
            "--rpc-url",
            &rpc,
            "--tempo.fee-token",
            &path_usd,
            "--async",
        ])
        .assert_success()
        .get_output()
        .stdout_lossy();

    assert_async_tx_hash(&stdout, "cast batch-send");
});

casttest!(batch_mktx_uses_tempo_session_id_env, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let tempo_home = tempfile::tempdir().unwrap();
    let path_usd = path_usd();
    let call = batch_send_transfer_call(&path_usd);
    let (session_id, _) = create_session(&mut cmd, tempo_home.path(), "31337");

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    cmd.env("TEMPO_SESSION_ID", &session_id);
    let stdout = cmd
        .args(["batch-mktx", "--call", &call, "--rpc-url", &rpc, "--tempo.fee-token", &path_usd])
        .assert_success()
        .get_output()
        .stdout_lossy();

    assert!(
        stdout.trim().starts_with("0x"),
        "expected cast batch-mktx to print raw tx hex, got:\n{stdout}"
    );
});

casttest!(batch_mktx_raw_unsigned_resolves_tempo_access_key_metadata, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let path_usd = path_usd();
    let call = batch_send_transfer_call(&path_usd);

    cmd.cast_fuse();
    let stderr = cmd
        .args([
            "batch-mktx",
            "--call",
            &call,
            "--raw-unsigned",
            "--from",
            accounts::ADDR1,
            "--nonce",
            "0",
            "--gas-price",
            "1",
            "--rpc-url",
            &rpc,
            "--tempo.fee-token",
            &path_usd,
            "--tempo.access-key",
            accounts::PK2,
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert!(
        stderr.contains("--tempo.root-account is required when --tempo.access-key is set"),
        "raw unsigned must still resolve Tempo access-key metadata, got:\n{stderr}"
    );
});

casttest!(vaddr_create_sync_json_uses_tempo_session_id_env, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let tempo_home = tempfile::tempdir().unwrap();
    let (session_id, _) =
        create_session_with_scope(&mut cmd, tempo_home.path(), "31337", ADDRESS_REGISTRY);

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    cmd.env("TEMPO_SESSION_ID", &session_id);
    let stdout = cmd
        .args([
            "--json",
            "vaddr",
            "create",
            "--owner",
            accounts::ADDR1,
            "--salt",
            PRECOMPUTED_VADDR_SALT_FOR_ADDR1,
            "--rpc-url",
            &rpc,
            "--sync",
        ])
        .assert_success()
        .get_output()
        .stdout_lossy();

    let envelope: serde_json::Value =
        serde_json::from_str(stdout.trim()).expect("vaddr create emits JSON");
    assert!(
        envelope["data"]["registration_tx_hash"]
            .as_str()
            .is_some_and(|hash| hash.starts_with("0x")),
        "expected vaddr create --json to include registration tx hash, got:\n{stdout}"
    );
});

casttest!(tip20_create_uses_tempo_session_id_env, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let tempo_home = tempfile::tempdir().unwrap();
    let path_usd = path_usd();
    let (session_id, _) =
        create_session_with_scope(&mut cmd, tempo_home.path(), "31337", &tip20_factory());

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    cmd.env("TEMPO_SESSION_ID", &session_id);
    let stdout = cmd
        .args([
            "tip20",
            "create",
            "Session Dollar",
            "SESS",
            "USD",
            &path_usd,
            accounts::ADDR1,
            "0x0000000000000000000000000000000000000000000000000000000000005151",
            "--rpc-url",
            &rpc,
            "--tempo.fee-token",
            &path_usd,
            "--async",
        ])
        .assert_success()
        .get_output()
        .stdout_lossy();

    assert_async_tx_hash(&stdout, "cast tip20 create");
});

casttest!(tip20_mine_register_uses_tempo_session_id_env, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let tempo_home = tempfile::tempdir().unwrap();
    let (session_id, _) =
        create_session_with_scope(&mut cmd, tempo_home.path(), "31337", ADDRESS_REGISTRY);

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    cmd.env("TEMPO_SESSION_ID", &session_id);
    let stdout = cmd
        .args([
            "tip20",
            "mine",
            accounts::ADDR1,
            "--salt",
            PRECOMPUTED_VADDR_SALT_FOR_ADDR1,
            "--register",
            "--rpc-url",
            &rpc,
            "--async",
        ])
        .assert_success()
        .get_output()
        .stdout_lossy();

    assert_contains_tx_hash(&stdout, "cast tip20 mine --register");
});

casttest!(erc20_transfer_uses_tempo_session_id_env, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let tempo_home = tempfile::tempdir().unwrap();
    let path_usd = path_usd();
    let (session_id, _) = create_session(&mut cmd, tempo_home.path(), "31337");

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    cmd.env("TEMPO_SESSION_ID", &session_id);
    let stdout = cmd
        .args([
            "erc20",
            "transfer",
            &path_usd,
            accounts::ADDR3,
            "0",
            "--rpc-url",
            &rpc,
            "--tempo.fee-token",
            &path_usd,
            "--async",
        ])
        .assert_success()
        .get_output()
        .stdout_lossy();

    assert_async_tx_hash(&stdout, "cast erc20 transfer");
});

casttest!(batch_mktx_rejects_session_with_explicit_signer, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let path_usd = path_usd();
    let call = batch_send_transfer_call(&path_usd);

    cmd.cast_fuse();
    let stderr = cmd
        .args([
            "batch-mktx",
            "--call",
            &call,
            "--tempo.session",
            MISSING_SESSION_ID,
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
            "--tempo.fee-token",
            &path_usd,
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert!(stderr.contains("explicit wallet signer"), "unexpected stderr:\n{stderr}");
});

casttest!(erc20_rejects_session_with_explicit_signer, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let path_usd = path_usd();

    cmd.cast_fuse();
    let stderr = cmd
        .args([
            "erc20",
            "transfer",
            &path_usd,
            accounts::ADDR3,
            "0",
            "--tempo.session",
            MISSING_SESSION_ID,
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
            "--tempo.fee-token",
            &path_usd,
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert!(stderr.contains("explicit wallet signer"), "unexpected stderr:\n{stderr}");
});

casttest!(batch_mktx_rejects_session_with_ethsign, |_prj, cmd| {
    let path_usd = path_usd();
    let call = batch_send_transfer_call(&path_usd);

    cmd.cast_fuse();
    let stderr = cmd
        .args([
            "batch-mktx",
            "--call",
            &call,
            "--tempo.session",
            MISSING_SESSION_ID,
            "--ethsign",
            "--from",
            accounts::ADDR1,
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert!(stderr.contains("cannot be combined with --ethsign"), "unexpected stderr:\n{stderr}");
});

casttest!(batch_mktx_rejects_session_with_raw_unsigned, |_prj, cmd| {
    let path_usd = path_usd();
    let call = batch_send_transfer_call(&path_usd);

    cmd.cast_fuse();
    let stderr = cmd
        .args([
            "batch-mktx",
            "--call",
            &call,
            "--tempo.session",
            MISSING_SESSION_ID,
            "--raw-unsigned",
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert!(
        stderr.contains("cannot be combined with --raw-unsigned"),
        "unexpected stderr:\n{stderr}"
    );
});

casttest!(batch_mktx_rejects_session_on_wrong_chain, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let tempo_home = tempfile::tempdir().unwrap();
    let path_usd = path_usd();
    let call = batch_send_transfer_call(&path_usd);
    let (session_id, _) = create_session(&mut cmd, tempo_home.path(), "31338");

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    let stderr = cmd
        .args([
            "batch-mktx",
            "--call",
            &call,
            "--tempo.session",
            &session_id,
            "--rpc-url",
            &rpc,
            "--tempo.fee-token",
            &path_usd,
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert_wrong_chain_error(&stderr);
});

casttest!(vaddr_create_rejects_session_with_explicit_signer, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();

    cmd.cast_fuse();
    let stderr = cmd
        .args([
            "vaddr",
            "create",
            "--owner",
            accounts::ADDR1,
            "--salt",
            PRECOMPUTED_VADDR_SALT_FOR_ADDR1,
            "--tempo.session",
            MISSING_SESSION_ID,
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert!(stderr.contains("explicit wallet signer"), "unexpected stderr:\n{stderr}");
});

casttest!(vaddr_create_rejects_session_with_browser, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();

    cmd.cast_fuse();
    let stderr = cmd
        .args([
            "vaddr",
            "create",
            "--owner",
            accounts::ADDR1,
            "--salt",
            PRECOMPUTED_VADDR_SALT_FOR_ADDR1,
            "--tempo.session",
            MISSING_SESSION_ID,
            "--browser",
            "--rpc-url",
            &rpc,
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert!(stderr.contains("cannot be combined with --browser"), "unexpected stderr:\n{stderr}");
});

casttest!(vaddr_create_rejects_session_on_wrong_chain, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let tempo_home = tempfile::tempdir().unwrap();
    let (session_id, _) =
        create_session_with_scope(&mut cmd, tempo_home.path(), "31338", ADDRESS_REGISTRY);

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    let stderr = cmd
        .args([
            "vaddr",
            "create",
            "--owner",
            accounts::ADDR1,
            "--salt",
            PRECOMPUTED_VADDR_SALT_FOR_ADDR1,
            "--tempo.session",
            &session_id,
            "--rpc-url",
            &rpc,
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert_wrong_chain_error(&stderr);
});

casttest!(tip20_create_rejects_session_with_explicit_signer, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let path_usd = path_usd();

    cmd.cast_fuse();
    let stderr = cmd
        .args([
            "tip20",
            "create",
            "Session Dollar",
            "SESS",
            "USD",
            &path_usd,
            accounts::ADDR1,
            "0x0000000000000000000000000000000000000000000000000000000000005152",
            "--tempo.session",
            MISSING_SESSION_ID,
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
            "--tempo.fee-token",
            &path_usd,
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert!(stderr.contains("explicit wallet signer"), "unexpected stderr:\n{stderr}");
});

casttest!(tip20_create_rejects_session_with_browser, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let path_usd = path_usd();

    cmd.cast_fuse();
    let stderr = cmd
        .args([
            "tip20",
            "create",
            "Session Dollar",
            "SESS",
            "USD",
            &path_usd,
            accounts::ADDR1,
            "0x0000000000000000000000000000000000000000000000000000000000005153",
            "--tempo.session",
            MISSING_SESSION_ID,
            "--browser",
            "--rpc-url",
            &rpc,
            "--tempo.fee-token",
            &path_usd,
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert!(stderr.contains("cannot be combined with --browser"), "unexpected stderr:\n{stderr}");
});

casttest!(tip20_create_rejects_session_on_wrong_chain, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let tempo_home = tempfile::tempdir().unwrap();
    let path_usd = path_usd();
    let (session_id, _) =
        create_session_with_scope(&mut cmd, tempo_home.path(), "31338", &tip20_factory());

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    let stderr = cmd
        .args([
            "tip20",
            "create",
            "Session Dollar",
            "SESS",
            "USD",
            &path_usd,
            accounts::ADDR1,
            "0x0000000000000000000000000000000000000000000000000000000000005154",
            "--tempo.session",
            &session_id,
            "--rpc-url",
            &rpc,
            "--tempo.fee-token",
            &path_usd,
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert_wrong_chain_error(&stderr);
});

casttest!(tip20_mine_register_rejects_session_with_explicit_signer, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();

    cmd.cast_fuse();
    let stderr = cmd
        .args([
            "tip20",
            "mine",
            accounts::ADDR1,
            "--salt",
            PRECOMPUTED_VADDR_SALT_FOR_ADDR1,
            "--register",
            "--tempo.session",
            MISSING_SESSION_ID,
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert!(stderr.contains("explicit wallet signer"), "unexpected stderr:\n{stderr}");
});

casttest!(tip20_mine_register_rejects_session_with_browser, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();

    cmd.cast_fuse();
    let stderr = cmd
        .args([
            "tip20",
            "mine",
            accounts::ADDR1,
            "--salt",
            PRECOMPUTED_VADDR_SALT_FOR_ADDR1,
            "--register",
            "--tempo.session",
            MISSING_SESSION_ID,
            "--browser",
            "--rpc-url",
            &rpc,
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert!(stderr.contains("cannot be combined with --browser"), "unexpected stderr:\n{stderr}");
});

casttest!(tip20_mine_register_rejects_session_on_wrong_chain, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let tempo_home = tempfile::tempdir().unwrap();
    let (session_id, _) =
        create_session_with_scope(&mut cmd, tempo_home.path(), "31338", ADDRESS_REGISTRY);

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    let stderr = cmd
        .args([
            "tip20",
            "mine",
            accounts::ADDR1,
            "--salt",
            PRECOMPUTED_VADDR_SALT_FOR_ADDR1,
            "--register",
            "--tempo.session",
            &session_id,
            "--rpc-url",
            &rpc,
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert_wrong_chain_error(&stderr);
});

casttest!(erc20_rejects_session_with_browser, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let path_usd = path_usd();

    cmd.cast_fuse();
    let stderr = cmd
        .args([
            "erc20",
            "transfer",
            &path_usd,
            accounts::ADDR3,
            "0",
            "--tempo.session",
            MISSING_SESSION_ID,
            "--browser",
            "--rpc-url",
            &rpc,
            "--tempo.fee-token",
            &path_usd,
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert!(stderr.contains("cannot be combined with --browser"), "unexpected stderr:\n{stderr}");
});

casttest!(erc20_rejects_session_on_wrong_chain, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let tempo_home = tempfile::tempdir().unwrap();
    let path_usd = path_usd();
    let (session_id, _) = create_session(&mut cmd, tempo_home.path(), "31338");

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    let stderr = cmd
        .args([
            "erc20",
            "transfer",
            &path_usd,
            accounts::ADDR3,
            "0",
            "--tempo.session",
            &session_id,
            "--rpc-url",
            &rpc,
            "--tempo.fee-token",
            &path_usd,
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert_wrong_chain_error(&stderr);
});

casttest!(wallet_session_run_for_grandchild_cast_send_inherits_session_key, async |prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let tempo_home = tempfile::tempdir().unwrap();
    let child_dir = tempfile::tempdir().unwrap();
    let child_script = child_dir.path().join("session-child.sh");
    let grandchild_script = child_dir.path().join("session-grandchild-cast-send.sh");
    let path_usd = path_usd();

    fs::write(
        &child_script,
        r#"#!/bin/sh
set -eu
test -n "${TEMPO_SESSION_ID:-}"
sh "$1"
"#,
    )
    .expect("write child script");

    fs::write(&grandchild_script, cast_send_session_script(&path_usd))
        .expect("write grandchild script");

    let for_command =
        format!("sh {} {}", child_script.to_slash_lossy(), grandchild_script.to_slash_lossy());

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    cmd.env("CAST_BIN", prj.foundry_bin_path("cast"));
    cmd.env("RPC_URL", &rpc);
    let assertion = cmd
        .args([
            "wallet",
            "session",
            "--root",
            accounts::ADDR1,
            "--expires",
            "10m",
            "--scope",
            &path_usd,
            "--spend-limit",
            "PathUSD=1000000",
            "--for",
            &for_command,
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
        ])
        .assert_success();
    let stdout = assertion.get_output().stdout_lossy();

    assert_contains_tx_hash(&stdout, "grandchild cast send");
    assert_accounts_store_key_retired(tempo_home.path(), "revoked");
});

casttest!(cast_send_rejects_session_with_explicit_signer, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();

    cmd.cast_fuse();
    let stderr = cmd
        .args([
            "send",
            accounts::ADDR3,
            "--value",
            "1",
            "--tempo.session",
            MISSING_SESSION_ID,
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert!(stderr.contains("explicit wallet signer"), "unexpected stderr:\n{stderr}");
});

casttest!(batch_send_rejects_session_with_explicit_signer, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let path_usd = path_usd();
    let call = batch_send_transfer_call(&path_usd);

    cmd.cast_fuse();
    let stderr = cmd
        .args([
            "batch-send",
            "--call",
            &call,
            "--tempo.session",
            MISSING_SESSION_ID,
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
            "--tempo.fee-token",
            &path_usd,
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert!(stderr.contains("explicit wallet signer"), "unexpected stderr:\n{stderr}");
});

casttest!(batch_send_rejects_session_with_unlocked, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let path_usd = path_usd();
    let call = batch_send_transfer_call(&path_usd);

    cmd.cast_fuse();
    let stderr = cmd
        .args([
            "batch-send",
            "--call",
            &call,
            "--tempo.session",
            MISSING_SESSION_ID,
            "--unlocked",
            "--from",
            accounts::ADDR1,
            "--rpc-url",
            &rpc,
            "--tempo.fee-token",
            &path_usd,
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert!(stderr.contains("cannot be combined with --unlocked"), "unexpected stderr:\n{stderr}");
});

casttest!(batch_send_rejects_session_on_wrong_chain, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let tempo_home = tempfile::tempdir().unwrap();
    let path_usd = path_usd();
    let call = batch_send_transfer_call(&path_usd);
    let (session_id, _) = create_session(&mut cmd, tempo_home.path(), "31338");

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    let stderr = cmd
        .args([
            "batch-send",
            "--call",
            &call,
            "--tempo.session",
            &session_id,
            "--rpc-url",
            &rpc,
            "--tempo.fee-token",
            &path_usd,
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();

    assert!(stderr.contains("is for chain 31338"), "unexpected stderr:\n{stderr}");
    assert!(stderr.contains("command is using chain 31337"), "unexpected stderr:\n{stderr}");
});

casttest!(wallet_session_run_for_cleans_key_material_when_child_fails, async |_prj, cmd| {
    let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
    let rpc = handle.http_endpoint();
    let tempo_home = tempfile::tempdir().unwrap();
    let path_usd = path_usd();
    let child_dir = tempfile::tempdir().unwrap();
    let child_script = child_dir.path().join("failing-session-child.sh");
    fs::write(
        &child_script,
        r#"#!/bin/sh
set -eu
test -n "${TEMPO_SESSION_ID:-}"
store_file="${TEMPO_HOME}/wallet/store.json"
test ! -e "${TEMPO_HOME}/wallet/sessions.toml"
grep -q '"privateKey":' "${store_file}"
exit 7
"#,
    )
    .expect("write child script");

    let for_command = format!("sh {}", child_script.to_slash_lossy());

    cmd.cast_fuse();
    cmd.env("TEMPO_HOME", tempo_home.path());
    let stderr = cmd
        .args([
            "wallet",
            "session",
            "--root",
            accounts::ADDR1,
            "--expires",
            "10m",
            "--scope",
            &path_usd,
            "--for",
            &for_command,
            "--private-key",
            accounts::PK1,
            "--rpc-url",
            &rpc,
        ])
        .assert_failure()
        .get_output()
        .stderr_lossy();
    assert!(stderr.contains("exited with code 7"), "unexpected stderr:\n{stderr}");
    assert_accounts_store_key_retired(tempo_home.path(), "failed");
});

casttest!(
    wallet_session_run_for_retires_local_key_when_revoke_preflight_fails,
    async |_prj, cmd| {
        let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await;
        let rpc = handle.http_endpoint();
        let tempo_home = tempfile::tempdir().unwrap();
        let path_usd = path_usd();
        let child_dir = tempfile::tempdir().unwrap();
        let child_script = child_dir.path().join("session-child.sh");
        fs::write(
            &child_script,
            r#"#!/bin/sh
set -eu
test -n "${TEMPO_SESSION_ID:-}"
"#,
        )
        .expect("write child script");

        let for_command = format!("sh {}", child_script.to_slash_lossy());

        cmd.cast_fuse();
        cmd.env("TEMPO_HOME", tempo_home.path());
        let stderr = cmd
            .args([
                "wallet",
                "session",
                "--root",
                accounts::ADDR1,
                "--chain-id",
                "31338",
                "--expires",
                "10m",
                "--scope",
                &path_usd,
                "--for",
                &for_command,
                "--private-key",
                accounts::PK1,
                "--rpc-url",
                &rpc,
            ])
            .assert_failure()
            .get_output()
            .stderr_lossy();

        assert!(stderr.contains("created for chain 31338"), "unexpected stderr:\n{stderr}");
        assert_accounts_store_key_retired(tempo_home.path(), "revoking");
    }
);
