Skip to main content

zinc_core/
listing.rs

1//! Fixed-price listing primitives inspired by ord.net passthrough sale PSBTs.
2//!
3//! This module is intentionally separate from the generic wallet signer. Listing
4//! sales require `SIGHASH_SINGLE | SIGHASH_ANYONECANPAY`, which is unsafe unless
5//! the exact seller payout shape is validated first.
6
7use crate::builder::{AddressScheme, ZincWallet};
8use crate::ZincError;
9use base64::Engine;
10use bdk_wallet::KeychainKind;
11use bdk_wallet::TxOrdering;
12use bitcoin::blockdata::opcodes::all::{OP_CHECKSIG, OP_CHECKSIGADD, OP_NUMEQUAL};
13use bitcoin::blockdata::script::Builder;
14use bitcoin::hashes::{sha256, Hash};
15use bitcoin::psbt::{Input as PsbtInput, Output as PsbtOutput, Psbt};
16use bitcoin::secp256k1::{Keypair, Message, Secp256k1, SecretKey, XOnlyPublicKey};
17use bitcoin::sighash::{Prevouts, SighashCache, TapSighashType};
18use bitcoin::taproot::TapLeafHash;
19use bitcoin::taproot::{ControlBlock, LeafVersion, TaprootBuilder};
20use bitcoin::{
21    absolute, Amount, FeeRate, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Weight,
22    Witness,
23};
24use serde::{Deserialize, Serialize};
25use std::str::FromStr;
26
27/// Taproot sale-path sighash required for passive seller listings.
28pub const LISTING_SALE_SIGHASH_U8: u8 = 0x83;
29
30const DEFAULT_LISTING_FOREIGN_INPUT_SATISFACTION_WEIGHT_WU: u64 = 272;
31
32/// Input parameters for deterministic fixed-price listing template construction.
33#[derive(Debug, Clone)]
34pub struct CreateListingRequest {
35    /// Seller x-only Taproot public key hex.
36    pub seller_pubkey_hex: String,
37    /// Coordinator x-only Taproot public key hex.
38    pub coordinator_pubkey_hex: String,
39    /// Bitcoin network identifier.
40    pub network: String,
41    /// Inscription identifier.
42    pub inscription_id: String,
43    /// Seller-controlled outpoint holding the inscription before listing.
44    pub seller_outpoint: OutPoint,
45    /// Prevout metadata for `seller_outpoint`.
46    pub seller_prevout: TxOut,
47    /// Script receiving ask + postage in the sale transaction.
48    pub seller_payout_script_pubkey: ScriptBuf,
49    /// Script receiving the inscription if seller recovers/cancels the listing.
50    pub recovery_script_pubkey: ScriptBuf,
51    /// Ask price in sats, excluding postage.
52    pub ask_sats: u64,
53    /// Target fee rate for the final sale.
54    pub fee_rate_sat_vb: u64,
55    /// UNIX timestamp (seconds) listing creation time.
56    pub created_at_unix: i64,
57    /// UNIX timestamp (seconds) listing expiration time.
58    pub expires_at_unix: i64,
59    /// Caller-controlled nonce for uniqueness.
60    pub nonce: u64,
61}
62
63/// Result payload for deterministic fixed-price listing template construction.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct CreateListingResultV1 {
66    /// Listing envelope ready for relay publication.
67    pub listing: ListingEnvelopeV1,
68    /// `TX1` output that creates the passthrough Taproot UTXO.
69    pub passthrough_outpoint: OutPoint,
70    /// Prevout metadata for the passthrough output.
71    pub passthrough_txout: TxOut,
72}
73
74/// Fixed-price listing envelope v1.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct ListingEnvelopeV1 {
77    /// Envelope schema version.
78    pub version: u8,
79    /// Seller x-only Taproot public key hex.
80    pub seller_pubkey_hex: String,
81    /// Coordinator x-only Taproot public key hex.
82    pub coordinator_pubkey_hex: String,
83    /// Bitcoin network identifier.
84    pub network: String,
85    /// Inscription identifier.
86    pub inscription_id: String,
87    /// Original seller-controlled inscription outpoint.
88    pub seller_outpoint: String,
89    /// `TX1` output that moves the inscription into the passthrough Taproot output.
90    pub passthrough_outpoint: String,
91    /// Seller payout scriptPubKey hex committed by the sale signature.
92    pub seller_payout_script_pubkey_hex: String,
93    /// Ask price in sats, excluding postage.
94    pub ask_sats: u64,
95    /// Postage value carried by the inscription output.
96    pub postage_sats: u64,
97    /// Target fee rate for the final sale.
98    pub fee_rate_sat_vb: u64,
99    /// Listing transaction PSBT/transaction payload, base64.
100    pub tx1_base64: String,
101    /// Seller sale-path PSBT, base64.
102    pub sale_psbt_base64: String,
103    /// Seller recovery PSBT/transaction payload, base64.
104    pub recovery_psbt_base64: String,
105    /// UNIX timestamp (seconds) listing creation time.
106    pub created_at_unix: i64,
107    /// UNIX timestamp (seconds) listing expiration time.
108    pub expires_at_unix: i64,
109    /// Caller-controlled nonce for uniqueness.
110    pub nonce: u64,
111}
112
113impl ListingEnvelopeV1 {
114    fn validate(&self) -> Result<(), ZincError> {
115        if self.version != 1 {
116            return Err(ZincError::OfferError(format!(
117                "unsupported listing version {}",
118                self.version
119            )));
120        }
121
122        if self.seller_pubkey_hex.is_empty()
123            || self.coordinator_pubkey_hex.is_empty()
124            || self.network.is_empty()
125            || self.inscription_id.is_empty()
126            || self.seller_outpoint.is_empty()
127            || self.passthrough_outpoint.is_empty()
128            || self.seller_payout_script_pubkey_hex.is_empty()
129            || self.tx1_base64.is_empty()
130            || self.sale_psbt_base64.is_empty()
131            || self.recovery_psbt_base64.is_empty()
132        {
133            return Err(ZincError::OfferError(
134                "listing contains empty required fields".to_string(),
135            ));
136        }
137
138        XOnlyPublicKey::from_str(&self.seller_pubkey_hex)
139            .map_err(|e| ZincError::OfferError(format!("invalid seller pubkey: {e}")))?;
140        XOnlyPublicKey::from_str(&self.coordinator_pubkey_hex)
141            .map_err(|e| ZincError::OfferError(format!("invalid coordinator pubkey: {e}")))?;
142        self.seller_outpoint
143            .parse::<OutPoint>()
144            .map_err(|e| ZincError::OfferError(format!("invalid seller_outpoint: {e}")))?;
145        self.passthrough_outpoint
146            .parse::<OutPoint>()
147            .map_err(|e| ZincError::OfferError(format!("invalid passthrough_outpoint: {e}")))?;
148        script_from_hex(&self.seller_payout_script_pubkey_hex)?;
149
150        if self.ask_sats == 0 {
151            return Err(ZincError::OfferError("ask_sats must be > 0".to_string()));
152        }
153        if self.postage_sats == 0 {
154            return Err(ZincError::OfferError(
155                "postage_sats must be > 0".to_string(),
156            ));
157        }
158        if self.expires_at_unix <= self.created_at_unix {
159            return Err(ZincError::OfferError(
160                "listing expiration must be greater than creation time".to_string(),
161            ));
162        }
163
164        Ok(())
165    }
166
167    /// Serialize this envelope using canonical JSON bytes.
168    pub fn canonical_json(&self) -> Result<Vec<u8>, ZincError> {
169        self.validate()?;
170        serde_json::to_vec(self).map_err(|e| ZincError::SerializationError(e.to_string()))
171    }
172
173    /// Compute the SHA-256 listing id digest bytes.
174    pub fn listing_id_digest(&self) -> Result<[u8; 32], ZincError> {
175        let canonical = self.canonical_json()?;
176        let digest = sha256::Hash::hash(&canonical);
177        Ok(digest.to_byte_array())
178    }
179
180    /// Compute the SHA-256 listing id hex string.
181    pub fn listing_id_hex(&self) -> Result<String, ZincError> {
182        let digest = self.listing_id_digest()?;
183        Ok(digest.iter().map(|b| format!("{b:02x}")).collect())
184    }
185}
186
187/// Sale signing metadata derived from a validated listing sale PSBT.
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189pub struct ListingSaleSigningPlanV1 {
190    /// Canonical listing id digest (sha256 hex).
191    pub listing_id: String,
192    /// Seller input index in `sale_psbt_base64`.
193    pub seller_input_index: usize,
194    /// Required seller sale signature sighash.
195    pub sighash_u8: u8,
196    /// Exact seller payout amount committed by `SIGHASH_SINGLE`.
197    pub seller_payout_sats: u64,
198}
199
200/// Buyer funding input metadata for completing a seller-signed listing PSBT.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct ListingBuyerFundingInput {
203    /// Buyer-controlled outpoint to append to the sale PSBT.
204    pub previous_output: OutPoint,
205    /// Prevout metadata required for signing and coordinator sighash validation.
206    pub witness_utxo: TxOut,
207}
208
209/// Request to turn a seller-signed listing sale PSBT into a buyer-funded sale PSBT.
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct FinalizeListingPurchaseRequest {
212    /// Listing envelope whose sale PSBT already contains the seller sale-path signature.
213    pub listing: ListingEnvelopeV1,
214    /// Buyer funding inputs to append after the passthrough inscription input.
215    pub buyer_inputs: Vec<ListingBuyerFundingInput>,
216    /// Script receiving the inscription postage output.
217    pub buyer_receive_script_pubkey: ScriptBuf,
218    /// Optional buyer change script.
219    pub change_script_pubkey: Option<ScriptBuf>,
220    /// Buyer change amount in sats. Set to zero for no change output.
221    pub change_sats: u64,
222    /// UNIX timestamp (seconds) used for listing expiration validation.
223    pub now_unix: i64,
224}
225
226/// Result of buyer-side listing purchase finalization.
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228pub struct FinalizeListingPurchaseResultV1 {
229    /// Listing envelope with `sale_psbt_base64` replaced by the buyer-funded PSBT.
230    pub listing: ListingEnvelopeV1,
231    /// Buyer-funded PSBT base64, ready for buyer input signing and coordinator pinning.
232    pub psbt_base64: String,
233    /// Computed fee in sats from PSBT prevout metadata and outputs.
234    pub fee_sats: u64,
235    /// Seller passthrough input index.
236    pub seller_input_index: usize,
237    /// Buyer inscription receive output index.
238    pub buyer_receive_output_index: usize,
239    /// Buyer change output index, if a change output was added.
240    pub change_output_index: Option<usize>,
241}
242
243/// Request to have the buyer wallet fund and sign buyer inputs for a listing purchase.
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245pub struct CreateListingPurchaseRequest {
246    /// Listing envelope whose sale PSBT already contains the seller sale-path signature.
247    pub listing: ListingEnvelopeV1,
248    /// UNIX timestamp (seconds) used for listing expiration validation.
249    pub now_unix: i64,
250}
251
252/// Result of wallet-funded buyer-side listing purchase construction.
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254pub struct CreateListingPurchaseResultV1 {
255    /// Listing envelope with `sale_psbt_base64` replaced by the buyer-funded, buyer-signed PSBT.
256    pub listing: ListingEnvelopeV1,
257    /// Buyer-funded and buyer-signed PSBT base64, ready for coordinator pinning.
258    pub psbt_base64: String,
259    /// Computed fee in sats from PSBT prevout metadata and outputs.
260    pub fee_sats: u64,
261    /// Seller passthrough input index.
262    pub seller_input_index: usize,
263    /// Number of buyer-owned inputs signed by the wallet.
264    pub buyer_input_count: usize,
265    /// Buyer inscription receive output index.
266    pub buyer_receive_output_index: usize,
267}
268
269/// Finalized listing sale transaction payload.
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271pub struct FinalizedListingSaleResultV1 {
272    /// Finalized PSBT base64 with the passthrough input witness populated.
273    pub finalized_psbt_base64: String,
274    /// Extracted transaction hex, ready for broadcast.
275    pub tx_hex: String,
276    /// Extracted transaction id.
277    pub txid: String,
278    /// Seller passthrough input index.
279    pub seller_input_index: usize,
280    /// Final witness stack size for the passthrough input.
281    pub passthrough_witness_items: usize,
282}
283
284/// Build the ord.net-style script-path leaf: `multi_a(2, seller, coordinator)`.
285pub fn passthrough_tapscript(
286    seller_pubkey: XOnlyPublicKey,
287    coordinator_pubkey: XOnlyPublicKey,
288) -> ScriptBuf {
289    Builder::new()
290        .push_x_only_key(&seller_pubkey)
291        .push_opcode(OP_CHECKSIG)
292        .push_x_only_key(&coordinator_pubkey)
293        .push_opcode(OP_CHECKSIGADD)
294        .push_int(2)
295        .push_opcode(OP_NUMEQUAL)
296        .into_script()
297}
298
299/// Build the passthrough Taproot scriptPubKey.
300pub fn passthrough_script_pubkey(
301    seller_pubkey: XOnlyPublicKey,
302    coordinator_pubkey: XOnlyPublicKey,
303) -> ScriptBuf {
304    let spend_info = passthrough_spend_info(seller_pubkey, coordinator_pubkey);
305    ScriptBuf::new_p2tr_tweaked(spend_info.output_key())
306}
307
308fn passthrough_spend_info(
309    seller_pubkey: XOnlyPublicKey,
310    coordinator_pubkey: XOnlyPublicKey,
311) -> bitcoin::taproot::TaprootSpendInfo {
312    let secp = Secp256k1::verification_only();
313    let script = passthrough_tapscript(seller_pubkey, coordinator_pubkey);
314    TaprootBuilder::new()
315        .add_leaf(0, script)
316        .expect("single-leaf taproot builder")
317        .finalize(&secp, seller_pubkey)
318        .expect("single-leaf taproot tree is finalizable")
319}
320
321/// Build the three PSBT templates and listing envelope for a passive fixed-price sale.
322pub fn create_listing(request: &CreateListingRequest) -> Result<CreateListingResultV1, ZincError> {
323    validate_create_listing_request(request)?;
324
325    let seller_pubkey = XOnlyPublicKey::from_str(&request.seller_pubkey_hex)
326        .map_err(|e| ZincError::OfferError(format!("invalid seller pubkey: {e}")))?;
327    let coordinator_pubkey = XOnlyPublicKey::from_str(&request.coordinator_pubkey_hex)
328        .map_err(|e| ZincError::OfferError(format!("invalid coordinator pubkey: {e}")))?;
329    let passthrough_script_pubkey = passthrough_script_pubkey(seller_pubkey, coordinator_pubkey);
330    let postage_sats = request.seller_prevout.value.to_sat();
331
332    let tx1 = Transaction {
333        version: bitcoin::transaction::Version(2),
334        lock_time: absolute::LockTime::ZERO,
335        input: vec![template_txin(request.seller_outpoint)],
336        output: vec![TxOut {
337            value: Amount::from_sat(postage_sats),
338            script_pubkey: passthrough_script_pubkey,
339        }],
340    };
341    let mut tx1_psbt = Psbt::from_unsigned_tx(tx1)
342        .map_err(|e| ZincError::OfferError(format!("failed to build tx1 psbt: {e}")))?;
343    tx1_psbt.inputs[0].witness_utxo = Some(request.seller_prevout.clone());
344
345    let passthrough_outpoint = OutPoint::new(tx1_psbt.unsigned_tx.compute_txid(), 0);
346    let passthrough_txout = tx1_psbt.unsigned_tx.output[0].clone();
347
348    let sale_psbt = build_sale_psbt(
349        request,
350        seller_pubkey,
351        coordinator_pubkey,
352        passthrough_outpoint,
353        passthrough_txout.clone(),
354    )?;
355    let recovery_psbt =
356        build_recovery_psbt(request, passthrough_outpoint, passthrough_txout.clone())?;
357
358    let listing = ListingEnvelopeV1 {
359        version: 1,
360        seller_pubkey_hex: request.seller_pubkey_hex.clone(),
361        coordinator_pubkey_hex: request.coordinator_pubkey_hex.clone(),
362        network: request.network.clone(),
363        inscription_id: request.inscription_id.clone(),
364        seller_outpoint: request.seller_outpoint.to_string(),
365        passthrough_outpoint: passthrough_outpoint.to_string(),
366        seller_payout_script_pubkey_hex: request.seller_payout_script_pubkey.to_hex_string(),
367        ask_sats: request.ask_sats,
368        postage_sats,
369        fee_rate_sat_vb: request.fee_rate_sat_vb,
370        tx1_base64: encode_psbt_base64(&tx1_psbt),
371        sale_psbt_base64: encode_psbt_base64(&sale_psbt),
372        recovery_psbt_base64: encode_psbt_base64(&recovery_psbt),
373        created_at_unix: request.created_at_unix,
374        expires_at_unix: request.expires_at_unix,
375        nonce: request.nonce,
376    };
377
378    prepare_listing_sale_signature(&listing, request.created_at_unix)?;
379
380    Ok(CreateListingResultV1 {
381        listing,
382        passthrough_outpoint,
383        passthrough_txout,
384    })
385}
386
387/// Sign a listing sale PSBT with the seller key using the isolated listing safety checks.
388///
389/// This intentionally does not relax the generic wallet signer. The sale PSBT must pass
390/// `prepare_listing_sale_signature` before the `SIGHASH_SINGLE|ANYONECANPAY` signature is added.
391pub fn sign_listing_sale_psbt(
392    listing: &ListingEnvelopeV1,
393    seller_secret_key_hex: &str,
394    now_unix: i64,
395) -> Result<String, ZincError> {
396    let plan = prepare_listing_sale_signature(listing, now_unix)?;
397    let seller_secret_key = SecretKey::from_str(seller_secret_key_hex)
398        .map_err(|e| ZincError::OfferError(format!("invalid seller secret key: {e}")))?;
399
400    let secp = Secp256k1::new();
401    let keypair = Keypair::from_secret_key(&secp, &seller_secret_key);
402    let (derived_seller_pubkey, _) = XOnlyPublicKey::from_keypair(&keypair);
403    let listing_seller_pubkey = XOnlyPublicKey::from_str(&listing.seller_pubkey_hex)
404        .map_err(|e| ZincError::OfferError(format!("invalid listing seller pubkey: {e}")))?;
405    if derived_seller_pubkey != listing_seller_pubkey {
406        return Err(ZincError::OfferError(
407            "seller secret key does not match listing seller pubkey".to_string(),
408        ));
409    }
410
411    let coordinator_pubkey = XOnlyPublicKey::from_str(&listing.coordinator_pubkey_hex)
412        .map_err(|e| ZincError::OfferError(format!("invalid coordinator pubkey: {e}")))?;
413    let mut psbt = decode_listing_sale_psbt(listing)?;
414    let input_index = plan.seller_input_index;
415    let (leaf_hash, _script) = find_passthrough_tap_leaf(
416        &psbt,
417        input_index,
418        listing_seller_pubkey,
419        coordinator_pubkey,
420    )?;
421
422    let prevouts: Vec<TxOut> = (0..psbt.inputs.len())
423        .map(|index| input_prevout(&psbt, index).cloned())
424        .collect::<Result<Vec<_>, _>>()?;
425    let sighash = SighashCache::new(&psbt.unsigned_tx)
426        .taproot_script_spend_signature_hash(
427            input_index,
428            &Prevouts::All(&prevouts),
429            leaf_hash,
430            TapSighashType::SinglePlusAnyoneCanPay,
431        )
432        .map_err(|e| ZincError::OfferError(format!("failed to compute sale sighash: {e}")))?;
433    let message = Message::from_digest(sighash.to_byte_array());
434    let signature = secp.sign_schnorr(&message, &keypair);
435    psbt.inputs[input_index].tap_script_sigs.insert(
436        (listing_seller_pubkey, leaf_hash),
437        bitcoin::taproot::Signature {
438            signature,
439            sighash_type: TapSighashType::SinglePlusAnyoneCanPay,
440        },
441    );
442
443    Ok(encode_psbt_base64(&psbt))
444}
445
446/// Sign a listing sale PSBT with the coordinator key using `SIGHASH_DEFAULT`.
447///
448/// The coordinator signature pins the final transaction after the seller has already
449/// authorized the sale input with `SIGHASH_SINGLE|ANYONECANPAY`.
450pub fn sign_listing_coordinator_psbt(
451    listing: &ListingEnvelopeV1,
452    coordinator_secret_key_hex: &str,
453    now_unix: i64,
454) -> Result<String, ZincError> {
455    let plan = prepare_listing_sale_signature_with_policy(listing, now_unix, true)?;
456    let coordinator_secret_key = SecretKey::from_str(coordinator_secret_key_hex)
457        .map_err(|e| ZincError::OfferError(format!("invalid coordinator secret key: {e}")))?;
458
459    let secp = Secp256k1::new();
460    let keypair = Keypair::from_secret_key(&secp, &coordinator_secret_key);
461    let (derived_coordinator_pubkey, _) = XOnlyPublicKey::from_keypair(&keypair);
462    let listing_coordinator_pubkey = XOnlyPublicKey::from_str(&listing.coordinator_pubkey_hex)
463        .map_err(|e| ZincError::OfferError(format!("invalid listing coordinator pubkey: {e}")))?;
464    if derived_coordinator_pubkey != listing_coordinator_pubkey {
465        return Err(ZincError::OfferError(
466            "coordinator secret key does not match listing coordinator pubkey".to_string(),
467        ));
468    }
469
470    let seller_pubkey = XOnlyPublicKey::from_str(&listing.seller_pubkey_hex)
471        .map_err(|e| ZincError::OfferError(format!("invalid listing seller pubkey: {e}")))?;
472    let mut psbt = decode_listing_sale_psbt(listing)?;
473    let input_index = plan.seller_input_index;
474    let (leaf_hash, _script) = find_passthrough_tap_leaf(
475        &psbt,
476        input_index,
477        seller_pubkey,
478        listing_coordinator_pubkey,
479    )?;
480    ensure_seller_sale_signature(&psbt, input_index, seller_pubkey, leaf_hash)?;
481
482    let prevouts: Vec<TxOut> = (0..psbt.inputs.len())
483        .map(|index| input_prevout(&psbt, index).cloned())
484        .collect::<Result<Vec<_>, _>>()?;
485    let sighash = SighashCache::new(&psbt.unsigned_tx)
486        .taproot_script_spend_signature_hash(
487            input_index,
488            &Prevouts::All(&prevouts),
489            leaf_hash,
490            TapSighashType::Default,
491        )
492        .map_err(|e| {
493            ZincError::OfferError(format!("failed to compute coordinator sighash: {e}"))
494        })?;
495    let message = Message::from_digest(sighash.to_byte_array());
496    let signature = secp.sign_schnorr(&message, &keypair);
497    psbt.inputs[input_index].tap_script_sigs.insert(
498        (listing_coordinator_pubkey, leaf_hash),
499        bitcoin::taproot::Signature {
500            signature,
501            sighash_type: TapSighashType::Default,
502        },
503    );
504
505    Ok(encode_psbt_base64(&psbt))
506}
507
508/// Append buyer funding and receive/change outputs to a seller-signed listing sale PSBT.
509///
510/// The seller's `SIGHASH_SINGLE|ANYONECANPAY` signature commits only to the passthrough
511/// input and seller payout output at the same index. This function preserves that pair
512/// and appends the buyer side of the transaction without adding coordinator signatures.
513pub fn finalize_listing_purchase(
514    request: &FinalizeListingPurchaseRequest,
515) -> Result<FinalizeListingPurchaseResultV1, ZincError> {
516    if request.buyer_inputs.is_empty() {
517        return Err(ZincError::OfferError(
518            "listing purchase requires at least one buyer funding input".to_string(),
519        ));
520    }
521    if request.buyer_receive_script_pubkey.is_empty() {
522        return Err(ZincError::OfferError(
523            "buyer receive scriptPubKey must not be empty".to_string(),
524        ));
525    }
526    if request.change_sats > 0
527        && request
528            .change_script_pubkey
529            .as_ref()
530            .is_none_or(|script| script.as_script().is_empty())
531    {
532        return Err(ZincError::OfferError(
533            "change scriptPubKey is required when change_sats > 0".to_string(),
534        ));
535    }
536
537    let plan =
538        prepare_listing_sale_signature_with_policy(&request.listing, request.now_unix, true)?;
539    if plan.seller_input_index != 0 {
540        return Err(ZincError::OfferError(format!(
541            "listing purchase passthrough input must be index 0; found {}",
542            plan.seller_input_index
543        )));
544    }
545
546    let seller_pubkey = XOnlyPublicKey::from_str(&request.listing.seller_pubkey_hex)
547        .map_err(|e| ZincError::OfferError(format!("invalid listing seller pubkey: {e}")))?;
548    let coordinator_pubkey = XOnlyPublicKey::from_str(&request.listing.coordinator_pubkey_hex)
549        .map_err(|e| ZincError::OfferError(format!("invalid coordinator pubkey: {e}")))?;
550    let passthrough_outpoint = request
551        .listing
552        .passthrough_outpoint
553        .parse::<OutPoint>()
554        .map_err(|e| ZincError::OfferError(format!("invalid passthrough_outpoint: {e}")))?;
555
556    let mut psbt = decode_listing_sale_psbt(&request.listing)?;
557    let (leaf_hash, _script) = find_passthrough_tap_leaf(
558        &psbt,
559        plan.seller_input_index,
560        seller_pubkey,
561        coordinator_pubkey,
562    )?;
563    ensure_seller_sale_signature(&psbt, plan.seller_input_index, seller_pubkey, leaf_hash)?;
564    if psbt.inputs[plan.seller_input_index]
565        .tap_script_sigs
566        .contains_key(&(coordinator_pubkey, leaf_hash))
567    {
568        return Err(ZincError::OfferError(
569            "listing purchase PSBT is already coordinator signed".to_string(),
570        ));
571    }
572
573    let mut buyer_input_total = 0u64;
574    for buyer_input in &request.buyer_inputs {
575        if buyer_input.previous_output == passthrough_outpoint {
576            return Err(ZincError::OfferError(
577                "buyer funding input duplicates passthrough outpoint".to_string(),
578            ));
579        }
580        if buyer_input.witness_utxo.value.to_sat() == 0 {
581            return Err(ZincError::OfferError(
582                "buyer funding input value must be > 0".to_string(),
583            ));
584        }
585        buyer_input_total = buyer_input_total
586            .checked_add(buyer_input.witness_utxo.value.to_sat())
587            .ok_or_else(|| ZincError::OfferError("buyer input value overflows u64".to_string()))?;
588    }
589
590    let buyer_receive_output_index = psbt.unsigned_tx.output.len();
591    psbt.unsigned_tx.output.push(TxOut {
592        value: Amount::from_sat(request.listing.postage_sats),
593        script_pubkey: request.buyer_receive_script_pubkey.clone(),
594    });
595    psbt.outputs.push(PsbtOutput::default());
596
597    let change_output_index = if request.change_sats > 0 {
598        let index = psbt.unsigned_tx.output.len();
599        psbt.unsigned_tx.output.push(TxOut {
600            value: Amount::from_sat(request.change_sats),
601            script_pubkey: request
602                .change_script_pubkey
603                .clone()
604                .expect("validated change script"),
605        });
606        psbt.outputs.push(PsbtOutput::default());
607        Some(index)
608    } else {
609        None
610    };
611
612    for buyer_input in &request.buyer_inputs {
613        psbt.unsigned_tx
614            .input
615            .push(template_txin(buyer_input.previous_output));
616        psbt.inputs.push(PsbtInput {
617            witness_utxo: Some(buyer_input.witness_utxo.clone()),
618            ..PsbtInput::default()
619        });
620    }
621
622    let total_input_sats = request
623        .listing
624        .postage_sats
625        .checked_add(buyer_input_total)
626        .ok_or_else(|| ZincError::OfferError("total input value overflows u64".to_string()))?;
627    let total_output_sats = psbt
628        .unsigned_tx
629        .output
630        .iter()
631        .try_fold(0u64, |total, output| {
632            total.checked_add(output.value.to_sat()).ok_or_else(|| {
633                ZincError::OfferError("total output value overflows u64".to_string())
634            })
635        })?;
636    let fee_sats = total_input_sats.checked_sub(total_output_sats).ok_or_else(|| {
637        ZincError::OfferError(format!(
638            "buyer funding is insufficient: inputs {total_input_sats} sats, outputs {total_output_sats} sats"
639        ))
640    })?;
641    if fee_sats == 0 {
642        return Err(ZincError::OfferError(
643            "listing purchase fee must be > 0".to_string(),
644        ));
645    }
646
647    let psbt_base64 = encode_psbt_base64(&psbt);
648    let mut listing = request.listing.clone();
649    listing.sale_psbt_base64 = psbt_base64.clone();
650
651    Ok(FinalizeListingPurchaseResultV1 {
652        listing,
653        psbt_base64,
654        fee_sats,
655        seller_input_index: plan.seller_input_index,
656        buyer_receive_output_index,
657        change_output_index,
658    })
659}
660
661/// Fund a seller-signed listing sale PSBT from the buyer wallet and sign buyer inputs only.
662#[allow(deprecated)]
663pub fn create_listing_purchase(
664    wallet: &mut ZincWallet,
665    request: &CreateListingPurchaseRequest,
666) -> Result<CreateListingPurchaseResultV1, ZincError> {
667    if !wallet.ordinals_verified {
668        return Err(ZincError::WalletError(
669            "Ordinals verification failed - safety lock engaged. Please retry sync.".to_string(),
670        ));
671    }
672
673    let plan =
674        prepare_listing_sale_signature_with_policy(&request.listing, request.now_unix, true)?;
675    if plan.seller_input_index != 0 {
676        return Err(ZincError::OfferError(format!(
677            "listing purchase passthrough input must be index 0; found {}",
678            plan.seller_input_index
679        )));
680    }
681
682    let seller_pubkey = XOnlyPublicKey::from_str(&request.listing.seller_pubkey_hex)
683        .map_err(|e| ZincError::OfferError(format!("invalid listing seller pubkey: {e}")))?;
684    let coordinator_pubkey = XOnlyPublicKey::from_str(&request.listing.coordinator_pubkey_hex)
685        .map_err(|e| ZincError::OfferError(format!("invalid coordinator pubkey: {e}")))?;
686    let sale_psbt = decode_listing_sale_psbt(&request.listing)?;
687    let (leaf_hash, _script) = find_passthrough_tap_leaf(
688        &sale_psbt,
689        plan.seller_input_index,
690        seller_pubkey,
691        coordinator_pubkey,
692    )?;
693    ensure_seller_sale_signature(
694        &sale_psbt,
695        plan.seller_input_index,
696        seller_pubkey,
697        leaf_hash,
698    )?;
699    if sale_psbt.inputs[plan.seller_input_index]
700        .tap_script_sigs
701        .contains_key(&(coordinator_pubkey, leaf_hash))
702    {
703        return Err(ZincError::OfferError(
704            "listing purchase PSBT is already coordinator signed".to_string(),
705        ));
706    }
707
708    let passthrough_outpoint = request
709        .listing
710        .passthrough_outpoint
711        .parse::<OutPoint>()
712        .map_err(|e| ZincError::OfferError(format!("invalid passthrough_outpoint: {e}")))?;
713    let seller_input = sale_psbt.inputs[plan.seller_input_index].clone();
714    let seller_payout_script = script_from_hex(&request.listing.seller_payout_script_pubkey_hex)?;
715    let seller_payout_sats = request
716        .listing
717        .ask_sats
718        .checked_add(request.listing.postage_sats)
719        .ok_or_else(|| ZincError::OfferError("ask_sats + postage overflows u64".to_string()))?;
720    let fee_rate = FeeRate::from_sat_per_vb(request.listing.fee_rate_sat_vb)
721        .ok_or_else(|| ZincError::OfferError("invalid fee rate".to_string()))?;
722
723    let buyer_receive_script = wallet
724        .vault_wallet
725        .peek_address(KeychainKind::External, 0)
726        .script_pubkey();
727    let protected_outpoints = wallet.inscribed_utxos.iter().copied().collect();
728    let signing_wallet = if wallet.scheme == AddressScheme::Dual {
729        wallet
730            .payment_wallet
731            .as_mut()
732            .ok_or_else(|| ZincError::WalletError("Payment wallet not initialized".to_string()))?
733    } else {
734        &mut wallet.vault_wallet
735    };
736    let change_script = signing_wallet
737        .peek_address(KeychainKind::External, 0)
738        .script_pubkey();
739
740    let mut builder = signing_wallet.build_tx();
741    if !wallet.inscribed_utxos.is_empty() {
742        builder.unspendable(protected_outpoints);
743    }
744    builder.ordering(TxOrdering::Untouched);
745    builder
746        .add_recipient(seller_payout_script, Amount::from_sat(seller_payout_sats))
747        .add_recipient(
748            buyer_receive_script,
749            Amount::from_sat(request.listing.postage_sats),
750        )
751        .drain_to(change_script)
752        .fee_rate(fee_rate)
753        .only_witness_utxo()
754        .add_foreign_utxo(
755            passthrough_outpoint,
756            seller_input,
757            Weight::from_wu(DEFAULT_LISTING_FOREIGN_INPUT_SATISFACTION_WEIGHT_WU),
758        )
759        .map_err(|e| ZincError::OfferError(format!("failed adding passthrough input: {e}")))?;
760
761    let mut psbt = builder.finish().map_err(|e| {
762        ZincError::OfferError(format!("failed to build listing purchase psbt: {e}"))
763    })?;
764    let seller_input_index = psbt
765        .unsigned_tx
766        .input
767        .iter()
768        .position(|input| input.previous_output == passthrough_outpoint)
769        .ok_or_else(|| {
770            ZincError::OfferError(format!(
771                "listing purchase psbt is missing passthrough input {passthrough_outpoint}"
772            ))
773        })?;
774    if seller_input_index != 0 {
775        return Err(ZincError::OfferError(format!(
776            "listing purchase passthrough input must be index 0; found {seller_input_index}"
777        )));
778    }
779
780    validate_seller_input(
781        &request.listing,
782        &psbt,
783        seller_input_index,
784        passthrough_outpoint,
785        true,
786    )?;
787    ensure_seller_sale_signature(&psbt, seller_input_index, seller_pubkey, leaf_hash)?;
788
789    let original_seller_input = psbt.inputs[seller_input_index].clone();
790    psbt.inputs[seller_input_index].sighash_type = None;
791    let buyer_input_indices: Vec<usize> = (0..psbt.inputs.len())
792        .filter(|index| *index != seller_input_index)
793        .collect();
794    if buyer_input_indices.is_empty() {
795        return Err(ZincError::OfferError(
796            "listing purchase must include at least one buyer input".to_string(),
797        ));
798    }
799
800    signing_wallet
801        .sign(
802            &mut psbt,
803            bdk_wallet::SignOptions {
804                trust_witness_utxo: true,
805                try_finalize: true,
806                ..Default::default()
807            },
808        )
809        .map_err(|e| ZincError::OfferError(format!("failed to sign buyer inputs: {e}")))?;
810    psbt.inputs[seller_input_index] = original_seller_input;
811
812    for index in &buyer_input_indices {
813        if !input_has_signature(&psbt.inputs[*index]) {
814            return Err(ZincError::OfferError(format!(
815                "buyer input #{} was not signed by this wallet",
816                index
817            )));
818        }
819    }
820
821    let fee_sats = psbt_fee_sats(&psbt)?;
822    if fee_sats == 0 {
823        return Err(ZincError::OfferError(
824            "listing purchase fee must be > 0".to_string(),
825        ));
826    }
827
828    let psbt_base64 = encode_psbt_base64(&psbt);
829    let mut listing = request.listing.clone();
830    listing.sale_psbt_base64 = psbt_base64.clone();
831    prepare_listing_sale_signature_with_policy(&listing, request.now_unix, true)?;
832
833    Ok(CreateListingPurchaseResultV1 {
834        listing,
835        psbt_base64,
836        fee_sats,
837        seller_input_index,
838        buyer_input_count: buyer_input_indices.len(),
839        buyer_receive_output_index: 1,
840    })
841}
842
843/// Finalize a coordinator-signed listing sale PSBT and extract the broadcast transaction.
844pub fn finalize_listing_sale(
845    listing: &ListingEnvelopeV1,
846    now_unix: i64,
847) -> Result<FinalizedListingSaleResultV1, ZincError> {
848    let plan = prepare_listing_sale_signature_with_policy(listing, now_unix, true)?;
849    let seller_pubkey = XOnlyPublicKey::from_str(&listing.seller_pubkey_hex)
850        .map_err(|e| ZincError::OfferError(format!("invalid listing seller pubkey: {e}")))?;
851    let coordinator_pubkey = XOnlyPublicKey::from_str(&listing.coordinator_pubkey_hex)
852        .map_err(|e| ZincError::OfferError(format!("invalid coordinator pubkey: {e}")))?;
853    let mut psbt = decode_listing_sale_psbt(listing)?;
854    let (control_block, leaf_hash, script) = find_passthrough_tap_leaf_entry(
855        &psbt,
856        plan.seller_input_index,
857        seller_pubkey,
858        coordinator_pubkey,
859    )?;
860    ensure_seller_sale_signature(&psbt, plan.seller_input_index, seller_pubkey, leaf_hash)?;
861    ensure_coordinator_default_signature(
862        &psbt,
863        plan.seller_input_index,
864        coordinator_pubkey,
865        leaf_hash,
866    )?;
867
868    let input = psbt
869        .inputs
870        .get_mut(plan.seller_input_index)
871        .ok_or_else(|| ZincError::OfferError("seller input metadata missing".to_string()))?;
872    let seller_sig = *input
873        .tap_script_sigs
874        .get(&(seller_pubkey, leaf_hash))
875        .expect("validated seller signature");
876    let coordinator_sig = *input
877        .tap_script_sigs
878        .get(&(coordinator_pubkey, leaf_hash))
879        .expect("validated coordinator signature");
880    input.final_script_witness = Some(Witness::from_slice(&[
881        coordinator_sig.to_vec(),
882        seller_sig.to_vec(),
883        script.to_bytes(),
884        control_block.serialize(),
885    ]));
886
887    for (index, input) in psbt.inputs.iter().enumerate() {
888        if input.final_script_witness.is_none() && input.final_script_sig.is_none() {
889            return Err(ZincError::OfferError(format!(
890                "input #{index} is not finalized"
891            )));
892        }
893    }
894
895    let finalized_psbt_base64 = encode_psbt_base64(&psbt);
896    let passthrough_witness_items = psbt.inputs[plan.seller_input_index]
897        .final_script_witness
898        .as_ref()
899        .expect("set witness")
900        .len();
901    let tx = psbt
902        .extract_tx()
903        .map_err(|e| ZincError::OfferError(format!("failed to extract finalized sale tx: {e}")))?;
904    let txid = tx.compute_txid().to_string();
905    let tx_hex = hex::encode(bitcoin::consensus::serialize(&tx));
906
907    Ok(FinalizedListingSaleResultV1 {
908        finalized_psbt_base64,
909        tx_hex,
910        txid,
911        seller_input_index: plan.seller_input_index,
912        passthrough_witness_items,
913    })
914}
915
916fn validate_create_listing_request(request: &CreateListingRequest) -> Result<(), ZincError> {
917    XOnlyPublicKey::from_str(&request.seller_pubkey_hex)
918        .map_err(|e| ZincError::OfferError(format!("invalid seller pubkey: {e}")))?;
919    XOnlyPublicKey::from_str(&request.coordinator_pubkey_hex)
920        .map_err(|e| ZincError::OfferError(format!("invalid coordinator pubkey: {e}")))?;
921
922    if request.network.trim().is_empty()
923        || request.inscription_id.trim().is_empty()
924        || request.seller_payout_script_pubkey.is_empty()
925        || request.recovery_script_pubkey.is_empty()
926    {
927        return Err(ZincError::OfferError(
928            "listing request contains empty required fields".to_string(),
929        ));
930    }
931    if request.ask_sats == 0 {
932        return Err(ZincError::OfferError("ask_sats must be > 0".to_string()));
933    }
934    if request.seller_prevout.value.to_sat() == 0 {
935        return Err(ZincError::OfferError(
936            "seller prevout value must be > 0".to_string(),
937        ));
938    }
939    if request.expires_at_unix <= request.created_at_unix {
940        return Err(ZincError::OfferError(
941            "listing expiration must be greater than creation time".to_string(),
942        ));
943    }
944
945    Ok(())
946}
947
948fn build_sale_psbt(
949    request: &CreateListingRequest,
950    seller_pubkey: XOnlyPublicKey,
951    coordinator_pubkey: XOnlyPublicKey,
952    passthrough_outpoint: OutPoint,
953    passthrough_txout: TxOut,
954) -> Result<Psbt, ZincError> {
955    let seller_payout_sats = request
956        .ask_sats
957        .checked_add(passthrough_txout.value.to_sat())
958        .ok_or_else(|| ZincError::OfferError("ask_sats + postage overflows u64".to_string()))?;
959    let tx = Transaction {
960        version: bitcoin::transaction::Version(2),
961        lock_time: absolute::LockTime::ZERO,
962        input: vec![template_txin(passthrough_outpoint)],
963        output: vec![TxOut {
964            value: Amount::from_sat(seller_payout_sats),
965            script_pubkey: request.seller_payout_script_pubkey.clone(),
966        }],
967    };
968    let mut psbt = Psbt::from_unsigned_tx(tx)
969        .map_err(|e| ZincError::OfferError(format!("failed to build sale psbt: {e}")))?;
970    psbt.inputs[0].witness_utxo = Some(passthrough_txout);
971    psbt.inputs[0].sighash_type = Some(bitcoin::psbt::PsbtSighashType::from_u32(u32::from(
972        LISTING_SALE_SIGHASH_U8,
973    )));
974    let tapscript = passthrough_tapscript(seller_pubkey, coordinator_pubkey);
975    let spend_info = passthrough_spend_info(seller_pubkey, coordinator_pubkey);
976    let control_block = spend_info
977        .control_block(&(tapscript.clone(), LeafVersion::TapScript))
978        .ok_or_else(|| ZincError::OfferError("missing passthrough control block".to_string()))?;
979    psbt.inputs[0]
980        .tap_scripts
981        .insert(control_block, (tapscript, LeafVersion::TapScript));
982    Ok(psbt)
983}
984
985fn build_recovery_psbt(
986    request: &CreateListingRequest,
987    passthrough_outpoint: OutPoint,
988    passthrough_txout: TxOut,
989) -> Result<Psbt, ZincError> {
990    let tx = Transaction {
991        version: bitcoin::transaction::Version(2),
992        lock_time: absolute::LockTime::ZERO,
993        input: vec![template_txin(passthrough_outpoint)],
994        output: vec![TxOut {
995            value: passthrough_txout.value,
996            script_pubkey: request.recovery_script_pubkey.clone(),
997        }],
998    };
999    let mut psbt = Psbt::from_unsigned_tx(tx)
1000        .map_err(|e| ZincError::OfferError(format!("failed to build recovery psbt: {e}")))?;
1001    psbt.inputs[0].witness_utxo = Some(passthrough_txout);
1002    Ok(psbt)
1003}
1004
1005fn template_txin(previous_output: OutPoint) -> TxIn {
1006    TxIn {
1007        previous_output,
1008        script_sig: ScriptBuf::new(),
1009        sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
1010        witness: Witness::new(),
1011    }
1012}
1013
1014fn encode_psbt_base64(psbt: &Psbt) -> String {
1015    base64::engine::general_purpose::STANDARD.encode(psbt.serialize())
1016}
1017
1018/// Validate that a listing sale PSBT is safe for the seller's sale-path signature.
1019pub fn prepare_listing_sale_signature(
1020    listing: &ListingEnvelopeV1,
1021    now_unix: i64,
1022) -> Result<ListingSaleSigningPlanV1, ZincError> {
1023    prepare_listing_sale_signature_with_policy(listing, now_unix, false)
1024}
1025
1026fn prepare_listing_sale_signature_with_policy(
1027    listing: &ListingEnvelopeV1,
1028    now_unix: i64,
1029    allow_existing_signature: bool,
1030) -> Result<ListingSaleSigningPlanV1, ZincError> {
1031    let listing_id = listing.listing_id_hex()?;
1032    if now_unix >= listing.expires_at_unix {
1033        return Err(ZincError::OfferError(format!(
1034            "listing has expired at {}",
1035            listing.expires_at_unix
1036        )));
1037    }
1038
1039    let passthrough_outpoint = listing
1040        .passthrough_outpoint
1041        .parse::<OutPoint>()
1042        .map_err(|e| {
1043            ZincError::OfferError(format!(
1044                "invalid passthrough_outpoint `{}`: {e}",
1045                listing.passthrough_outpoint
1046            ))
1047        })?;
1048    let psbt = decode_listing_sale_psbt(listing)?;
1049
1050    let seller_indices: Vec<usize> = psbt
1051        .unsigned_tx
1052        .input
1053        .iter()
1054        .enumerate()
1055        .filter_map(|(index, input)| {
1056            (input.previous_output == passthrough_outpoint).then_some(index)
1057        })
1058        .collect();
1059
1060    match seller_indices.len() {
1061        0 => {
1062            return Err(ZincError::OfferError(format!(
1063                "sale psbt contains no passthrough input `{passthrough_outpoint}`"
1064            )))
1065        }
1066        1 => {}
1067        count => {
1068            return Err(ZincError::OfferError(format!(
1069                "sale psbt contains {count} passthrough inputs `{passthrough_outpoint}`"
1070            )))
1071        }
1072    }
1073
1074    let seller_input_index = seller_indices[0];
1075    validate_seller_input(
1076        listing,
1077        &psbt,
1078        seller_input_index,
1079        passthrough_outpoint,
1080        allow_existing_signature,
1081    )?;
1082
1083    Ok(ListingSaleSigningPlanV1 {
1084        listing_id,
1085        seller_input_index,
1086        sighash_u8: LISTING_SALE_SIGHASH_U8,
1087        seller_payout_sats: listing
1088            .ask_sats
1089            .checked_add(listing.postage_sats)
1090            .ok_or_else(|| ZincError::OfferError("ask_sats + postage overflows u64".to_string()))?,
1091    })
1092}
1093
1094fn decode_listing_sale_psbt(listing: &ListingEnvelopeV1) -> Result<Psbt, ZincError> {
1095    let bytes = base64::engine::general_purpose::STANDARD
1096        .decode(listing.sale_psbt_base64.as_bytes())
1097        .map_err(|e| ZincError::OfferError(format!("invalid sale psbt base64: {e}")))?;
1098    Psbt::deserialize(&bytes).map_err(|e| ZincError::OfferError(format!("invalid sale psbt: {e}")))
1099}
1100
1101fn validate_seller_input(
1102    listing: &ListingEnvelopeV1,
1103    psbt: &Psbt,
1104    seller_input_index: usize,
1105    passthrough_outpoint: OutPoint,
1106    allow_existing_signature: bool,
1107) -> Result<(), ZincError> {
1108    let seller_input = psbt
1109        .inputs
1110        .get(seller_input_index)
1111        .ok_or_else(|| ZincError::OfferError("seller input metadata missing".to_string()))?;
1112
1113    if !allow_existing_signature && input_has_signature(seller_input) {
1114        return Err(ZincError::OfferError(format!(
1115            "passthrough input `{passthrough_outpoint}` must be unsigned"
1116        )));
1117    }
1118
1119    let sighash_u8 = seller_input
1120        .sighash_type
1121        .map(|sighash| sighash.to_u32() as u8)
1122        .ok_or_else(|| {
1123            ZincError::OfferError(
1124                "sale psbt seller input must request SIGHASH_SINGLE|SIGHASH_ANYONECANPAY"
1125                    .to_string(),
1126            )
1127        })?;
1128    if sighash_u8 != LISTING_SALE_SIGHASH_U8 {
1129        return Err(ZincError::OfferError(format!(
1130            "sale psbt seller input must request SIGHASH_SINGLE|SIGHASH_ANYONECANPAY ({LISTING_SALE_SIGHASH_U8:#x}); found {sighash_u8:#x}"
1131        )));
1132    }
1133
1134    let seller_prevout = input_prevout(psbt, seller_input_index)?;
1135    if seller_prevout.value.to_sat() != listing.postage_sats {
1136        return Err(ZincError::OfferError(format!(
1137            "passthrough input postage must equal {} sats; found {} sats",
1138            listing.postage_sats,
1139            seller_prevout.value.to_sat()
1140        )));
1141    }
1142
1143    let seller_output = psbt
1144        .unsigned_tx
1145        .output
1146        .get(seller_input_index)
1147        .ok_or_else(|| {
1148            ZincError::OfferError(
1149                "sale psbt missing seller payout output required by SIGHASH_SINGLE".to_string(),
1150            )
1151        })?;
1152    let expected_seller_payout = listing
1153        .ask_sats
1154        .checked_add(listing.postage_sats)
1155        .ok_or_else(|| ZincError::OfferError("ask_sats + postage overflows u64".to_string()))?;
1156    if seller_output.value.to_sat() != expected_seller_payout {
1157        return Err(ZincError::OfferError(format!(
1158            "seller payout output must equal ask+postage {} sats; found {} sats",
1159            expected_seller_payout,
1160            seller_output.value.to_sat()
1161        )));
1162    }
1163
1164    let expected_script = script_from_hex(&listing.seller_payout_script_pubkey_hex)?;
1165    if seller_output.script_pubkey != expected_script {
1166        return Err(ZincError::OfferError(
1167            "seller payout script does not match listing".to_string(),
1168        ));
1169    }
1170
1171    Ok(())
1172}
1173
1174fn find_passthrough_tap_leaf(
1175    psbt: &Psbt,
1176    input_index: usize,
1177    seller_pubkey: XOnlyPublicKey,
1178    coordinator_pubkey: XOnlyPublicKey,
1179) -> Result<(TapLeafHash, ScriptBuf), ZincError> {
1180    let (_control_block, leaf_hash, script) =
1181        find_passthrough_tap_leaf_entry(psbt, input_index, seller_pubkey, coordinator_pubkey)?;
1182    Ok((leaf_hash, script))
1183}
1184
1185fn find_passthrough_tap_leaf_entry(
1186    psbt: &Psbt,
1187    input_index: usize,
1188    seller_pubkey: XOnlyPublicKey,
1189    coordinator_pubkey: XOnlyPublicKey,
1190) -> Result<(ControlBlock, TapLeafHash, ScriptBuf), ZincError> {
1191    let expected_script = passthrough_tapscript(seller_pubkey, coordinator_pubkey);
1192    let input = psbt
1193        .inputs
1194        .get(input_index)
1195        .ok_or_else(|| ZincError::OfferError("seller input metadata missing".to_string()))?;
1196    for (control_block, (script, leaf_version)) in &input.tap_scripts {
1197        if *leaf_version == LeafVersion::TapScript && *script == expected_script {
1198            return Ok((
1199                control_block.clone(),
1200                TapLeafHash::from_script(script, *leaf_version),
1201                script.clone(),
1202            ));
1203        }
1204    }
1205
1206    Err(ZincError::OfferError(
1207        "missing passthrough tap leaf metadata".to_string(),
1208    ))
1209}
1210
1211fn ensure_seller_sale_signature(
1212    psbt: &Psbt,
1213    input_index: usize,
1214    seller_pubkey: XOnlyPublicKey,
1215    leaf_hash: TapLeafHash,
1216) -> Result<(), ZincError> {
1217    let input = psbt
1218        .inputs
1219        .get(input_index)
1220        .ok_or_else(|| ZincError::OfferError("seller input metadata missing".to_string()))?;
1221    let Some(signature) = input.tap_script_sigs.get(&(seller_pubkey, leaf_hash)) else {
1222        return Err(ZincError::OfferError(
1223            "missing seller sale signature".to_string(),
1224        ));
1225    };
1226    if signature.sighash_type != TapSighashType::SinglePlusAnyoneCanPay {
1227        return Err(ZincError::OfferError(format!(
1228            "seller sale signature must use SIGHASH_SINGLE|SIGHASH_ANYONECANPAY; found {}",
1229            signature.sighash_type
1230        )));
1231    }
1232    Ok(())
1233}
1234
1235fn ensure_coordinator_default_signature(
1236    psbt: &Psbt,
1237    input_index: usize,
1238    coordinator_pubkey: XOnlyPublicKey,
1239    leaf_hash: TapLeafHash,
1240) -> Result<(), ZincError> {
1241    let input = psbt
1242        .inputs
1243        .get(input_index)
1244        .ok_or_else(|| ZincError::OfferError("seller input metadata missing".to_string()))?;
1245    let Some(signature) = input.tap_script_sigs.get(&(coordinator_pubkey, leaf_hash)) else {
1246        return Err(ZincError::OfferError(
1247            "missing coordinator signature".to_string(),
1248        ));
1249    };
1250    if signature.sighash_type != TapSighashType::Default {
1251        return Err(ZincError::OfferError(format!(
1252            "coordinator signature must use SIGHASH_DEFAULT; found {}",
1253            signature.sighash_type
1254        )));
1255    }
1256    Ok(())
1257}
1258
1259fn input_prevout(psbt: &Psbt, index: usize) -> Result<&TxOut, ZincError> {
1260    let input = psbt
1261        .inputs
1262        .get(index)
1263        .ok_or_else(|| ZincError::OfferError("input metadata missing".to_string()))?;
1264    input
1265        .witness_utxo
1266        .as_ref()
1267        .or_else(|| {
1268            input.non_witness_utxo.as_ref().and_then(|prev_tx| {
1269                psbt.unsigned_tx
1270                    .input
1271                    .get(index)
1272                    .and_then(|txin| prev_tx.output.get(txin.previous_output.vout as usize))
1273            })
1274        })
1275        .ok_or_else(|| ZincError::OfferError(format!("input #{index} is missing prevout metadata")))
1276}
1277
1278fn input_has_signature(input: &PsbtInput) -> bool {
1279    input.final_script_sig.is_some()
1280        || input.final_script_witness.is_some()
1281        || !input.partial_sigs.is_empty()
1282        || input.tap_key_sig.is_some()
1283        || !input.tap_script_sigs.is_empty()
1284}
1285
1286fn psbt_fee_sats(psbt: &Psbt) -> Result<u64, ZincError> {
1287    let total_input_sats = (0..psbt.inputs.len()).try_fold(0u64, |total, index| {
1288        total
1289            .checked_add(input_prevout(psbt, index)?.value.to_sat())
1290            .ok_or_else(|| ZincError::OfferError("total input value overflows u64".to_string()))
1291    })?;
1292    let total_output_sats = psbt
1293        .unsigned_tx
1294        .output
1295        .iter()
1296        .try_fold(0u64, |total, output| {
1297            total.checked_add(output.value.to_sat()).ok_or_else(|| {
1298                ZincError::OfferError("total output value overflows u64".to_string())
1299            })
1300        })?;
1301    total_input_sats.checked_sub(total_output_sats).ok_or_else(|| {
1302        ZincError::OfferError(format!(
1303            "buyer funding is insufficient: inputs {total_input_sats} sats, outputs {total_output_sats} sats"
1304        ))
1305    })
1306}
1307
1308fn script_from_hex(hex_script: &str) -> Result<ScriptBuf, ZincError> {
1309    let bytes = hex::decode(hex_script)
1310        .map_err(|e| ZincError::OfferError(format!("invalid scriptPubKey hex: {e}")))?;
1311    Ok(ScriptBuf::from_bytes(bytes))
1312}