use serde::{Deserialize, Serialize};
#[cfg(any(target_arch = "wasm32", test))]
use std::future::Future;
use wasm_bindgen::prelude::*;
#[macro_use]
mod logging;
pub mod builder;
pub mod crypto;
pub mod error;
pub mod history;
pub mod keys;
pub mod listing;
pub mod listing_nostr;
#[cfg(not(target_arch = "wasm32"))]
pub mod listing_relay;
pub mod offer;
pub mod offer_accept;
pub mod offer_create;
pub mod offer_nostr;
#[cfg(not(target_arch = "wasm32"))]
pub mod offer_relay;
pub mod ordinals;
pub mod sign_intent;
pub use builder::{
Account, AddressScheme, CreatePsbtRequest, CreatePsbtTransportRequest, DerivationMode,
DiscoveryAccountPlan, DiscoveryContext, PaymentAddressType, ProfileMode, ScanPolicy, Seed64,
SignOptions, SyncRequestType, SyncSleeper, WalletBuilder, WalletKind, ZincBalance,
ZincPersistence, ZincSyncRequest, ZincWallet,
};
pub use error::{ZincError, ZincResult};
pub use history::TxItem;
pub use keys::{taproot_descriptors, DescriptorPair, ZincMnemonic};
pub use listing::{
create_listing, create_listing_purchase, finalize_listing_purchase, finalize_listing_sale,
passthrough_script_pubkey, passthrough_tapscript, prepare_listing_sale_signature,
sign_listing_coordinator_psbt, sign_listing_sale_psbt, CreateListingPurchaseRequest,
CreateListingPurchaseResultV1, CreateListingRequest, CreateListingResultV1,
FinalizeListingPurchaseRequest, FinalizeListingPurchaseResultV1, FinalizedListingSaleResultV1,
ListingBuyerFundingInput, ListingEnvelopeV1, ListingSaleSigningPlanV1, LISTING_SALE_SIGHASH_U8,
};
pub use listing_nostr::{NostrListingEvent, LISTING_EVENT_KIND};
#[cfg(not(target_arch = "wasm32"))]
pub use listing_relay::{
ListingRelayPublishResult, ListingRelayQueryOptions, NostrListingRelayClient,
};
pub use offer::OfferEnvelopeV1;
pub use offer_accept::{prepare_offer_acceptance, OfferAcceptancePlanV1};
pub use offer_create::{CreateOfferRequest, OfferCreateResultV1};
pub use offer_nostr::{NostrOfferEvent, OFFER_EVENT_KIND};
#[cfg(not(target_arch = "wasm32"))]
pub use offer_relay::{NostrRelayClient, RelayPublishResult, RelayQueryOptions};
pub use ordinals::client::OrdClient;
pub use ordinals::types::{Inscription, RuneBalance, Satpoint};
pub use sign_intent::{
build_pairing_transport_event, build_signed_pairing_ack, build_signed_pairing_ack_with_granted,
build_signed_pairing_complete_receipt, build_signed_sign_intent_approved_receipt,
build_signed_sign_intent_rejection_receipt, decode_pairing_ack_envelope_event,
decode_pairing_ack_envelope_event_with_secret,
decode_pairing_transport_event_content_with_secret,
decode_signed_pairing_complete_receipt_event,
decode_signed_pairing_complete_receipt_event_with_secret, decode_signed_sign_intent_event,
decode_signed_sign_intent_event_with_secret, decode_signed_sign_intent_receipt_event,
decode_signed_sign_intent_receipt_event_with_secret, decrypt_pairing_transport_content,
encrypt_pairing_transport_content, generate_secret_key_hex, pairing_tag_hash_hex,
pairing_transport_tags, pubkey_hex_from_secret_key, verify_pairing_approval,
verify_sign_seller_input_scope, verify_sign_seller_input_scope_json, BuildBuyerOfferIntentV1,
CapabilityPolicyV1, NostrTransportEventV1, PairingAckDecisionV1, PairingAckEnvelopeV1,
PairingAckV1, PairingCompleteReceiptStatusV1, PairingCompleteReceiptV1, PairingLinkApprovalV1,
PairingRequestV1, SignIntentActionV1, SignIntentPayloadV1, SignIntentReceiptStatusV1,
SignIntentReceiptV1, SignIntentV1, SignSellerInputIntentV1, SignSellerInputScopeV1,
SignedPairingAckV1, SignedPairingCompleteReceiptV1, SignedPairingRequestV1,
SignedSignIntentReceiptV1, SignedSignIntentV1, NOSTR_PAIRING_ACK_TYPE_TAG_VALUE,
NOSTR_PAIRING_COMPLETE_RECEIPT_TYPE_TAG_VALUE, NOSTR_SIGN_INTENT_APP_TAG_VALUE,
NOSTR_SIGN_INTENT_RECEIPT_TYPE_TAG_VALUE, NOSTR_SIGN_INTENT_TYPE_TAG_VALUE, NOSTR_TAG_APP_KEY,
NOSTR_TAG_PAIRING_HASH_KEY, NOSTR_TAG_RECIPIENT_PUBKEY_KEY, NOSTR_TAG_TYPE_KEY,
PAIRING_TRANSPORT_EVENT_KIND,
};
pub use bdk_wallet::bitcoin::Network;
use bdk_wallet::KeychainKind;
#[doc(hidden)]
pub struct WalletResult {
pub phrase: String,
pub words: Vec<String>,
}
#[doc(hidden)]
pub fn generate_wallet_internal(word_count: u8) -> Result<WalletResult, ZincError> {
let mnemonic = ZincMnemonic::generate(word_count)?;
Ok(WalletResult {
phrase: mnemonic.phrase(),
words: mnemonic.words(),
})
}
#[doc(hidden)]
pub fn validate_mnemonic_internal(phrase: &str) -> bool {
ZincMnemonic::parse(phrase).is_ok()
}
#[doc(hidden)]
pub fn derive_address_internal(phrase: &str, network: Network) -> Result<String, ZincError> {
let mnemonic = ZincMnemonic::parse(phrase)?;
let descriptors = crate::keys::taproot_descriptors(&mnemonic, network)?;
let wallet = bdk_wallet::Wallet::create(
descriptors.external.to_string(),
descriptors.internal.to_string(),
)
.network(network)
.create_wallet_no_persist()
.map_err(|e| ZincError::BdkError(e.to_string()))?;
let address = wallet.peek_address(KeychainKind::External, 0);
Ok(address.address.to_string())
}
#[doc(hidden)]
pub fn encrypt_wallet_internal(mnemonic: &str, password: &str) -> Result<String, ZincError> {
let m = ZincMnemonic::parse(mnemonic)?;
let encrypted = crypto::encrypt_seed(m.phrase().as_bytes(), password)?;
serde_json::to_string(&encrypted).map_err(|e| ZincError::SerializationError(e.to_string()))
}
#[doc(hidden)]
pub fn decrypt_wallet_internal(
encrypted_json: &str,
password: &str,
) -> Result<WalletResult, ZincError> {
let encrypted: crypto::EncryptedWallet = serde_json::from_str(encrypted_json)
.map_err(|e| ZincError::SerializationError(e.to_string()))?;
let plaintext = crypto::decrypt_seed(&encrypted, password)?;
let phrase = zeroize::Zeroizing::new(
String::from_utf8(plaintext.to_vec())
.map_err(|e| ZincError::SerializationError(format!("Invalid UTF-8: {e}")))?,
);
let mnemonic = ZincMnemonic::parse(&phrase)?;
Ok(WalletResult {
phrase: mnemonic.phrase(),
words: mnemonic.words(),
})
}
#[doc(hidden)]
pub fn encrypt_secret_internal(secret: &str, password: &str) -> Result<String, ZincError> {
let encrypted = crypto::encrypt_seed(secret.as_bytes(), password)?;
serde_json::to_string(&encrypted).map_err(|e| ZincError::SerializationError(e.to_string()))
}
#[doc(hidden)]
pub fn decrypt_secret_internal(encrypted_json: &str, password: &str) -> Result<String, ZincError> {
let encrypted: crypto::EncryptedWallet = serde_json::from_str(encrypted_json)
.map_err(|e| ZincError::SerializationError(e.to_string()))?;
let plaintext = crypto::decrypt_seed(&encrypted, password)?;
String::from_utf8(plaintext.to_vec())
.map_err(|e| ZincError::SerializationError(format!("Invalid UTF-8: {e}")))
}
use std::sync::Once;
static INIT: Once = Once::new();
const LOG_TARGET_WASM: &str = "zinc_core::wasm";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InscriptionPreview {
pub id: String,
pub content_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountDiscoveryReport {
pub index: u32,
pub path_type: String, pub primary_address: String,
pub spendable_sats: u64,
pub postage_sats: u64,
pub inscription_count: u32,
pub inscriptions: Vec<InscriptionPreview>,
pub taproot_external: String,
pub taproot_internal: String,
pub payment_external: Option<String>,
pub payment_internal: Option<String>,
}
#[cfg(any(target_arch = "wasm32", test))]
#[allow(dead_code)]
async fn probe_single_account(
client: &reqwest::Client,
esplora_url: &str,
ord_url: &str,
network: Network,
fingerprint_hex: &str,
index: u32,
taproot_xpub: &str,
payment_xpub: Option<&String>,
path_type: &str,
) -> Option<AccountDiscoveryReport> {
let (t_ext, t_int) = if path_type == "legacy" {
let path = format!(
"86'/{}'/0'",
if network == Network::Bitcoin { 0 } else { 1 }
);
(
format!("tr([{fingerprint_hex}/{path}]{taproot_xpub}/0/{index})"),
format!("tr([{fingerprint_hex}/{path}]{taproot_xpub}/1/{index})"),
)
} else {
let path = format!(
"86'/{}'/{index}'",
if network == Network::Bitcoin { 0 } else { 1 }
);
(
format!("tr([{fingerprint_hex}/{path}]{taproot_xpub}/0/*)"),
format!("tr([{fingerprint_hex}/{path}]{taproot_xpub}/1/*)"),
)
};
let (p_ext, p_int) = if let Some(xpub) = payment_xpub {
if path_type == "legacy" {
let path = format!(
"84'/{}'/0'",
if network == Network::Bitcoin { 0 } else { 1 }
);
(
Some(format!("wpkh([{fingerprint_hex}/{path}]{xpub}/0/{index})")),
Some(format!("wpkh([{fingerprint_hex}/{path}]{xpub}/1/{index})")),
)
} else {
let path = format!(
"84'/{}'/{index}'",
if network == Network::Bitcoin { 0 } else { 1 }
);
(
Some(format!("wpkh([{fingerprint_hex}/{path}]{xpub}/0/*)")),
Some(format!("wpkh([{fingerprint_hex}/{path}]{xpub}/1/*)")),
)
}
} else {
(None, None)
};
if path_type == "standard" && index > 0 {
return None;
}
let builder = WalletBuilder::new(network);
let wallet = match builder.build_hardware(
fingerprint_hex,
t_ext.clone(),
t_int.clone(),
p_ext.clone(),
p_int.clone(),
) {
Ok(w) => w,
Err(_) => return None,
};
let vault_addr = wallet.peek_taproot_address(0).to_string();
let payment_addr = wallet.peek_payment_address(0).map(|a| a.to_string());
let mut spendable_sats = 0u64;
let mut postage_sats = 0u64;
let mut total_inscriptions = 0u32;
let mut inscriptions = Vec::new();
let mut has_activity = false;
if let Some((bal, ins_list, active)) =
fetch_addr_stats(client, esplora_url, ord_url, &vault_addr).await
{
postage_sats += bal;
total_inscriptions += ins_list.len() as u32;
inscriptions.extend(ins_list);
if active {
has_activity = true;
}
}
if let Some(p_addr) = payment_addr {
if let Some((bal, ins_list, active)) =
fetch_addr_stats(client, esplora_url, ord_url, &p_addr).await
{
spendable_sats += bal;
total_inscriptions += ins_list.len() as u32;
inscriptions.extend(ins_list);
if active {
has_activity = true;
}
}
}
if has_activity || index == 0 {
Some(AccountDiscoveryReport {
index,
path_type: path_type.to_string(),
primary_address: vault_addr,
spendable_sats,
postage_sats,
inscription_count: total_inscriptions,
inscriptions,
taproot_external: t_ext,
taproot_internal: t_int,
payment_external: p_ext,
payment_internal: p_int,
})
} else {
None
}
}
#[cfg(any(target_arch = "wasm32", test))]
#[allow(dead_code)]
async fn fetch_addr_stats(
client: &reqwest::Client,
esplora_url: &str,
ord_url: &str,
address: &str,
) -> Option<(u64, Vec<InscriptionPreview>, bool)> {
let url = format!("{}/address/{}", esplora_url, address);
let mut balance = 0u64;
let mut has_history = false;
if let Ok(resp) = client.get(&url).send().await {
if let Ok(json) = resp.json::<serde_json::Value>().await {
let chain_stats = &json["chain_stats"];
let mempool_stats = &json["mempool_stats"];
let chain_funded = chain_stats["funded_txo_sum"].as_u64().unwrap_or(0);
let chain_spent = chain_stats["spent_txo_sum"].as_u64().unwrap_or(0);
let chain_sats = chain_funded.saturating_sub(chain_spent);
let mempool_funded = mempool_stats["funded_txo_sum"].as_u64().unwrap_or(0);
let mempool_spent = mempool_stats["spent_txo_sum"].as_u64().unwrap_or(0);
let mempool_sats = mempool_funded.saturating_sub(mempool_spent);
balance = chain_sats.saturating_add(mempool_sats);
let tx_count = chain_stats["tx_count"].as_u64().unwrap_or(0)
+ mempool_stats["tx_count"].as_u64().unwrap_or(0);
if tx_count > 0 {
has_history = true;
}
}
}
let mut inscriptions = Vec::new();
let ord_addr_url = format!("{}/address/{}", ord_url, address);
if let Ok(resp) = client
.get(&ord_addr_url)
.header("Accept", "application/json")
.send()
.await
{
if let Ok(json) = resp.json::<serde_json::Value>().await {
let list_opt = if let Some(list) = json["inscriptions"].as_array() {
Some(list)
} else if let Some(list) = json.as_array() {
Some(list)
} else {
None
};
if let Some(list) = list_opt {
for item in list.iter().take(10) {
if let Some(id) = item.as_str() {
inscriptions.push(InscriptionPreview {
id: id.to_string(),
content_type: None,
});
} else if let Some(obj) = item.as_object() {
let id_opt = obj
.get("id")
.or(obj.get("inscription_id"))
.or(obj.get("inscriptionId"));
if let Some(id) = id_opt.and_then(|v| v.as_str()) {
let ct = obj
.get("content_type")
.or(obj.get("contentType"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
inscriptions.push(InscriptionPreview {
id: id.to_string(),
content_type: ct,
});
}
}
}
}
}
}
if has_history || balance > 0 || !inscriptions.is_empty() {
Some((balance, inscriptions, true))
} else {
Some((0, Vec::new(), false))
}
}
#[cfg(any(target_arch = "wasm32", test))]
async fn first_active_receive_index_from_scan<F, Fut>(
address_scan_depth: u32,
mut has_activity_at: F,
) -> Option<u32>
where
F: FnMut(u32) -> Fut,
Fut: Future<Output = bool>,
{
let depth = address_scan_depth.max(1);
const ADDRESS_SCAN_BATCH_SIZE: u32 = 20;
let mut batch_start = 0;
while batch_start < depth {
let batch_end = (batch_start + ADDRESS_SCAN_BATCH_SIZE).min(depth);
let mut checks = Vec::with_capacity((batch_end - batch_start) as usize);
for address_index in batch_start..batch_end {
checks.push(has_activity_at(address_index));
}
let results = futures_util::future::join_all(checks).await;
if let Some(offset) = results.iter().position(|is_active| *is_active) {
return Some(batch_start + offset as u32);
}
batch_start = batch_end;
}
None
}
#[cfg(any(target_arch = "wasm32", test))]
async fn account_is_active_from_receive_scan<F, Fut>(
address_scan_depth: u32,
has_activity_at: F,
) -> bool
where
F: FnMut(u32) -> Fut,
Fut: Future<Output = bool>,
{
first_active_receive_index_from_scan(address_scan_depth, has_activity_at)
.await
.is_some()
}
#[cfg(target_arch = "wasm32")]
#[derive(Clone, Copy)]
enum ImportDiscoveryBranch {
Taproot,
Payment,
}
#[cfg(target_arch = "wasm32")]
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ImportPathAccountHit {
index: u32,
first_active_address_index: u32,
}
#[cfg(target_arch = "wasm32")]
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ImportPathDiscoveryResponse {
active_accounts: Vec<ImportPathAccountHit>,
}
#[wasm_bindgen(start)]
pub fn init() {
zinc_log_trace!(target: LOG_TARGET_WASM, "init invoked");
INIT.call_once(|| {
console_error_panic_hook::set_once();
zinc_log_info!(target: LOG_TARGET_WASM, "WASM module initialized");
});
}
#[wasm_bindgen]
pub fn set_log_level(level: &str) -> Result<(), JsValue> {
let Some(parsed) = logging::parse_level(level) else {
zinc_log_warn!(
target: LOG_TARGET_WASM,
"rejected invalid log level request ({})",
logging::redacted_field("requested_level", level)
);
zinc_log_error!(
target: LOG_TARGET_WASM,
"invalid runtime log level request rejected"
);
return Err(JsValue::from_str(
"Invalid log level. Use one of: off, error, warn, info, debug, trace",
));
};
logging::set_log_level(parsed);
zinc_log_info!(
target: LOG_TARGET_WASM,
"runtime log level updated to {}",
parsed.as_str()
);
Ok(())
}
#[wasm_bindgen]
pub fn set_logging_enabled(enabled: bool) {
logging::set_logging_enabled(enabled);
zinc_log_info!(
target: LOG_TARGET_WASM,
"runtime logging {}",
if enabled { "enabled" } else { "disabled" }
);
}
#[wasm_bindgen]
pub fn get_log_level() -> String {
logging::get_log_level().as_str().to_string()
}
#[wasm_bindgen]
pub fn generate_wallet(word_count: u8) -> Result<JsValue, JsValue> {
let result =
generate_wallet_internal(word_count).map_err(|e| JsValue::from_str(&e.to_string()))?;
let js_result = serde_json::json!({
"words": result.words,
"phrase": result.phrase,
});
serde_wasm_bindgen::to_value(&js_result).map_err(|e| JsValue::from_str(&e.to_string()))
}
#[wasm_bindgen]
pub fn validate_mnemonic(phrase: &str) -> bool {
validate_mnemonic_internal(phrase)
}
#[wasm_bindgen]
pub fn derive_address(phrase: &str, network: &str) -> Result<String, JsValue> {
let network = match network {
"mainnet" | "bitcoin" => Network::Bitcoin,
"signet" => Network::Signet,
"testnet" => Network::Testnet,
"regtest" => Network::Regtest,
_ => return Err(JsValue::from_str("Invalid network")),
};
derive_address_internal(phrase, network).map_err(|e| JsValue::from_str(&e.to_string()))
}
#[wasm_bindgen]
pub fn encrypt_wallet(mnemonic: &str, password: &str) -> Result<String, JsValue> {
encrypt_wallet_internal(mnemonic, password).map_err(|e| JsValue::from_str(&e.to_string()))
}
#[wasm_bindgen]
pub fn encrypt_secret(secret: &str, password: &str) -> Result<String, JsValue> {
encrypt_secret_internal(secret, password).map_err(|e| JsValue::from_str(&e.to_string()))
}
#[derive(Serialize)]
pub struct DecryptResponse {
pub success: bool,
pub phrase: String,
pub words: Vec<String>,
}
#[wasm_bindgen]
pub fn decrypt_wallet(encrypted_json: &str, password: &str) -> Result<JsValue, JsValue> {
zinc_log_debug!(target: LOG_TARGET_WASM,
"decrypt_wallet called. Encrypted length: {}, Password length: {}",
encrypted_json.len(),
password.len()
);
let result = match decrypt_wallet_internal(encrypted_json, password) {
Ok(res) => {
zinc_log_debug!(target: LOG_TARGET_WASM,
"Internal decryption success. Phrase length: {}",
res.phrase.len()
);
res
}
Err(e) => {
zinc_log_debug!(target: LOG_TARGET_WASM, "Internal decryption failed: {:?}", e);
return Err(JsValue::from_str(&e.to_string()));
}
};
let response = DecryptResponse {
success: true,
phrase: result.phrase,
words: result.words,
};
zinc_log_debug!(target: LOG_TARGET_WASM, "Serializing response...");
match serde_wasm_bindgen::to_value(&response) {
Ok(val) => {
zinc_log_debug!(target: LOG_TARGET_WASM, "Serialization success.");
Ok(val)
}
Err(e) => {
zinc_log_debug!(target: LOG_TARGET_WASM, "Serialization failed: {:?}", e);
Err(JsValue::from_str(&e.to_string()))
}
}
}
#[wasm_bindgen]
pub fn decrypt_secret(encrypted_json: &str, password: &str) -> Result<String, JsValue> {
decrypt_secret_internal(encrypted_json, password).map_err(|e| JsValue::from_str(&e.to_string()))
}
#[wasm_bindgen]
pub fn validate_signed_pairing_request_json(payload_json: &str) -> Result<String, JsValue> {
let pairing_id = crate::sign_intent::validate_signed_pairing_request_json(payload_json)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(serde_json::json!({
"ok": true,
"pairingId": pairing_id
})
.to_string())
}
#[wasm_bindgen]
pub fn validate_signed_pairing_ack_json(payload_json: &str) -> Result<String, JsValue> {
let ack_id = crate::sign_intent::validate_signed_pairing_ack_json(payload_json)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(serde_json::json!({
"ok": true,
"ackId": ack_id
})
.to_string())
}
#[wasm_bindgen]
pub fn validate_pairing_ack_envelope_json(payload_json: &str) -> Result<String, JsValue> {
let envelope_id = crate::sign_intent::validate_pairing_ack_envelope_json(payload_json)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(serde_json::json!({
"ok": true,
"envelopeId": envelope_id
})
.to_string())
}
#[wasm_bindgen]
pub fn validate_signed_pairing_complete_receipt_json(
payload_json: &str,
) -> Result<String, JsValue> {
let receipt_id =
crate::sign_intent::validate_signed_pairing_complete_receipt_json(payload_json)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(serde_json::json!({
"ok": true,
"receiptId": receipt_id
})
.to_string())
}
#[wasm_bindgen]
pub fn validate_nostr_transport_event_json(payload_json: &str) -> Result<String, JsValue> {
let event_id = crate::sign_intent::validate_nostr_transport_event_json(payload_json)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(serde_json::json!({
"ok": true,
"eventId": event_id
})
.to_string())
}
#[wasm_bindgen]
pub fn validate_signed_sign_intent_json(payload_json: &str) -> Result<String, JsValue> {
let intent_id = crate::sign_intent::validate_signed_sign_intent_json(payload_json)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(serde_json::json!({
"ok": true,
"intentId": intent_id
})
.to_string())
}
#[wasm_bindgen]
pub fn validate_signed_sign_intent_receipt_json(payload_json: &str) -> Result<String, JsValue> {
let receipt_id = crate::sign_intent::validate_signed_sign_intent_receipt_json(payload_json)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(serde_json::json!({
"ok": true,
"receiptId": receipt_id
})
.to_string())
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateListingTransportRequest {
pub seller_pubkey_hex: String,
pub coordinator_pubkey_hex: String,
pub network: String,
pub inscription_id: String,
pub seller_outpoint: String,
pub seller_prevout_value_sats: u64,
pub seller_prevout_script_pubkey_hex: String,
pub seller_payout_script_pubkey_hex: String,
pub recovery_script_pubkey_hex: String,
pub ask_sats: u64,
pub fee_rate_sat_vb: u64,
pub created_at_unix: i64,
pub expires_at_unix: i64,
pub nonce: u64,
}
impl TryFrom<CreateListingTransportRequest> for listing::CreateListingRequest {
type Error = ZincError;
fn try_from(value: CreateListingTransportRequest) -> Result<Self, Self::Error> {
let seller_outpoint = value.seller_outpoint.parse().map_err(|e| {
ZincError::OfferError(format!(
"invalid seller_outpoint `{}`: {e}",
value.seller_outpoint
))
})?;
let script_from_hex = |label: &str, hex_script: &str| {
let bytes = hex::decode(hex_script)
.map_err(|e| ZincError::OfferError(format!("invalid {label}: {e}")))?;
Ok(bitcoin::ScriptBuf::from_bytes(bytes))
};
Ok(Self {
seller_pubkey_hex: value.seller_pubkey_hex,
coordinator_pubkey_hex: value.coordinator_pubkey_hex,
network: value.network,
inscription_id: value.inscription_id,
seller_outpoint,
seller_prevout: bitcoin::TxOut {
value: bitcoin::Amount::from_sat(value.seller_prevout_value_sats),
script_pubkey: script_from_hex(
"seller_prevout_script_pubkey_hex",
&value.seller_prevout_script_pubkey_hex,
)?,
},
seller_payout_script_pubkey: script_from_hex(
"seller_payout_script_pubkey_hex",
&value.seller_payout_script_pubkey_hex,
)?,
recovery_script_pubkey: script_from_hex(
"recovery_script_pubkey_hex",
&value.recovery_script_pubkey_hex,
)?,
ask_sats: value.ask_sats,
fee_rate_sat_vb: value.fee_rate_sat_vb,
created_at_unix: value.created_at_unix,
expires_at_unix: value.expires_at_unix,
nonce: value.nonce,
})
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListingEnvelopeTransportRequest {
pub listing: listing::ListingEnvelopeV1,
pub now_unix: i64,
}
#[wasm_bindgen(js_name = createListing)]
pub fn create_listing_js(request: JsValue) -> Result<JsValue, JsValue> {
let transport: CreateListingTransportRequest = serde_wasm_bindgen::from_value(request)
.map_err(|e| JsValue::from_str(&format!("Invalid listing request: {e}")))?;
let request = listing::CreateListingRequest::try_from(transport)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let created =
listing::create_listing(&request).map_err(|e| JsValue::from_str(&e.to_string()))?;
serde_wasm_bindgen::to_value(&created)
.map_err(|e| JsValue::from_str(&format!("failed to serialize listing result: {e}")))
}
#[wasm_bindgen(js_name = prepareListingSaleSignature)]
pub fn prepare_listing_sale_signature_js(request: JsValue) -> Result<JsValue, JsValue> {
let request: ListingEnvelopeTransportRequest = serde_wasm_bindgen::from_value(request)
.map_err(|e| JsValue::from_str(&format!("Invalid listing request: {e}")))?;
let plan = listing::prepare_listing_sale_signature(&request.listing, request.now_unix)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
serde_wasm_bindgen::to_value(&plan)
.map_err(|e| JsValue::from_str(&format!("failed to serialize listing plan: {e}")))
}
#[wasm_bindgen(js_name = signListingSalePsbt)]
pub fn sign_listing_sale_psbt_js(
listing: JsValue,
seller_secret_key_hex: &str,
now_unix: i64,
) -> Result<String, JsValue> {
let listing: listing::ListingEnvelopeV1 = serde_wasm_bindgen::from_value(listing)
.map_err(|e| JsValue::from_str(&format!("Invalid listing: {e}")))?;
listing::sign_listing_sale_psbt(&listing, seller_secret_key_hex, now_unix)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
#[wasm_bindgen(js_name = signListingCoordinatorPsbt)]
pub fn sign_listing_coordinator_psbt_js(
listing: JsValue,
coordinator_secret_key_hex: &str,
now_unix: i64,
) -> Result<String, JsValue> {
let listing: listing::ListingEnvelopeV1 = serde_wasm_bindgen::from_value(listing)
.map_err(|e| JsValue::from_str(&format!("Invalid listing: {e}")))?;
listing::sign_listing_coordinator_psbt(&listing, coordinator_secret_key_hex, now_unix)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
#[wasm_bindgen(js_name = finalizeListingSale)]
pub fn finalize_listing_sale_js(request: JsValue) -> Result<JsValue, JsValue> {
let request: ListingEnvelopeTransportRequest = serde_wasm_bindgen::from_value(request)
.map_err(|e| JsValue::from_str(&format!("Invalid listing request: {e}")))?;
let finalized = listing::finalize_listing_sale(&request.listing, request.now_unix)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
serde_wasm_bindgen::to_value(&finalized)
.map_err(|e| JsValue::from_str(&format!("failed to serialize finalized sale: {e}")))
}
#[wasm_bindgen]
pub fn verify_pairing_approval_json(
signed_request_json: &str,
signed_ack_json: &str,
now_unix: i64,
) -> Result<String, JsValue> {
let approval = crate::sign_intent::verify_pairing_approval_json(
signed_request_json,
signed_ack_json,
now_unix,
)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(serde_json::json!({
"ok": true,
"approval": approval
})
.to_string())
}
use std::cell::{Cell, RefCell};
use std::rc::Rc;
const VITALITY_MAGIC: u32 = 0x005a_11ad;
#[cfg(target_arch = "wasm32")]
const SYNC_STALE_ERROR: &str = "Wallet state changed during sync; stale result discarded";
#[cfg(target_arch = "wasm32")]
const ORD_SYNC_STALE_ERROR: &str =
"Wallet state changed during ordinals sync; stale result discarded";
#[derive(Clone, Copy)]
struct WalletState {
network: Network,
scheme: AddressScheme,
derivation_mode: DerivationMode,
payment_address_type: PaymentAddressType,
account_index: u32,
}
#[derive(Clone)]
enum WalletMaterial {
MnemonicPhrase(String),
WatchAddress(String),
Hardware { _fingerprint: [u8; 4] },
}
#[wasm_bindgen]
pub struct ZincWasmWallet {
inner: Rc<RefCell<ZincWallet>>,
material: WalletMaterial,
state: Cell<WalletState>,
vitality: u32,
}
#[wasm_bindgen]
impl ZincWasmWallet {
fn parse_network_label(network: &str) -> Result<Network, JsValue> {
match network {
"mainnet" | "bitcoin" => Ok(Network::Bitcoin),
"signet" => Ok(Network::Signet),
"testnet" => Ok(Network::Testnet),
"regtest" => Ok(Network::Regtest),
_ => Err(JsValue::from_str("Invalid network")),
}
}
fn build_seed_wallet(
network: Network,
phrase: &str,
scheme: AddressScheme,
derivation_mode: DerivationMode,
payment_address_type: PaymentAddressType,
account_index: u32,
persistence_json: Option<&str>,
) -> Result<ZincWallet, JsValue> {
let mnemonic =
ZincMnemonic::parse(phrase).map_err(|e| JsValue::from_str(&e.to_string()))?;
let mut builder = WalletBuilder::from_mnemonic(network, &mnemonic);
builder = builder
.with_scheme(scheme)
.with_derivation_mode(derivation_mode)
.with_payment_address_type(payment_address_type)
.with_account_index(account_index);
if let Some(json) = persistence_json {
builder = builder
.with_persistence(json)
.map_err(|e| JsValue::from_str(&e))?;
}
builder.build().map_err(|e| JsValue::from_str(&e))
}
#[wasm_bindgen]
pub fn new_hardware(
network: &str,
fingerprint_hex: &str,
taproot_external_desc: &str,
taproot_internal_desc: &str,
payment_external_desc: Option<String>,
payment_internal_desc: Option<String>,
account_index: u32,
persistence_json: Option<String>,
) -> Result<ZincWasmWallet, JsValue> {
let network_enum = match network {
"mainnet" | "bitcoin" => Network::Bitcoin,
"signet" => Network::Signet,
"testnet" => Network::Testnet,
"regtest" => Network::Regtest,
_ => return Err(JsValue::from_str("Invalid network")),
};
let mut fingerprint = [0u8; 4];
if let Ok(fp_bytes) = hex::decode(fingerprint_hex) {
if fp_bytes.len() == 4 {
fingerprint.copy_from_slice(&fp_bytes);
}
}
let persistence = if let Some(json) = persistence_json {
Some(
serde_json::from_str::<ZincPersistence>(&json)
.map_err(|e| JsValue::from_str(&e.to_string()))?,
)
} else {
None
};
let mut builder = WalletBuilder::new(network_enum).with_account_index(account_index);
if let Some(p) = persistence {
builder = builder.persistence(p);
}
let wallet = builder
.build_hardware(
fingerprint_hex,
taproot_external_desc.to_string(),
taproot_internal_desc.to_string(),
payment_external_desc.clone(),
payment_internal_desc.clone(),
)
.map_err(|e| JsValue::from_str(&e))?;
zinc_log_debug!(
target: LOG_TARGET_WASM,
"new_hardware - network: {:?}, fp: {:?}, tap_ext: {}, pay_ext: {:?}",
network_enum,
fingerprint,
taproot_external_desc,
payment_external_desc
);
Ok(ZincWasmWallet {
inner: std::rc::Rc::new(std::cell::RefCell::new(wallet)),
material: WalletMaterial::Hardware {
_fingerprint: fingerprint,
},
state: std::cell::Cell::new(WalletState {
network: network_enum,
scheme: AddressScheme::Dual,
derivation_mode: DerivationMode::Account,
payment_address_type: PaymentAddressType::NativeSegwit,
account_index,
}),
vitality: VITALITY_MAGIC,
})
}
fn build_watch_wallet(
network: Network,
watch_address: &str,
scheme: AddressScheme,
derivation_mode: DerivationMode,
payment_address_type: PaymentAddressType,
account_index: u32,
persistence_json: Option<&str>,
) -> Result<ZincWallet, JsValue> {
let mut builder = WalletBuilder::from_watch_only(network)
.with_watch_address(watch_address)
.map_err(|e| JsValue::from_str(&e))?;
builder = builder
.with_scheme(scheme)
.with_derivation_mode(derivation_mode)
.with_payment_address_type(payment_address_type)
.with_account_index(account_index);
if let Some(json) = persistence_json {
builder = builder
.with_persistence(json)
.map_err(|e| JsValue::from_str(&e))?;
}
builder.build().map_err(|e| JsValue::from_str(&e))
}
fn build_wallet_for_state(
material: &WalletMaterial,
next_state: WalletState,
) -> Result<ZincWallet, JsValue> {
match material {
WalletMaterial::MnemonicPhrase(phrase) => Self::build_seed_wallet(
next_state.network,
phrase,
next_state.scheme,
next_state.derivation_mode,
next_state.payment_address_type,
next_state.account_index,
None,
),
WalletMaterial::WatchAddress(address) => Self::build_watch_wallet(
next_state.network,
address,
next_state.scheme,
next_state.derivation_mode,
next_state.payment_address_type,
next_state.account_index,
None,
),
WalletMaterial::Hardware { .. } => Err(JsValue::from_str(
"Dynamic state updates are not yet supported for hardware wallets in this handle",
)),
}
}
#[allow(dead_code)]
fn seed_phrase(&self) -> Result<&str, JsValue> {
match &self.material {
WalletMaterial::MnemonicPhrase(phrase) => Ok(phrase.as_str()),
WalletMaterial::WatchAddress(_) | WalletMaterial::Hardware { .. } => {
Err(JsValue::from_str(
"Operation is unavailable for watch-address and hardware profiles",
))
}
}
}
#[wasm_bindgen(constructor)]
#[allow(clippy::needless_pass_by_value)]
pub fn new(
network: &str,
phrase: &str,
scheme_str: Option<String>,
persistence_json: Option<String>,
account_index: Option<u32>,
) -> Result<ZincWasmWallet, JsValue> {
let network_enum = Self::parse_network_label(network)?;
let scheme = match scheme_str.as_deref() {
Some("dual") => AddressScheme::Dual,
_ => AddressScheme::Unified,
};
let active_index = account_index.unwrap_or(0);
let wallet = Self::build_seed_wallet(
network_enum,
phrase,
scheme,
DerivationMode::Account,
PaymentAddressType::NativeSegwit,
active_index,
persistence_json.as_deref(),
)?;
Ok(ZincWasmWallet {
inner: Rc::new(RefCell::new(wallet)),
material: WalletMaterial::MnemonicPhrase(phrase.to_string()),
state: Cell::new(WalletState {
network: network_enum,
scheme,
derivation_mode: DerivationMode::Account,
payment_address_type: PaymentAddressType::NativeSegwit,
account_index: active_index,
}),
vitality: VITALITY_MAGIC,
})
}
#[wasm_bindgen]
pub fn new_encrypted(
network: &str,
encrypted_json: &str,
password: &str,
scheme_str: Option<String>,
persistence_json: Option<String>,
account_index: Option<u32>,
) -> Result<ZincWasmWallet, JsValue> {
let network_enum = Self::parse_network_label(network)?;
let scheme = match scheme_str.as_deref() {
Some("dual") => AddressScheme::Dual,
_ => AddressScheme::Unified,
};
let active_index = account_index.unwrap_or(0);
let result = decrypt_wallet_internal(encrypted_json, password)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let wallet = Self::build_seed_wallet(
network_enum,
&result.phrase,
scheme,
DerivationMode::Account,
PaymentAddressType::NativeSegwit,
active_index,
persistence_json.as_deref(),
)?;
Ok(ZincWasmWallet {
inner: Rc::new(RefCell::new(wallet)),
material: WalletMaterial::MnemonicPhrase(result.phrase),
state: Cell::new(WalletState {
network: network_enum,
scheme,
derivation_mode: DerivationMode::Account,
payment_address_type: PaymentAddressType::NativeSegwit,
account_index: active_index,
}),
vitality: VITALITY_MAGIC,
})
}
#[wasm_bindgen]
pub fn new_watch_address(
network: &str,
watch_address: &str,
persistence_json: Option<String>,
account_index: Option<u32>,
) -> Result<ZincWasmWallet, JsValue> {
let network_enum = Self::parse_network_label(network)?;
let active_index = account_index.unwrap_or(0);
let wallet = Self::build_watch_wallet(
network_enum,
watch_address,
AddressScheme::Unified,
DerivationMode::Account,
PaymentAddressType::NativeSegwit,
active_index,
persistence_json.as_deref(),
)?;
Ok(ZincWasmWallet {
inner: Rc::new(RefCell::new(wallet)),
material: WalletMaterial::WatchAddress(watch_address.to_string()),
state: Cell::new(WalletState {
network: network_enum,
scheme: AddressScheme::Unified,
derivation_mode: DerivationMode::Account,
payment_address_type: PaymentAddressType::NativeSegwit,
account_index: active_index,
}),
vitality: VITALITY_MAGIC,
})
}
fn check_vitality(&self) -> Result<(), JsValue> {
if self.vitality != VITALITY_MAGIC {
return Err(JsValue::from_str("Wallet handle is stale or corrupted due to context destruction. Please reload the extension."));
}
let sc = Rc::strong_count(&self.inner);
if sc == 0 {
return Err(JsValue::from_str(
"Internal error: Rc strong count is 0 (memory corruption). Please reload the extension."
));
}
Ok(())
}
fn state_snapshot(&self) -> WalletState {
self.state.get()
}
fn replace_wallet(
&self,
mut next_wallet: ZincWallet,
next_state: WalletState,
busy_context: &str,
) -> Result<(), JsValue> {
match self.inner.try_borrow_mut() {
Ok(mut inner) => {
next_wallet.account_generation = inner.account_generation().wrapping_add(1);
*inner = next_wallet;
self.state.set(next_state);
Ok(())
}
Err(e) => Err(JsValue::from_str(&format!(
"Wallet busy ({busy_context}): {e}"
))),
}
}
#[cfg(target_arch = "wasm32")]
fn generation_mismatch_error(
inner_rc: &Rc<RefCell<ZincWallet>>,
expected_generation: u64,
message: &str,
) -> Option<JsValue> {
match inner_rc.try_borrow() {
Ok(inner) if inner.account_generation() != expected_generation => {
Some(JsValue::from_str(message))
}
_ => None,
}
}
#[cfg(target_arch = "wasm32")]
fn clear_syncing_if_generation_matches(
inner_rc: &Rc<RefCell<ZincWallet>>,
expected_generation: u64,
) {
if let Ok(mut inner) = inner_rc.try_borrow_mut() {
if inner.account_generation() == expected_generation {
inner.is_syncing = false;
}
}
}
pub fn export_changeset(&self) -> Result<String, JsValue> {
self.check_vitality()?;
zinc_log_debug!(target: LOG_TARGET_WASM, "export_changeset called (wrapper)");
let res = match self.inner.try_borrow() {
Ok(inner) => inner
.export_changeset()
.map_err(|e| JsValue::from_str(&e))
.and_then(|p| {
serde_json::to_string(&p).map_err(|e| JsValue::from_str(&e.to_string()))
}),
Err(e) => {
zinc_log_debug!(target: LOG_TARGET_WASM, "export_changeset failed to borrow: {:?}", e);
Err(JsValue::from_str(&format!(
"Wallet busy (export_changeset): {e}"
)))
}
};
zinc_log_debug!(target: LOG_TARGET_WASM, "export_changeset finished (wrapper)");
res
}
pub fn set_scheme(&self, scheme_str: &str) -> Result<(), JsValue> {
self.check_vitality()?;
let new_scheme = match scheme_str {
"dual" => AddressScheme::Dual,
"unified" => AddressScheme::Unified,
_ => return Err(JsValue::from_str("Invalid scheme")),
};
let state = self.state_snapshot();
if state.scheme == new_scheme {
return Ok(());
}
let next_state = WalletState {
scheme: new_scheme,
..state
};
let next_wallet = Self::build_wallet_for_state(&self.material, next_state)?;
self.replace_wallet(next_wallet, next_state, "set_scheme")
}
pub fn set_active_account(&self, account_index: u32) -> Result<(), JsValue> {
self.check_vitality()?;
let state = self.state_snapshot();
if state.account_index == account_index {
return Ok(());
}
let next_state = WalletState {
account_index,
..state
};
let next_wallet = Self::build_wallet_for_state(&self.material, next_state)?;
self.replace_wallet(next_wallet, next_state, "set_active_account")
}
pub fn set_network(&self, network_str: &str) -> Result<(), JsValue> {
self.check_vitality()?;
let new_network = match network_str {
"mainnet" => Network::Bitcoin,
"testnet" => Network::Testnet,
"signet" => Network::Signet,
"regtest" => Network::Regtest,
_ => return Err(JsValue::from_str("Invalid network")),
};
let state = self.state_snapshot();
if state.network == new_network {
return Ok(());
}
let next_state = WalletState {
network: new_network,
..state
};
let next_wallet = Self::build_wallet_for_state(&self.material, next_state)?;
self.replace_wallet(next_wallet, next_state, "set_network")
}
pub fn set_derivation_mode(&self, mode_str: &str) -> Result<(), JsValue> {
self.check_vitality()?;
let new_mode = match mode_str {
"account" => DerivationMode::Account,
"index" => DerivationMode::Index,
_ => return Err(JsValue::from_str("Invalid derivation mode")),
};
let state = self.state_snapshot();
if state.derivation_mode == new_mode {
return Ok(());
}
let next_state = WalletState {
derivation_mode: new_mode,
..state
};
let next_wallet = Self::build_wallet_for_state(&self.material, next_state)?;
self.replace_wallet(next_wallet, next_state, "set_derivation_mode")
}
pub fn get_derivation_mode(&self) -> String {
match self.state_snapshot().derivation_mode {
DerivationMode::Account => "account".to_string(),
DerivationMode::Index => "index".to_string(),
}
}
pub fn set_payment_address_type(&self, address_type_str: &str) -> Result<(), JsValue> {
self.check_vitality()?;
let new_type = match address_type_str {
"native" => PaymentAddressType::NativeSegwit,
"nested" => PaymentAddressType::NestedSegwit,
"legacy" => PaymentAddressType::Legacy,
_ => return Err(JsValue::from_str("Invalid payment address type")),
};
let state = self.state_snapshot();
if state.payment_address_type == new_type {
return Ok(());
}
let next_state = WalletState {
payment_address_type: new_type,
..state
};
let next_wallet = Self::build_wallet_for_state(&self.material, next_state)?;
self.replace_wallet(next_wallet, next_state, "set_payment_address_type")
}
pub fn get_payment_address_type(&self) -> String {
match self.state_snapshot().payment_address_type {
PaymentAddressType::NativeSegwit => "native".to_string(),
PaymentAddressType::NestedSegwit => "nested".to_string(),
PaymentAddressType::Legacy => "legacy".to_string(),
}
}
#[wasm_bindgen(js_name = get_accounts)]
pub fn get_accounts(&self, count: u32) -> Result<JsValue, JsValue> {
self.check_vitality()?;
let inner = self
.inner
.try_borrow()
.map_err(|e| JsValue::from_str(&format!("Wallet busy (get_accounts): {}", e)))?;
let accounts = inner.get_accounts(count);
Ok(serde_wasm_bindgen::to_value(&accounts)?)
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen(js_name = probeHardwareAccounts)]
pub fn probe_hardware_accounts(
network: String,
fingerprint_hex: String,
esplora_url: String,
ord_url: String,
standard_taproot_xpub: String,
standard_payment_xpub: String,
legacy_taproot_xpub: String,
legacy_payment_xpub: String,
start_index: u32,
end_index: u32,
) -> Result<js_sys::Promise, JsValue> {
let network_enum = match network.as_str() {
"mainnet" | "bitcoin" => Network::Bitcoin,
"signet" => Network::Signet,
"testnet" => Network::Testnet,
"regtest" => Network::Regtest,
_ => return Err(JsValue::from_str("Invalid network")),
};
Ok(wasm_bindgen_futures::future_to_promise(async move {
let client = reqwest::Client::new();
let mut reports = Vec::new();
const ACCOUNT_BATCH_SIZE: usize = 5;
for batch_start in (start_index..=end_index).step_by(ACCOUNT_BATCH_SIZE) {
let batch_end = (batch_start + ACCOUNT_BATCH_SIZE as u32).min(end_index + 1);
let mut batch_futures = Vec::new();
for idx in batch_start..batch_end {
let client = client.clone();
let esplora = esplora_url.clone();
let ord = ord_url.clone();
let s_t_xpub = standard_taproot_xpub.clone();
let s_p_xpub = standard_payment_xpub.clone();
let l_t_xpub = legacy_taproot_xpub.clone();
let l_p_xpub = legacy_payment_xpub.clone();
let fp = fingerprint_hex.clone();
batch_futures.push(async move {
let standard_report = probe_single_account(
&client,
&esplora,
&ord,
network_enum,
&fp,
idx,
&s_t_xpub,
Some(&s_p_xpub),
"standard",
)
.await;
let legacy_report = probe_single_account(
&client,
&esplora,
&ord,
network_enum,
&fp,
idx,
&l_t_xpub,
Some(&l_p_xpub),
"legacy",
)
.await;
(standard_report, legacy_report)
});
}
let batch_results = futures_util::future::join_all(batch_futures).await;
for (s, l) in batch_results {
if let Some(r) = s {
reports.push(r);
}
if let Some(r) = l {
reports.push(r);
}
}
}
Ok(serde_wasm_bindgen::to_value(&reports)?)
}))
}
pub fn get_inscriptions(&self) -> Result<JsValue, JsValue> {
self.check_vitality()?;
match self.inner.try_borrow() {
Ok(inner) => serde_wasm_bindgen::to_value(&inner.inscriptions)
.map_err(|e| JsValue::from_str(&format!("Failed to serialize inscriptions: {e}"))),
Err(e) => Err(JsValue::from_str(&format!(
"Wallet busy (get_inscriptions): {e}"
))),
}
}
#[wasm_bindgen(js_name = getRuneBalances)]
pub fn get_rune_balances(&self) -> Result<JsValue, JsValue> {
self.check_vitality()?;
match self.inner.try_borrow() {
Ok(inner) => serde_wasm_bindgen::to_value(inner.rune_balances())
.map_err(|e| JsValue::from_str(&format!("Failed to serialize rune balances: {e}"))),
Err(e) => Err(JsValue::from_str(&format!(
"Wallet busy (get_rune_balances): {e}"
))),
}
}
pub fn get_balance(&self) -> Result<JsValue, JsValue> {
self.check_vitality()?;
match self.inner.try_borrow() {
Ok(inner) => {
let balance = inner.get_balance();
let json = serde_json::json!({
"total": {
"confirmed": balance.total.confirmed.to_sat(),
"trusted_pending": balance.total.trusted_pending.to_sat(),
"untrusted_pending": balance.total.untrusted_pending.to_sat(),
"immature": balance.total.immature.to_sat(),
},
"spendable": {
"confirmed": balance.spendable.confirmed.to_sat(),
"trusted_pending": balance.spendable.trusted_pending.to_sat(),
"untrusted_pending": balance.spendable.untrusted_pending.to_sat(),
"immature": balance.spendable.immature.to_sat(),
},
"display_spendable": {
"confirmed": balance.display_spendable.confirmed.to_sat(),
"trusted_pending": balance.display_spendable.trusted_pending.to_sat(),
"untrusted_pending": balance.display_spendable.untrusted_pending.to_sat(),
"immature": balance.display_spendable.immature.to_sat(),
},
"inscribed": balance.inscribed
});
let serializer =
serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true);
json.serialize(&serializer)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
Err(e) => Err(JsValue::from_str(&format!(
"Wallet busy (get_balance): {e}"
))),
}
}
pub fn get_transactions(&self, limit: usize) -> Result<JsValue, JsValue> {
self.check_vitality()?;
match self.inner.try_borrow() {
Ok(inner) => {
let txs = inner.get_transactions(limit);
serde_wasm_bindgen::to_value(&txs)
.map_err(|e| JsValue::from(format!("Failed to serialize transactions: {e}")))
}
Err(e) => Err(JsValue::from_str(&format!(
"Wallet busy (get_transactions): {e}"
))),
}
}
pub fn get_addresses(&self) -> Result<JsValue, JsValue> {
self.check_vitality()?;
match self.inner.try_borrow() {
Ok(inner) => {
let account_idx = inner.account_index;
let vault_addr = inner.peek_taproot_address(0);
let vault_pubkey = inner
.get_taproot_public_key(0)
.unwrap_or_else(|_| String::new());
zinc_log_debug!(
target: LOG_TARGET_WASM,
"get_addresses - account: {}, taproot: {}",
account_idx,
vault_addr
);
let (payment_addr, payment_pubkey) = if inner.is_unified() {
(Some(vault_addr.to_string()), Some(vault_pubkey.clone()))
} else {
let addr = inner
.peek_payment_address(0)
.ok_or_else(|| JsValue::from_str("Payment wallet missing in dual mode"))?;
let pubkey = inner
.get_payment_public_key(0)
.unwrap_or_else(|_| String::new());
zinc_log_debug!(target: LOG_TARGET_WASM, "get_addresses - payment: {}", addr);
(Some(addr.to_string()), Some(pubkey))
};
let json = serde_json::json!({
"account_index": account_idx,
"taproot": vault_addr.to_string(),
"taprootPublicKey": vault_pubkey,
"payment": payment_addr,
"paymentPublicKey": payment_pubkey,
"vault": vault_addr.to_string(),
"vaultPublicKey": vault_pubkey
});
serde_wasm_bindgen::to_value(&json).map_err(|e| JsValue::from(e.to_string()))
}
Err(e) => Err(JsValue::from_str(&format!(
"Wallet busy (get_addresses): {e}"
))),
}
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen(js_name = sync)]
pub fn sync(&self, esplora_url: String) -> Result<js_sys::Promise, JsValue> {
self.check_vitality()?;
use crate::builder::{SyncRequestType, SyncSleeper};
use bdk_esplora::EsploraAsyncExt;
let inner_rc = self.inner.clone();
Ok(wasm_bindgen_futures::future_to_promise(async move {
zinc_log_debug!(
target: LOG_TARGET_WASM,
"sync start ({})",
logging::redacted_field("esplora_url", &esplora_url)
);
let (sync_req, sync_generation) = {
match inner_rc.try_borrow_mut() {
Ok(mut inner) => {
if inner.is_syncing {
zinc_log_debug!(target: LOG_TARGET_WASM, "Sync already in progress, skipping.");
return Err(JsValue::from_str("Wallet Busy: Sync already in progress"));
}
inner.is_syncing = true;
zinc_log_debug!(target: LOG_TARGET_WASM, "borrow successful, preparing requests");
(inner.prepare_requests(), inner.account_generation())
}
Err(e) => {
zinc_log_debug!(target: LOG_TARGET_WASM, "sync: FAILED TO BORROW INNER: {:?}", e);
return Err(JsValue::from_str(&format!(
"Failed to borrow wallet inner state: {}",
e
)));
}
}
};
let client = match esplora_client::Builder::new(&esplora_url)
.build_async_with_sleeper::<SyncSleeper>()
{
Ok(c) => c,
Err(e) => {
zinc_log_error!(target: LOG_TARGET_WASM, "failed to create esplora client");
zinc_log_debug!(
target: LOG_TARGET_WASM,
"failed to create esplora client: {:?}",
e
);
ZincWasmWallet::clear_syncing_if_generation_matches(&inner_rc, sync_generation);
if let Some(stale) = ZincWasmWallet::generation_mismatch_error(
&inner_rc,
sync_generation,
SYNC_STALE_ERROR,
) {
return Err(stale);
}
return Err(JsValue::from(format!("{:?}", e)));
}
};
let vault_update_res: Result<bdk_wallet::Update, JsValue> = match sync_req.taproot {
SyncRequestType::Full(req) => {
zinc_log_info!(target: LOG_TARGET_WASM, "starting taproot full scan");
client
.full_scan(req, 20, 1)
.await
.map(|u| u.into())
.map_err(|e| {
zinc_log_debug!(target: LOG_TARGET_WASM, "Vault full scan failed: {:?}", e);
JsValue::from(e.to_string())
})
}
SyncRequestType::Incremental(req) => {
zinc_log_info!(target: LOG_TARGET_WASM, "starting taproot incremental sync");
client.sync(req, 1).await.map(|u| u.into()).map_err(|e| {
zinc_log_debug!(target: LOG_TARGET_WASM, "Vault sync failed: {:?}", e);
JsValue::from(e.to_string())
})
}
};
let vault_update = match vault_update_res {
Ok(u) => u,
Err(e) => {
ZincWasmWallet::clear_syncing_if_generation_matches(&inner_rc, sync_generation);
if let Some(stale) = ZincWasmWallet::generation_mismatch_error(
&inner_rc,
sync_generation,
SYNC_STALE_ERROR,
) {
return Err(stale);
}
return Err(e);
}
};
let payment_update: Option<bdk_wallet::Update> = if let Some(req_type) =
sync_req.payment
{
let update_res: Result<bdk_wallet::Update, JsValue> = match req_type {
SyncRequestType::Full(req) => {
zinc_log_info!(target: LOG_TARGET_WASM, "starting payment full scan");
client
.full_scan(req, 20, 1)
.await
.map(|u| u.into())
.map_err(|e| {
zinc_log_debug!(target: LOG_TARGET_WASM, "Payment full scan failed: {:?}", e);
JsValue::from(e.to_string())
})
}
SyncRequestType::Incremental(req) => {
zinc_log_info!(
target: LOG_TARGET_WASM,
"starting payment incremental sync"
);
client.sync(req, 1).await.map(|u| u.into()).map_err(|e| {
zinc_log_debug!(target: LOG_TARGET_WASM, "Payment sync failed: {:?}", e);
JsValue::from(e.to_string())
})
}
};
match update_res {
Ok(u) => Some(u),
Err(e) => {
ZincWasmWallet::clear_syncing_if_generation_matches(
&inner_rc,
sync_generation,
);
if let Some(stale) = ZincWasmWallet::generation_mismatch_error(
&inner_rc,
sync_generation,
SYNC_STALE_ERROR,
) {
return Err(stale);
}
return Err(e);
}
}
} else {
None
};
zinc_log_debug!(target: LOG_TARGET_WASM, "sync: chain client returned");
let events = {
match inner_rc.try_borrow_mut() {
Ok(mut inner) => {
if inner.account_generation() != sync_generation {
return Err(JsValue::from_str(SYNC_STALE_ERROR));
}
zinc_log_debug!(target: LOG_TARGET_WASM, "sync: applying updates");
let res = inner
.apply_sync(vault_update, payment_update)
.map_err(|e| {
inner.is_syncing = false;
zinc_log_error!(target: LOG_TARGET_WASM, "failed to apply sync");
zinc_log_debug!(
target: LOG_TARGET_WASM,
"failed to apply sync update: {}",
e
);
JsValue::from(e)
})?;
inner.is_syncing = false;
zinc_log_debug!(target: LOG_TARGET_WASM, "sync: updates applied");
res
}
Err(e) => {
zinc_log_debug!(target: LOG_TARGET_WASM, "FAILED TO BORROW MUT INNER: {:?}", e);
return Err(JsValue::from_str(&format!(
"Failed to borrow wallet inner state (mut): {}",
e
)));
}
}
};
zinc_log_debug!(target: LOG_TARGET_WASM, "sync: finished. events: {:?}", events);
serde_wasm_bindgen::to_value(&events).map_err(|e| JsValue::from(e.to_string()))
}))
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen(js_name = discoverAccounts)]
pub fn discover_accounts(
&self,
esplora_url: String,
account_gap_limit: u32,
address_scan_depth: Option<u32>,
timeout_ms: Option<u32>,
) -> Result<js_sys::Promise, JsValue> {
self.check_vitality()?;
let mnemonic = crate::keys::ZincMnemonic::parse(self.seed_phrase()?)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let seed = crate::builder::Seed64::from_array(*mnemonic.to_seed(""));
let state = self.state_snapshot();
let network = state.network;
let scheme = state.scheme;
let derivation_mode = state.derivation_mode;
let payment_address_type = state.payment_address_type;
let account_gap_limit = account_gap_limit.max(1);
let requested_address_scan_depth = address_scan_depth.unwrap_or(20).max(1);
let address_scan_depth = requested_address_scan_depth;
let timeout_ms = timeout_ms.unwrap_or(120_000).max(1);
Ok(wasm_bindgen_futures::future_to_promise(async move {
zinc_log_debug!(
target: LOG_TARGET_WASM,
"discover_accounts start ({}, account_gap_limit={}, requested_scan_depth={}, effective_scan_depth={}, timeout_ms={})",
logging::redacted_field("esplora_url", &esplora_url),
account_gap_limit,
requested_address_scan_depth,
address_scan_depth,
timeout_ms
);
let client = reqwest::Client::new();
let mut max_active_index: i32 = -1;
let mut current_gap = 0;
let mut account_index: u32 = 0;
let start_ms = js_sys::Date::now();
let deadline_ms = start_ms + f64::from(timeout_ms);
loop {
if js_sys::Date::now() >= deadline_ms {
zinc_log_warn!(
target: LOG_TARGET_WASM,
"discover_accounts reached timeout budget after {}ms (best_so_far_max_active={})",
timeout_ms,
max_active_index
);
break;
}
if current_gap >= account_gap_limit {
break;
}
let mut builder = WalletBuilder::from_seed(network, seed.clone());
builder = builder
.with_scheme(scheme)
.with_derivation_mode(derivation_mode)
.with_payment_address_type(payment_address_type)
.with_account_index(account_index);
let zwallet = builder.build().map_err(|e| JsValue::from_str(&e))?;
let timed_out = std::cell::Cell::new(false);
const ADDRESS_REQUEST_TIMEOUT_MS: u32 = 2_000;
let check_activity = |addr_str: String| {
let client = client.clone();
let url = format!("{}/address/{}", esplora_url, addr_str);
async move {
let request = async {
if let Ok(resp) = client.get(&url).send().await {
if let Ok(json) = resp.json::<serde_json::Value>().await {
let chain_txs =
json["chain_stats"]["tx_count"].as_u64().unwrap_or(0);
let mempool_txs =
json["mempool_stats"]["tx_count"].as_u64().unwrap_or(0);
return chain_txs > 0 || mempool_txs > 0;
}
}
false
};
let timeout =
gloo_timers::future::TimeoutFuture::new(ADDRESS_REQUEST_TIMEOUT_MS);
futures_util::pin_mut!(request);
futures_util::pin_mut!(timeout);
match futures_util::future::select(request, timeout).await {
futures_util::future::Either::Left((value, _)) => value,
futures_util::future::Either::Right((_timed_out, _)) => false,
}
}
};
let has_activity =
account_is_active_from_receive_scan(address_scan_depth, |address_index| {
let vault_addr = zwallet.peek_taproot_address(address_index).to_string();
let payment_addr = if scheme == AddressScheme::Dual {
zwallet
.peek_payment_address(address_index)
.map(|addr| addr.to_string())
} else {
None
};
async {
if js_sys::Date::now() >= deadline_ms {
timed_out.set(true);
return false;
}
if check_activity(vault_addr).await {
return true;
}
if let Some(payment_addr) = payment_addr {
return check_activity(payment_addr).await;
}
false
}
})
.await;
if timed_out.get() {
zinc_log_warn!(
target: LOG_TARGET_WASM,
"discover_accounts stopped mid-account scan due to timeout budget (account_index={})",
account_index
);
break;
}
if has_activity {
max_active_index = account_index as i32;
current_gap = 0;
} else {
current_gap += 1;
}
account_index += 1;
}
let discovered_count = (max_active_index + 1) as u32;
let final_count = if discovered_count > 0 {
discovered_count
} else {
1
};
zinc_log_debug!(target: LOG_TARGET_WASM,
"discover_accounts finished. Found max active = {}, returning discovery count {}",
max_active_index,
final_count
);
Ok(JsValue::from(final_count))
}))
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen(js_name = discoverImportPath)]
pub fn discover_import_path(
&self,
branch_str: &str,
esplora_url: String,
account_gap_limit: u32,
address_scan_depth: Option<u32>,
timeout_ms: Option<u32>,
) -> Result<js_sys::Promise, JsValue> {
self.check_vitality()?;
let branch = match branch_str {
"taproot" => ImportDiscoveryBranch::Taproot,
"payment" => ImportDiscoveryBranch::Payment,
_ => return Err(JsValue::from_str("Invalid import discovery branch")),
};
let mnemonic = crate::keys::ZincMnemonic::parse(self.seed_phrase()?)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let seed = crate::builder::Seed64::from_array(*mnemonic.to_seed(""));
let state = self.state_snapshot();
let network = state.network;
let scheme = state.scheme;
let derivation_mode = state.derivation_mode;
let payment_address_type = state.payment_address_type;
let account_gap_limit = account_gap_limit.max(1);
let address_scan_depth = address_scan_depth.unwrap_or(20).max(1);
let timeout_ms = timeout_ms.unwrap_or(45_000).max(1);
let branch_label = branch_str.to_string();
Ok(wasm_bindgen_futures::future_to_promise(async move {
zinc_log_debug!(
target: LOG_TARGET_WASM,
"discover_import_path start ({}, branch={}, account_gap_limit={}, address_scan_depth={}, timeout_ms={})",
logging::redacted_field("esplora_url", &esplora_url),
branch_label,
account_gap_limit,
address_scan_depth,
timeout_ms
);
let client = reqwest::Client::new();
let mut active_accounts = Vec::new();
let mut current_gap = 0;
let mut account_index: u32 = 0;
let start_ms = js_sys::Date::now();
let deadline_ms = start_ms + f64::from(timeout_ms);
loop {
if js_sys::Date::now() >= deadline_ms {
zinc_log_warn!(
target: LOG_TARGET_WASM,
"discover_import_path reached timeout budget after {}ms (active_accounts={})",
timeout_ms,
active_accounts.len()
);
break;
}
if current_gap >= account_gap_limit {
break;
}
let mut builder = WalletBuilder::from_seed(network, seed.clone());
builder = builder
.with_scheme(scheme)
.with_derivation_mode(derivation_mode)
.with_payment_address_type(payment_address_type)
.with_account_index(account_index);
let zwallet = builder.build().map_err(|e| JsValue::from_str(&e))?;
let timed_out = std::cell::Cell::new(false);
const ADDRESS_REQUEST_TIMEOUT_MS: u32 = 2_000;
let check_activity = |addr_str: String| {
let client = client.clone();
let url = format!("{}/address/{}", esplora_url, addr_str);
async move {
let request = async {
if let Ok(resp) = client.get(&url).send().await {
if let Ok(json) = resp.json::<serde_json::Value>().await {
let chain_txs =
json["chain_stats"]["tx_count"].as_u64().unwrap_or(0);
let mempool_txs =
json["mempool_stats"]["tx_count"].as_u64().unwrap_or(0);
return chain_txs > 0 || mempool_txs > 0;
}
}
false
};
let timeout =
gloo_timers::future::TimeoutFuture::new(ADDRESS_REQUEST_TIMEOUT_MS);
futures_util::pin_mut!(request);
futures_util::pin_mut!(timeout);
match futures_util::future::select(request, timeout).await {
futures_util::future::Either::Left((value, _)) => value,
futures_util::future::Either::Right((_timed_out, _)) => false,
}
}
};
let first_active_address_index =
first_active_receive_index_from_scan(address_scan_depth, |address_index| {
let branch_address = match branch {
ImportDiscoveryBranch::Taproot => {
Some(zwallet.peek_taproot_address(address_index).to_string())
}
ImportDiscoveryBranch::Payment => {
if scheme == AddressScheme::Dual {
zwallet
.peek_payment_address(address_index)
.map(|addr| addr.to_string())
} else {
None
}
}
};
async {
if js_sys::Date::now() >= deadline_ms {
timed_out.set(true);
return false;
}
if let Some(address) = branch_address {
return check_activity(address).await;
}
false
}
})
.await;
if timed_out.get() {
zinc_log_warn!(
target: LOG_TARGET_WASM,
"discover_import_path stopped mid-account scan due to timeout budget (account_index={})",
account_index
);
break;
}
if let Some(first_active_address_index) = first_active_address_index {
active_accounts.push(ImportPathAccountHit {
index: account_index,
first_active_address_index,
});
current_gap = 0;
} else {
current_gap += 1;
}
account_index += 1;
}
serde_wasm_bindgen::to_value(&ImportPathDiscoveryResponse { active_accounts })
.map_err(|e| JsValue::from(e.to_string()))
}))
}
#[wasm_bindgen(js_name = loadInscriptions)]
pub fn load_inscriptions(&self, val: JsValue) -> Result<u32, JsValue> {
self.check_vitality()?;
zinc_log_debug!(target: LOG_TARGET_WASM, "load_inscriptions called with JsValue");
let inscriptions: Vec<crate::ordinals::types::Inscription> =
serde_wasm_bindgen::from_value(val).map_err(|e| {
JsValue::from_str(&format!("Failed to parse inscriptions from JsValue: {e}"))
})?;
zinc_log_debug!(target: LOG_TARGET_WASM,
"Parsed {} inscriptions from JsValue. Updating wallet state...",
inscriptions.len()
);
match self.inner.try_borrow_mut() {
Ok(mut inner) => {
let count = inner.apply_unverified_inscriptions_cache(inscriptions);
zinc_log_debug!(target: LOG_TARGET_WASM, "Inscriptions applied. New count: {}", count);
Ok(count as u32)
}
Err(e) => {
zinc_log_debug!(target: LOG_TARGET_WASM, "load_inscriptions FAILED to borrow mutable: {}", e);
Err(JsValue::from_str(&format!(
"Wallet busy (load_inscriptions): {e}"
)))
}
}
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen(js_name = syncOrdinals)]
pub fn sync_ordinals(&self, ord_url: String) -> Result<js_sys::Promise, JsValue> {
self.check_vitality()?;
let inner_rc = self.inner.clone();
Ok(wasm_bindgen_futures::future_to_promise(async move {
zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals start");
let (addresses, wallet_height, sync_generation) = {
match inner_rc.try_borrow_mut() {
Ok(mut inner) => {
if inner.is_syncing {
zinc_log_debug!(target: LOG_TARGET_WASM, "Ord sync skipped: Wallet is busy syncing.");
return Err(JsValue::from_str(
"Wallet Busy: Operation already in progress",
));
}
inner.is_syncing = true;
zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals: collecting active addresses...");
let addrs = inner.collect_active_addresses();
zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals: collected {} addresses", addrs.len());
for a in &addrs {
zinc_log_debug!(
target: LOG_TARGET_WASM,
"sync_ordinals address queued: {}",
a
);
}
let height = inner.vault_wallet.local_chain().tip().height();
(addrs, height, inner.account_generation())
}
Err(e) => {
zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals: FAILED TO BORROW INNER: {:?}", e);
return Err(JsValue::from_str(&format!("Failed to borrow: {}", e)));
}
}
};
let client = crate::ordinals::OrdClient::new(ord_url.to_string());
let ord_height = match client.get_indexing_height().await {
Ok(h) => h,
Err(e) => {
zinc_log_debug!(target: LOG_TARGET_WASM, "Failed to get ord height: {:?}", e);
ZincWasmWallet::clear_syncing_if_generation_matches(&inner_rc, sync_generation);
if let Some(stale) = ZincWasmWallet::generation_mismatch_error(
&inner_rc,
sync_generation,
ORD_SYNC_STALE_ERROR,
) {
return Err(stale);
}
return Err(JsValue::from_str(&e.to_string()));
}
};
if ord_height < wallet_height.saturating_sub(1) {
zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals: Ord lagging, setting verified=false");
match inner_rc.try_borrow_mut() {
Ok(mut inner) => {
if inner.account_generation() != sync_generation {
return Err(JsValue::from_str(ORD_SYNC_STALE_ERROR));
}
inner.ordinals_verified = false;
inner.is_syncing = false;
}
Err(e) => {
zinc_log_debug!(target: LOG_TARGET_WASM,
"sync_ordinals: Failed to borrow mut for lag update: {}",
e
);
}
}
return Err(JsValue::from_str(&format!(
"Ord Indexer is lagging! Ord: {}, Wallet: {}. Safety lock engaged.",
ord_height, wallet_height
)));
}
let rune_balances = match client.get_rune_balances_for_addresses(&addresses).await {
Ok(balances) => balances,
Err(e) => {
zinc_log_debug!(
target: LOG_TARGET_WASM,
"Failed to fetch rune balances: {:?}",
e
);
ZincWasmWallet::clear_syncing_if_generation_matches(&inner_rc, sync_generation);
if let Some(stale) = ZincWasmWallet::generation_mismatch_error(
&inner_rc,
sync_generation,
ORD_SYNC_STALE_ERROR,
) {
return Err(stale);
}
return Err(JsValue::from_str(&format!(
"Failed to fetch rune balances: {}",
e
)));
}
};
zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals: fetching inscriptions");
let mut all_inscriptions = Vec::new();
let mut protected_outpoints = std::collections::HashSet::new();
for addr_str in addresses {
match client.get_inscriptions(&addr_str).await {
Ok(list) => {
zinc_log_debug!(target: LOG_TARGET_WASM,
"sync_ordinals: found {} inscriptions for {}",
list.len(),
addr_str
);
all_inscriptions.extend(list);
}
Err(e) => {
zinc_log_debug!(target: LOG_TARGET_WASM, "Failed to fetch inscriptions for {}: {}", addr_str, e);
ZincWasmWallet::clear_syncing_if_generation_matches(
&inner_rc,
sync_generation,
);
if let Some(stale) = ZincWasmWallet::generation_mismatch_error(
&inner_rc,
sync_generation,
ORD_SYNC_STALE_ERROR,
) {
return Err(stale);
}
return Err(JsValue::from_str(&format!(
"Failed to fetch for {}: {}",
addr_str, e
)));
}
}
match client.get_protected_outpoints(&addr_str).await {
Ok(outpoints) => {
zinc_log_debug!(target: LOG_TARGET_WASM,
"sync_ordinals: found {} protected outputs for {}",
outpoints.len(),
addr_str
);
protected_outpoints.extend(outpoints);
}
Err(e) => {
zinc_log_debug!(target: LOG_TARGET_WASM,
"Failed to fetch protected outputs for {}: {}",
addr_str,
e
);
match inner_rc.try_borrow_mut() {
Ok(mut inner) => {
if inner.account_generation() != sync_generation {
return Err(JsValue::from_str(ORD_SYNC_STALE_ERROR));
}
inner.ordinals_verified = false;
inner.is_syncing = false;
}
Err(_) => {}
}
return Err(JsValue::from_str(&format!(
"Failed to fetch protected outputs for {}: {}",
addr_str, e
)));
}
}
}
zinc_log_debug!(target: LOG_TARGET_WASM,
"sync_ordinals: total inscriptions found: {}",
all_inscriptions.len()
);
zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals: applying update (borrow mut)");
let count = {
match inner_rc.try_borrow_mut() {
Ok(mut inner) => {
if inner.account_generation() != sync_generation {
return Err(JsValue::from_str(ORD_SYNC_STALE_ERROR));
}
let c = inner.apply_verified_ordinals_update(
all_inscriptions,
protected_outpoints,
rune_balances,
);
inner.is_syncing = false; c
}
Err(e) => {
zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals: FAILED TO BORROW MUT: {:?}", e);
return Err(JsValue::from_str(&format!("Failed to borrow mut: {}", e)));
}
}
};
Ok(JsValue::from(count as u32))
}))
}
fn create_psbt_with_transport(
&self,
transport: crate::builder::CreatePsbtTransportRequest,
busy_label: &str,
) -> Result<String, JsValue> {
let request = crate::builder::CreatePsbtRequest::try_from(transport)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
match self.inner.try_borrow_mut() {
Ok(mut inner) => inner
.create_psbt_base64(&request)
.map_err(|e| JsValue::from_str(&e.to_string())),
Err(e) => Err(JsValue::from_str(&format!(
"Wallet busy ({busy_label}): {e}"
))),
}
}
#[wasm_bindgen(js_name = createPsbt)]
pub fn create_psbt_request(&self, request: JsValue) -> Result<String, JsValue> {
self.check_vitality()?;
let transport: crate::builder::CreatePsbtTransportRequest =
serde_wasm_bindgen::from_value(request)
.map_err(|e| JsValue::from_str(&format!("Invalid request: {e}")))?;
self.create_psbt_with_transport(transport, "createPsbt")
}
#[wasm_bindgen(js_name = createListingPurchase)]
pub fn create_listing_purchase_request(&self, request: JsValue) -> Result<JsValue, JsValue> {
self.check_vitality()?;
let request: crate::listing::CreateListingPurchaseRequest =
serde_wasm_bindgen::from_value(request)
.map_err(|e| JsValue::from_str(&format!("Invalid request: {e}")))?;
match self.inner.try_borrow_mut() {
Ok(mut inner) => {
let result = inner
.create_listing_purchase(&request)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
serde_wasm_bindgen::to_value(&result).map_err(|e| {
JsValue::from_str(&format!("failed to serialize listing purchase: {e}"))
})
}
Err(e) => Err(JsValue::from_str(&format!(
"Wallet busy (createListingPurchase): {e}"
))),
}
}
#[doc(hidden)]
pub fn create_psbt(
&self,
recipient: &str,
amount_sats: u64,
fee_rate_sat_vb: u64,
) -> Result<String, JsValue> {
self.check_vitality()?;
self.create_psbt_with_transport(
crate::builder::CreatePsbtTransportRequest {
recipient: recipient.to_string(),
amount_sats,
fee_rate_sat_vb,
},
"create_psbt",
)
}
#[wasm_bindgen(js_name = signPsbt)]
pub fn sign_psbt(&self, psbt_base64: &str, options: JsValue) -> Result<String, JsValue> {
self.check_vitality()?;
let sign_opts: Option<crate::builder::SignOptions> =
if options.is_null() || options.is_undefined() {
None
} else {
match serde_wasm_bindgen::from_value(options) {
Ok(opts) => Some(opts),
Err(e) => return Err(JsValue::from_str(&format!("Invalid options: {e}"))),
}
};
match self.inner.try_borrow_mut() {
Ok(mut inner) => inner
.sign_psbt(psbt_base64, sign_opts)
.map_err(JsValue::from),
Err(e) => Err(JsValue::from_str(&format!("Wallet busy (sign_psbt): {e}"))),
}
}
#[wasm_bindgen(js_name = prepareExternalSignPsbt)]
pub fn prepare_external_sign_psbt(
&self,
psbt_base64: &str,
options: JsValue,
) -> Result<String, JsValue> {
self.check_vitality()?;
let sign_opts: Option<crate::builder::SignOptions> =
if options.is_null() || options.is_undefined() {
None
} else {
match serde_wasm_bindgen::from_value(options) {
Ok(opts) => Some(opts),
Err(e) => return Err(JsValue::from_str(&format!("Invalid options: {e}"))),
}
};
match self.inner.try_borrow() {
Ok(inner) => inner
.prepare_external_sign_psbt(psbt_base64, sign_opts)
.map_err(JsValue::from),
Err(e) => Err(JsValue::from_str(&format!(
"Wallet busy (prepare_external_sign_psbt): {e}"
))),
}
}
#[wasm_bindgen(js_name = verifyExternalSignedPsbt)]
pub fn verify_external_signed_psbt(
&self,
original_psbt_base64: &str,
signed_psbt_base64: &str,
required_input_indices: JsValue,
finalize: bool,
) -> Result<String, JsValue> {
self.check_vitality()?;
let indices: Option<Vec<usize>> =
if required_input_indices.is_null() || required_input_indices.is_undefined() {
None
} else {
match serde_wasm_bindgen::from_value(required_input_indices) {
Ok(val) => Some(val),
Err(e) => {
return Err(JsValue::from_str(&format!(
"Invalid required_input_indices: {e}"
)))
}
}
};
match self.inner.try_borrow() {
Ok(inner) => inner
.verify_external_signed_psbt(
original_psbt_base64,
signed_psbt_base64,
indices.as_deref(),
finalize,
)
.map_err(JsValue::from),
Err(e) => Err(JsValue::from_str(&format!(
"Wallet busy (verify_external_signed_psbt): {e}"
))),
}
}
#[wasm_bindgen(js_name = analyzePsbt)]
pub fn analyze_psbt(&self, psbt_base64: &str) -> Result<String, JsValue> {
self.check_vitality()?;
match self.inner.try_borrow() {
Ok(inner) => inner.analyze_psbt(psbt_base64).map_err(JsValue::from),
Err(e) => Err(JsValue::from_str(&format!(
"Wallet busy (analyze_psbt): {e}"
))),
}
}
#[wasm_bindgen(js_name = auditPsbt)]
pub fn audit_psbt(&self, psbt_base64: &str, options: JsValue) -> Result<(), JsValue> {
self.check_vitality()?;
let sign_opts: Option<crate::builder::SignOptions> =
if options.is_null() || options.is_undefined() {
None
} else {
match serde_wasm_bindgen::from_value(options) {
Ok(opts) => Some(opts),
Err(e) => return Err(JsValue::from_str(&format!("Invalid options: {e}"))),
}
};
use base64::Engine;
let psbt_bytes = base64::engine::general_purpose::STANDARD
.decode(psbt_base64)
.map_err(|e| JsValue::from_str(&format!("Invalid base64: {e}")))?;
let psbt = bitcoin::psbt::Psbt::deserialize(&psbt_bytes)
.map_err(|e| JsValue::from_str(&format!("Invalid PSBT: {e}")))?;
let inner = self
.inner
.try_borrow()
.map_err(|e| JsValue::from_str(&format!("Wallet busy (audit_psbt): {e}")))?;
let mut known_inscriptions: std::collections::HashMap<
(bitcoin::Txid, u32),
Vec<(String, u64)>,
> = std::collections::HashMap::new();
for ins in &inner.inscriptions {
known_inscriptions
.entry((ins.satpoint.outpoint.txid, ins.satpoint.outpoint.vout))
.or_default()
.push((ins.id.clone(), ins.satpoint.offset));
}
let allowed_inputs = sign_opts.as_ref().and_then(|o| o.sign_inputs.as_deref());
crate::ordinals::shield::audit_psbt(
&psbt,
&known_inscriptions,
allowed_inputs,
inner.vault_wallet.network(),
)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
pub fn sign_message(&self, address: &str, message: &str) -> Result<String, JsValue> {
self.check_vitality()?;
match self.inner.try_borrow() {
Ok(inner) => inner
.sign_message(address, message)
.map_err(|e| JsValue::from_str(&e)),
Err(e) => Err(JsValue::from_str(&format!(
"Wallet busy (sign_message): {e}"
))),
}
}
#[wasm_bindgen(js_name = build_signed_pairing_ack)]
pub fn build_signed_pairing_ack(
&self,
signed_request_json: &str,
now_unix: i64,
ack_ttl_secs: u32,
granted_capabilities_json: Option<String>,
) -> Result<String, JsValue> {
self.check_vitality()?;
match self.inner.try_borrow() {
Ok(inner) => {
let wallet_secret_key_hex = inner
.get_pairing_secret_key_hex()
.map_err(|e| JsValue::from_str(&e))?;
let signed_request: crate::sign_intent::SignedPairingRequestV1 =
serde_json::from_str(signed_request_json).map_err(|e| {
JsValue::from_str(&format!("invalid signed pairing request json: {e}"))
})?;
let granted_capabilities = match granted_capabilities_json {
Some(raw_json) => {
let policy: crate::sign_intent::CapabilityPolicyV1 =
serde_json::from_str(&raw_json).map_err(|e| {
JsValue::from_str(&format!(
"invalid granted capabilities json: {e}"
))
})?;
Some(policy)
}
None => None,
};
let signed_ack = crate::sign_intent::build_signed_pairing_ack_with_granted(
&signed_request,
&wallet_secret_key_hex,
now_unix,
i64::from(ack_ttl_secs),
granted_capabilities,
)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
serde_json::to_string(&signed_ack)
.map_err(|e| JsValue::from_str(&format!("failed to serialize signed ack: {e}")))
}
Err(e) => Err(JsValue::from_str(&format!(
"Wallet busy (build_signed_pairing_ack): {e}"
))),
}
}
#[wasm_bindgen(js_name = get_pairing_pubkey_hex)]
pub fn get_pairing_pubkey_hex(&self) -> Result<String, JsValue> {
self.check_vitality()?;
match self.inner.try_borrow() {
Ok(inner) => {
let secret_hex = inner
.get_pairing_secret_key_hex()
.map_err(|e| JsValue::from_str(&e))?;
crate::sign_intent::pubkey_hex_from_secret_key(&secret_hex)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
Err(e) => Err(JsValue::from_str(&format!(
"Wallet busy (get_pairing_pubkey_hex): {e}"
))),
}
}
#[wasm_bindgen(js_name = build_signed_sign_intent_rejection_receipt_json)]
pub fn build_signed_sign_intent_rejection_receipt_json(
&self,
signed_intent_json: &str,
created_at_unix: i64,
rejection_reason: &str,
) -> Result<String, JsValue> {
self.check_vitality()?;
let signed_intent: crate::sign_intent::SignedSignIntentV1 =
serde_json::from_str(signed_intent_json)
.map_err(|e| JsValue::from_str(&format!("invalid signed sign intent json: {e}")))?;
match self.inner.try_borrow() {
Ok(inner) => {
let secret_hex = inner
.get_pairing_secret_key_hex()
.map_err(|e| JsValue::from_str(&e))?;
let signed_receipt =
crate::sign_intent::build_signed_sign_intent_rejection_receipt(
&signed_intent,
&secret_hex,
created_at_unix,
rejection_reason,
)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
serde_json::to_string(&signed_receipt).map_err(|e| {
JsValue::from_str(&format!(
"failed to serialize signed sign intent receipt: {e}"
))
})
}
Err(e) => Err(JsValue::from_str(&format!(
"Wallet busy (build_signed_sign_intent_rejection_receipt_json): {e}"
))),
}
}
#[wasm_bindgen(js_name = build_signed_sign_intent_approved_receipt_json)]
pub fn build_signed_sign_intent_approved_receipt_json(
&self,
signed_intent_json: &str,
created_at_unix: i64,
signed_psbt_base64: Option<String>,
artifact_json: Option<String>,
) -> Result<String, JsValue> {
self.check_vitality()?;
let signed_intent: crate::sign_intent::SignedSignIntentV1 =
serde_json::from_str(signed_intent_json)
.map_err(|e| JsValue::from_str(&format!("invalid signed sign intent json: {e}")))?;
match self.inner.try_borrow() {
Ok(inner) => {
let secret_hex = inner
.get_pairing_secret_key_hex()
.map_err(|e| JsValue::from_str(&e))?;
let signed_receipt = crate::sign_intent::build_signed_sign_intent_approved_receipt(
&signed_intent,
&secret_hex,
created_at_unix,
signed_psbt_base64.as_deref(),
artifact_json.as_deref(),
)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
serde_json::to_string(&signed_receipt).map_err(|e| {
JsValue::from_str(&format!(
"failed to serialize signed sign intent receipt: {e}"
))
})
}
Err(e) => Err(JsValue::from_str(&format!(
"Wallet busy (build_signed_sign_intent_approved_receipt_json): {e}"
))),
}
}
#[wasm_bindgen(js_name = verify_sign_seller_input_scope_json)]
pub fn verify_sign_seller_input_scope_json(
&self,
signed_intent_json: &str,
now_unix: i64,
) -> Result<String, JsValue> {
self.check_vitality()?;
let plan =
crate::sign_intent::verify_sign_seller_input_scope_json(signed_intent_json, now_unix)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
serde_json::to_string(&plan)
.map_err(|e| JsValue::from_str(&format!("failed to serialize scope plan: {e}")))
}
#[wasm_bindgen(js_name = build_pairing_ack_envelope_json)]
pub fn build_pairing_ack_envelope_json(
&self,
signed_ack_json: &str,
created_at_unix: i64,
) -> Result<String, JsValue> {
self.check_vitality()?;
let signed_ack: crate::sign_intent::SignedPairingAckV1 =
serde_json::from_str(signed_ack_json)
.map_err(|e| JsValue::from_str(&format!("invalid signed pairing ack json: {e}")))?;
let envelope = crate::sign_intent::PairingAckEnvelopeV1::new(signed_ack, created_at_unix)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
serde_json::to_string(&envelope).map_err(|e| {
JsValue::from_str(&format!("failed to serialize pairing ack envelope: {e}"))
})
}
#[wasm_bindgen(js_name = build_pairing_transport_event_json)]
pub fn build_pairing_transport_event_json(
&self,
content_json: &str,
type_tag: &str,
pairing_id: &str,
recipient_pubkey_hex: &str,
created_at_unix: u64,
) -> Result<String, JsValue> {
self.check_vitality()?;
match self.inner.try_borrow() {
Ok(inner) => {
let secret_hex = inner
.get_pairing_secret_key_hex()
.map_err(|e| JsValue::from_str(&e))?;
let event = crate::sign_intent::build_pairing_transport_event(
content_json,
type_tag,
pairing_id,
recipient_pubkey_hex,
created_at_unix,
&secret_hex,
)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
serde_json::to_string(&event).map_err(|e| {
JsValue::from_str(&format!("failed to serialize pairing transport event: {e}"))
})
}
Err(e) => Err(JsValue::from_str(&format!(
"Wallet busy (build_pairing_transport_event_json): {e}"
))),
}
}
#[wasm_bindgen(js_name = decode_pairing_transport_event_content_json)]
pub fn decode_pairing_transport_event_content_json(
&self,
event_json: &str,
) -> Result<String, JsValue> {
self.check_vitality()?;
let event: crate::sign_intent::NostrTransportEventV1 = serde_json::from_str(event_json)
.map_err(|e| JsValue::from_str(&format!("invalid transport event json: {e}")))?;
event
.verify()
.map_err(|e| JsValue::from_str(&e.to_string()))?;
match self.inner.try_borrow() {
Ok(inner) => {
let secret_hex = inner
.get_pairing_secret_key_hex()
.map_err(|e| JsValue::from_str(&e))?;
crate::sign_intent::decode_pairing_transport_event_content_with_secret(
&event,
&secret_hex,
)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
Err(e) => Err(JsValue::from_str(&format!(
"Wallet busy (decode_pairing_transport_event_content_json): {e}"
))),
}
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen(js_name = broadcast)]
pub fn broadcast(
&self,
signed_psbt_base64: String,
esplora_url: String,
) -> Result<js_sys::Promise, JsValue> {
self.check_vitality()?;
use crate::builder::SyncSleeper;
Ok(wasm_bindgen_futures::future_to_promise(async move {
use base64::Engine;
let psbt_bytes = base64::engine::general_purpose::STANDARD
.decode(&signed_psbt_base64)
.map_err(|e| JsValue::from_str(&format!("Invalid base64: {e}")))?;
let psbt = bitcoin::psbt::Psbt::deserialize(&psbt_bytes)
.map_err(|e| JsValue::from_str(&format!("Invalid PSBT: {e}")))?;
let tx = psbt
.extract_tx()
.map_err(|e| JsValue::from_str(&format!("Failed to extract tx: {e}")))?;
let client = esplora_client::Builder::new(&esplora_url)
.build_async_with_sleeper::<SyncSleeper>()
.map_err(|e| JsValue::from_str(&format!("Failed to create client: {e:?}")))?;
client
.broadcast(&tx)
.await
.map_err(|e| JsValue::from_str(&format!("Broadcast failed: {e}")))?;
Ok(JsValue::from(tx.compute_txid().to_string()))
}))
}
}
#[cfg(test)]
pub mod tests;