Skip to main content

ark_client/
asset.rs

1use crate::error::ErrorContext;
2use crate::swap_storage::SwapStorage;
3use crate::wallet::BoardingWallet;
4use crate::wallet::OnchainWallet;
5use crate::Blockchain;
6use crate::Client;
7use crate::Error;
8use ark_core::asset::AssetId;
9use ark_core::asset::ControlAssetConfig;
10use ark_core::coin_select::select_vtxos;
11use ark_core::coin_select::select_vtxos_for_asset;
12use ark_core::coin_select::VirtualTxOutPoint;
13use ark_core::send::build_asset_burn_transactions;
14use ark_core::send::build_asset_reissuance_transactions;
15use ark_core::send::build_self_asset_issuance_transactions;
16use ark_core::send::AssetReissuanceTransactions;
17use ark_core::send::SelfAssetIssuanceTransactions;
18use bitcoin::Amount;
19use bitcoin::ScriptBuf;
20use bitcoin::Txid;
21use std::collections::HashMap;
22use std::collections::HashSet;
23
24/// Result of an asset issuance.
25#[derive(Debug, Clone)]
26pub struct IssueAssetResult {
27    /// The Ark transaction ID.
28    pub ark_txid: Txid,
29    /// The issued asset IDs. If a new control asset was created, it is first.
30    pub asset_ids: Vec<AssetId>,
31}
32
33impl<B, W, S, K> Client<B, W, S, K>
34where
35    B: Blockchain,
36    W: BoardingWallet + OnchainWallet,
37    S: SwapStorage + 'static,
38    K: crate::KeyProvider,
39{
40    /// Issue a new asset.
41    ///
42    /// Creates a fresh asset with the given `amount`. The asset is sent to the caller's own
43    /// address. If `control_asset` is provided, the asset can be reissued in the future.
44    pub async fn issue_asset(
45        &self,
46        amount: u64,
47        control_asset_config: Option<ControlAssetConfig>,
48        metadata: Option<Vec<(String, String)>>,
49    ) -> Result<IssueAssetResult, Error> {
50        if amount == 0 {
51            return Err(Error::ad_hoc("asset amount must be > 0"));
52        }
53
54        let server_info = self.server_info()?;
55        let (own_address, _) = self.get_offchain_address()?;
56        let (spendable, script_pubkey_to_vtxo_map) = self.spendable_virtual_vtxos().await?;
57
58        let selected_coins = select_vtxos(spendable, server_info.dust, server_info.dust, true)
59            .map_err(Error::from)
60            .context("failed to select coins for asset issuance")?;
61
62        let issuance_inputs = self.build_vtxo_inputs(selected_coins, &script_pubkey_to_vtxo_map)?;
63        let (change_address, change_address_vtxo) = self.get_offchain_address()?;
64
65        let SelfAssetIssuanceTransactions {
66            ark_tx,
67            checkpoint_txs,
68            asset_ids,
69        } = build_self_asset_issuance_transactions(
70            &own_address,
71            &change_address,
72            &issuance_inputs,
73            &server_info,
74            amount,
75            control_asset_config,
76            metadata,
77        )
78        .map_err(Error::from)
79        .context("failed to build asset issuance transactions")?;
80
81        let pending_tx = self
82            .submit_built_offchain_send(ark_tx, checkpoint_txs, change_address_vtxo.owner_pk())
83            .await
84            .context("failed to submit asset issuance transaction")?;
85
86        let ark_txid = pending_tx.ark_txid;
87        self.sign_and_finalize_pending_tx(pending_tx)
88            .await
89            .context("failed to finalize asset issuance transaction")?;
90
91        Ok(IssueAssetResult {
92            ark_txid,
93            asset_ids,
94        })
95    }
96
97    /// Reissue additional units of an existing asset.
98    ///
99    /// The asset must have been created with a control asset. The control asset is spent as input
100    /// and sent back to the caller, while the new asset units are minted.
101    pub async fn reissue_asset(&self, asset_id: AssetId, amount: u64) -> Result<Txid, Error> {
102        if amount == 0 {
103            return Err(Error::ad_hoc("reissue amount must be > 0"));
104        }
105
106        let server_info = self.server_info()?;
107        let asset_info = self
108            .get_asset(asset_id)
109            .await
110            .context("failed to get asset info")?;
111
112        let control_asset_id = asset_info.control_asset_id.ok_or_else(|| {
113            Error::ad_hoc(format!(
114                "Asset {} can't be reissued, no control asset",
115                asset_id
116            ))
117        })?;
118
119        let (spendable, script_pubkey_to_vtxo_map) = self.spendable_virtual_vtxos().await?;
120
121        let (control_coins, _control_change) =
122            select_vtxos_for_asset(&spendable, 1, control_asset_id)
123                .map_err(Error::from)
124                .context("failed to select control asset for reissuance")?;
125
126        let mut selected_outpoints: HashSet<_> =
127            control_coins.iter().map(|coin| coin.outpoint).collect();
128        let mut selected = control_coins;
129        let btc_provided: Amount = selected.iter().map(|coin| coin.amount).sum();
130        let btc_shortfall = server_info
131            .dust
132            .checked_sub(btc_provided)
133            .unwrap_or(Amount::ZERO);
134
135        if btc_shortfall > Amount::ZERO {
136            let available: Vec<_> = spendable
137                .iter()
138                .filter(|coin| !selected_outpoints.contains(&coin.outpoint))
139                .cloned()
140                .collect();
141
142            let btc_coins = select_vtxos(available, btc_shortfall, server_info.dust, true)
143                .map_err(Error::from)
144                .context("failed to select BTC coins for reissuance")?;
145
146            for coin in btc_coins {
147                if selected_outpoints.insert(coin.outpoint) {
148                    selected.push(coin);
149                }
150            }
151        }
152
153        let reissuance_inputs = self.build_vtxo_inputs(selected, &script_pubkey_to_vtxo_map)?;
154        let (self_address, _) = self.get_offchain_address()?;
155        let (change_address, change_address_vtxo) = self.get_offchain_address()?;
156
157        let AssetReissuanceTransactions {
158            ark_tx,
159            checkpoint_txs,
160        } = build_asset_reissuance_transactions(
161            &self_address,
162            &change_address,
163            &reissuance_inputs,
164            &server_info,
165            asset_id,
166            control_asset_id,
167            amount,
168        )
169        .map_err(Error::from)
170        .context("failed to build asset reissuance transactions")?;
171
172        let pending_tx = self
173            .submit_built_offchain_send(ark_tx, checkpoint_txs, change_address_vtxo.owner_pk())
174            .await
175            .context("failed to submit reissuance transaction")?;
176
177        let ark_txid = pending_tx.ark_txid;
178        self.sign_and_finalize_pending_tx(pending_tx)
179            .await
180            .context("failed to finalize reissuance transaction")?;
181
182        Ok(ark_txid)
183    }
184
185    /// Burn a specific amount of an asset.
186    pub async fn burn_asset(&self, asset_id: AssetId, amount: u64) -> Result<Txid, Error> {
187        if amount == 0 {
188            return Err(Error::ad_hoc("burn amount must be > 0"));
189        }
190
191        let server_info = self.server_info()?;
192        let (spendable, script_pubkey_to_vtxo_map) = self.spendable_virtual_vtxos().await?;
193
194        let (asset_coins, asset_change) = select_vtxos_for_asset(&spendable, amount, asset_id)
195            .map_err(Error::from)
196            .context("failed to select coins for asset burn")?;
197
198        let mut selected_outpoints: HashSet<_> =
199            asset_coins.iter().map(|coin| coin.outpoint).collect();
200        let mut selected = asset_coins;
201
202        let mut carries_asset_change = asset_change > 0;
203        for coin in &selected {
204            if coin.assets.iter().any(|asset| asset.asset_id != asset_id) {
205                carries_asset_change = true;
206                break;
207            }
208        }
209
210        let btc_provided: Amount = selected.iter().map(|coin| coin.amount).sum();
211        let mut btc_needed = server_info.dust;
212        if carries_asset_change {
213            btc_needed += server_info.dust;
214        }
215
216        let btc_shortfall = btc_needed.checked_sub(btc_provided).unwrap_or(Amount::ZERO);
217        if btc_shortfall > Amount::ZERO {
218            let available: Vec<_> = spendable
219                .iter()
220                .filter(|coin| !selected_outpoints.contains(&coin.outpoint))
221                .cloned()
222                .collect();
223
224            let btc_coins = select_vtxos(available, btc_shortfall, server_info.dust, true)
225                .map_err(Error::from)
226                .context("failed to select BTC coins for asset burn")?;
227
228            for coin in btc_coins {
229                if selected_outpoints.insert(coin.outpoint) {
230                    selected.push(coin);
231                }
232            }
233        }
234
235        let burn_inputs = self.build_vtxo_inputs(selected, &script_pubkey_to_vtxo_map)?;
236        let (own_address, _) = self.get_offchain_address()?;
237        let (change_address, change_address_vtxo) = self.get_offchain_address()?;
238
239        let offchain = build_asset_burn_transactions(
240            &own_address,
241            &change_address,
242            &burn_inputs,
243            &server_info,
244            asset_id,
245            amount,
246        )
247        .map_err(Error::from)
248        .context("failed to build asset burn transactions")?;
249
250        let pending_tx = self
251            .submit_built_offchain_send(
252                offchain.ark_tx,
253                offchain.checkpoint_txs,
254                change_address_vtxo.owner_pk(),
255            )
256            .await
257            .context("failed to submit asset burn transaction")?;
258
259        let ark_txid = pending_tx.ark_txid;
260        self.sign_and_finalize_pending_tx(pending_tx)
261            .await
262            .context("failed to finalize asset burn transaction")?;
263
264        Ok(ark_txid)
265    }
266
267    async fn spendable_virtual_vtxos(
268        &self,
269    ) -> Result<(Vec<VirtualTxOutPoint>, HashMap<ScriptBuf, ark_core::Vtxo>), Error> {
270        let (vtxo_list, script_pubkey_to_vtxo_map) =
271            self.list_vtxos().await.context("failed to list VTXOs")?;
272
273        let now = crate::utils::unix_now()?;
274        let server_info = self.server_info()?;
275        let spendable = vtxo_list
276            .spendable_offchain_at(&server_info, now, |script| {
277                script_pubkey_to_vtxo_map
278                    .get(script)
279                    .map(|vtxo| vtxo.server_pk())
280            })
281            .map(|vtxo| VirtualTxOutPoint {
282                outpoint: vtxo.outpoint,
283                script_pubkey: vtxo.script.clone(),
284                expire_at: vtxo.expires_at,
285                amount: vtxo.amount,
286                assets: vtxo.assets.clone(),
287            })
288            .collect();
289
290        Ok((spendable, script_pubkey_to_vtxo_map))
291    }
292}