1use 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
27pub const LISTING_SALE_SIGHASH_U8: u8 = 0x83;
29
30const DEFAULT_LISTING_FOREIGN_INPUT_SATISFACTION_WEIGHT_WU: u64 = 272;
31
32#[derive(Debug, Clone)]
34pub struct CreateListingRequest {
35 pub seller_pubkey_hex: String,
37 pub coordinator_pubkey_hex: String,
39 pub network: String,
41 pub inscription_id: String,
43 pub seller_outpoint: OutPoint,
45 pub seller_prevout: TxOut,
47 pub seller_payout_script_pubkey: ScriptBuf,
49 pub recovery_script_pubkey: ScriptBuf,
51 pub ask_sats: u64,
53 pub fee_rate_sat_vb: u64,
55 pub created_at_unix: i64,
57 pub expires_at_unix: i64,
59 pub nonce: u64,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct CreateListingResultV1 {
66 pub listing: ListingEnvelopeV1,
68 pub passthrough_outpoint: OutPoint,
70 pub passthrough_txout: TxOut,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct ListingEnvelopeV1 {
77 pub version: u8,
79 pub seller_pubkey_hex: String,
81 pub coordinator_pubkey_hex: String,
83 pub network: String,
85 pub inscription_id: String,
87 pub seller_outpoint: String,
89 pub passthrough_outpoint: String,
91 pub seller_payout_script_pubkey_hex: String,
93 pub ask_sats: u64,
95 pub postage_sats: u64,
97 pub fee_rate_sat_vb: u64,
99 pub tx1_base64: String,
101 pub sale_psbt_base64: String,
103 pub recovery_psbt_base64: String,
105 pub created_at_unix: i64,
107 pub expires_at_unix: i64,
109 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189pub struct ListingSaleSigningPlanV1 {
190 pub listing_id: String,
192 pub seller_input_index: usize,
194 pub sighash_u8: u8,
196 pub seller_payout_sats: u64,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct ListingBuyerFundingInput {
203 pub previous_output: OutPoint,
205 pub witness_utxo: TxOut,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct AnchorOutput {
212 pub script_pubkey: ScriptBuf,
214 pub value_sats: u64,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq)]
220pub struct FinalizeListingPurchaseRequest {
221 pub listing: ListingEnvelopeV1,
223 pub buyer_inputs: Vec<ListingBuyerFundingInput>,
225 pub buyer_receive_script_pubkey: ScriptBuf,
227 pub change_script_pubkey: Option<ScriptBuf>,
229 pub change_sats: u64,
231 pub anchor_output: Option<AnchorOutput>,
233 pub now_unix: i64,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239pub struct FinalizeListingPurchaseResultV1 {
240 pub listing: ListingEnvelopeV1,
242 pub psbt_base64: String,
244 pub fee_sats: u64,
246 pub seller_input_index: usize,
248 pub buyer_receive_output_index: usize,
250 pub change_output_index: Option<usize>,
252 pub anchor_output_index: Option<usize>,
254}
255
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258pub struct CreateListingPurchaseRequest {
259 pub listing: ListingEnvelopeV1,
261 pub now_unix: i64,
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267pub struct CreateListingPurchaseResultV1 {
268 pub listing: ListingEnvelopeV1,
270 pub psbt_base64: String,
272 pub fee_sats: u64,
274 pub seller_input_index: usize,
276 pub buyer_input_count: usize,
278 pub buyer_receive_output_index: usize,
280}
281
282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284pub struct FinalizedListingSaleResultV1 {
285 pub finalized_psbt_base64: String,
287 pub tx_hex: String,
289 pub txid: String,
291 pub seller_input_index: usize,
293 pub passthrough_witness_items: usize,
295}
296
297pub 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
312pub 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
334pub 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
400pub 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
459pub 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
521pub 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#[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
879pub 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
1054pub 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}