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