use crate::builder::{AddressScheme, SignOptions, ZincWallet};
use crate::{prepare_offer_acceptance, OfferEnvelopeV1, ZincError};
use base64::Engine;
use bdk_wallet::bitcoin::address::NetworkUnchecked;
use bdk_wallet::bitcoin::psbt::Input as PsbtInput;
use bdk_wallet::bitcoin::secp256k1::XOnlyPublicKey;
use bdk_wallet::bitcoin::{Address, Amount, FeeRate, OutPoint, TxOut, Weight};
use bdk_wallet::KeychainKind;
use bdk_wallet::TxOrdering;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
const DEFAULT_FOREIGN_INPUT_SATISFACTION_WEIGHT_WU: u64 = 272;
#[derive(Debug, Clone)]
pub struct CreateOfferRequest {
pub inscription_id: String,
pub seller_outpoint: OutPoint,
pub seller_input_address: String,
pub seller_payout_address: String,
pub seller_output_value_sats: u64,
pub ask_sats: u64,
pub fee_rate_sat_vb: u64,
pub created_at_unix: i64,
pub expires_at_unix: i64,
pub nonce: u64,
pub publisher_pubkey_hex: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfferCreateResultV1 {
pub psbt: String,
pub seller_address: String,
pub inscription: String,
pub seller_outpoint: String,
pub postage_sats: u64,
pub ask_sats: u64,
pub fee_rate_sat_vb: u64,
pub seller_input_index: usize,
pub buyer_input_count: usize,
pub offer: OfferEnvelopeV1,
}
pub fn create_offer(
wallet: &mut ZincWallet,
request: &CreateOfferRequest,
) -> Result<OfferCreateResultV1, ZincError> {
validate_request(wallet, request)?;
let wallet_network = wallet.vault_wallet.network();
let expected_seller_payout_address = wallet
.peek_payment_address(0)
.ok_or_else(|| ZincError::WalletError("Payment wallet not initialized".to_string()))?;
let seller_input_address = request
.seller_input_address
.parse::<Address<NetworkUnchecked>>()
.map_err(|e| ZincError::OfferError(format!("invalid seller input address: {e}")))?
.require_network(wallet_network)
.map_err(|e| {
ZincError::OfferError(format!("seller input address network mismatch: {e}"))
})?;
let seller_payout_address = request
.seller_payout_address
.parse::<Address<NetworkUnchecked>>()
.map_err(|e| ZincError::OfferError(format!("invalid seller payout address: {e}")))?
.require_network(wallet_network)
.map_err(|e| {
ZincError::OfferError(format!("seller payout address network mismatch: {e}"))
})?;
if seller_payout_address.script_pubkey() != expected_seller_payout_address.script_pubkey() {
return Err(ZincError::OfferError(format!(
"seller_payout_address must match wallet main payment address {expected_seller_payout_address}"
)));
}
let buyer_receive_address = wallet
.vault_wallet
.peek_address(KeychainKind::External, 0)
.address;
let seller_payout_sats = request
.ask_sats
.checked_add(request.seller_output_value_sats)
.ok_or_else(|| ZincError::OfferError("ask_sats + postage overflows u64".to_string()))?;
let fee_rate = FeeRate::from_sat_per_vb(request.fee_rate_sat_vb)
.ok_or_else(|| ZincError::OfferError("invalid fee rate".to_string()))?;
let seller_psbt_input = PsbtInput {
witness_utxo: Some(TxOut {
value: Amount::from_sat(request.seller_output_value_sats),
script_pubkey: seller_input_address.script_pubkey(),
}),
..Default::default()
};
let signing_wallet = if wallet.scheme == AddressScheme::Dual {
wallet
.payment_wallet
.as_mut()
.ok_or_else(|| ZincError::WalletError("Payment wallet not initialized".to_string()))?
} else {
&mut wallet.vault_wallet
};
let main_change_script = signing_wallet
.peek_address(KeychainKind::External, 0)
.script_pubkey();
let mut builder = signing_wallet.build_tx();
if !wallet.inscribed_utxos.is_empty() {
builder.unspendable(wallet.inscribed_utxos.iter().copied().collect());
}
builder.ordering(TxOrdering::Untouched);
builder
.add_recipient(
buyer_receive_address.script_pubkey(),
Amount::from_sat(request.seller_output_value_sats),
)
.add_recipient(
seller_payout_address.script_pubkey(),
Amount::from_sat(seller_payout_sats),
)
.drain_to(main_change_script)
.fee_rate(fee_rate)
.only_witness_utxo()
.add_foreign_utxo(
request.seller_outpoint,
seller_psbt_input,
Weight::from_wu(DEFAULT_FOREIGN_INPUT_SATISFACTION_WEIGHT_WU),
)
.map_err(|e| ZincError::OfferError(format!("failed adding seller input: {e}")))?;
let unsigned_psbt = builder
.finish()
.map_err(|e| ZincError::OfferError(format!("failed to build offer psbt: {e}")))?;
let unsigned_psbt_base64 =
base64::engine::general_purpose::STANDARD.encode(unsigned_psbt.serialize());
let seller_input_index = unsigned_psbt
.unsigned_tx
.input
.iter()
.position(|input| input.previous_output == request.seller_outpoint)
.ok_or_else(|| {
ZincError::OfferError(format!(
"offer psbt is missing seller input {}",
request.seller_outpoint
))
})?;
let buyer_input_indices: Vec<usize> = (0..unsigned_psbt.inputs.len())
.filter(|index| *index != seller_input_index)
.collect();
if buyer_input_indices.is_empty() {
return Err(ZincError::OfferError(
"offer psbt must include at least one buyer input".to_string(),
));
}
let signed_psbt = wallet
.sign_psbt(
&unsigned_psbt_base64,
Some(SignOptions {
sign_inputs: Some(buyer_input_indices.clone()),
sighash: None,
finalize: true,
}),
)
.map_err(ZincError::OfferError)?;
let seller_pubkey_hex = resolve_publisher_pubkey(wallet, request)?;
let offer = OfferEnvelopeV1 {
version: 1,
seller_pubkey_hex,
network: network_name(wallet_network).to_string(),
inscription_id: request.inscription_id.clone(),
seller_outpoint: request.seller_outpoint.to_string(),
ask_sats: request.ask_sats,
fee_rate_sat_vb: request.fee_rate_sat_vb,
psbt_base64: signed_psbt.clone(),
created_at_unix: request.created_at_unix,
expires_at_unix: request.expires_at_unix,
nonce: request.nonce,
};
let plan = prepare_offer_acceptance(&offer, request.created_at_unix)?;
Ok(OfferCreateResultV1 {
psbt: signed_psbt,
seller_address: seller_payout_address.to_string(),
inscription: request.inscription_id.clone(),
seller_outpoint: request.seller_outpoint.to_string(),
postage_sats: request.seller_output_value_sats,
ask_sats: request.ask_sats,
fee_rate_sat_vb: request.fee_rate_sat_vb,
seller_input_index: plan.seller_input_index,
buyer_input_count: buyer_input_indices.len(),
offer,
})
}
fn validate_request(wallet: &ZincWallet, request: &CreateOfferRequest) -> Result<(), ZincError> {
if !wallet.ordinals_verified {
return Err(ZincError::WalletError(
"Ordinals verification failed - safety lock engaged. Please retry sync.".to_string(),
));
}
if request.inscription_id.trim().is_empty() {
return Err(ZincError::OfferError(
"inscription_id must not be empty".to_string(),
));
}
if request.seller_input_address.trim().is_empty() {
return Err(ZincError::OfferError(
"seller_input_address must not be empty".to_string(),
));
}
if request.seller_payout_address.trim().is_empty() {
return Err(ZincError::OfferError(
"seller_payout_address must not be empty".to_string(),
));
}
if request.ask_sats == 0 {
return Err(ZincError::OfferError("ask_sats must be > 0".to_string()));
}
if request.seller_output_value_sats == 0 {
return Err(ZincError::OfferError(
"seller output value must be > 0".to_string(),
));
}
if request.expires_at_unix <= request.created_at_unix {
return Err(ZincError::OfferError(
"offer expiration must be greater than creation time".to_string(),
));
}
Ok(())
}
fn resolve_publisher_pubkey(
wallet: &ZincWallet,
request: &CreateOfferRequest,
) -> Result<String, ZincError> {
if let Some(pubkey_hex) = &request.publisher_pubkey_hex {
XOnlyPublicKey::from_str(pubkey_hex)
.map_err(|e| ZincError::OfferError(format!("invalid publisher_pubkey_hex: {e}")))?;
return Ok(pubkey_hex.clone());
}
wallet
.get_taproot_public_key(0)
.map_err(ZincError::WalletError)
}
fn network_name(network: bdk_wallet::bitcoin::Network) -> &'static str {
match network {
bdk_wallet::bitcoin::Network::Bitcoin => "bitcoin",
bdk_wallet::bitcoin::Network::Testnet => "testnet",
bdk_wallet::bitcoin::Network::Signet => "signet",
bdk_wallet::bitcoin::Network::Regtest => "regtest",
_ => "bitcoin",
}
}