zinc-core 0.4.0

Core Rust library for Zinc Bitcoin + Ordinals wallet
Documentation
use crate::listing::ListingEnvelopeV1;
use crate::listing_nostr::{NostrListingEvent, LISTING_EVENT_KIND};
use bdk_wallet::bitcoin::hashes::{sha256, Hash};
use bdk_wallet::bitcoin::secp256k1::{Keypair, Message, Secp256k1, SecretKey, XOnlyPublicKey};
use std::str::FromStr;

fn test_secret_hex() -> &'static str {
    "0001020304050607080900010203040506070809000102030405060708090001"
}

fn pubkey_hex_from_secret(secret_hex: &str) -> String {
    let secret_key = SecretKey::from_str(secret_hex).expect("valid secret key");
    let secp = Secp256k1::new();
    let keypair = Keypair::from_secret_key(&secp, &secret_key);
    let (xonly, _) = XOnlyPublicKey::from_keypair(&keypair);
    xonly.to_string()
}

fn sample_listing(seller_pubkey_hex: &str) -> ListingEnvelopeV1 {
    ListingEnvelopeV1 {
        version: 1,
        seller_pubkey_hex: seller_pubkey_hex.to_string(),
        coordinator_pubkey_hex: "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
            .to_string(),
        network: "regtest".to_string(),
        inscription_id: "6fb976ab49dcec017f1e201e84395983204ae1a7c2abf7ced0a85d692e442799i0"
            .to_string(),
        seller_outpoint: "6fb976ab49dcec017f1e201e84395983204ae1a7c2abf7ced0a85d692e442799:0"
            .to_string(),
        passthrough_outpoint: "25f976ab49dcec017f1e201e84395983204ae1a7c2abf7ced0a85d692e442799:0"
            .to_string(),
        seller_payout_script_pubkey_hex: "0014751e76e8199196d454941c45d1b3a323f1433bd6".to_string(),
        ask_sats: 100_000,
        postage_sats: 10_000,
        fee_rate_sat_vb: 2,
        tx1_base64: "cHNidP8BAAoCAAAAAQAAAAA=".to_string(),
        sale_psbt_base64: "cHNidP8BAAoCAAAAAQAAAAA=".to_string(),
        recovery_psbt_base64: "cHNidP8BAAoCAAAAAQAAAAA=".to_string(),
        created_at_unix: 1_710_000_000,
        expires_at_unix: 1_710_086_400,
        nonce: 42,
    }
}

#[test]
fn nostr_listing_event_roundtrip_verifies_and_decodes() {
    let seller_pubkey_hex = pubkey_hex_from_secret(test_secret_hex());
    let listing = sample_listing(&seller_pubkey_hex);

    let event = NostrListingEvent::from_listing(&listing, test_secret_hex(), 1_710_000_100)
        .expect("event creation should succeed");

    assert_eq!(event.pubkey, seller_pubkey_hex);
    assert_eq!(event.kind, LISTING_EVENT_KIND);
    assert!(event
        .tags
        .iter()
        .any(|tag| tag.len() == 2 && tag[0] == "z" && tag[1] == "zinc-listing-v1"));
    let expected_expiration = listing.expires_at_unix.to_string();
    assert_eq!(
        event.tag_value("expiration"),
        Some(expected_expiration.as_str())
    );

    event.verify().expect("signature should verify");
    let decoded = event.decode_listing().expect("listing should decode");
    assert_eq!(decoded, listing);
}

#[test]
fn nostr_listing_event_verification_fails_when_content_tampered() {
    let seller_pubkey_hex = pubkey_hex_from_secret(test_secret_hex());
    let listing = sample_listing(&seller_pubkey_hex);

    let mut event = NostrListingEvent::from_listing(&listing, test_secret_hex(), 1_710_000_100)
        .expect("event creation should succeed");
    event.content.push(' ');

    assert!(event.verify().is_err());
}

#[test]
fn nostr_listing_event_creation_rejects_secret_key_pubkey_mismatch() {
    let seller_pubkey_hex = pubkey_hex_from_secret(test_secret_hex());
    let listing = sample_listing(&seller_pubkey_hex);

    let wrong_secret = "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd";
    let err = NostrListingEvent::from_listing(&listing, wrong_secret, 1_710_000_100)
        .expect_err("mismatch should fail");

    assert!(err
        .to_string()
        .contains("secret key does not match listing seller_pubkey_hex"));
}

#[test]
fn nostr_listing_event_id_and_sig_are_deterministic_for_same_payload() {
    let seller_pubkey_hex = pubkey_hex_from_secret(test_secret_hex());
    let listing = sample_listing(&seller_pubkey_hex);

    let event_a = NostrListingEvent::from_listing(&listing, test_secret_hex(), 1_710_000_100)
        .expect("event A");
    let event_b = NostrListingEvent::from_listing(&listing, test_secret_hex(), 1_710_000_100)
        .expect("event B");

    assert_eq!(event_a.id, event_b.id);
    assert_eq!(event_a.sig, event_b.sig);
}

#[test]
fn nostr_listing_event_decode_rejects_expiration_tag_mismatch() {
    let secret_key = SecretKey::from_str(test_secret_hex()).expect("valid secret key");
    let seller_pubkey_hex = pubkey_hex_from_secret(test_secret_hex());
    let listing = sample_listing(&seller_pubkey_hex);
    let listing_id = listing.listing_id_hex().expect("listing id");
    let content = String::from_utf8(listing.canonical_json().expect("canonical listing json"))
        .expect("utf8 listing json");
    let created_at = 1_710_000_100_u64;
    let tags = vec![
        vec!["z".to_string(), "zinc-listing-v1".to_string()],
        vec!["network".to_string(), listing.network.clone()],
        vec!["inscription".to_string(), listing.inscription_id.clone()],
        vec!["listing_id".to_string(), listing_id],
        vec!["expiration".to_string(), "1".to_string()],
    ];
    let payload = serde_json::json!([
        0,
        seller_pubkey_hex,
        created_at,
        LISTING_EVENT_KIND,
        tags,
        content
    ]);
    let id = sha256::Hash::hash(&serde_json::to_vec(&payload).expect("payload")).to_string();
    let digest: [u8; 32] = hex::decode(&id)
        .expect("hex")
        .try_into()
        .expect("digest length");
    let secp = Secp256k1::new();
    let keypair = Keypair::from_secret_key(&secp, &secret_key);
    let sig = secp
        .sign_schnorr_no_aux_rand(&Message::from_digest(digest), &keypair)
        .to_string();

    let event = NostrListingEvent {
        id,
        pubkey: pubkey_hex_from_secret(test_secret_hex()),
        created_at,
        kind: LISTING_EVENT_KIND,
        tags,
        content,
        sig,
    };

    let err = event.decode_listing().expect_err("expiration mismatch");
    assert!(err.to_string().contains("expiration tag"));
}