Skip to main content

zinc_core/
offer_create.rs

1//! Offer creation helpers aligned with ord-style offer PSBT construction.
2//!
3//! This module builds a buyer-funded PSBT that includes one unsigned seller
4//! input (the inscription outpoint) and signed buyer inputs, then wraps that
5//! PSBT in `OfferEnvelopeV1` for relay publication.
6
7use 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/// Input parameters for building an ord-compatible buyer offer.
22#[derive(Debug, Clone)]
23pub struct CreateOfferRequest {
24    /// Target inscription id for the offer.
25    pub inscription_id: String,
26    /// Seller outpoint containing the inscription.
27    pub seller_outpoint: OutPoint,
28    /// Address currently controlling `seller_outpoint` (used for seller input prevout metadata).
29    pub seller_input_address: String,
30    /// Seller payout address receiving ask payment + postage.
31    pub seller_payout_address: String,
32    /// Value (postage) currently held by the inscription output.
33    pub seller_output_value_sats: u64,
34    /// Ask amount (in sats) offered to the seller.
35    pub ask_sats: u64,
36    /// Desired fee rate (sat/vB).
37    pub fee_rate_sat_vb: u64,
38    /// Offer creation unix timestamp.
39    pub created_at_unix: i64,
40    /// Offer expiration unix timestamp.
41    pub expires_at_unix: i64,
42    /// Caller-provided nonce.
43    pub nonce: u64,
44    /// Optional explicit offer publisher x-only pubkey hex.
45    ///
46    /// If omitted, this defaults to the active wallet account taproot pubkey at index `0`.
47    pub publisher_pubkey_hex: Option<String>,
48}
49
50/// Result payload for ord-compatible offer creation.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct OfferCreateResultV1 {
53    /// Buyer-offer PSBT base64 (same field shape as `ord wallet offer create`).
54    pub psbt: String,
55    /// Seller payout address included in output #1.
56    pub seller_address: String,
57    /// Target inscription id.
58    pub inscription: String,
59    /// Seller outpoint (`txid:vout`) included in the offer.
60    pub seller_outpoint: String,
61    /// Postage value preserved to buyer output.
62    pub postage_sats: u64,
63    /// Ask amount in sats.
64    pub ask_sats: u64,
65    /// Fee rate in sat/vB.
66    pub fee_rate_sat_vb: u64,
67    /// Index of the seller input in the PSBT.
68    pub seller_input_index: usize,
69    /// Number of buyer-owned inputs signed in the PSBT.
70    pub buyer_input_count: usize,
71    /// Offer envelope ready for relay publication.
72    pub offer: OfferEnvelopeV1,
73}
74
75/// Build and sign a buyer offer PSBT plus envelope.
76pub 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    // Match ord's offer template invariants:
134    // - preserve recipient insertion order (buyer postage, then seller payout),
135    // - keep manually added seller foreign input ahead of algorithmic buyer inputs.
136    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}