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