miden-multisig-client 0.18.0

High-level SDK for interacting with multisig accounts on Miden via GUARDIAN
Documentation
//! Shared fixtures for the client test modules: fully offline
//! [`MultisigClient`]s (SQLite store in a temp dir, mock chain RPC, optional
//! mock note transport) and the account/note builders the recovery test
//! suites drive them with.

use std::path::Path;
use std::sync::Arc;

use base64::Engine as _;
use guardian_client::{
    AccountState, DeltaObject as ProtoDeltaObject, DeltaStatus, GetStateResponse, PendingStatus,
    delta_status,
};
use guardian_shared::SignatureScheme;
use miden_client::Serializable;
use miden_client::builder::ClientBuilder;
use miden_client::keystore::FilesystemKeyStore;
use miden_client::note_transport::NoteTransportClient;
use miden_client::rpc::Endpoint;
use miden_client::testing::mock::MockRpcApi;
use miden_client::testing::note_transport::{MockNoteTransportApi, MockNoteTransportNode};
use miden_client_sqlite_store::SqliteStore;
use miden_confidential_contracts::multisig_guardian::{
    MultisigGuardianBuilder, MultisigGuardianConfig,
};
use miden_protocol::Word;
use miden_protocol::account::Account;
use miden_protocol::account::auth::AuthSecretKey;
use miden_protocol::asset::FungibleAsset;
use miden_protocol::crypto::dsa::falcon512_poseidon2::SecretKey;
use miden_protocol::crypto::rand::RandomCoin;
use miden_protocol::note::{Note, NoteDetails, NoteType};
use miden_standards::note::P2idNote;
use miden_tx::utils::sync::RwLock;

use super::MultisigClient;
use crate::keystore::{GuardianKeyStore, KeyManager};
use crate::prover::ProverConfig;
use crate::rpc::RpcConfig;
use crate::transaction::word_to_hex;

const BASE64: base64::engine::general_purpose::GeneralPurpose =
    base64::engine::general_purpose::STANDARD;

pub(crate) async fn offline_client_parts(
    dir: &Path,
    node: Arc<MockRpcApi>,
    transport: Option<Arc<dyn NoteTransportClient>>,
) -> (MultisigClient, Arc<SqliteStore>) {
    offline_client_parts_with_keystore(dir, node, transport, Arc::new(GuardianKeyStore::generate()))
        .await
}

/// [`offline_client_parts`] with an injected keystore, for tests that need
/// the client's signer commitment known up front (e.g. to build a multisig
/// account whose cosigner set contains this client's key) or that need a
/// signer of a specific scheme.
pub(crate) async fn offline_client_parts_with_keystore(
    dir: &Path,
    node: Arc<MockRpcApi>,
    transport: Option<Arc<dyn NoteTransportClient>>,
    keystore: Arc<dyn KeyManager>,
) -> (MultisigClient, Arc<SqliteStore>) {
    let store = Arc::new(
        SqliteStore::new(dir.join("store.sqlite3"))
            .await
            .expect("sqlite store opens"),
    );
    let keystore_dir = dir.join("keys");
    std::fs::create_dir_all(&keystore_dir).expect("keystore dir");

    // A sync stores the protocol configuration each header commits to. Seed the
    // mock chain's one so a test that executes before its first sync resolves the
    // same fee asset the chain does.
    let protocol_config = node.protocol_config();
    let mut builder = ClientBuilder::<FilesystemKeyStore>::new()
        .rpc(node)
        .store(store.clone())
        .filesystem_keystore(keystore_dir)
        .expect("keystore opens");
    if let Some(transport) = transport {
        builder = builder.note_transport(transport);
    }
    let miden_client = builder.build().await.expect("miden client builds");
    miden_client
        .seed_protocol_config(protocol_config)
        .await
        .expect("mock protocol config is stored");

    let client = MultisigClient::new(
        miden_client,
        keystore,
        "http://localhost:1".to_string(),
        dir.to_path_buf(),
        Endpoint::localhost(),
        None,
        ProverConfig::new(),
        RpcConfig::new(),
    );
    (client, store)
}

/// Fully offline MultisigClient: mock chain RPC for the inner client, SQLite
/// store in a temp dir, and (optionally) the upstream mock note transport.
/// The direct node channel stays endpoint-built and unreachable.
pub(crate) async fn offline_client(
    dir: &Path,
    transport: Option<Arc<dyn NoteTransportClient>>,
) -> MultisigClient {
    offline_client_parts(dir, Arc::new(MockRpcApi::default()), transport)
        .await
        .0
}

/// Fully offline MultisigClient with `node` injected into both the inner
/// Miden client and the direct node channel, so node-backed primitives and
/// store syncs see the same mock chain.
pub(crate) async fn offline_client_with_node(dir: &Path, node: Arc<MockRpcApi>) -> MultisigClient {
    offline_client_with_node_parts(dir, node).await.0
}

/// [`offline_client_with_node`] variant that also returns the store handle,
/// for tests that seed records directly.
pub(crate) async fn offline_client_with_node_parts(
    dir: &Path,
    node: Arc<MockRpcApi>,
) -> (MultisigClient, Arc<SqliteStore>) {
    let (mut client, store) = offline_client_parts(dir, node.clone(), None).await;
    client.set_node_rpc_client(node);
    (client, store)
}

/// The upstream mock note transport and its backing node handle, for seeding
/// a transport backlog.
pub(crate) fn mock_transport() -> (
    Arc<RwLock<MockNoteTransportNode>>,
    Arc<dyn NoteTransportClient>,
) {
    let node = Arc::new(RwLock::new(MockNoteTransportNode::new()));
    let api: Arc<dyn NoteTransportClient> = Arc::new(MockNoteTransportApi::new(node.clone()));
    (node, api)
}

/// Adds `note` to the mock transport's backlog the way a sender would relay
/// it: header plus serialized details.
pub(crate) fn add_to_transport(node: &Arc<RwLock<MockNoteTransportNode>>, note: Note) {
    let header = *note.header();
    let details_bytes = NoteDetails::from(note).to_bytes();
    node.write().add_note(header, details_bytes);
}

/// A plain wallet account with a fresh (per-`seed`) seed; enough for the
/// store-side account/tag behavior under test (no multisig components
/// needed).
pub(crate) fn test_wallet(seed: u8) -> Account {
    use miden_client::account::component::BasicWallet;
    use miden_client::account::{AccountBuilder, AccountBuilderSchemaCommitmentExt, AccountType};
    use miden_protocol::account::auth::AuthScheme;
    use miden_standards::account::auth::{Approver, AuthSingleSig};

    let key_pair = AuthSecretKey::new_falcon512_poseidon2();
    let auth_component = AuthSingleSig::new(Approver::new(
        key_pair.public_key().to_commitment(),
        AuthScheme::Falcon512Poseidon2,
    ));
    AccountBuilder::new([seed; 32])
        .account_type(AccountType::Private)
        .with_component(auth_component)
        .with_component(BasicWallet)
        .build_with_schema_commitment()
        .expect("test wallet builds")
}

/// A mock chain holding the given output notes in its first post-genesis
/// block, with the tip advanced a few blocks past it — the note-bearing
/// block sits strictly below the tip.
pub(crate) fn chain_with_notes(
    notes: Vec<miden_protocol::transaction::RawOutputNote>,
) -> Arc<MockRpcApi> {
    let mut builder = miden_client::testing::MockChain::builder();
    for note in notes {
        builder.add_output_note(note);
    }
    let api = Arc::new(MockRpcApi::new(builder.build().expect("mock chain builds")));
    api.advance_blocks(4);
    api
}

/// A guarded-multisig account, the account shape every create path builds requests for.
pub(crate) fn guarded_multisig_account() -> Account {
    let signer_commitment = SecretKey::new().public_key().to_commitment();

    MultisigGuardianBuilder::new(MultisigGuardianConfig::new(
        1,
        vec![signer_commitment],
        Word::from([9u32, 8, 7, 6]),
    ))
    .build()
    .expect("guarded multisig account builds")
}

/// A distinct (per `seed`) P2ID note addressed at `target` from an unrelated
/// sender wallet.
pub(crate) fn p2id_note_for(target: &Account, seed: u32, note_type: NoteType) -> Note {
    let mut rng = RandomCoin::new(Word::from(&[seed, 0, 0, 0]));
    P2idNote::builder()
        .sender(test_wallet(200).id())
        .target(target.id())
        .asset(FungibleAsset::mock(1))
        .note_type(note_type)
        .generate_serial_number(&mut rng)
        .build()
        .expect("p2id note builds")
        .into()
}

/// A real 1-of-1 multisig-guardian account whose cosigner is `signer` — the
/// same construction `MultisigClient::create_account` performs, minus the
/// GUARDIAN pubkey fetch (the guardian commitment is fixed by the test).
pub(crate) fn multisig_account(signer: Word, guardian_commitment: Word, seed: u8) -> Account {
    multisig_account_with_scheme(signer, guardian_commitment, seed, SignatureScheme::Falcon)
}

/// [`multisig_account`] for a given signer scheme, so a test can pair the
/// account with a keystore of the same scheme.
pub(crate) fn multisig_account_with_scheme(
    signer: Word,
    guardian_commitment: Word,
    seed: u8,
    scheme: SignatureScheme,
) -> Account {
    let config = MultisigGuardianConfig::new(1, vec![signer], guardian_commitment)
        .with_signature_scheme(scheme);
    MultisigGuardianBuilder::new(config)
        .with_seed([seed; 32])
        .build()
        .expect("multisig account builds")
}

/// The canned `get_state` a mock GUARDIAN serves for `account`: the same
/// commitment the client holds, so guardian sync is a no-op.
pub(crate) fn registered_state(account: &Account) -> GetStateResponse {
    GetStateResponse {
        success: true,
        message: String::new(),
        state: Some(AccountState {
            account_id: account.id().to_string(),
            state_json: serde_json::json!({
                "data": BASE64.encode(account.to_bytes()),
            })
            .to_string(),
            commitment: word_to_hex(&account.to_commitment()),
            created_at: String::new(),
            updated_at: String::new(),
        }),
    }
}

/// Wraps a delta payload as the pending proto `DeltaObject` a GUARDIAN
/// listing would serve.
pub(crate) fn pending_proto_delta(
    account: &Account,
    nonce: u64,
    delta_payload: String,
    proposer_hex: &str,
) -> ProtoDeltaObject {
    ProtoDeltaObject {
        account_id: account.id().to_string(),
        nonce,
        prev_commitment: word_to_hex(&account.to_commitment()),
        delta_payload,
        new_commitment: String::new(),
        ack_sig: String::new(),
        candidate_at: String::new(),
        canonical_at: None,
        discarded_at: None,
        status: Some(DeltaStatus {
            status: Some(delta_status::Status::Pending(PendingStatus {
                timestamp: "2026-01-01T00:00:00Z".to_string(),
                proposer_id: proposer_hex.to_string(),
                cosigner_sigs: vec![],
            })),
            discard_reason: String::new(),
            retain_reason: String::new(),
        }),
        ack_pubkey: None,
        ack_scheme: None,
    }
}