1use crate::builder::{AddressScheme, SignOptions, ZincWallet};
8use crate::{prepare_offer_acceptance, OfferEnvelopeV1, ZincError};
9use base64::Engine;
10use bdk_wallet::bitcoin::address::NetworkUnchecked;
11use bdk_wallet::bitcoin::psbt::Input as PsbtInput;
12use bdk_wallet::bitcoin::secp256k1::XOnlyPublicKey;
13use bdk_wallet::bitcoin::{Address, Amount, FeeRate, OutPoint, TxOut, Weight};
14use bdk_wallet::KeychainKind;
15use bdk_wallet::TxOrdering;
16use serde::{Deserialize, Serialize};
17use std::str::FromStr;
18
19const DEFAULT_FOREIGN_INPUT_SATISFACTION_WEIGHT_WU: u64 = 272;
20
21#[derive(Debug, Clone)]
23pub struct CreateOfferRequest {
24 pub inscription_id: String,
26 pub seller_outpoint: OutPoint,
28 pub seller_input_address: String,
30 pub seller_payout_address: String,
32 pub seller_output_value_sats: u64,
34 pub ask_sats: u64,
36 pub fee_rate_sat_vb: u64,
38 pub created_at_unix: i64,
40 pub expires_at_unix: i64,
42 pub nonce: u64,
44 pub publisher_pubkey_hex: Option<String>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct OfferCreateResultV1 {
53 pub psbt: String,
55 pub seller_address: String,
57 pub inscription: String,
59 pub seller_outpoint: String,
61 pub postage_sats: u64,
63 pub ask_sats: u64,
65 pub fee_rate_sat_vb: u64,
67 pub seller_input_index: usize,
69 pub buyer_input_count: usize,
71 pub offer: OfferEnvelopeV1,
73}
74
75pub fn create_offer(
77 wallet: &mut ZincWallet,
78 request: &CreateOfferRequest,
79) -> Result<OfferCreateResultV1, ZincError> {
80 validate_request(wallet, request)?;
81
82 let wallet_network = wallet.vault_wallet.network();
83 let seller_input_address = request
84 .seller_input_address
85 .parse::<Address<NetworkUnchecked>>()
86 .map_err(|e| ZincError::OfferError(format!("invalid seller input address: {e}")))?
87 .require_network(wallet_network)
88 .map_err(|e| {
89 ZincError::OfferError(format!("seller input address network mismatch: {e}"))
90 })?;
91 let seller_payout_address = request
92 .seller_payout_address
93 .parse::<Address<NetworkUnchecked>>()
94 .map_err(|e| ZincError::OfferError(format!("invalid seller payout address: {e}")))?
95 .require_network(wallet_network)
96 .map_err(|e| {
97 ZincError::OfferError(format!("seller payout address network mismatch: {e}"))
98 })?;
99
100 let buyer_receive_address = wallet
101 .vault_wallet
102 .peek_address(KeychainKind::Internal, 0)
103 .address;
104 let seller_payout_sats = request
105 .ask_sats
106 .checked_add(request.seller_output_value_sats)
107 .ok_or_else(|| ZincError::OfferError("ask_sats + postage overflows u64".to_string()))?;
108
109 let fee_rate = FeeRate::from_sat_per_vb(request.fee_rate_sat_vb)
110 .ok_or_else(|| ZincError::OfferError("invalid fee rate".to_string()))?;
111 let seller_psbt_input = PsbtInput {
112 witness_utxo: Some(TxOut {
113 value: Amount::from_sat(request.seller_output_value_sats),
114 script_pubkey: seller_input_address.script_pubkey(),
115 }),
116 ..Default::default()
117 };
118
119 let signing_wallet = if wallet.scheme == AddressScheme::Dual {
120 wallet
121 .payment_wallet
122 .as_mut()
123 .ok_or_else(|| ZincError::WalletError("Payment wallet not initialized".to_string()))?
124 } else {
125 &mut wallet.vault_wallet
126 };
127
128 let mut builder = signing_wallet.build_tx();
129 if !wallet.inscribed_utxos.is_empty() {
130 builder.unspendable(wallet.inscribed_utxos.iter().copied().collect());
131 }
132
133 builder.ordering(TxOrdering::Untouched);
137
138 builder
139 .add_recipient(
140 buyer_receive_address.script_pubkey(),
141 Amount::from_sat(request.seller_output_value_sats),
142 )
143 .add_recipient(
144 seller_payout_address.script_pubkey(),
145 Amount::from_sat(seller_payout_sats),
146 )
147 .fee_rate(fee_rate)
148 .only_witness_utxo()
149 .add_foreign_utxo(
150 request.seller_outpoint,
151 seller_psbt_input,
152 Weight::from_wu(DEFAULT_FOREIGN_INPUT_SATISFACTION_WEIGHT_WU),
153 )
154 .map_err(|e| ZincError::OfferError(format!("failed adding seller input: {e}")))?;
155
156 let unsigned_psbt = builder
157 .finish()
158 .map_err(|e| ZincError::OfferError(format!("failed to build offer psbt: {e}")))?;
159 let unsigned_psbt_base64 =
160 base64::engine::general_purpose::STANDARD.encode(unsigned_psbt.serialize());
161
162 let seller_input_index = unsigned_psbt
163 .unsigned_tx
164 .input
165 .iter()
166 .position(|input| input.previous_output == request.seller_outpoint)
167 .ok_or_else(|| {
168 ZincError::OfferError(format!(
169 "offer psbt is missing seller input {}",
170 request.seller_outpoint
171 ))
172 })?;
173
174 let buyer_input_indices: Vec<usize> = (0..unsigned_psbt.inputs.len())
175 .filter(|index| *index != seller_input_index)
176 .collect();
177 if buyer_input_indices.is_empty() {
178 return Err(ZincError::OfferError(
179 "offer psbt must include at least one buyer input".to_string(),
180 ));
181 }
182
183 let signed_psbt = wallet
184 .sign_psbt(
185 &unsigned_psbt_base64,
186 Some(SignOptions {
187 sign_inputs: Some(buyer_input_indices.clone()),
188 sighash: None,
189 finalize: true,
190 }),
191 )
192 .map_err(ZincError::OfferError)?;
193
194 let seller_pubkey_hex = resolve_publisher_pubkey(wallet, request)?;
195 let offer = OfferEnvelopeV1 {
196 version: 1,
197 seller_pubkey_hex,
198 network: network_name(wallet_network).to_string(),
199 inscription_id: request.inscription_id.clone(),
200 seller_outpoint: request.seller_outpoint.to_string(),
201 ask_sats: request.ask_sats,
202 fee_rate_sat_vb: request.fee_rate_sat_vb,
203 psbt_base64: signed_psbt.clone(),
204 created_at_unix: request.created_at_unix,
205 expires_at_unix: request.expires_at_unix,
206 nonce: request.nonce,
207 };
208
209 let plan = prepare_offer_acceptance(&offer, request.created_at_unix)?;
210 Ok(OfferCreateResultV1 {
211 psbt: signed_psbt,
212 seller_address: request.seller_payout_address.clone(),
213 inscription: request.inscription_id.clone(),
214 seller_outpoint: request.seller_outpoint.to_string(),
215 postage_sats: request.seller_output_value_sats,
216 ask_sats: request.ask_sats,
217 fee_rate_sat_vb: request.fee_rate_sat_vb,
218 seller_input_index: plan.seller_input_index,
219 buyer_input_count: buyer_input_indices.len(),
220 offer,
221 })
222}
223
224fn validate_request(wallet: &ZincWallet, request: &CreateOfferRequest) -> Result<(), ZincError> {
225 if !wallet.ordinals_verified {
226 return Err(ZincError::WalletError(
227 "Ordinals verification failed - safety lock engaged. Please retry sync.".to_string(),
228 ));
229 }
230
231 if request.inscription_id.trim().is_empty() {
232 return Err(ZincError::OfferError(
233 "inscription_id must not be empty".to_string(),
234 ));
235 }
236 if request.seller_input_address.trim().is_empty() {
237 return Err(ZincError::OfferError(
238 "seller_input_address must not be empty".to_string(),
239 ));
240 }
241 if request.seller_payout_address.trim().is_empty() {
242 return Err(ZincError::OfferError(
243 "seller_payout_address must not be empty".to_string(),
244 ));
245 }
246 if request.ask_sats == 0 {
247 return Err(ZincError::OfferError("ask_sats must be > 0".to_string()));
248 }
249 if request.seller_output_value_sats == 0 {
250 return Err(ZincError::OfferError(
251 "seller output value must be > 0".to_string(),
252 ));
253 }
254 if request.expires_at_unix <= request.created_at_unix {
255 return Err(ZincError::OfferError(
256 "offer expiration must be greater than creation time".to_string(),
257 ));
258 }
259
260 Ok(())
261}
262
263fn resolve_publisher_pubkey(
264 wallet: &ZincWallet,
265 request: &CreateOfferRequest,
266) -> Result<String, ZincError> {
267 if let Some(pubkey_hex) = &request.publisher_pubkey_hex {
268 XOnlyPublicKey::from_str(pubkey_hex)
269 .map_err(|e| ZincError::OfferError(format!("invalid publisher_pubkey_hex: {e}")))?;
270 return Ok(pubkey_hex.clone());
271 }
272
273 wallet
274 .get_taproot_public_key(0)
275 .map_err(ZincError::WalletError)
276}
277
278fn network_name(network: bdk_wallet::bitcoin::Network) -> &'static str {
279 match network {
280 bdk_wallet::bitcoin::Network::Bitcoin => "bitcoin",
281 bdk_wallet::bitcoin::Network::Testnet => "testnet",
282 bdk_wallet::bitcoin::Network::Signet => "signet",
283 bdk_wallet::bitcoin::Network::Regtest => "regtest",
284 _ => "bitcoin",
285 }
286}