zinc-core 0.4.0

Core Rust library for Zinc Bitcoin + Ordinals wallet
Documentation
use crate::offer::OfferEnvelopeV1;
use crate::offer_accept::prepare_offer_acceptance;
use base64::Engine;
use bdk_wallet::bitcoin::hashes::Hash as _;
use bdk_wallet::bitcoin::psbt::Psbt;
use bdk_wallet::bitcoin::{
    absolute, Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness,
};

const ASK_SATS: u64 = 100_000;
const SELLER_POSTAGE_SATS: u64 = 330;

fn sample_seller_txid() -> Txid {
    Txid::from_slice(&[0x11; 32]).expect("valid txid")
}

fn sample_buyer_txid() -> Txid {
    Txid::from_slice(&[0x22; 32]).expect("valid txid")
}

fn build_offer(
    now_unix: i64,
    seller_outpoint: OutPoint,
    psbt_base64: String,
    expires_at_unix: i64,
) -> OfferEnvelopeV1 {
    OfferEnvelopeV1 {
        version: 1,
        seller_pubkey_hex: "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
            .to_string(),
        network: "regtest".to_string(),
        inscription_id: "6fb976ab49dcec017f1e201e84395983204ae1a7c2abf7ced0a85d692e442799i0"
            .to_string(),
        seller_outpoint: seller_outpoint.to_string(),
        ask_sats: ASK_SATS,
        fee_rate_sat_vb: 1,
        psbt_base64,
        created_at_unix: now_unix - 10,
        expires_at_unix,
        nonce: 7,
    }
}

fn psbt_base64(
    seller_outpoint: OutPoint,
    include_duplicate_seller_input: bool,
    seller_signed: bool,
    buyer_signed: bool,
) -> String {
    let mut inputs = vec![
        TxIn {
            previous_output: seller_outpoint,
            script_sig: ScriptBuf::new(),
            sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
            witness: Witness::new(),
        },
        TxIn {
            previous_output: OutPoint {
                txid: sample_buyer_txid(),
                vout: 1,
            },
            script_sig: ScriptBuf::new(),
            sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
            witness: Witness::new(),
        },
    ];

    if include_duplicate_seller_input {
        inputs.push(TxIn {
            previous_output: seller_outpoint,
            script_sig: ScriptBuf::new(),
            sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
            witness: Witness::new(),
        });
    }

    let tx = Transaction {
        version: bdk_wallet::bitcoin::transaction::Version(2),
        lock_time: absolute::LockTime::ZERO,
        input: inputs,
        output: vec![
            TxOut {
                value: Amount::from_sat(SELLER_POSTAGE_SATS),
                script_pubkey: ScriptBuf::new(),
            },
            TxOut {
                value: Amount::from_sat(ASK_SATS + SELLER_POSTAGE_SATS),
                script_pubkey: ScriptBuf::new(),
            },
        ],
    };
    let mut psbt = Psbt::from_unsigned_tx(tx).expect("psbt");
    psbt.inputs[0].witness_utxo = Some(TxOut {
        value: Amount::from_sat(SELLER_POSTAGE_SATS),
        script_pubkey: ScriptBuf::new(),
    });

    if seller_signed {
        let stack = vec![b"seller-sig".to_vec()];
        psbt.inputs[0].final_script_witness = Some(Witness::from_slice(&stack));
    }
    if buyer_signed {
        let stack = vec![b"buyer-sig".to_vec()];
        psbt.inputs[1].final_script_witness = Some(Witness::from_slice(&stack));
    }

    base64::engine::general_purpose::STANDARD.encode(psbt.serialize())
}

#[test]
fn prepare_offer_acceptance_returns_plan_for_valid_offer() {
    let now_unix = 1_800_000_000;
    let seller_outpoint = OutPoint {
        txid: sample_seller_txid(),
        vout: 0,
    };
    let psbt = psbt_base64(seller_outpoint, false, false, true);
    let offer = build_offer(now_unix, seller_outpoint, psbt, now_unix + 3600);

    let plan = prepare_offer_acceptance(&offer, now_unix).expect("valid acceptance plan");

    assert_eq!(plan.seller_input_index, 0);
    assert_eq!(plan.input_count, 2);
    assert!(!plan.offer_id.is_empty());
}

#[test]
fn prepare_offer_acceptance_rejects_expired_offer() {
    let now_unix = 1_800_000_000;
    let seller_outpoint = OutPoint {
        txid: sample_seller_txid(),
        vout: 0,
    };
    let psbt = psbt_base64(seller_outpoint, false, false, true);
    let offer = build_offer(now_unix, seller_outpoint, psbt, now_unix - 1);

    let err = prepare_offer_acceptance(&offer, now_unix).expect_err("expired offer");
    assert!(err.to_string().contains("offer has expired"));
}

#[test]
fn prepare_offer_acceptance_rejects_missing_seller_input() {
    let now_unix = 1_800_000_000;
    let seller_outpoint_in_offer = OutPoint {
        txid: Txid::from_slice(&[0x33; 32]).expect("txid"),
        vout: 0,
    };
    let seller_outpoint_in_psbt = OutPoint {
        txid: sample_seller_txid(),
        vout: 0,
    };
    let psbt = psbt_base64(seller_outpoint_in_psbt, false, false, true);
    let offer = build_offer(now_unix, seller_outpoint_in_offer, psbt, now_unix + 3600);

    let err = prepare_offer_acceptance(&offer, now_unix).expect_err("missing seller input");
    assert!(err.to_string().contains("contains no seller input"));
}

#[test]
fn prepare_offer_acceptance_rejects_duplicate_seller_input() {
    let now_unix = 1_800_000_000;
    let seller_outpoint = OutPoint {
        txid: sample_seller_txid(),
        vout: 0,
    };
    let psbt = psbt_base64(seller_outpoint, true, false, true);
    let offer = build_offer(now_unix, seller_outpoint, psbt, now_unix + 3600);

    let err = prepare_offer_acceptance(&offer, now_unix).expect_err("duplicate seller inputs");
    assert!(err.to_string().contains("contains 2 seller inputs"));
}

#[test]
fn prepare_offer_acceptance_rejects_signed_seller_input() {
    let now_unix = 1_800_000_000;
    let seller_outpoint = OutPoint {
        txid: sample_seller_txid(),
        vout: 0,
    };
    let psbt = psbt_base64(seller_outpoint, false, true, true);
    let offer = build_offer(now_unix, seller_outpoint, psbt, now_unix + 3600);

    let err = prepare_offer_acceptance(&offer, now_unix).expect_err("signed seller input");
    assert!(err.to_string().contains("seller input"));
    assert!(err.to_string().contains("must be unsigned"));
}

#[test]
fn prepare_offer_acceptance_rejects_unsigned_buyer_input() {
    let now_unix = 1_800_000_000;
    let seller_outpoint = OutPoint {
        txid: sample_seller_txid(),
        vout: 0,
    };
    let psbt = psbt_base64(seller_outpoint, false, false, false);
    let offer = build_offer(now_unix, seller_outpoint, psbt, now_unix + 3600);

    let err = prepare_offer_acceptance(&offer, now_unix).expect_err("unsigned buyer input");
    assert!(err.to_string().contains("buyer input"));
    assert!(err.to_string().contains("must be signed"));
}

#[test]
fn prepare_offer_acceptance_rejects_seller_input_not_first() {
    let now_unix = 1_800_000_000;
    let seller_outpoint = OutPoint {
        txid: sample_seller_txid(),
        vout: 0,
    };
    let mut psbt = {
        let encoded = psbt_base64(seller_outpoint, false, false, true);
        let bytes = base64::engine::general_purpose::STANDARD
            .decode(encoded.as_bytes())
            .expect("base64");
        Psbt::deserialize(&bytes).expect("psbt")
    };

    psbt.unsigned_tx.input.swap(0, 1);
    psbt.inputs.swap(0, 1);

    let encoded = base64::engine::general_purpose::STANDARD.encode(psbt.serialize());
    let offer = build_offer(now_unix, seller_outpoint, encoded, now_unix + 3600);

    let err = prepare_offer_acceptance(&offer, now_unix).expect_err("seller input must be first");
    assert!(err.to_string().contains("must be first input"));
}

#[test]
fn prepare_offer_acceptance_rejects_non_ord_output_layout() {
    let now_unix = 1_800_000_000;
    let seller_outpoint = OutPoint {
        txid: sample_seller_txid(),
        vout: 0,
    };
    let mut psbt = {
        let encoded = psbt_base64(seller_outpoint, false, false, true);
        let bytes = base64::engine::general_purpose::STANDARD
            .decode(encoded.as_bytes())
            .expect("base64");
        Psbt::deserialize(&bytes).expect("psbt")
    };

    psbt.unsigned_tx.output.swap(0, 1);
    psbt.outputs.swap(0, 1);

    let encoded = base64::engine::general_purpose::STANDARD.encode(psbt.serialize());
    let offer = build_offer(now_unix, seller_outpoint, encoded, now_unix + 3600);

    let err =
        prepare_offer_acceptance(&offer, now_unix).expect_err("must reject non-canonical outputs");
    assert!(err
        .to_string()
        .contains("buyer postage output must be first"));
}