agent-first-pay 0.7.0

A payment tool for AI agents — send and receive across five networks through one interface, with spending limits you control.
Documentation
use crate::provider::PayError;
use crate::store::wallet::{self, WalletMetadata};
use crate::types::*;
use bdk_wallet::bitcoin::bip32::Xpriv;
use bdk_wallet::bitcoin::Network as BtcNetwork;
use bdk_wallet::chain::Merge;
use bdk_wallet::keys::bip39::Mnemonic;
use bdk_wallet::{KeychainKind, Wallet};

pub(crate) fn btc_network_for_meta(meta: &WalletMetadata) -> BtcNetwork {
    match meta.btc_network.as_deref() {
        Some("signet") => BtcNetwork::Signet,
        _ => BtcNetwork::Bitcoin,
    }
}

pub(crate) fn descriptors_from_mnemonic(
    mnemonic_str: &str,
    btc_network: BtcNetwork,
    address_type: &str,
) -> Result<(String, String), PayError> {
    let mnemonic = Mnemonic::parse(mnemonic_str)
        .map_err(|e| PayError::InternalError(format!("invalid mnemonic: {e}")))?;
    let seed = mnemonic.to_seed("");
    let xprv = Xpriv::new_master(btc_network, &seed)
        .map_err(|e| PayError::InternalError(format!("derive master key: {e}")))?;

    let coin_type = match btc_network {
        BtcNetwork::Bitcoin => 0,
        _ => 1,
    };

    let (external, internal) = match address_type {
        "segwit" => (
            format!("wpkh({xprv}/84'/{coin_type}'/0'/0/*)"),
            format!("wpkh({xprv}/84'/{coin_type}'/0'/1/*)"),
        ),
        _ => (
            format!("tr({xprv}/86'/{coin_type}'/0'/0/*)"),
            format!("tr({xprv}/86'/{coin_type}'/0'/1/*)"),
        ),
    };
    Ok((external, internal))
}

pub(crate) fn open_bdk_wallet_with_dir(
    data_dir: &str,
    meta: &WalletMetadata,
) -> Result<Wallet, PayError> {
    let seed_secret = meta
        .seed_secret
        .as_deref()
        .ok_or_else(|| PayError::InternalError("wallet has no seed_secret".to_string()))?;

    let btc_net = btc_network_for_meta(meta);
    let addr_type = meta.btc_address_type.as_deref().unwrap_or("taproot");
    let (external, internal) = descriptors_from_mnemonic(seed_secret, btc_net, addr_type)?;

    let changeset_path = wallet::wallet_data_directory_path_for_wallet_metadata(data_dir, meta)
        .join("bdk_changeset.json");

    if changeset_path.exists() {
        if let Ok(raw) = std::fs::read_to_string(&changeset_path) {
            if let Ok(changeset) = serde_json::from_str::<bdk_wallet::ChangeSet>(&raw) {
                if let Ok(Some(wallet)) = Wallet::load()
                    .descriptor(KeychainKind::External, Some(external.clone()))
                    .descriptor(KeychainKind::Internal, Some(internal.clone()))
                    .extract_keys()
                    .load_wallet_no_persist(changeset)
                {
                    return Ok(wallet);
                }
            }
        }
    }

    Wallet::create(external, internal)
        .network(btc_net)
        .create_wallet_no_persist()
        .map_err(|e| PayError::InternalError(format!("create bdk wallet: {e}")))
}

pub(crate) fn persist_changeset(
    data_dir: &str,
    meta: &WalletMetadata,
    wallet: &mut Wallet,
) -> Result<(), PayError> {
    let staged = wallet.staged();
    if staged.is_none() {
        return Ok(());
    }
    let changeset_path = wallet::wallet_data_directory_path_for_wallet_metadata(data_dir, meta)
        .join("bdk_changeset.json");

    let mut full_changeset = if changeset_path.exists() {
        let raw = std::fs::read_to_string(&changeset_path)
            .map_err(|e| PayError::InternalError(format!("read changeset: {e}")))?;
        serde_json::from_str::<bdk_wallet::ChangeSet>(&raw).unwrap_or_default()
    } else {
        bdk_wallet::ChangeSet::default()
    };

    full_changeset.merge(wallet.take_staged().unwrap_or_default());

    let json = serde_json::to_string(&full_changeset)
        .map_err(|e| PayError::InternalError(format!("serialize changeset: {e}")))?;
    std::fs::write(&changeset_path, json)
        .map_err(|e| PayError::InternalError(format!("write changeset: {e}")))?;
    Ok(())
}

pub(crate) fn wallet_address(meta: &WalletMetadata) -> Result<String, PayError> {
    let seed_secret = meta
        .seed_secret
        .as_deref()
        .ok_or_else(|| PayError::InternalError("wallet has no seed_secret".to_string()))?;
    let btc_net = btc_network_for_meta(meta);
    let addr_type = meta.btc_address_type.as_deref().unwrap_or("taproot");
    let (external, internal) = descriptors_from_mnemonic(seed_secret, btc_net, addr_type)?;
    let wallet = Wallet::create(external, internal)
        .network(btc_net)
        .create_wallet_no_persist()
        .map_err(|e| PayError::InternalError(format!("derive address: {e}")))?;
    let addr_info = wallet.peek_address(KeychainKind::External, 0);
    Ok(addr_info.address.to_string())
}

pub(crate) fn btc_wallet_summary(meta: WalletMetadata, address: String) -> WalletSummary {
    WalletSummary {
        id: meta.id,
        network: Network::Btc,
        label: meta.label,
        address,
        backend: meta.backend.clone(),
        mint_url: None,
        rpc_endpoints: None,
        chain_id: None,
        created_at_epoch_s: meta.created_at_epoch_s,
    }
}

#[derive(Debug)]
pub(crate) struct BtcTransferTarget {
    pub address: String,
    pub amount_sats: u64,
}

pub(crate) fn parse_transfer_target(to: &str) -> Result<BtcTransferTarget, PayError> {
    let stripped = to.strip_prefix("bitcoin:").unwrap_or(to);

    let (addr_str, query) = if let Some(idx) = stripped.find('?') {
        (&stripped[..idx], Some(&stripped[idx + 1..]))
    } else {
        (stripped, None)
    };

    let mut amount_sats: Option<u64> = None;
    if let Some(q) = query {
        for pair in q.split('&') {
            if let Some(val) = pair.strip_prefix("amount=") {
                amount_sats =
                    Some(val.parse::<u64>().map_err(|e| {
                        PayError::InvalidAmount(format!("invalid amount in URI: {e}"))
                    })?);
            }
        }
    }

    let amount_sats = amount_sats.ok_or_else(|| {
        PayError::InvalidAmount(
            "amount is required; use bitcoin:<addr>?amount=<sats> or <addr>?amount=<sats>"
                .to_string(),
        )
    })?;

    Ok(BtcTransferTarget {
        address: addr_str.to_string(),
        amount_sats,
    })
}